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

4

阅读以下12问答,并使用下面讨论的技术多年来在x86架构上使用GCC和MSVC没有看到问题后,我现在非常困惑正确但同样重要的是以“最有效”的方式使用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;
}

现在我理解reinterpret_cast的作用是告诉编译器它可以将buffer中的内存视为整数,随后可以发出需要/假定某些数据对齐方式的整数兼容指令 - 唯一的开销是当CPU检测到它正在执行面向对齐方式的指令的地址实际上未对齐时,会产生额外的读取和移位。

尽管如此,以上提供的答案似乎表明就C ++而言,这都是未定义的行为。

假设从将进行转换的buffer位置的对齐方式不符合要求,则唯一的解决方案是逐个复制字节吗?是否有更有效的技术?

此外,多年来我看到过许多情况,其中完全由pod组成的结构体(使用编译器特定的pragma删除填充)被转换为char *,随后写入文件或套接字,然后稍后读回缓冲区并将缓冲区转换回原始结构体的指针,(忽略机器之间的潜在大小端和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
@abarnert 上面的例子很简单,通常情况下,人们会将许多不同类型的序列化顺序存储到一个缓冲区中,并期望将它们全部读回来,此外,“缓冲区”可能是从一个池中请求的,该池可能没有进行任何对齐等操作... - Sami Kenjat
从你的问题语气来看,似乎你并不是在问“我能做到吗”(你知道你不能),或者“为什么它不起作用?”(已经有人向你解释过了),而是在问“为什么他们没有设计一种不同的语言让它可以工作?”(甚至可能是,“是否有一种接近C++的语言被设计成可以工作?”) - abarnert
1
@abarnert:我理解标准为什么不想在类型别名从任意内存中说出具体内容的架构原因,我的问题更多是关于高效但正确和定义行为的解决方案 - 如果有的话,特别是不需要memcpy的解决方案,即使对于4或8字节的复制也会非常昂贵...实际上比为非对齐读取进行校正而进行的额外读取和左/右移位操作更昂贵。 - Sami Kenjat
@abarnet: 我做了一些测试,看起来读取操作和移位等操作是同时进行的,而memcpy函数则需要调用和设置栈的开销,以及复制过程(如果编译器没有将其视为特殊函数的话 - GCC会处理)。 - Sami Kenjat
没有人说你必须始终编写符合标准的代码。当有人说“你的代码会在ARM上崩溃”时,这意味着你最好不要在可能需要编译为ARM的代码中这样做。如果你将其用于只会在x86_64 Windows 7上运行的代码中,那就没问题了。你应该“知道”你的代码不符合标准,并且它会在ARM上崩溃,以防你需要将其移植或在iOS应用程序中编写类似的东西,但你仍然可以使用它来编写Windows 7应用程序。 - abarnert
显示剩余6条评论
1个回答

8
首先,您可以使用std :: aligned_storage :: value> :: type等来正确、可移植和高效地解决对齐问题(例如,而不是char [sizeof(int)]),即使您正在处理复杂的POD,aligned_stored和alignment_of也会为您提供缓冲区,您可以将POD复制到其中,并从中构建它等等。在一些更复杂的情况下,您需要编写更复杂的代码,可能需要使用编译时算术和基于模板的静态开关等,但就我所知,在C ++ 11的讨论期间,没有人提出过无法使用新功能处理的情况。
然而,仅仅在随机char对齐缓冲区上使用reinterpret_cast是不够的。让我们看看原因:是的,但是您还表明它可以假定缓冲区已正确对齐以进行整数操作。如果你在撒谎,那么它可以自由地生成错误的代码。是的,它可以发出要求这些对齐或者假设它们已经被处理的指令。是的,它可能会发出带有额外读取和移位的指令。但是它也可能发出不执行它们的指令,因为您告诉它不必这样做。因此,它可能会发出“读取对齐字”指令,该指令在非对齐地址上使用时会引发中断。
有些处理器没有“读取对齐字”指令,只有比没有对齐更快的“读取字”。其他处理器可以配置为抑制陷阱,并转而退回到更慢的“读取字”。但是像ARM这样的处理器将失败。
您不需要逐个字节地复制字节。例如,您可以将每个变量一个接一个地复制到正确对齐的存储器中。
关于使用编译器特定的pragma将POD转换为char*,然后再转换回去……任何依赖于编译器特定pragma正确性的代码(而不是为了效率),显然都不是正确、可移植的C++。有时,“在任何64位小端平台上使用IEEE 64位双精度标准和g++ 3.4或更高版本是正确的”对你的用例来说已经足够了,但这并不等同于实际上是有效的C++。而且,你肯定不能指望它能在32位大端平台上使用80位双精度标准的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上,这将大约需要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);

假设您使用的是任何类型的流(无论是`ifstream`、`FILE*`还是其他类型),它都有一个缓冲区,因此`readFromStream(&f)`将检查是否有`sizeof(float)`可用字节,如果没有,则读取下一个缓冲区,然后将缓冲区的前`sizeof(float)`个字节复制到`f`的地址中。 (实际上,它甚至可能更聪明——例如,如果库实现者认为这是一个好主意,它可以检查您是否仅接近缓冲区的末尾,如果是,则发出异步预读取。)标准不说明它必须如何进行复制。标准库不必运行在除它们所属的实现之外的任何地方,因此您平台的`ifstream`可以使用`memcpy`或`*(float*)`或编译器内置函数或内联汇编——并且它可能会使用在您的平台上最快的方法。
那么,非对齐访问如何帮助您优化或简化它呢?
在几乎所有情况下,选择正确的流,并使用其读写方法,是最有效的读写方式。而且,如果您从标准库中选择了一个流,它也是保证正确的。因此,您拥有最佳的双重优势。
如果您的应用程序有某些特殊之处,使得其他不同的方法更有效——或者如果您是编写标准库的人——那么当然可以继续这样做。只要您(和代码的任何潜在用户)知道您违反了标准并且为什么(而且您确实正在优化事情,而不仅仅是因为“看起来应该更快”),那么这是完全合理的。
您似乎认为将它们放入某种“紧凑结构”中并只写入该结构会有所帮助,但是C++标准没有任何类似于“紧凑结构”的东西。一些实现具有您可以用于此的非标准功能。例如,MSVC和gcc都将在i386上将其压缩为18个字节,您可以将该紧凑结构复制到`char*`以发送到网络,或使用`reinterpret_cast`转换。但它不会与由不理解您编译器的特殊编译指示的相同编译器编译的完全相同的代码兼容。它甚至不兼容相关的编译器,例如ARM的gcc,后者将相同的内容压缩为20个字节。当您使用不可移植的标准扩展时,结果是不可移植的。

2
abarnert:“你似乎认为将它们放入某种“紧凑结构”中会有所帮助——不,那不是我说的,我只是说在我看过的代码库中使用这样的技巧很常见。” - Sami Kenjat
@SamiKenjat:你应该意识到ifstream是有缓冲区的,而且这个缓冲区是由为你的平台编写标准库的人完成的,除非存在一些不寻常的特定于应用程序的问题,否则他们可能会比你做得更好,对吧? - abarnert
2
@abarnet:ofstream 对吧?这是一个 QA 网站,因此示例保持简单 - 玩具示例,在现实生活中,人们通常会序列化 MB 或甚至 GB 的数据,这个 64KB 的 ofstream 缓冲区是无用的,当写入大于缓冲区的内容时,内部 ofstream 缓冲区将被完全忽略,至于另一方面,请还要考虑许多其他方式通过哪些数据可以进入程序(套接字、USB 等),这些输入类型都没有本地缓冲。 - Sami Kenjat
我之所以说“ifstream”,是因为你说“返回是有问题的”,而这就是“ifstream”的问题。但是在两个方向上都是一样的。此外,为什么64KB缓冲区是无用的?当你序列化GB级别的数据时,你仍然会写入8K磁盘块或4K网络发送。无论如何,如果你不喜欢这个缓冲区,你可以用更大的缓冲区替换它,或者从头开始构建自己的缓冲流。你可以为你认为比实现者更好优化的平台编写特定于平台的代码,并为其他所有内容编写可移植的代码。所以,我仍然看不出你的问题在哪里。 - abarnert
让我们在聊天室继续这个讨论。链接 - Sami Kenjat
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接