C 结构体内存布局、对齐机制初探
▲ ◁ ▼ ▷
在初次接触C结构体数据类型时,为了便于初学者的学习和记忆,暂且将结构体类型与基本数据类型视为等同,而不必去理会结构体作为一类数据结构的特性。这类学习路线在一定程度上表现出对初学者的友好,不过也会在深入学习结构体性质的过程中,因为思维惯性的缘故造成挥之不去的干扰,因此即便无需在初学阶段深究结构体特性,也需要强调结构体的一些基础且重要特性等概念,并积极鼓励初学者亲自动手实践加以验证。
当初自己在学习C结构体时,并没有人向我强调结构体内存布局和对齐机制等概念,更别提其中的具体表现和深层次原理,以至于我在后来的实践过程中遇到一些自认为是莫名其妙的偏差和错误时,往往无从下手。在自己的固有印象中,为什么一个大小理应为23字节的结构体变量,实际测量发现却为40字节大小?多余使用的字节中到底存储着怎样的内容?为何需要多余使用这些空间?以及这么做有何意义,这么做是不是在浪费宝贵的内存空间?即便我在初步了解如文章标题中所描述的技术之后,我依旧会被突如其来的思维方式转变而无所适从,也就是说理论无法充分地联系实际。因此我希望再次以一位初学者的视角,初探C结构体内存布局与对齐机制。
◒ ◐ ◕ ◔
请注意,这里所讨论的内存布局是指进程在虚拟内存系统下的直观表现,现代操作系统为用户提供了比物理内存更为高效、安全的上层抽象,允许我们使用统一的内存模型去描述进程,不过这都是题外话了。既然已经知道C结构体在一般情形下会遵循内存对齐的规定,那么这样做到底有何意义?是不是浪费内存空间?对此网上已有较为详细的解释,一是为了跨平台可移植性;二是为了尽可能提升不同处理器对内存访问效率,这里不作过多赘述。一言蔽之,浪费内存空间了但又没完全浪费,为了避免不必要的效能损失,拿空间换时间是惯用做法。相比之下,在一些特定情形中,由用户自身造成的内存浪费往往会比对齐所舍弃的空间要严重得多。
既然在大多数情形中,结构体的内存对齐机制不可避免,那么多余使用的字节中到底存储着怎样的内容?取决于编译器具体实现,但无论如何不会是你所需要的填充内容(padding),不过这对我们来说是无关紧要的,编译器会帮忙处理好这一切。需要重点关注的是:结构体的内存布局是依据何种方式或规则对齐的?归纳起来无外乎是以下三项原则:
结构体的首地址(同时也是结构体中首个成员的起始地址)能够被其最宽基本类型成员的大小所整除(PS:且只可能是1,2,4,8这类宽度的整数倍);
结构体中各成员相对结构体首地址的偏移量(offset)是该成员类型宽度和平台默认对齐宽度中较小值的整数倍;
结构体的整体大小是其最宽基本类型成员大小的整数倍。
如果在对齐的过程中遇到不满足上述原则的情形,则使用字节填充的方式直至满足规则。乍一看这些规则既绕口又抽象,一时间让人无从下手,强行解释反而适得其反,索性结合实例逐条理解也许是个不错的选择。不过在此之前有必要描述有关演示环境的相关信息,以x86-64平台为例,在64位操作系统上,演示所用到的基本数据类型占用字节大小分别如下:
查阅相关资料可知,该环境下默认对齐宽度为8byte。于是我们定义两个结构体类型用以演示,如下所示,结构体BasicType中只包含有基本数据类型的成员,结构体MixedType中包含有更为复杂的组合数据类型:
其中各结构体声明末尾处的注释部分是我依据对齐基本原则计算得到的结构体大小估值,接下来我们通过更加直观的方式来观察内存对齐后的效果与布局,首先是BasicType结构体变量:

和预期中的一样,对齐后的BasicType结构体变量整体大小为24字节,而不是简单地将成员变量类型大小累加之后的23字节,究其原因是在uchar成员变量之后填充了1字节,从上图中uchar与ushort两者之间的起始地址差值(0x7ffff0b39582 - 0x7ffff0b39580)也能看出来,原理是依据原则2,与默认对齐宽度相比,ushort数据类型宽度较小且为2,则要求到结构体首地址的偏移量为2的整数倍,uchar仅需使用1字节,继而填充1字节以满足条件。同时我们也能直接观察得知,结构体首地址与结构体首个成员的起始地址是一致的(0x7ffff0b39580)。以上的描述可能还是比较笼统,所以我用excel表格绘制了一张内存对齐布局的示意图,也仅仅是符合逻辑上的局部,实际物理布局绝非如此:

这样看待对齐局部便能做到一目了然,便于我们从头说起。依据对齐原则1,让我们检查一下结构体变量首地址,0x7ffff0b39580能够被其包含的最宽基本类型大小所整除,此时最宽基本类型与平台默认对齐宽度一致,同为8,最宽基本类型可以是double、unsigned long。成员uchar和ushort的对齐情况上面已经说过,此时uint的偏移量为4,满足原则2,紧接着占用4字节;随后ulong的偏移量为8,满足原则2,紧接着占用8字节,以此类推。最后当dbl也完成对齐并占用字节后,检查此时结构体整体的大小为24,符合原则3,无需再填充额外字节用以对齐。至此,BasicType结构体变量的最终大小为24字节(可视范围)。
让我们继续观察MixedType结构体变量的表现,不出所料,对齐后的MixedType结构体变量整体大小为56字节,首地址(0x7ffff0b39540)同样与其首个成员的起始地址一致,也能够被其包含的最宽基本类型大小所整除。

这里需要着重解释一下,对于像MixedType这类包含组合类型的结构体该如何计算其最宽基本类型大小,并与平台默认对齐宽度作比较。对于结构体中所包含的数组类型成员,其类型宽度就是它自身基本数据类型的宽度,例如成员char chs[n],其类型宽度是char基本数据类型的宽度1;对于结构体中所包含的结构体类型成员,其类型宽度就是它自身所包含的最宽基本数据类型,例如包含在MixedType结构体中的BasicType结构体成员,其类型宽度就是double、unsigned long基本数据类型的宽度8,所以当我们将结构体成员视为整体时,其类型宽度计算如上,除了作为判断最外层结构体的最宽数据类型大小的依据之一以外,对于结构体成员的内存对齐填充具有重要的指示作用。这部分概念确实比较绕口和生涩难懂,但也是解决复杂结构体内存对齐问题的关键所在,值得反复揣摩。
同样给出我用excel表绘制的内存对齐布局示意图,便于理解:

如果你能较为清晰地理解我着重解释的内容,那么在你的脑海当中描绘出以上这张图并不会太难,所要做的无非是搞清楚每一次对齐填充的依据是什么?尽管成员变量integers是以unsigned数组的形式存在,其类型宽度与unsigned基本数据类型一致,在面对占用7字节的成员变量chars给自身所造成的偏移量时,毅然选择了对齐填充1字节,使得自身偏移量为8,满足是自身类型宽度整数倍的要求。当我们刚刚结束对成员变量pstr的安排,打算为结构体成员变量S_basic_type计算其自身偏移量和类型宽度时,请把它当作整体来看待,而不是完全展开后单独为S_basic_type.uchar量身做嫁衣,只有当我们将结构体成员整体安排妥当之后再考虑其家族中个人的事。无论你是否愿意这么做,解决MixedType结构体变量的内存对齐问题确实给我们上了深刻的一节课。

至此我们得到了两种结构体在进行对齐之后的内存布局和最终可视的大小。为何要这么说?原因是在我验证过程中另外观察到了一些细微的误差。其实以上这两个不同类型的结构体变量,是紧挨着进行声明和定义的局部变量,按照C程序的进程内存模型,这两个局部变量是先后相邻着压入进程的栈区,在逻辑上这两者的虚拟地址是非常相近的,实际结果也是如此:

S_basic_type比S_mixed_type更早声明和定义,因此两者的起始地址分别为0x7ffff0b39580,0x7ffff0b39540,非常接近不是吗?此外,由于进程栈区的增长方向是从高位地址向低位生长,S_basic_type比S_mixed_type更早入栈,所以两者起始地址的大小顺序也是符合条件的,似乎并无异样。让我们计算一下两个地址的偏移值,0x7ffff0b39580 - 0x7ffff0b39540 = 0x000000000040,简单点0x40,对应十进制是64,也就是说对应间隙的大小为64字节,让我们再绘制一个栈区的局部示意图:

无需我多说,非常直观。而且可以确定的是,S_mixed_type对齐后的最终大小为56字节,满足各项对齐原则无需额外的8字节继续对齐。那么这多余的8字节从何而来?为何而来?我首先怀疑的是,如果S_mixed_type按照自己的实际大小,向上覆盖这8字节紧挨着S_basic_type,那么S_mixed_type的起始地址将变为0x7ffff0b39548,是否会违背对齐原则1呢?显然0x7ffff0b39540是8的整数倍,0x7ffff0b39540 + 8又何尝不是8的整数倍?所以理论上0x7ffff0b39548是可以充当S_mixed_type的起始地址,因此这额外的8字节大概率是故意为之。那么为何要额外保留这8字节?我个人的猜想是:
由具体平台或编译器具体实现所致,需要以某种固定模式来组织、管理进程栈区所保存的数据;
或是这额外的8字节中保存有S_basic_type结构信息,我们知道C结构体并不是基本数据类型,而更像是一种数据结构,作为数据结构不可避免地需要保留少量的结构信息,这些信息是正确访问这类数据结构的重要组成部分,往往对用户不可见,因此我才将结构体对齐后的最终大小称之为可视大小。
但无论是何种猜想都需要更加深入一系列底层实现,这显然超出了我们的讨论范围,以上这些内容无关文章标题,仅仅是作为验证过程的副产物,因此点到为止。

让我们继续探讨C结构体对齐和内存布局这一话题,还记得上文说过:在某些特定情形中,由用户自身造成的内存浪费往往会比对齐所舍弃的空间要严重得多这句话吗?准确来说是因为用户自身优化原因显著放大了对齐所舍弃的空间,从而造成不必要的内存浪费。下面我用几个例子来说明,而这些例子与上面示例中的结构体相比,仅仅是调整了声明结构体成员的先后顺序,但最终的结果却大相径庭:
在揭晓答案之前可以先自行估算一下,检验一下自己对结构体对齐规则的理解程度。这里我在注释中给出了自己的预估值,所以就不卖关子了,下面分别给出实际结果和布局示意图:




上图给我们的直观感受是调整成员顺序后的结构体在内存中的布局似乎不再像之前那样紧凑,转而出现了许多因对齐填充而导致的内存空间的浪费,更有甚者出现了接近一半的空间浪费。希望在你的脑海中已经拥有了清晰的思路来解释这一现象;以及我们在使用结构体的同时,该如何避免出现这样的情况?我们当然知道,出现这种不必要的空间浪费直观感受便是结构体对齐机制的锅;当我们再往上翻看,只是因为结构体成员声明顺序发生了改变,从而导致了这样的灾变,顺着对齐原则的思路似乎可以得出以下结论,当我们在声明和定义一个结构体时:
结构体类型不等同于基本数据类型,它有自己的定位;
不要轻易无视结构体对齐机制的存在,除非你很清楚自己在做什么,不要擅自改变这一现状;
结构体中各成员的声明顺序将会影响到整体最终的对齐方式、内存布局以及大小;
声明结构体成员时,顺序上,类型宽度较小的成员应先于宽度较大的成员,相同类型或类型宽度相同的成员应尽可能相邻。
以上便是本次对C结构体初探的学习思路、实践过程以及初步结论。作为一名初学者从感性认知再到理性认知的过程必不可少,我也很热衷于反复回顾这些基础理论知识,子曰:温故而知新可以为师矣。当然,讲得有不到位、谬误之处也请批评指正。

参考资料:
https://www.bilibili.com/read/cv12221662 (C语言结构体内存布局问题)
https://blog.csdn.net/Carrot_ly/article/details/118242788 (结构体内存对齐的意义)
