编译器的结构重新排序

Struct Reordering by compiler

本文关键字:排序 新排序 结构 编译器      更新时间:2023-10-16

假设我有一个这样的结构:

struct MyStruct
{
  uint8_t var0;
  uint32_t var1;
  uint8_t var2;
  uint8_t var3;
  uint8_t var4;
};

这可能会浪费一堆(不是一吨)空间。 这是因为uint32_t变量的必要对齐。

实际上(在对齐结构以便它可以实际使用 uint32_t 变量之后),它可能看起来像这样:

struct MyStruct
{
  uint8_t var0;
  uint8_t unused[3];  //3 bytes of wasted space
  uint32_t var1;
  uint8_t var2;
  uint8_t var3;
  uint8_t var4;
};

更有效的结构是:

struct MyStruct
{
  uint8_t var0;
  uint8_t var2;
  uint8_t var3;
  uint8_t var4;
  uint32_t var1;
};

现在,问题是:

为什么编译器(根据标准)禁止对结构进行重新排序?

如果结构被重新排序,我看不出有什么办法可以

搬起石头砸自己的脚。

为什么编译器(根据标准)禁止对结构进行重新排序?

基本原因是:为了与 C 兼容。

请记住,C 最初是一种高级汇编语言。在 C 中,通过将字节重新解释为特定struct来查看内存(网络数据包等)是很常见的。

这导致多个功能依赖于此属性:

  • C 保证struct的地址和它的第一个数据成员的地址是相同的,所以C++也是如此(在没有virtual继承/方法的情况下)。

  • C 保证,如果你有两个struct AB,并且都以数据成员char开头,后跟一个数据成员int(以及之后的任何内容),那么当你将它们放入union时,你可以编写B成员并通过其A成员读取charint, C++也是如此:标准布局。

后者非常广泛,并且完全阻止了大多数struct(或class)的数据成员的任何重新排序。


请注意,该标准确实允许一些重新排序:由于 C 没有访问控制的概念,C++指定未指定具有不同访问控制说明符的两个数据成员的相对顺序。

据我所知,没有编译器试图利用它;但理论上他们可以。

在C++之外,像 Rust 这样的语言允许编译器对字段重新排序,而主 Rust 编译器 (rustc) 默认这样做。只有历史决策和对向后兼容性的强烈愿望才能阻止C++这样做。

如果结构被重新排序,我看不出有什么办法可以搬起石头砸自己的脚。

真?如果允许这样做,默认情况下,即使在同一个进程中,库/模块之间的通信也是非常危险的。

"在宇宙中"的论点

我们必须能够知道我们的结构是按照我们要求的方式定义的。填充未指定已经够糟糕的了!幸运的是,您可以在需要时控制它。

好的,从理论上讲,可以制作一种新的语言,这样,除非给出一些属性,否则成员也是可重新排序的。毕竟,我们不应该对对象进行内存级别的魔法,所以如果只使用C++习语,默认情况下你是安全的。

但这不是我们生活的实际现实。


"走出宇宙"的论点

用你的话来说,如果"每次都使用相同的重新排序",你可以让事情变得安全。措辞必须明确说明成员的顺序。这在标准中编写起来很复杂,理解起来很复杂,实施起来也很复杂。

保证顺序与

代码中的顺序相同,并将这些决定留给程序员要容易得多。请记住,这些规则起源于旧的 C,而旧的 C 赋予程序员权力。

您已经在问题中展示了通过简单的代码更改使结构填充高效是多么容易。无需在语言级别增加任何复杂性即可为您执行此操作。

该标准保证了分配顺序,因为结构可以表示特定的内存布局,例如数据协议或硬件寄存器的集合。例如,程序员和编译器都不能自由地重新排列TPC/IP协议中的字节顺序或微控制器的硬件寄存器。

如果顺序不能保证,structs将是纯粹的抽象数据容器(类似于C++向量),我们不能假设太多,除了它们以某种方式包含我们放入其中的数据。在进行任何形式的低级编程时,这将使它们更加无用。

编译器

应保持其成员的顺序,以防结构被另一个编译器或其他语言生成的任何其他低级代码读取。假设您正在创建一个操作系统,并且您决定用 C 编写其中的一部分,用汇编编写一部分。您可以定义以下结构:

struct keyboard_input
{
    uint8_t modifiers;
    uint32_t scancode;
}

将此传递给程序集例程,在该例程中需要手动指定结构的内存布局。您希望能够在具有 4 字节对齐的系统上编写以下代码。

; The memory location of the structure is located in ebx in this example
mov al, [ebx]
mov edx, [ebx+4]

现在假设编译器将以实现定义的方式更改结构中成员的顺序,这意味着根据您使用的编译器和传递给它的标志,您可能最终会得到 al 中 scancode 成员的第一个字节,或者使用修饰符成员。

当然,问题不仅归结为带有汇编例程的低级接口,而且如果用不同的编译器构建的库会相互调用(例如,使用 windows API 使用 mingw 构建程序),也会出现问题。

正因为如此,语言只是迫使你考虑结构布局。

请记住,不仅自动重新排序元素以改进打包可能会损害特定的内存布局或二进制序列化,而且程序员可能已经仔细选择了属性的顺序,以使常用成员的缓存位置相对于更罕见访问的成员有益。

丹尼斯·里奇(Dennis Ritchie)设计的语言不是根据行为来定义结构的语义,而是根据内存布局来定义结构的语义。 如果结构 S 在偏移量 X 处具有 T 类型的成员 M,则 M.S 的行为被定义为获取 S 的地址,向其添加 X 字节,将其解释为指向 T 的指针,并将由此标识的存储解释为左值。 编写结构成员将更改其关联存储的内容,更改成员存储的内容将更改成员的值。 代码可以自由使用各种方法来操作与结构成员关联的存储,并且语义将根据对该存储的操作来定义。

代码可以操纵与结构关联的存储的有用方法之一是使用 memcpy() 将一个结构的任意部分复制到另一个结构的相应部分,或使用 memset() 清除结构的任意部分。 由于结构成员是按顺序布置的,因此可以使用单个 memcpy() 或 memset() 调用复制或清除一系列成员。

标准委员会定义的语言在许多情况下消除了对结构成员的更改必须影响底层存储的要求,或者对存储的更改会影响成员值的要求,使得结构布局的保证不如 Ritchie 的语言有用。 尽管如此,使用 memcpy() 和 memset() 的能力被保留了下来,保持这种能力需要保持结构元素的顺序。

你也引用了C++,所以我会给你一个实际的理由,为什么这不会发生。

鉴于 classstruct 之间没有区别,请考虑:

class MyClass
{
    string s;
    anotherObject b;
    MyClass() : s{"hello"}, b{s} 
    {}
};

现在C++要求非静态数据成员按照声明的顺序进行初始化:

— 然后,非静态数据成员按其顺序初始化 在类定义中声明

根据 [ base.class.init/13 ]。因此,编译器无法对类定义中的字段重新排序,因为否则(例如)依赖于其他初始化的成员将无法工作。

编译器并没有严格要求不要在内存中对它们进行重新排序(对于我可以说的)——但是,特别是考虑到上面的例子,跟踪它会非常痛苦。与填充不同,我怀疑任何性能改进。

想象一下,这个结构布局实际上是"通过网络"接收的内存序列,比如以太网数据包。 如果编译器重新调整内容以提高效率,那么您将不得不按所需的顺序提取字节,而不是仅使用以正确的顺序和位置具有所有正确字节的结构。