C++ 数据对齐 问题

上一篇文章提到了数据对齐问题,那我们就来看看C++数据对齐的规则,举例分析一下,最后看一个由数据对齐引起的BUG,并思考其带给我们的意义。

首先为什么要数据对齐?现代的64位计算机,总线位宽为64个比特(bit),即8个字节(byte),CPU的寄存器也是8个字节大小,所以访问内存也总是以8个字节为单位。如果一份不足8字节大小的数据因为布局问题跨越了两个8字节单位,这样要读这份数据的时候,就要通过两次总线传输,传递16字节的数据,CPU也要分级缓存这16字节的数据,这实在是太糟糕了。同理,对于32位的系统,小于4字节的数据就不应该夸两个4字节空间,还有其他的平台可能数据访问一次只有2字节甚至1字节。综合这些,数据对齐就是要求数据应该排放在其大小的自然边界上,简单说就是大小为N(1,2,4,8,16,32)的数据不应该放在两个N格子之间,而应该放在一个单独的N格子之内。

上面解释了为什么要数据对齐,要对齐的情况好像还挺多的,也挺复杂的。我们把编译器关于数据对齐的策略总结为三条规则。

规则一:成员变量根据自己的大小来对齐。

规则一的意思就是,关于结构体内该成员是否要对齐,对齐到什么位置,不是看上一个变量,也不是看下一个变量,而是看自己。定义一个int,就要对齐到sizeof(int)整数倍的位置,定义一个double,就要对齐到sizeof(double)整数倍的位置。

规则二:结构体大小必须是最大成员变量大小的整数倍。

规则二定义的是结构体数组最后补齐的情况,如果一个结构体中定义了一个int与一个double,那么最大成员变量是double,大小为8。这时结构体虽然只有4(int) + 8(double)= 12字节大小,但会补齐为8的整数倍 16。这样做是为了处理结构体数组时,每一个结构体内的每一个成员变量都处于边界上。

结合规则一与规则二,我们就可以处理简单结构体的数据对齐了,先来看个例子:

struct s1
{
    char c;
    int  n;
    short s;
}

对于结构体s1,我们来看看对齐后的内存布局:图中每个格子代表一个字节,*代表用于内存对齐的无效数据。

cache1

第一个成员是一个char,大小为1(字节)。第二个成员是一个int,大小为4,根据规则一,int的布局要对齐到4的整数倍上,所以会从第5个格子开始,于是char后面会有3个字节的空位,是用于内存对齐的无效数据。第三个成员是一个short,大小为2。根据规则二,整个s1的大小要是最大成员(int,4)的整数倍,所以在short后还会填充两个字节的无效数据用作数据对齐。

规则一二规定了简单结构体的数据对齐情况,所谓的简单结构体就是结构体内的成员都是基础数据。对于结构体内包含另外一个结构体的情况,又应该如何对齐呢?

规则三:对于结构体内的子结构体,子结构体以其最大成员大小为边界进行对齐。

规则三其实是规则一的补充,就是当成员也是一个结构体时,这时候这个成员对齐的边界不是整个子结构体的大小,而是子结构体内最大成员的大小。例如:

struct s1
{
    short a;
    long b;
};
struct s2
{
    char c;
    s1 d;
    long long e;
};

对于这种情况,s2的第二个成员是结构体s1,在处理s1的对齐时,应该按照s1的最大成员long的大小(4字节)来对齐,而不是整个s1的大小(8字节)来对齐。s2具体内存布局如下:
cache2

s2的第一个成员是char,摆在第一个格子,第二个成员是子结构体s1,根据规则三,它按照4的大小对齐,所以摆在第5个格子,第三个结构体是longlong,根据规则一,它按照8的大小对齐,所以不能紧接着s1,而是对齐到16边界。这样导致s1后还有4个用于对齐的无效数据。

通过上面总结的三条规则我们掌握了编译器关于数据对齐的策略,我们还可以通过编译指令
#pragma pack (n) 告诉编译器按照n大小进行对齐。n可以取值(1,2,4,8,16,32),指定了大小n以后,编译器就会按照min(n,sizeof(data))对齐。例如我们指定了#pragma pack (1), 无论成员数据多大,都会按照1字节对齐。如果我们指定#pragma pack (4),小于4字节的会按照自己的大小对齐,大于4字节的会按照4字节对齐。

掌握了数据对齐的规则后,我们写代码的时候应该注意什么呢?
第一,我们应该更合理的组织我们的结构。例如两个char一个int的结构体,我们把两个char放在一起,这个结构体就只需要8字节,但如果int夹在两个char之间,这个结构体就要12字节。

第二,我们应该回避依赖内存布局的算法。我们一起来看个BUG,对于自行实现的字符串缓存池,定义了一个字符串数据结构体:

struct StrData
{
    unsigned int uLength;
    const char* cpszData;
};

这个结构体第一个成员是字符串长度,第二个成员是字符串指针,指向字符串缓存池内的字符起始地址。外界使用时拿着cpszData当成普通的字符串指针使用即可。对于这个字符串指针,要求字符串长度时本来应该使用strlen库函数的,但因为StrData的内存布局,实现了一个取巧的取长度方法,就是通过指针向上偏移sizeof(unsigned int),访问到uLength。

这个算法看着没有问题,也一直工作得挺好的,但代码移植到64位的时候就出BUG了。因为在64位开发环境下,一个char指针大小是8,而int 依旧是4,这样导致char指针会对齐到8的位置上,在int 与 char* 之间填充了4个字节的无效数据用于对齐。当我们相对于cpszData地址向上偏移sizeof(unsigned int)的时候,实际上指向的是填充的那4个字节的无效数据,而不再是uLength。

这个BUG 可以说相当隐晦,在32位下它是工作正常的,只有移植到64位下才会产生问题。因为内存布局由编译器决定,虽然我们已经深入分析了它的规则,但在系统移植的情况下它还是会发生改变,而这些改变往往就会导致我们的算法出错。所以说我们应该回避依赖内存布局的算法。

我们通过三条规则总结了编译器关于内存对齐的处理规律,根据这些特性还提出了两点注意的地方,希望对大家有所帮助,更好的掌握程序的各项细节,写出更好的代码。

Tagged , . Bookmark the permalink.

Comments are closed.