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

Sam*_*jat 2 c c++ binary serialization 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;
}
Run Code Online (Sandbox Code Playgroud)

现在,据我所知,重新解释类型转换向编译器表明它可以将缓冲区中的内存视为整数,并且随后可以发出整数兼容指令,这些指令要求/假定所讨论的数据具有某些对齐方式-唯一的开销是当CPU检测到其试图执行面向对齐指令的地址实际上未对齐时,多余的读取和移位。

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

假定将从中进行强制转换的缓冲区位置的对齐方式不一致,那么,对于此问题,唯一的解决方案是将字节一乘一地复制吗?也许有一种更有效的技术?

此外,多年来,我已经看到很多情况,其中将完全由Pod组成的结构(使用特定于编译器的编译指示来删除填充)转换为char *,然后写入文件或套接字,然后再读回缓冲区并且将缓冲区转换回原始结构的指针(忽略机器之间的潜在字节序和浮点/双格式问题),这种代码是否也被视为未定义的行为?

以下是更复杂的示例:

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;
}
Run Code Online (Sandbox Code Playgroud)

aba*_*ert 5

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

即使您正在处理复杂的POD,aligned_storedalignment_of将为您提供一个缓冲区,您可以memcpy将POD放入和移出,构造成POD等。

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

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

重新解释类型转换向编译器指示它可以将缓冲区的内存视为整数

是的,但是您还表示它可以假定缓冲区已正确对齐整数。如果您对此撒谎,可以免费生成损坏的代码。

随后可以自由发布整数兼容指令,这些指令要求/假定相关数据的某些对齐方式

是的,可以自由发出要求进行这些对齐或假定已经进行了对齐的说明。

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

是的,它可能会发出额外的读取和移位指令。但它也可能发出不执行的指令,因为您已经告诉它不必这样做。因此,它可能会发出“读取对齐字”指令,该指令在用于非对齐地址时会引发中断。

一些处理器没有“读取对齐字”指令,只有对齐时“读取字”比没有对齐更快。其他可以配置为抑制陷阱,而是回退到较慢的“读取字”。但是其他人(例如ARM)将失败。

假定将从中进行强制转换的缓冲区位置的对齐方式不一致,那么,对于此问题,唯一的解决方案是将字节一乘一地复制吗?也许有一种更有效的技术?

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

至于将POD转换为char *并使用特定于编译器的编译指示回传……好吧,任何依赖于特定于编译器的编译指示的正确性(而不是效率)的代码显然都是不正确的可移植C ++。有时,“对于任何具有IEEE 64位双精度的64位Little-endian平台,在g ++ 3.4或更高版本上都正确”对于您的用例已经足够了,但这与实际上是有效的C ++并不相同。例如,您当然不能期望它与Sun cc在具有80位双精度的32位big-endian平台上一起工作,然后抱怨说它不起作用。

对于稍后添加的示例:

// 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);
Run Code Online (Sandbox Code Playgroud)

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

int i[2];
char *c = reinterpret_cast<char *>(i) + 1;
int *j = reinterpret_cast<int *>(c);
int k = *j;
Run Code Online (Sandbox Code Playgroud)

该变量i将在某个地址处对齐,该地址可以被4整除,例如0x01000000。因此,j将为0x01000001。因此,该行将int k = *j发出一条指令,以从0x01000001读取4字节对齐的4字节值。例如,在PPC64上,所需时间仅为的8倍int k = *i,而在ARM上,它将崩溃。

因此,如果您有:

int    i = 0;
short  s = 0;
float  f = 0.0f;
double d = 0.0;
Run Code Online (Sandbox Code Playgroud)

而且您想将其写入流中,该怎么做?

writeToStream(&i);
writeToStream(&s);
writeToStream(&f);
writeToStream(&d);
Run Code Online (Sandbox Code Playgroud)

您如何从流中回读?

readFromStream(&i);
readFromStream(&s);
readFromStream(&f);
readFromStream(&d);
Run Code Online (Sandbox Code Playgroud)

大概您正在使用的任何类型的流(无论是ifstreamFILE*什么)中都有缓冲区,因此readFromStream(&f)将要检查是否有sizeof(float)可用字节,如果没有,请读取下一个缓冲区,然后将sizeof(float)缓冲区中的第一个字节复制到地址的f。(实际上,它甚至可能更聪明,它可以(例如)检查您是否就在缓冲区的末尾附近,如果可以,则发出异步预读,如果库实现者认为这是一个好主意。 。)标准没有说明必须如何进行复制。标准库不必在任何地方运行,而是可以在它们所在的实现上运行,因此您的平台ifstream可以使用memcpy*(float*),或编译器内部或内联程序集-它可能会使用平台上最快的速度。

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

在几乎每种情况下,选择正确的流并使用其读取和写入方法是读取和写入的最有效方法。而且,如果您从标准库中选择了一个流,那么也保证它是正确的。因此,您两全其美。

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

您似乎认为将它们放入某种“压缩结构”中并进行编写会有所帮助,但是C ++标准没有诸如“压缩结构”之类的东西。某些实现具有非标准功能,可用于此目的。例如,MSVC和GCC都将让你包以上为18个字节在i386,你可以采取的包装结构和memcpy它,reinterpret_castchar *要通过网络发送,等等。但是,它与不了解您的编译器特殊编译指示的其他编译器所编译的完全相同的代码不兼容。它甚至不会与相关的编译器兼容,例如ARM的gcc,后者会将相同内容打包成20个字节。当使用非便携式扩展标准时,结果不可移植。

  • abarnert:“您似乎认为将它们放入某种“打包的结构”中会有所帮助-没错,我并不是说什么,我只是说这是我见过使用的代码库的普遍做法这样的把戏。 (2认同)
  • @abarnet:ofstream对吗?这是一个QA站点,因为这样的示例保持简单-玩具示例,在现实生活中,通常可以序列化MB甚至GB的数据,此64KB的流缓冲区是无用的,并且当写入量大于缓冲区时,内部的流缓冲区完全忽略了这一点,就相反而言,还请考虑数据可以通过多种其他方式进入程序(套接字,usb等),这些输入类型都不是本地缓冲的。 (2认同)