对齐准则
先来看四个重要的基本概念:
1) 数据类型自身的对齐值:char型数据自身对齐值为1字节,short型数据为2字节,int/float型为4字节,double型为8字节。
2) 结构体或类的自身对齐值:其成员中自身对齐值最大的那个值。
3) 指定对齐值:#pragma pack (value)时的指定对齐值value。
4) 数据成员、结构体和类的有效对齐值:自身对齐值和指定对齐值中较小者,即有效对齐值=min{自身对齐值,当前指定的pack值}。
基于上面这些值,就可以方便地讨论具体数据结构的成员和其自身的对齐方式。
其中,有效对齐值N是最终用来决定数据存放地址方式的值。有效对齐N表示“对齐在N上”,即该数据的“存放起始地址%N=0”。而数据结构中的数据变量都是按定义的先后顺序存放。第一个数据变量的起始地址就是数据结构的起始地址。结构体的成员变量要对齐存放,结构体本身也要根据自身的有效对齐值圆整(即结构体成员变量占用总长度为结构体有效对齐值的整数倍)。
上面的概念非常便于理解,不过个人还是更喜欢下面的对齐准则。
结构体字节对齐的细节和具体编译器实现相关,但一般而言满足三个准则:
1) 结构体变量的首地址能够被其最宽基本类型成员的大小所整除;
2) 结构体每个成员相对结构体首地址的偏移量(offset)都是成员大小的整数倍,如有需要编译器会在成员之间加上填充字节(internal adding);
3) 结构体的总大小为结构体最宽基本类型成员大小的整数倍,如有需要编译器会在最末一个成员之后加上填充字节{trailing padding}。
对于以上规则的说明如下:
第一条:编译器在给结构体开辟空间时,首先找到结构体中最宽的基本数据类型,然后寻找内存地址能被该基本数据类型所整除的位置,作为结构体的首地址。将这个最宽的基本数据类型的大小作为上面介绍的对齐模数。
第二条:为结构体的一个成员开辟空间之前,编译器首先检查预开辟空间的首地址相对于结构体首地址的偏移是否是本成员大小的整数倍,若是,则存放本成员,反之,则在本成员和上一个成员之间填充一定的字节,以达到整数倍的要求,也就是将预开辟空间的首地址后移几个字节。
第三条:结构体总大小是包括填充字节,最后一个成员满足上面两条以外,还必须满足第三条,否则就必须在最后填充几个字节以达到本条要求。
对齐的隐患
1.数据类型转换
代码中关于对齐的隐患,很多是隐式的。例如,在强制类型转换的时候:
1 int main(void){ 2 unsigned int i = 0x12345678; 3 4 unsigned char *p = (unsigned char *)&i; 5 *p = 0x00; 6 unsigned short *p1 = (unsigned short *)(p+1); 7 *p1 = 0x0000; 8 9 return 0; 10 }
最后两句代码,从奇数边界去访问unsigned short型变量,显然不符合对齐的规定。在X86上,类似的操作只会影响效率;但在MIPS或者SPARC上可能导致error,因为它们要求必须字节对齐。
又如对于3.1.1节的结构体struct B,定义如下函数:
1 void Func(struct B *p){ 2 //Code 3 }
在函数体内如果直接访问p->a,则很可能会异常。因为MIPS认为a是int,其地址应该是4的倍数,但p->a的地址很可能不是4的倍数。
如果p的地址不在对齐边界上就可能出问题,比如p来自一个跨CPU的数据包(多种数据类型的数据被按顺序放置在一个数据包中传输),或p是经过指针移位算出来的。因此要特别注意跨CPU数据的接口函数对接口输入数据的处理,以及指针移位再强制转换为结构指针进行访问时的安全性。
解决方式如下:
1) 定义一个此结构的局部变量,用memmove方式将数据拷贝进来。
1 void Func(struct B *p){ 2 struct B tData; 3 memmove(&tData, p, sizeof(struct B)); 4 //此后可安全访问tData.a,因为编译器已将tData分配在正确的起始地址上 5 }
注意:如果能确定p的起始地址没问题,则不需要这么处理;如果不能确定(比如跨CPU输入数据、或指针移位运算出来的数据要特别小心),则需要这样处理。
2) 用#pragma pack (1)将STRUCT_T定义为1字节对齐方式。
2.处理器间数据通信
处理器间通过消息(对于C/C++而言就是结构体)进行通信时,需要注意字节对齐以及字节序的问题。
大多数编译器提供内存对其的选项供用户使用。这样用户可以根据处理器的情况选择不同的字节对齐方式。例如C/C++编译器提供的#pragma pack(n) n=1,2,4等,让编译器在生成目标文件时,使内存数据按照指定的方式排布在1,2,4等字节整除的内存地址处。
然而在不同编译平台或处理器上,字节对齐会造成消息结构长度的变化。编译器为了使字节对齐可能会对消息结构体进行填充,不同编译平台可能填充为不同的形式,大大增加处理器间数据通信的风险。
下面以32位处理器为例,提出一种内存对齐方法以解决上述问题。
对于本地使用的数据结构,为提高内存访问效率,采用四字节对齐方式;同时为了减少内存的开销,合理安排结构体成员的位置,减少四字节对齐导致的成员之间的空隙,降低内存开销。
对于处理器之间的数据结构,需要保证消息长度不会因不同编译平台或处理器而导致消息结构体长度发生变化,使用一字节对齐方式对消息结构进行紧缩;为保证处理器之间的消息数据结构的内存访问效率,采用字节填充的方式自己对消息中成员进行四字节对齐。
数据结构的成员位置要兼顾成员之间的关系、数据访问效率和空间利用率。顺序安排原则是:四字节的放在最前面,两字节的紧接最后一个四字节成员,一字节紧接最后一个两字节成员,填充字节放在最后。
举例如下:
1 typedef struct tag_T_MSG{ 2 long ParaA; 3 long ParaB; 4 short ParaC; 5 char ParaD; 6 char Pad; //填充字节 7 }T_MSG;
3. 排查对齐问题
如果出现对齐或者赋值问题可查看:
1) 编译器的字节序大小端设置;
2) 处理器架构本身是否支持非对齐访问;
3) 如果支持看设置对齐与否,如果没有则看访问时需要加某些特殊的修饰来标志其特殊访问操作。
4. 更改对齐方式
主要是更改C编译器的缺省字节对齐方式。
在缺省情况下,C编译器为每一个变量或是数据单元按其自然对界条件分配空间。一般地,可以通过下面的方法来改变缺省的对界条件:
- 使用伪指令#pragma pack(n):C编译器将按照n个字节对齐;
- 使用伪指令#pragma pack(): 取消自定义字节对齐方式。
另外,还有如下的一种方式(GCC特有语法):
- __attribute((aligned (n))): 让所作用的结构成员对齐在n字节自然边界上。如果结构体中有成员的长度大于n,则按照最大成员的长度来对齐。
- __attribute__ ((packed)): 取消结构在编译过程中的优化对齐,按照实际占用字节数进行对齐。
【注】__attribute__机制是GCC的一大特色,可以设置函数属性(Function Attribute)、变量属性(Variable Attribute)和类型属性(Type Attribute)。
位域对齐
1. 位域定义
有些信息在存储时,并不需要占用一个完整的字节,而只需占几个或一个二进制位。例如在存放一个开关量时,只有0和1两种状态,用一位二进位即可。为了节省存储空间和处理简便,C语言提供了一种数据结构,称为“位域”或“位段”。
位域是一种特殊的结构成员或联合成员(即只能用在结构或联合中),用于指定该成员在内存存储时所占用的位数,从而在机器内更紧凑地表示数据。每个位域有一个域名,允许在程序中按域名操作对应的位。这样就可用一个字节的二进制位域来表示几个不同的对象。
位域定义与结构定义类似,其形式为:
struct 位域结构名 { 位域列表 }; |
其中位域列表的形式为:
类型说明符位域名:位域长度 |
位域的使用和结构成员的使用相同,其一般形式为:
位域变量名.位域名 |
位域允许用各种格式输出。
位域在本质上就是一种结构类型,不过其成员是按二进位分配的。位域变量的说明与结构变量说明的方式相同,可先定义后说明、同时定义说明或直接说明。
位域的使用主要为下面两种情况:
1) 当机器可用内存空间较少而使用位域可大量节省内存时。如把结构作为大数组的元素时。
2) 当需要把一结构体或联合映射成某预定的组织结构时。如需要访问字节内的特定位时。
2. 对齐准则
位域成员不能单独被取sizeof值。下面主要讨论含有位域的结构体的sizeof。
C99规定int、unsigned int和bool可以作为位域类型,但编译器几乎都对此作了扩展,允许其它类型的存在。位域作为嵌入式系统中非常常见的一种编程工具,优点在于压缩程序的存储空间。
其对齐规则大致为:
1) 如果相邻位域字段的类型相同,且其位宽之和小于类型的sizeof大小,则后面的字段将紧邻前一个字段存储,直到不能容纳为止;
2) 如果相邻位域字段的类型相同,但其位宽之和大于类型的sizeof大小,则后面的字段将从新的存储单元开始,其偏移量为其类型大小的整数倍;
3) 如果相邻的位域字段的类型不同,则各编译器的具体实现有差异,VC6采取不压缩方式,Dev-C++和GCC采取压缩方式;
4) 如果位域字段之间穿插着非位域字段,则不进行压缩;
5) 整个结构体的总大小为最宽基本类型成员大小的整数倍,而位域则按照其最宽类型字节数对齐。
【例5】
1 struct BitField{ 2 char element1 : 1; 3 char element2 : 4; 4 char element3 : 5; 5 };
位域类型为char,第1个字节仅能容纳下element1和element2,所以element1和element2被压缩到第1个字节中,而element3只能从下一个字节开始。因此sizeof(BitField)的结果为2。
【例6】
1 struct BitField1{ 2 char element1 : 1; 3 short element2 : 5; 4 char element3 : 7; 5 };
由于相邻位域类型不同,在VC6中其sizeof为6,在Dev-C++中为2。
【例7】
1 struct BitField2{ 2 char element1 : 3; 3 char element2 ; 4 char element3 : 5; 5 };
非位域字段穿插在其中,不会产生压缩,在VC6和Dev-C++中得到的大小均为3。
【例8】
1 struct StructBitField{ 2 int element1 : 1; 3 int element2 : 5; 4 int element3 : 29; 5 int element4 : 6; 6 char element5 :2; 7 char stelement; //在含位域的结构或联合中也可同时说明普通成员 8 };
位域中最宽类型int的字节数为4,因此结构体按4字节对齐,在VC6中其sizeof为16。
3. 注意事项
关于位域操作有几点需要注意:
1) 位域的地址不能访问,因此不允许将&运算符用于位域。不能使用指向位域的指针也不能使用位域的数组(数组是种特殊指针)。
例如,scanf函数无法直接向位域中存储数据:
1 int main(void){ 2 struct BitField1 tBit; 3 scanf("%d", &tBit.element2); //error: cannot take address of bit-field ‘element2‘ 4 return 0; 5 }
可用scanf函数将输入读入到一个普通的整型变量中,然后再赋值给tBit.element2。
2) 位域不能作为函数返回的结果。
3) 位域以定义的类型为单位,且位域的长度不能够超过所定义类型的长度。例如定义int a:33是不允许的。
4) 位域可以不指定位域名,但不能访问无名的位域。
位域可以无位域名,只用作填充或调整位置,占位大小取决于该类型。例如,char :0表示整个位域向后推一个字节,即该无名位域后的下一个位域从下一个字节开始存放,同理short :0和int :0分别表示整个位域向后推两个和四个字节。
当空位域的长度为具体数值N时(如int :2),该变量仅用来占位N位。
【例9】
1 struct BitField3{ 2 char element1 : 3; 3 char :6; 4 char element3 : 5; 5 };
结构体大小为3。因为element1占3位,后面要保留6位而char为8位,所以保留的6位只能放到第2个字节。同样element3只能放到第3字节。
1 struct BitField4{ 2 char element1 : 3; 3 char :0; 4 char element3 : 5; 5 };
长度为0的位域告诉编译器将下一个位域放在一个存储单元的起始位置。如上,编译器会给成员element1分配3位,接着跳过余下的4位到下一个存储单元,然后给成员element3分配5位。故上面的结构体大小为2。
5) 位域的表示范围。
- 位域的赋值不能超过其可以表示的范围;
- 位域的类型决定该编码能表示的值的结果。
对于第二点,若位域为unsigned类型,则直接转化为正数;若非unsigned类型,则先判断最高位是否为1,若为1表示补码,则对其除符号位外的所有位取反再加一得到最后的结果数据(原码)。如:
1 unsigned int p:3 = 111; //p表示7 2 int p:3 = 111; //p 表示-1,对除符号位之外的所有位取反再加一
6) 带位域的结构在内存中各个位域的存储方式取决于编译器,既可从左到右也可从右到左存储。
【例10】在VC6下执行下面的代码:
int main(void){ union{ int i; struct{ char a : 1; char b : 1; char c : 2; }bits; }num; printf("Input an integer for i(0~15): "); scanf("%d", &num.i); printf("i = %d, cba = %d %d %d\n", num.i, num.bits.c, num.bits.b, num.bits.a); return 0; }
输入i值为11,则输出为i = 11, cba = -2 -1 -1。
Intel x86处理器按小字节序存储数据,所以bits中的位域在内存中放置顺序为ccba。当num.i置为11时,bits的最低有效位(即位域a)的值为1,a、b、c按低地址到高地址分别存储为10、1、1(二进制)。
但为什么最后的打印结果是a=-1而不是1?
因为位域a定义的类型signed char是有符号数,所以尽管a只有1位,仍要进行符号扩展。1做为补码存在,对应原码-1。
如果将a、b、c的类型定义为unsigned char,即可得到cba = 2 1 1。1011即为11的二进制数。
注:C语言中,不同的成员使用共同的存储区域的数据构造类型称为联合(或共用体)。联合占用空间的大小取决于类型长度最大的成员。联合在定义、说明和使用形式上与结构体相似。
7) 位域的实现会因编译器的不同而不同,使用位域会影响程序可移植性。因此除非必要否则最好不要使用位域。
8) 尽管使用位域可以节省内存空间,但却增加了处理时间。当访问各个位域成员时,需要把位域从它所在的字中分解出来或反过来把一值压缩存到位域所在的字位中。