4

在阅读了以下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++ 而言,上面提供的答案似乎表明这都是未定义的行为。

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

此外,多年来我见过许多完全由 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;
}
4

1 回答 1

7

首先,您可以使用 std::aligned_storage::value>::type 代替 char[sizeof(int)] (或者,如果您没有 C++ 11,可能有类似的编译器特定的功能)。

即使您正在处理一个复杂的 POD,aligned_stored并且alignment_of会给您一个缓冲区,您可以memcpy将 POD 放入和取出,将其构造成等等。

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

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

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

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

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

是的,可以自由发布需要这些对齐的指令,或者假设它们已经被处理好。

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

是的,它可能会发出带有额外读取和移位的指令。但它也可能会发出不这样做的指令,因为你告诉它它不必这样做。因此,它可以发出“读取对齐字”指令,当用于非对齐地址时会引发中断。

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

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

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

至于将 POD 转换为 char* 并使用编译器特定的编译指示返回……嗯,任何依赖编译器特定编译指示来确保正确性(而不是效率)的代码显然都是不正确的、可移植的 C++。有时“在具有 IEEE 64 位双精度的任何 64 位 little-endian 平台上使用 g++ 3.4 或更高版本正确”对于您的用例来说已经足够了,但这与实际上是有效的 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 上,它只需要大约 8 倍的时间int k = *i,但在 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 个字节,并且您可以将打包的结构和memcpyreinterpret_cast通过char *网络发送,无论如何。但它与由不理解您的编译器特殊编译指示的不同编译器编译的完全相同的代码不兼容。它甚至不兼容相关的编译器,比如 ARM 的 gcc,它将相同的东西打包成 20 个字节。当您使用标准的不可移植扩展时,结果是不可移植的。

于 2012-11-09T05:28:36.883 回答