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

Correct way to serialize binary data in C++

本文关键字:方法 数据 C++ 序列化 二进制      更新时间:2023-10-16

在阅读了下面的1和2 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;
}

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

即使你在处理一个复杂的POD,aligned_storedalignment_of也会给你一个缓冲区,你可以将PODmemcpy放入和取出,将其构建到等等。

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

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

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

是的,但您也指出,它可以假设缓冲区对整数正确对齐。如果你在这方面撒谎,可以免费生成损坏的代码。

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

是的,可以免费发布需要这些对齐或假设它们已经得到处理的指令。

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

是的,它可能会发出带有额外读取和移位的指令。但它也可能发出不执行这些操作的指令,因为你已经告诉它不必执行这些操作。因此,它可能会发出"读取对齐单词"指令,当在不对齐的地址上使用时会引发中断。

有些处理器没有"读取对齐的单词"指令,只是有对齐的"读取单词"比没有对齐的更快。其他人可以被配置为抑制陷阱,转而回到较慢的"读字"。但其他的——比如ARM——将会失败。

假设缓冲区中发生强制转换的位置对齐不一致,那么这个问题的唯一解决方案是将字节1乘1复制吗?也许还有更有效的技术吗?

您不需要将字节1乘1进行复制。例如,您可以将memcpy每个变量一个接一个地放入正确对齐的存储中。(如果你的所有变量都是1字节长的,那么这只会是1乘1复制字节,在这种情况下,你一开始就不会担心对齐…)

至于将POD转换为char*并使用编译器特定的杂注返回……好吧,任何依赖编译器特定杂注来获得正确性(而不是效率)的代码显然都是不正确的、可移植的C++。有时,"在任何具有IEEE 64位双精度的64位小端平台上使用g++3.4或更高版本进行更正"对于您的用例来说已经足够好了,但这与实际有效的C++不同。当然,你不能指望它在32位big-endian平台上与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)将检查是否有sizeof(float)字节可用,如果没有,则读取下一个缓冲区,然后将第一个sizeof(float)字节从缓冲区复制到f的地址。(事实上,它甚至可能更聪明——例如,它允许检查您是否只是接近缓冲区的末尾,如果是,则发出异步预读,如果库实现者认为这是个好主意的话。)标准没有说明它必须如何进行复制。标准库不必在任何地方运行,而是在它们所属的实现上运行,因此您平台的ifstream可以使用memcpy*(float*)、编译器内部程序集或内联程序集,而且它可能会使用您平台上最快的程序集。

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

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

如果你的应用程序有一些特殊之处,使一些不同的东西更高效——或者如果你是编写标准库的人——那么你当然应该继续这样做。只要你(以及你代码的任何潜在用户)知道你在哪里违反了标准以及为什么(你实际上是在优化事情,而不仅仅是因为"看起来应该更快"而做一些事情),这是完全合理的。

你似乎认为能够将它们放入某种"压缩结构"中并编写它会有所帮助,但C++标准没有任何"压缩结构体"这样的东西。有些实现具有非标准特性,您可以使用这些特性。例如,MSVC和gcc都允许您在i386上将上面的内容打包为18个字节,您可以将打包后的结构和memcpyreinterpret_cast带到char *,通过网络发送,不管怎样。但它与由不理解编译器特殊杂注的不同编译器编译的完全相同的代码不兼容。它甚至不会与相关的编译器兼容,比如用于ARM的gcc,后者会将相同的东西打包到20个字节中。当您使用标准的不可移植扩展时,结果是不可移植的。