谈谈对齐(中)
1.3 变量对齐会带来什么麻烦?
我在变量对齐问题上吃过一次亏,可以作为本节的一个例子。不过要理解这个例子,读者必须知道ARM CPU的一个特点:就是长度为m的基本变量必须放在mn边界上,否则读写时会发生数据访问错误,其中m=2或4。这就是第3节要介绍的数据对齐。
事情是这样,我定义了几个缓冲区(大数组),然后动态分配这些内存。我的错误在于将这些数组定义为字节数组。我的分配算法是按块分配,每个数据块的大小都是4的整数倍。读者能猜到错误产生的原因了吗?
由于我把缓冲区定义为字节数组,编译器就可以把它们放在1n边界。如果缓冲区的起始地址是奇数地址,从缓冲区分配的内存块的起始地址都是奇数地址。如果这些内存块被用于需要按2或4字节对齐的变量,读写时就会发生数据访问错误。如果编译器恰好把这些缓冲区放在4n边界上,问题就不会暴露出来。所以前一次编译可能是好的,但是下一次编译就会发生莫名其妙的错误。调试程序与侦破案件差不多,离犯罪现场越远的凶手就越难发现。在我透过各种表象找到根源之前,吃点苦头是难免的。
解决问题的方法很简单,将缓冲区定义为unsigned int(下文记作uint32)的数组,编译器自然会把它们放到4n边界。在嵌入式系统中,我们经常要为任务定义堆栈。这些堆栈通常都是uint32类型的数组。你知道为什么要把它们定义成uint32数组,而不能定义成字节数组了吗?
2 结构对齐
2.1 基本长度
为了描述方便,我们定义一个基本长度的概念。一个基本变量的基本长度就是它的长度,一个结构变量的基本长度就是结构成员中基本变量的最大长度。前面说过:在默认情况下,结构变量就是按照其基本长度对齐的。
2.2 对齐
在默认情况下,可以认为结构的成员按照默认方式对齐,即长度为m的基本变量放在mn边界上,其中m=1,2,4或8。因为要把成员对齐,结构的各成员间就可能出现填充字节,结构的大小可能大于各成员大小之和。例如:
typedef struct St1Tag { char ch1; int num1; short sh1; short sh2; char ch2; } St1;
这个结构的基本长度是4,所以这个结构的变量要放在4n边界。成员num1的基本长度为4,所以也要放在4n边界。成员ch1从4n边界开始,只占1个字节,所以在ch1和num1之间有3个填充字节。
在对齐时,编译器会将结构长度取整到基本长度的整数倍。这样以该结构为基本类型的数组既可以连续排列,每个元素又可以对齐放置。所以,sizeof(St1)的值是16,在St1的最后一个成员ch2后面还有3个填充字节。
2.3 紧缩
各编译器都支持结构的紧缩,即连续排列结构的各成员变量,各成员变量之间没有任何填充字节。这时,结构的大小等于各成员变量大小的和。紧缩结构的变量可以放在1n边界,即任意地址边界。
在gcc中可以这样定义紧缩结构:
typedef struct St2Tag { St1 st1; char ch2; } __attribute__ ((packed)) St2;
armcc是这样的:
typedef __packed struct St2Tag { St1 st1; char ch2; } St2;
VC的写法最麻烦:
#pragma pack(1) typedef struct St2Tag { St1 st1; char ch2; } St2; #pragma pack()
如果要同时支持gcc、armcc、VC平台,可以把代码写成这样:
#ifdef __GNUC__ #define GNUC_PACKED __attribute__ ((packed)) #else #define GNUC_PACKED #endif #ifdef __arm #define ARM_PACKED __packed #else #define ARM_PACKED #endif #ifdef WIN32 #pragma pack(1) #endif typedef ARM_PACKED struct St2Tag { St1 st1; char ch2; } GNUC_PACKED St2; #ifdef WIN32 #pragma pack() #endif
其中:__GNUC__是gcc的预定义宏,__arm__是ARM编译器的预定义宏(__arm和__arm__都可以),可以用它们识别当前的编译器。
2.4 全局设置
在VC中,有的程序员习惯设置整个工程的struct member alignment,这对应于命令行选项“/Zpi”,其中i=1,2,4,8,16。如果将这个值设为1,工程中所有结构都是紧缩排列。紧缩排列会增大代码量,降低结构访问效率。我们应该仅在必要的时候使用紧缩结构。
“/Zp1”是紧缩排列,那么“/Zp2”,“/Zp4”等选项是怎样排列的呢?
设选项“/Zpi”中设定的长度是i,设某个结构成员的基本长度是m,则该结构成员按照m和i中较小的值对齐。例如:如果我们设置了“/Zp2”,则基本长度不大于2的成员按照基本长度对齐,基本长度大于2的成员按照2对齐。
其实,我们不应该使用“/Zp2”这么奇怪的选项,除非有非如此不可的理由。
2.5 紧缩结构的用途
其实最常用的结构对齐选项就是:默认对齐和紧缩。在两个程序,或者两个平台之间传递数据时,我们通常会将数据结构设置为紧缩的。这样不仅可以减小通信量,还可以避免对齐带来的麻烦。假设甲乙双方进行跨平台通信,甲方使用了“/Zp2”这么奇怪的对齐选项,而乙方的编译器不支持这种对齐方式,那么乙方就可以理解什么叫欲哭无泪了。
当我们需要一个字节一个字节访问结构数据时,我们通常都会希望结构是紧缩的,这样就不必考虑哪个字节是填充字节了。我们把数据保存到非易失设备时,通常也会采用紧缩结构,既减小存储量,也方便其它程序读出。
2.6 细节
最后记录一个小细节。gcc编译器和VC编译器都支持在紧缩结构中包含非紧缩结构,例如前面例子中的St2可以包含非紧缩的St1。但对于ARM编译器而言,紧缩结构包含的其它结构必须是紧缩的。如果紧缩的St2包含了非紧缩的St1,编译时就会报错:
error: #1031: Definition of "struct St1Tag" in packed "struct St1T2g" must be __packed