概念讨论
内存对齐是一个很基础的概念。虽然内存地址的单位是字节,但是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