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



可能重复:
为什么不';GCC是否优化结构
为什么不';C++是否使结构更紧密?

考虑以下32位x86机器上的示例:

由于对齐限制,以下结构

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

如果像中那样对成员进行重新排序,则可以更有效地表示内存(12字节对8字节)

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

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

编辑:感谢大家提供的非常有用的答案。你很好地解释了为什么由于语言的设计方式,重新排列不起作用。然而,这让我思考:如果重排是语言的一部分,这些论点还会成立吗?假设有一些特定的重排规则,我们至少需要

  1. 只有在实际需要时,我们才应该重新组织结构(如果结构已经"紧密",则不要执行任何操作)
  2. 该规则只查看结构的定义,而不查看内部结构。这确保了一个结构类型具有相同的布局,无论它是否在另一个结构中是内部的
  3. 给定结构的编译内存布局在给定其定义的情况下是可预测的(也就是说,规则是固定的)

逐一解决你的论点我的理由:

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

  • 编译器差异:要求3消除了此问题。事实上,从@David Heffernan的评论来看,我们今天似乎遇到了这个问题,因为不同的编译器的填充方式不同?

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

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

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

请注意,我不是在谈论改变语言——只有在它可以(/应该)以不同的方式设计的情况下。

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

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

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

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

    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编译器不知道数据是否会通过与编译器看到的类型不同的类型访问,例如:

    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:

    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移动到一个单独的结构会禁用一些优化:

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

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

观察NetBSD的ip.h:中的实际代码


/*
 * 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是专门设计的,使其能够编写用于驱动硬件的非便携式高级代码。这就是它在生活中的作用。

作为WG14的成员,我不能说任何明确的话,但我有自己的想法:

  1. 这违反了最不意外的原则——也许有一个非常好的理由让我想按特定的顺序排列元素,不管它是否是最节省空间的,而且我不希望编译器重新排列这些元素;

  2. 它有可能打破大量现有代码——有很多遗留代码依赖于结构的地址与第一个成员的地址相同(看到了很多做出这种假设的经典MacOS代码);

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

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

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

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

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

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

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

结构用于表示最低级别的物理硬件。因此,编译器无法将事物移动一轮以适应该级别。

然而,使用#pragma让编译器重新排列仅在程序内部使用的纯基于内存的结构并不是没有道理的。然而,我不知道有这样的野兽(但这并不意味着蹲着-我与C/C++脱节)

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

更一般地说,位于同一个较大对象内的子对象的指针必须始终允许指针比较。我可以想象,如果你颠倒顺序,一些使用这个功能的代码会崩溃。对于这种比较,编译器在定义时的知识不会有帮助:指向子对象的指针没有"标记",它属于哪个更大的对象。当传递给另一个函数时,可能上下文的所有信息都会丢失。

到目前为止我没有看到一个原因-如果没有标准的重排规则,它会破坏源文件之间的兼容性

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

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

假设您有一个带有的标头a.h

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

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

如果允许编译器以其喜欢的任何方式对成员进行重新排序,这将是不可能的,因为客户端编译器不知道是按原样使用结构还是优化结构(然后b是在前面还是后面),甚至不知道每个成员都按4字节的对齐

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

添加一个禁止优化的#pragma很容易,因为当你需要的时候,特定结构的布局正是你所需要的,所以

没有问题

最新更新