概念讨论
内存对齐是一个很基础的概念。虽然内存地址的单位是字节,但是CPU访存指令大部分都是以机器字长(64位的CPU机器字长即8字节)为单位,并且由于内存提供的访存电路接口限制,CPU只能从机器字长的倍数的地址开始访问。即CPU能够在一条指令中访存0000-0007,但是不能够从0003-0010,而必须拆分成两条访存指令,分别从0000和0008开始。
CPU按机器字长访存容易理解,但是只能从倍数地址开始访问难以让人理解。据参考,倍数地址的限制更多来源于内存访问器的硬件连线限制。
在没有内存对齐的情况下,假设有两个连续数据INT8,INT64,分别占据了地址0000和0001-0008。此时64位CPU需要几次访存才能拿到INT64数据呢?
- 首先访存0000-0007,拿到0001-0007的部分。
- 再次访存0008-0015,拿到0008的部分。
即需要访存两次才能取到数据。因此,通常程序会对数据的布局作出内存对齐,以优化访存效率。例如还是INT8和INT64,对齐后地址分别为0000和0008-0015。这样取INT64就只需要访存一次。
据此,业界给出了一个内存对齐的基本原则:将X字节的数据放在X倍数的起始地址上。这样即可在尽可能节约内存空间的情况下,减少访存指令。
代码分析
再来看看实际中的内存对齐规则是什么。如上基本原则,每个数据会希望把自己放在对齐数倍数的起始地址上:
- 基本类型,对齐数 = sizeof(TYPE) ,即数据类型大小
- struct/class 作为一个类型,对齐数 = 最大的成员对齐数
- struct/class 尾部填充。除了保证内部成员的对齐,由于数组的地址连续,我们还必须保证对于 struct[5] 这样的数组,每一个 struct[i] 的起始地址都是对齐的,因此会在struct的尾部填充字节,并且计算在sizeof内,使得sizeof的结果是struct的对齐数的倍数。
按上述原则分析下面的结构体:
- short f起始为 0000,占据0000-0001,第一个元素不用考虑对齐。
- double s对齐数 = 8,因此对齐后占据 0008-0015。
- char i对齐数 = 1,因此对齐后占据 0016。
- int c对齐数 = 4,因此对齐后占据 0020-0023。
- char a对齐数 = 1,因此对齐后占据 0024。
- 尾部填充。在没有进行尾部填充时,目前S1的大小为25字节(0000-0024)。S1的对齐数
= 最大的成员对齐数 =
8,根据对齐原则sizeof(S1)应该为8的倍数,所以最终将大小从25扩到32。
| 1 | struct S1 | 
再来看嵌套结构体:
- int d起始为 0000,占据 0000-0003,第一个元素不用考虑对齐。
- S1 s1对齐数为 8,大小为32字节,因此对齐后占据 0008-0039。
- char c对齐数为 1,因此对齐后占据 0040。
- int i对齐数为 4,因此对齐后占据 0044-0047。
- 尾部填充,S2 对齐数为 8,目前大小为48字节,满足要求,不需要尾部填充。
| 1 | struct S2 | 
class和struct的对齐规则是一样的,不过需要注意两点:
- 继承了基类成员的class,其会在头部先排基类成员。
- 拥有虚函数的class其会在头部添加一个指针成员vfptr,即虚函数表的指针。
餐后甜点
- cpp中可以通过#pragma pack(max_size)命令强制设置一个对齐数的上限,例如在#pragma pack(4)的环境下S1中的double的对齐数会变为4。
- cpp中空class的大小为1,便于实例化。
- 其他更详细的class在继承多态下的成员分析,请参考这位病毒种的Blog