为什么C编译器不能重新排列结构成员以消除对齐填充?

Why can't C compilers rearrange struct members to eliminate alignment padding?

本问题已经有最佳答案,请猛点这里访问。

Possible Duplicate:
Why doesn't GCC optimize structs?
Why doesn't C++ make the structure tighter?

在32位x86计算机上考虑以下示例:

由于对齐约束,以下结构

1
2
3
4
5
6
7
struct s1 {
    char a;
    int b;
    char c;
    char d;
    char e;
}

如果成员按中的顺序重新排序,则可以更有效地表示内存(12字节对8字节)

1
2
3
4
5
6
7
struct s2 {
    int b;
    char a;
    char c;
    char d;
    char e;
}

我知道C/C++编译器是不允许这样做的。我的问题是为什么语言是这样设计的。毕竟,我们最终可能会浪费大量的内存,而像struct_ref->b这样的引用则不会在意这些差异。

编辑:感谢你们所有人提供的非常有用的答案。你很好地解释了为什么由于语言的设计方式,重新排列不起作用。然而,这让我想:如果重新排列是语言的一部分,这些论点是否仍然有效?假设有一个特定的重新排列规则,我们至少需要它

  • 我们只应在实际需要时重新组织结构(如果结构已经"紧",则不做任何操作)
  • 规则只查看结构的定义,而不查看内部结构。这将确保结构类型具有相同的布局,无论它是否在其他结构中是内部的。
  • 给定结构的已编译内存布局可以根据其定义(即规则是固定的)进行预测。
  • 一个接一个地解决你的论点,我的理由是:

    • 低级数据映射,"最不令人惊讶的元素":只需自己用紧凑的样式编写结构(如@perry的答案),就不会有任何变化(要求1)。如果出于某种奇怪的原因,您希望内部填充在那里,那么可以使用虚拟变量手动插入它,和/或可能有关键字/指令。

    • 编译器差异:需求3消除了这个问题。实际上,从@david heffernan的评论中,我们今天似乎遇到了这个问题,因为不同的编译器的pad不同?

    • 优化:重新排序的关键是(内存)优化。我在这里看到了很多潜力。我们可能无法一起删除填充,但我不知道重新排序如何以任何方式限制优化。

    • 打字:在我看来,这是最大的问题。不过,还是有办法解决这个问题。由于规则在语言中是固定的,所以编译器能够确定成员是如何重新排序的,并做出相应的反应。如上所述,在需要完全控制的情况下,始终可以防止重新排序。另外,要求2确保类型安全代码永远不会中断。

    我认为这样的规则有意义的原因是,我发现按结构成员的内容而不是按其类型分组更自然。而且,对于编译器来说,选择最佳顺序比在我有很多内部结构时更容易。最佳布局甚至可能是我无法用类型安全的方式表达的布局。另一方面,它似乎会使语言更加复杂,这当然是一个缺点。

    请注意,我并不是在讨论更改语言——只有当它(应该)的设计有所不同时。

    我知道我的问题是假设性的,但我认为讨论提供了对机器和语言设计较低层次的更深入的理解。

    我是新来的,所以我不知道我是否应该为此提出一个新问题。如果是这样的话,请告诉我。


    C编译器无法自动重新排序字段有多种原因:

    • C编译器不知道struct是否表示当前编译单元以外的对象的内存结构(例如:外部库、磁盘上的文件、网络数据、CPU页表等)。在这种情况下,数据的二进制结构也在编译器无法访问的地方定义,因此重新排序struct字段将创建与其他定义不一致的数据类型。例如,zip文件中的文件头包含多个未对齐的32位字段。重新排序字段将使C代码无法直接读取或写入头(假设zip实现希望直接访问数据):

      1
      2
      3
      4
      5
      6
      struct __attribute__((__packed__)) LocalFileHeader {
          uint32_t signature;
          uint16_t minVersion, flag, method, modTime, modDate;
          uint32_t crc32, compressedSize, uncompressedSize;
          uint16_t nameLength, extraLength;
      };

      packed属性防止编译器根据字段的自然对齐方式来对齐字段,与字段排序问题无关。可以对LocalFileHeader的字段进行重新排序,使结构具有最小的大小,并且所有字段都与它们的自然对齐。但是,编译器不能选择重新排序字段,因为它不知道结构实际上是由zip文件规范定义的。

    • C是一种不安全的语言。C编译器不知道是否将通过与编译器看到的不同类型访问数据,例如:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      struct S {
          char a;
          int b;
          char c;
      };

      struct S_head {
          char a;
      };

      struct S_ext {
          char a;
          int b;
          char c;
          int d;
          char e;
      };

      struct S s;
      struct S_head *head = (struct S_head*)&s;
      fn1(head);

      struct S_ext ext;
      struct S *sp = (struct S*)&ext;
      fn2(sp);

      这是一种广泛使用的低级编程模式,尤其是当头包含位于头后面的数据类型ID时。

    • 如果一个struct类型嵌入到另一个struct类型中,则不可能内联内部struct

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      struct S {
          char a;
          int b;
          char c, d, e;
      };

      struct T {
          char a;
          struct S s; // Cannot inline S into T, 's' has to be compact in memory
          char b;
      };

      这也意味着将一些字段从S移动到单独的结构将禁用一些优化:

      1
      2
      3
      4
      5
      6
      7
      // Cannot fully optimize S
      struct BC { int b; char c; };
      struct S {
          char a;
          struct BC bc;
          char d, e;
      };
    • 由于大多数C编译器都在优化编译器,因此重新排序结构字段需要实现新的优化。这些优化是否能够做得比程序员能够写的更好,这是值得怀疑的。手工设计数据结构比其他编译器任务(如寄存器分配、函数内联、常量折叠、将switch语句转换为二进制搜索等)花费的时间要短得多,因此允许编译器优化数据结构所获得的好处似乎比传统的compi要少。LER优化。


    C的设计和意图是使用高级语言编写非便携式硬件和格式化相关代码成为可能。重新安排程序员背后的结构内容会破坏这种能力。

    注意netbsd的ip.h中的实际代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    /*
     * Structure of an internet header, naked of options.
     */

    struct ip {
    #if BYTE_ORDER == LITTLE_ENDIAN
        unsigned int ip_hl:4,       /* header length */
                 ip_v:4;        /* version */
    #endif
    #if BYTE_ORDER == BIG_ENDIAN
        unsigned int ip_v:4,        /* version */
                 ip_hl:4;       /* header length */
    #endif
        u_int8_t  ip_tos;       /* type of service */
        u_int16_t ip_len;       /* total length */
        u_int16_t ip_id;        /* identification */
        u_int16_t ip_off;       /* fragment offset field */
        u_int8_t  ip_ttl;       /* time to live */
        u_int8_t  ip_p;         /* protocol */
        u_int16_t ip_sum;       /* checksum */
        struct    in_addr ip_src, ip_dst; /* source and dest address */
    } __packed;

    该结构在布局上与IP数据报的报头相同。它用于直接将以太网控制器发出的内存块解释为IP数据报头。想象一下,如果编译器在作者的领导下随意地重新安排内容,那将是一场灾难。

    是的,它不是完全可移植的(甚至还有通过__packed宏给出的不可移植的gcc指令),但这不是重点。C是专门设计用来为驱动硬件编写非便携式高级代码的。这就是它在生活中的作用。


    C(和C++)被视为系统编程语言,因此它们通过指针提供对硬件的低级别访问,例如存储器。程序员可以访问一个数据块并将其强制转换为一个结构,并且[很容易]访问各种成员。

    另一个例子是像下面这样的结构,它存储可变大小的数据。

    1
    2
    3
    4
    struct {
      uint32_t data_size;
      uint8_t  data[1]; // this has to be the last member
    } _vv_a;


    我不是14工作组的成员,我不能说任何明确的话,但我有自己的想法:

  • 这将违反最不令人惊讶的原则——可能有一个该死的好理由,为什么我要将元素按特定的顺序排列,不管它是否是最节省空间的,我不希望编译器重新排列这些元素;

  • 它有可能破坏大量现有代码——有很多遗留代码依赖于诸如结构的地址与第一个成员的地址相同之类的东西(看到了许多做出这种假设的经典MacOS代码);

  • C99基本原理直接解决第二点("现有代码很重要,现有实现不重要"),间接解决第一点("信任程序员")。


    它将改变指针操作的语义来重新排序结构成员。如果您关心紧凑的内存表示,作为一个程序员,您有责任了解您的目标体系结构,并相应地组织您的结构。


    如果您在C结构中读/写二进制数据,那么重新排序struct成员将是一个灾难。例如,没有实际的方法来实际地从缓冲区填充结构。


    结构用于表示最低级别的物理硬件。因此,编译器不能将对象移动到适合该级别的位置。

    但是,如果有一个杂注让编译器重新安排纯粹基于内存的结构,而该结构只在程序内部使用,这并不不合理。但是我不知道这样的野兽(但这并不意味着蹲下——我与C/C++失去联系)


    请记住,变量声明(如结构)被设计为变量的"公共"表示。它不仅被编译器使用,而且也可用于其他编译器,以表示该数据类型。它可能会以.h文件结尾。因此,如果编译器要自由地组织结构中的成员,那么所有编译器都必须能够遵循相同的规则。否则,正如前面提到的,指针算法会在不同的编译器之间混淆。


    这是我至今没有看到的一个原因——如果没有标准的重新排列规则,它将破坏源文件之间的兼容性。

    假设一个结构在头文件中定义,并在两个文件中使用。这两个文件都是单独编译的,稍后链接。编译可能在不同的时间(可能您只接触了一次,所以必须重新编译),也可能在不同的计算机上(如果文件在网络驱动器上),甚至是不同的编译器版本上。如果在同一时间,编译器决定重新排序,而在另一时间,它不会,这两个文件将不会在字段的位置上达成一致。

    举个例子,我们可以考虑一下stat系统调用和struct stat系统调用。在安装Linux时(例如),您会得到libc,其中包括stat,它是由某人编译的。然后使用编译器和优化标志编译应用程序,并期望两者在结构的布局上达成一致。


    您的案例非常具体,因为它需要重新订购struct的第一个元素。这是不可能的,因为在struct中首先定义的元素必须始终位于偏移量0处。如果允许的话,很多(伪造的)代码会被破坏。

    通常情况下,位于同一较大对象内的子对象的指针必须始终允许指针比较。我可以想象,如果您颠倒顺序,一些使用此功能的代码将会中断。对于这种比较,编译器在定义点上的知识是没有帮助的:指向子对象的指针没有"标记"来标记它所属的更大的对象。当这样传递给另一个函数时,可能上下文的所有信息都将丢失。


    假设你有一个A.H标题

    1
    2
    3
    4
    5
    6
    7
    struct s1 {
        char a;
        int b;
        char c;
        char d;
        char e;
    }

    这是一个单独的库的一部分(其中只有由未知编译器编译的二进制文件),您希望使用这个结构与这个库通信,

    如果允许编译器以任意方式重新排序成员,这将是不可能的,因为客户端编译器不知道是按原样使用结构,还是按优化的方式使用结构(然后,b是在前面还是在后面),或者甚至完全填充每个按4字节间隔对齐的成员

    要解决这个问题,您可以定义一个用于压缩的确定性算法,但这需要所有编译器来实现它,并且该算法是一个很好的算法(从效率方面来说)。在填充规则上达成一致比重新排序更容易。

    很容易添加一个#pragma,它禁止在需要特定结构的布局时进行优化,这正是您所需要的,因此这不是问题。