在C ++中序列化二进制数据的正确方法

时间:2012-11-09 05:09:00

标签: c++ c serialization binary undefined-behavior

在阅读了以下12 Q / As并使用了下面讨论的技术多年来在使用GCC和MSVC的x86架构上并且没有看到问题之后,我现在非常混淆了什么应该是正确但也是重要的“最有效”的方法来序列化然后使用C ++反序列化二进制数据。

鉴于以下“错误”代码:

int main()
{
   std::ifstream strm("file.bin");

   char buffer[sizeof(int)] = {0};

   strm.read(buffer,sizeof(int));

   int i = 0;

   // Experts seem to think doing the following is bad and
   // could crash entirely when run on ARM processors:
   i = reinterpret_cast<int*>(buffer); 

   return 0;
}

现在我理解了一些事情,重新解释强制转换向编译器表明它可以将缓冲区中的内存视为一个整数,然后可以自由发出整数兼容指令,这些指令需要/假设某些对齐的数据 - 与当CPU检测到它试图执行面向对齐的指令的地址实际上没有对齐时,只有开销是额外的读取和移位。

这就是说上面提供的答案似乎表明,就C ++而言,这是所有未定义的行为。

假设将发生转换的缓冲区中的位置对齐不符合,那么解决此问题的唯一方法是将字节1复制1吗?是否有更有效的技术?

此外,我多年来看到很多情况下,一个完全由pod构成的结构(使用编译器特定的编译指示删除填充)被强制转换为char *并随后写入文件或套接字,然后再回读进入缓冲区并将缓冲区强制转换为原始结构的指针,(忽略机器之间潜在的endian和float / double格式问题),这种代码是否也被认为是未定义的行为?

以下是更复杂的例子:

int main()
{
   std::ifstream strm("file.bin");

   char buffer[1000] = {0};

   const std::size_t size = sizeof(int) + sizeof(short) + sizeof(float) + sizeof(double);

   const std::size_t weird_offset = 3;

   buffer += weird_offset;

   strm.read(buffer,size);

   int    i = 0;
   short  s = 0;
   float  f = 0.0f;
   double d = 0.0;

   // Experts seem to think doing the following is bad and
   // could crash entirely when run on ARM processors:
   i = reinterpret_cast<int*>(buffer); 
   buffer += sizeof(int);

   s = reinterpret_cast<short*>(buffer); 
   buffer += sizeof(short);

   f = reinterpret_cast<float*>(buffer); 
   buffer += sizeof(float);

   d = reinterpret_cast<double*>(buffer); 
   buffer += sizeof(double);

   return 0;
}

2 个答案:

答案 0 :(得分:4)

首先,你可以使用例如std :: aligned_storage :: value&gt; :: type而不是char [sizeof(int)]来正确,便携和有效地解决对齐问题(或者,如果你没有&#39;有C ++ 11,可能有类似的编译器特定功能。)

即使您正在处理复杂的POD,aligned_storedalignment_of也会为您提供一个缓冲区,您可以将memcpy POD导入和导出,将其构建为等等。

在一些更复杂的情况下,你需要编写更复杂的代码,可能使用编译时算术和基于模板的静态开关等等,但据我所知,在C +期间没有人想出一个案例+11审议无法处理新功能。

但是,仅在随机字符对齐的缓冲区上使用reinterpret_cast是不够的。让我们来看看原因:

  

重新解释强制转换表明编译器可以将缓冲区中的内存视为整数

是的,但您还指出它可以假设缓冲区已正确对齐整数。如果您对此撒谎,则可以自由地生成损坏的代码。

  

随后可以自由发布整数兼容指令,这些指令需要/假设某些对齐的数据

是的,它可以自由发布要求这些对齐的说明,或假设它们已经处理完毕。

  

唯一的开销是额外的读取和移位,当CPU检测到它试图执行面向对齐的指令的地址实际上没有对齐。

是的,它可能会发出额外的读取和转移指令。但它也可能会发出不执行此操作的说明,因为您已经告诉过它并非必须这样做。因此,它可以发出一个&#34;读取对齐的单词&#34;用于非对齐地址时引发中断的指令。

有些处理器没有&#34;读取对齐的字&#34;指导,只是&#34;读字&#34;对齐比没有对齐更快。其他人可以配置为抑制陷阱,而是回到较慢的“读”字#34;。但其他人 - 比如ARM--将会失败。

  

假设将发生转换的缓冲区中的位置对齐不符合,那么解决此问题的唯一方法是将字节1复制1吗?是否有更有效的技术?

您不需要将字节1复制为1.例如,您可以将每个变量memcpy逐个复制到正确对齐的存储中。 (如果你的所有变量都是1字节长,那只会逐字节地复制字节,在这种情况下你首先不会担心对齐...)

至于使用特定于编译器的编译指示将POD转换为char *并返回...好吧,依赖于编译器特定的编译指示以获得正确性(而不是效率)的任何代码显然都不正确,可移植的C ++。有时&#34;在任何带有IEEE 64位双精度的64位小端平台上使用g ++ 3.4或更高版本进行校正&#34;对于你的用例来说已经足够了,但这与实际有效的C ++不同。而且你当然不能期望它在一个拥有80位双打的32位大端平台上使用Sun cc,然后抱怨它没有。

对于您稍后添加的示例:

// Experts seem to think doing the following is bad and
// could crash entirely when run on ARM processors:
buffer += weird_offset;

i = reinterpret_cast<int*>(buffer); 
buffer += sizeof(int);

专家是对的。这是同一件事的一个简单例子:

int i[2];
char *c = reinterpret_cast<char *>(i) + 1;
int *j = reinterpret_cast<int *>(c);
int k = *j;

变量i将在某个可被4整除的地址对齐,比如0x01000000。因此,j将位于0x01000001。因此,行int k = *j将发出一条指令,从0x01000001读取一个4字节对齐的4字节值。比方说,就像PPC64一样,它只需要int k = *i的8倍,但就ARM来说,它会崩溃。

所以,如果你有这个:

int    i = 0;
short  s = 0;
float  f = 0.0f;
double d = 0.0;

你想把它写成一个流,你是怎么做到的?

writeToStream(&i);
writeToStream(&s);
writeToStream(&f);
writeToStream(&d);

你如何从流中回读?

readFromStream(&i);
readFromStream(&s);
readFromStream(&f);
readFromStream(&d);

据推测,您使用的任何类型的流(无论ifstreamFILE*,无论如何)都有一个缓冲区,因此readFromStream(&f)将检查是否存在{ {1}}字节可用,如果没有,则读取下一个缓冲区,然后将第一个sizeof(float)字节从缓冲区复制到sizeof(float)的地址。 (事实上​​,它甚至可能更聪明 - 它可以被允许,例如,检查你是否只是接近缓冲区的末尾,如果是,则发出异步读取 - 如果图书馆实施者认为这是一个好主意,那么,标准并没有说明它如何进行复制。标准库不必在任何地方运行,而是在他们所参与的实施中运行,因此您的平台f可以使用ifstreammemcpy,或编译器内在的,或内联汇编 - 它可能会在您的平台上使用最快的。

那么,未对齐访问究竟是如何帮助您优化或简化它的呢?

几乎在所有情况下,选择正确类型的流并使用其读写方法是最有效的读写方式。而且,如果您从标准库中选择了一个流,它也可以保证是正确的。所以,你已经两全其美。

如果您的应用程序有一些特殊的东西可以使某些东西变得更有效 - 或者如果您是编写标准库的人 - 那么您当然应该继续这样做。只要您(以及您的代码的任何潜在用户)都知道您违反标准的地方和原因(并且您实际上是在优化事物,而不仅仅是做某事,因为它&#34;似乎应该更快&#34;),这是完全合理的。

您似乎认为能够将它们放入某种类型的&#34;压缩结构中会有所帮助。并且只是写出来,但C ++标准没有任何&#34;压缩结构&#34;。某些实现具有可用于此的非标准功能。例如,MSVC和gcc都允许你在i386上将上面的内容打包成18个字节,你可以把那个打包的结构和*(float*)它,memcpy转发到reinterpret_cast来发送网络,无论如何。但它不能与由不同编译器编译的完全相同的代码兼容,而这些编译器并不了解编译器的特殊编译指示。它甚至不能与相关的编译器兼容,比如ARM的gcc,它会将相同的东西打包成20个字节。当您对标准使用非可移植扩展时,结果是不可移植的。

答案 1 :(得分:0)

序列化基本上是将一个类转换为二进制形式,以便以后可以读取它或通过网络发送,然后从文件中读出或作为对象通过网络发送。它实际上是一个简单但非常强大的概念,它允许对象在网络中保持其形式。 Here就是一个例子,可以让你正确地了解如何在c ++中进行序列化。