在C语言中,结构体的大小会因为变量的重新排列而发生变化。

5

我试图理解当我移动结构体变量时,为什么结构体的大小会发生变化。我知道其中涉及到填充,但是它在后台具体做了什么并不明显。

struct test1 {
    long y;
    int a;
    short int b;
    short int t;
}

sizeof(struct test1) = 16

struct test2 {
    long y;
    short int b;
    int a;
    short int t;
}

sizeof(struct test2) = 24

struct test3 {
    int a;
    long y;
    short int b;
    short int t;
}

sizeof(struct test3) = 24

我知道test1的大小为8+(4+2+2),没有填充, 但我不明白为什么test2没有返回相同的结果,即8+(2+4+2)没有填充。
第三个测试test3中我们看到int占用4字节+4个填充字节,long占用8字节,short int占用2字节+2字节+4个填充字节。
如果test3可以使两个short int变得连续,为什么test2不能使short int、int和short int变得连续?
这是否意味着我们应该始终重新排序结构体成员以最小化填充?
所以,与test2和test3相比,test1声明总是更好的吗?
编辑: 作为后续问题,
struct test4 {
    char asdf[3];
    short int b;
};

sizeof(struct test4) = 6

短整型(short int)不应该像数组大小为3的字符(char)一样填充到4字节吗?


4
通常规则是按照尺寸从大到小的顺序,先放置较大的成员,再放置较小的成员,这样编译器就不会为了对齐成员而添加不必要的填充。 - David C. Rankin
1
@DavidRankin-ReinstateMonica:不,一般规则是先放置具有最严格对齐要求的成员。将较大的成员放在前面并不能帮助它们,而且可能会损害它们,特别是当它们是具有低对齐要求的结构体、字符数组等时。 - Eric Postpischil
1
你在吹毛求疵,Eric。"larger" 的意思就是这个,不是字面上的“最大”,char buf[4000] 是这种类型最小的尺寸,而 uint64_t 则更大一些。 - David C. Rankin
3个回答

11
背景中发生的是对齐操作。对齐是数据类型具有地址可被一些单位整除的要求。如果该对齐单元是类型本身的大小,则这是C兼容实现中存在的最严格对齐。
即使需求不来自目标硬件,C编译器通常也会确保结构布局中的某些对齐。
例如,如果我们有一个长为4个字节,紧接着是2个字节短的结构体成员,那么该短成员可以立即放置在长成员之后,因为4字节偏移量足够用于2字节类型的对齐。然后,这两个成员之后的偏移量为6。但是,您的编译器不认为6是适合4字节int类型的对齐方式,它想要4的倍数。于是,插入了两个字节的填充来将该int移到偏移8。
当然,实际数字是特定于编译器的。您必须了解自己的类型大小和对齐要求和规则。
如果最小化结构体大小在您的应用程序中很重要,那么您必须将成员从最严格对齐到最少对齐排序。如果最小结构大小不重要,则您不必关心此问题。
其他考虑因素可能会起作用,例如与外部强制布局的兼容性。
另外一个考虑是增量增长。如果在多个版本上维护公共使用的结构体(由编译代码的众多实例引用,例如可执行文件和动态库),通常只需要在末尾添加新成员。在这种情况下,即使我们希望如此,也无法获得最小大小的最佳顺序。
不应该将short int填充到4字节,因为char [4]数组之后的一个字节填充将偏移量设置为4。该偏移量足够放置两个字节的短类型。此外,在该短型之后不需要填充。为什么? 短型之后的偏移量为6。结构体中对齐要求最严格的成员是该短型,其对齐要求为2。6可以被2整除。下面是需要翻译的内容:

下面这个例子展示了在两个字节短整型后面加上对齐的情况:struct { long x; short y; },假设 long 占 4 个字节。或者假设占 8 个字节也无所谓。 如果我们把 2 个字节的 short 放在 8 个字节的 long 后面,那么结构体的大小就是 10 个字节。如果我们声明一个这个结构体的数组 aa[1].x 将会距离数组基础偏移量为 10 个字节:也就是说,x 的对齐不正确。最严格对齐要求的结构成员是 x,其对齐要求与其大小相同(比如说 8)。因此,为了保证数组对齐,结构体的大小必须填充至 8 的倍数,这样就需要在结构体末尾填充 6 个字节。

本质上,在成员之前填充是为了保证该成员的对齐,而在结构末尾填充是为了使得所有成员都能够在数组中对齐,这取决于最严格对齐要求的成员。

在某些平台上,对齐是硬件要求!如果一个 4 字节的数据类型被访问的地址不能被 4 整除,则会引发 CPU 异常。在某些这样的平台上,操作系统可以处理这个异常,以软件的方式实现不正确的访问,而不是向进程传递潜在的致命信号。这种访问非常昂贵,可能需要大约数百条指令。我记得在 Linux 的 MIPS 版本中,这是一个针对每个进程的选项:可以为一些非可移植程序(比如开发给 Intel x86 平台的程序)打开处理不正确访问的选项,而对于那些只是由于某种破坏性错误(比如未初始化的指针恰巧指向有效内存,但在不正确的地址处)而执行了不正确访问的程序,则不需要打开这个选项。

在一些平台上,硬件会处理不正确的访问,但与对齐访问相比,代价仍然有点高。例如,可能需要进行两次内存访问,而不是一次。

C 语言编译器倾向于在分配结构体成员和变量时强制执行对齐,即使目标机器不强制执行对齐。这可能是为了各种原因,比如性能和兼容性。


2

由于填充和对齐,大小不同。

此外,这是否意味着我们应该始终确保重新排序结构成员以最小化填充?

可能没有必要,只需要使用packed属性,所有这些都将是相同的16字节。假设使用gcc。

struct __attribute__((__packed__)) test2 {
    long y;
    short int b;
    int a;
    short int t;
};

然而,大小和速度之间存在权衡:

当一个结构体被打包时,这些填充字节不会被插入。编译器必须生成更多的代码(运行速度较慢)来提取非对齐的数据成员,并且写入它们也需要更多的代码。

什么是C语言中的“打包”结构体?


2
除了未对齐的负载会影响程序性能,不是吗? - Jerry Jeremiah
attribute((packed)) 真的很有用!但这是否意味着我应该在每个结构体中都使用它? - jimmyhuang0904
@jimmyhuang0904 不是因为编译器而添加填充,而是为了保持值对其自然地址边界的对齐。根据平台的不同,访问未对齐的字段可能会导致性能下降。如果您想最小化填充,请按照从大到小的顺序排序成员。 - Matteo Italia
1
@jimmyhuang0904 不要这样做!你应该将元素放置在最佳位置,按大小顺序排列,先放大的。只有当你真正需要并知道为什么需要它时,才使用那个属性。 - Sami Kuhmonen
谢谢!我现在掌握了最佳代码实践。我只是不得不打起魔鬼的辩护人,质疑一下,以确保我完全理解它。 - jimmyhuang0904

2
为了正确对齐数据:
  • long 应该从8的倍数地址开始
  • int 应该从4的倍数地址开始
  • short 应该从2的倍数地址开始

Test1 没有填充,将所有内容都对齐了:

+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|           y           |     a     |  b  |  t  |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

Test2需要在b后添加填充,以便a位于4字节边界上。但编译器希望结构体是8字节的倍数,这样它就可以被细分(例如,在数组中),因此它在结尾处添加了填充:

+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|           y           |  b  |  p  |     a     |  t  |        p        |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

Test3需要在a之后添加填充,以便y处于8字节边界上。但是,编译器希望结构体的大小是8字节的倍数,这样它可以被分割(例如,在数组中),因此它会在结尾添加填充:

+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|     a     |     p     |           y           |  b  |  t  |     p     |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

我认为你关心的是结构体末尾的填充。如果您要创建其中一个数组,则编译器必须在每个数组元素中放置填充,以便成员在每个索引处正确对齐,但由于第一个数据元素必须具有与结构相同的地址,因此编译器将额外的填充添加到结构末尾。如果您只会有这些类型的离散变量,则可能不需要这样做,但对于编译器来说,这可能更容易无条件地完成。

这个视觉效果很棒!感谢您的见解! - jimmyhuang0904

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接