字节对齐简介
1. 基本原则
任何K字节的基本对象的地址必须是K的倍数。
基本对象:int、double、long、short等基本数据类型,不包括struct、union、class。
2. 结构体的字节对齐
一个结构体,首地址必须是结构体中最大基本元素长度的整数倍。
例如:struct S1 { int i; char c; int j; };
设S1的首地址为xp,对于S1来说,最大的基本元素为int,所以**xp**必须是4的整数倍,称S1满足4字节对齐。
解释
回顾字节对齐的基本原则:任何K字节基本对象的地址必须是K的整数倍。对于一个结构体,要满足这个要求又有两个方面:
(1)结构体内部任何K字节的基本对象相对于结构体首地址的偏移,必须是K的整数倍。
对于S1来说,结构体内部各基本对象相对首地址偏移如下:
struct S1
{
int i; //0
char c; //4
int j; //5
};
显然,j的偏移5并不是j的长度(int 4字节)的整数倍。所以需要在char c后面补充3个字节进行对齐,对齐后的结构体如下:
struct S1
{
int i; //0
char c; //4
char gap[3];
int j; //8
};
至此,结构体满足内部所有基本对象的偏移都是对象大小的整数倍。
(2)在满足第一个要求后,只要满足S1首地址为S1中最大基本对象长度的整数倍,即可满足字节对齐的要求。
对于S1中的对象“j”,其相对于S1的偏移为8,可以被“j”的大小4整除,所以只要S1的首地址可以被4整除,那“j”的地址一定能被4整除;相反,S1的首地址不能被4整除,那“j”的地址也一定不能被4整除。
此外,能被4整除就一定能被2整除,也一定能被1整除。所以只要结构体首地址能被其中最大对象的长度整除,那就能被所有对象的长度整除。
隐含条件:
至此,考虑另外一种情况:
struct S2
{
int i; //0
int j; //4
char c; //8
};
当前结构体内部元素的偏移满足条件(1),看似没什么问题。但是,当定义变量S2 a[2];
时可以发现如果a的地址为xs,那么a[1]的地址为xs + 9,显然不能被4整除,违背了条件(2)。所以第条件(2)有一个隐含条件:结构体的长度要能被其中最大对象的长度整除。
于是需要在S2的对象“c”后面添加3个字节,使S2的总长度变为12,如下所示:
struct S2
{
int i; //0
int j; //4
char c; //8
char gap[3];
};
3. C语言 #pragma pack N 的功能
在C语言中,当使用了#pragma pack N后(不使用时N为默认值)字节对齐的规则为:
任何K字节基本对象的地址必须是K和N之间较小者的整数倍。
例如:
#pragma pack 2
struct S1
{
int i; //0
char c; //4
char gap;
int j; //6(能被2整除就行了)
};
#pragma pack()
测试用例:
#pragma pack (2)
typedef struct
{
int i;
char c;
int j;
}S1;
#pragma pack()
int main()
{
S1 a;
char* p;
int len;
len = sizeof(a); //观察不同字节对齐对于结构体长度的影响
memset(&a, 0, len);
a.i = 1;
a.c = 1;
a.j = 1;
p = (char*)&a; //观察字节对齐情况
return 0;
}
为什么要字节对齐
1. 计算机如何读取内存
上图展示了一个内存模块的基本思想。模块有8个64Mbit(注意是Mbit不是Mbyte)的8M*8的DRAM芯片,总容量64MB。要取出地址为A处的一个字(这里指的机器字长),内存控制器将A转换成一个supercell地址(i,j),并将它发送到内存模块,然后内存模块再将i和j广播到每个DRAM。作为响应,每个DRAM输出它的(i,j)对应的supercell的8位内容。内存模块收集这些输出,并把他们合并成一个64位的字,再返回给内存控制器。
从上面的描述中可以发现几个要点:
- 一个内存模块含有8个DRAM芯片。
- 对于内存的一次读操作会读出1个机器字长(64bit CPU为8字节),而非一字节,这称为内存访问粒度(Memory access granularity)。
- 8个字节是并行地从8个DRAM芯片中读取。
2. 字节不对齐会怎样?
假设我们现在需要从0x00处取8个字节的数据,即我们需要取出位于0x00-0x07的数据。根据上面的描述,我们不难推断,0x00位于DRAM0,0x01位于DRAM1,0x02位于DRAM2以此类推。不难看出在这种情况下只需要对内存进行一次读操作就可以取出全部8字节数据。
情况1:
现在我们考虑另一种情况,需要从0x01处取4个字节的数据,即我们需要取出位于0x01-0x04的数据。在这种情况下,CPU会获取0x00-0x07的8个字节数据,然后从中读取0x01-0x04,这种情况也只需读一次内存。
情况2:
我们再考虑一种情况,需要从0x01处取8个字节的数据,即我们需要取出位于0x01-0x08的数据。在这种情况下CPU需要做如下操作:
- 获取0x00-0x07的8个字节数据。
- 获取0x08-0x10的8个字节数据。
- 从0x00-0x07中获取0x01-0x07的7字节数据,从0x08-0x10获取0x08的1字节数据,组合成8字节。
可以看出在这种情况下CPU不光需要读两次内存,还需要做额外的“组合”操作。性能会大大降低,并且某些CPU根本不支持这样的操作。这就是为什么需要要求任何K字节的基本对象的地址必须是K的倍数,也就是字节对齐。
3. 从缓存角度看字节对齐
现代的计算机体系结构在CPU和内存之间还存在高速缓存。当CPU需要从内存中获取数据时,会首先访问高速缓存,如果高速缓存中有相应数据,则从缓存中直接读取,否则需要首先将数据从内存中加载到缓存中。那么当数据是如何从内存加载到缓存中的?是否也涉及字节对齐问题呢?
高速缓存结构
上图展示了高速缓存的结构,一个高速缓存有S个高速缓存组,每个组包含E个高速缓存行,每个行含有B字节的数据块。
数据加载
- 一次读多少数据?
高速缓存一次从内存读取B个字节的数据,通常B和机器字长相等(至少Core i7 L1~L3缓存的数据块大小都是64字节)。可以简单的理解为一次读一个缓存行(缓存行中除了数据块之外,还有Vaild和Tag)
- 读到哪个缓存行?
CPU是按照一定规则将内存中的数据加载到对应缓存行的,对于起始地址为A的m位地址,会被解析成以下几个部分:
b由数据块大小决定:B = 2bs由高速缓存组个数决定:S = 2s
t = m - (s + b)
这里不用纠结具体细节(具体细节详见CSAPP Chapter 6,要理解透彻需要把Chapter 6整个看完!),只需要知道一个事实:内存数据被读到哪个缓存行是由该内存数据的起始地址决定的!
结论
结合上面两点:
(1) 高速缓存一次从内存读取B个字节的数据,B为机器字长。
(2) 内存数据被读到哪个缓存行是由该内存数据的起始地址决定的。
我们不难得出一个结论:
如果机器字长为64位,即8字节,那么位于0x00-0x07的数据和位于0x08-0x10的数据绝对不会被加载到同一个缓存行!
对于上面的情况2,如果我们需要从0x01处取8个字节的数据,即我们需要取出位于0x01-0x08的数据,那么即便是可以直接从高速缓存中获取,也需要访问两个缓存行,并且同样需要合并操作!可见字节对齐是多么的重要!
参考资料
- CSAPP 3.9.3 以及 Chapter 6
- https://developer.ibm.com/articles/pa-dalign/