C/C++编程笔记:C语言对齐问题【结构体、栈内存以及位域对齐】

引言

考虑下面的结构体定义:编程

假设这个结构体的成员在内存中是紧凑排列的,且c1的起始地址是0,则s的地址就是1,c2的地址是3,i的地址是4。数组

如今,咱们编写一个简单的程序:安全

运行后输出:微信

为何会这样?这就是字节对齐致使的问题。数据结构

本文在参考诸多资料的基础上,详细介绍常见的字节对齐问题。因成文较早,资料来源大多已不可考,敬请谅解。架构

一,什么是字节对齐

现代计算机中,内存空间按照字节划分,理论上能够从任何起始地址访问任意类型的变量。但实际中在访问特定类型变量时常常在特定的内存地址访问,这就须要各类类型数据按照必定的规则在空间上排列,而不是顺序一个接一个地存放,这就是对齐。函数

二,对齐的缘由和做用

不一样硬件平台对存储空间的处理上存在很大的不一样。某些平台对特定类型的数据只能从特定地址开始存取,而不容许其在内存中任意存放。例如Motorola 68000处理器不容许16位的字存放在奇地址,不然会触发异常,所以在这种架构下编程必须保证字节对齐。工具

但最多见的状况是,若是不按照平台要求对数据存放进行对齐,会带来存取效率上的损失。好比32位的Intel处理器经过总线访问(包括读和写)内存数据。每一个总线周期从偶地址开始访问32位内存数据,内存数据以字节为单位存放。若是一个32位的数据没有存放在4字节整除的内存地址处,那么处理器就须要2个总线周期对其进行访问,显然访问效率降低不少。性能

所以,经过合理的内存对齐能够提升访问效率。为使CPU可以对数据进行快速访问,数据的起始地址应具备“对齐”特性。好比4字节数据的起始地址应位于4字节边界上,即起始地址可以被4整除。学习

此外,合理利用字节对齐还能够有效地节省存储空间。但要注意,在32位机中使用1字节或2字节对齐,反而会下降变量访问速度。所以须要考虑处理器类型。还应考虑编译器的类型。在VC/C++和GNU GCC中都是默认是4字节对齐。

三,对齐的分类和准则

主要基于Intel X86架构介绍结构体对齐和栈内存对齐,位域本质上为结构体类型。

对于Intel X86平台,每次分配内存应该是从4的整数倍地址开始分配,不管是对结构体变量仍是简单类型的变量。

3.1 结构体对齐

在C语言中,结构体是种复合数据类型,其构成元素既能够是基本数据类型(如int、long、float等)的变量,也能够是一些复合数据类型(如数组、结构体、联合等)的数据单元。编译器为结构体的每一个成员按照其天然边界(alignment)分配空间。各成员按照它们被声明的顺序在内存中顺序存储,第一个成员的地址和整个结构的地址相同。

字节对齐的问题主要就是针对结构体。

3.1.1 简单示例

先看个简单的例子(32位,X86处理器,GCC编译器):

【例1】设结构体以下定义:

已知32位机器上各数据类型的长度为:char为1字节、short为2字节、int为4字节、long为4字节、float为4字节、double为8字节。那么上面两个结构体大小如何呢?

结果是:sizeof(strcut A)值为8;sizeof(struct B)的值倒是12。

结构体A中包含一个4字节的int数据,一个1字节char数据和一个2字节short数据;B也同样。按理说A和B大小应该都是7字节。之因此出现上述结果,就是由于编译器要对数据成员在空间上进行对齐。

3.1.2 对齐准则

先来看四个重要的基本概念:

(1)数据类型自身的对齐值:char型数据自身对齐值为1字节,short型数据为2字节,int/float型为4字节,double型为8字节。

(2)结构体或类的自身对齐值:其成员中自身对齐值最大的那个值。

(3)指定对齐值:#pragma pack (value)时的指定对齐值value。

(4)数据成员、结构体和类的有效对齐值:自身对齐值和指定对齐值中较小者,即有效对齐值=min{自身对齐值,当前指定的pack值}。

基于上面这些值,就能够方便地讨论具体数据结构的成员和其自身的对齐方式。

其中,有效对齐值N是最终用来决定数据存放地址方式的值。有效对齐N表示“对齐在N上”,即该数据的“存放起始地址%N=0”。而数据结构中的数据变量都是按定义的前后顺序存放。第一个数据变量的起始地址就是数据结构的起始地址。结构体的成员变量要对齐存放,结构体自己也要根据自身的有效对齐值圆整(即结构体成员变量占用总长度为结构体有效对齐值的整数倍)。

以此分析3.1.1节中的结构体B:

假设B从地址空间0x0000开始存放,且指定对齐值默认为4(4字节对齐)。成员变量b的自身对齐值是1,比默认指定对齐值4小,因此其有效对齐值为1,其存放地址0x0000符合0x0000%1=0。成员变量a自身对齐值为4,因此有效对齐值也为4,只能存放在起始地址为0x0004~0x0007四个连续的字节空间中,符合0x0004%4=0且紧靠第一个变量。变量c自身对齐值为2,因此有效对齐值也是2,可存放在0x0008~0x0009两个字节空间中,符合0x0008%2=0。因此从0x0000~0x0009存放的都是B内容。

再看数据结构B的自身对齐值为其变量中最大对齐值(这里是b)因此就是4,因此结构体的有效对齐值也是4。根据结构体圆整的要求,0x0000~0x0009=10字节,(10+2)%4=0。因此0x0000A~0x000B也为结构体B所占用。故B从0x0000到0x000B共有12个字节,sizeof(struct B)=12。

之因此编译器在后面补充2个字节,是为了实现结构数组的存取效率。试想若是定义一个结构B的数组,那么第一个结构起始地址是0没有问题,可是第二个结构呢?按照数组的定义,数组中全部元素都紧挨着。若是咱们不把结构体大小补充为4的整数倍,那么下一个结构的起始地址将是0x0000A,这显然不能知足结构的地址对齐。所以要把结构体补充成有效对齐大小的整数倍。其实对于char/short/int/float/double等已有类型的自身对齐值也是基于数组考虑的,只是由于这些类型的长度已知,因此他们的自身对齐值也就已知。

上面的概念很是便于理解,不过我的仍是更喜欢下面的对齐准则。

结构体字节对齐的细节和具体编译器实现相关,但通常而言知足三个准则:

(1)结构体变量的首地址可以被其最宽基本类型成员的大小所整除;

(2)结构体每一个成员相对结构体首地址的偏移量(offset)都是成员大小的整数倍,若有须要编译器会在成员之间加上填充字节(internal adding);

(3)结构体的总大小为结构体最宽基本类型成员大小的整数倍,若有须要编译器会在最末一个成员以后加上填充字节{trailing padding}。

对于以上规则的说明以下:

(1)编译器在给结构体开辟空间时,首先找到结构体中最宽的基本数据类型,而后寻找内存地址能被该基本数据类型所整除的位置,做为结构体的首地址。将这个最宽的基本数据类型的大小做为上面介绍的对齐模数。

(2)为结构体的一个成员开辟空间以前,编译器首先检查预开辟空间的首地址相对于结构体首地址的偏移是不是本成员大小的整数倍,如果,则存放本成员,反之,则在本成员和上一个成员之间填充必定的字节,以达到整数倍的要求,也就是将预开辟空间的首地址后移几个字节。

(3)结构体总大小是包括填充字节,最后一个成员知足上面两条之外,还必须知足第三条,不然就必须在最后填充几个字节以达到本条要求。

【例2】假设4字节对齐,如下程序的输出结果是多少?

执行后输出以下:

下面来具体分析:

首先char a占用1个字节,没问题。

short b自己占用2个字节,根据上面准则2,须要在b和a之间填充1个字节。

char c占用1个字节,没问题。

int d自己占用4个字节,根据准则2,须要在d和c之间填充3个字节。

char e[3];自己占用3个字节,根据原则3,须要在其后补充1个字节。

所以,sizeof(T_Test) = 1 + 1 + 2 + 1 + 3 + 4 + 3 + 1 = 16字节。

3.1.3 对齐的隐患

3.1.3.1 数据类型转换

代码中关于对齐的隐患,不少是隐式的。例如,在强制类型转换的时候:

最后两句代码,从奇数边界去访问unsigned short型变量,显然不符合对齐的规定。在X86上,相似的操做只会影响效率;但在MIPS或者SPARC上可能致使error,由于它们要求必须字节对齐。

又如对于3.1.1节的结构体struct B,定义以下函数:

在函数体内若是直接访问p->a,则极可能会异常。由于MIPS认为a是int,其地址应该是4的倍数,但p->a的地址极可能不是4的倍数。

若是p的地址不在对齐边界上就可能出问题,好比p来自一个跨CPU的数据包(多种数据类型的数据被按顺序放置在一个数据包中传输),或p是通过指针移位算出来的。所以要特别注意跨CPU数据的接口函数对接口输入数据的处理,以及指针移位再强制转换为结构指针进行访问时的安全性。

解决方式以下:

定义一个此结构的局部变量,用memmove方式将数据拷贝进来。

注意:若是能肯定p的起始地址没问题,则不须要这么处理;若是不能肯定(好比跨CPU输入数据、或指针移位运算出来的数据要特别当心),则须要这样处理。

用#pragma pack (1)将STRUCT_T定义为1字节对齐方式。

3.1.3.2 处理器间数据通讯

处理器间经过消息(对于C/C++而言就是结构体)进行通讯时,须要注意字节对齐以及字节序的问题。

大多数编译器提供内存对其的选项供用户使用。这样用户能够根据处理器的状况选择不一样的字节对齐方式。例如C/C++编译器提供的#pragma pack(n) n=1,2,4等,让编译器在生成目标文件时,使内存数据按照指定的方式排布在1,2,4等字节整除的内存地址处。

然而在不一样编译平台或处理器上,字节对齐会形成消息结构长度的变化。编译器为了使字节对齐可能会对消息结构体进行填充,不一样编译平台可能填充为不一样的形式,大大增长处理器间数据通讯的风险。

下面以32位处理器为例,提出一种内存对齐方法以解决上述问题。

对于本地使用的数据结构,为提升内存访问效率,采用四字节对齐方式;同时为了减小内存的开销,合理安排结构体成员的位置,减小四字节对齐致使的成员之间的空隙,下降内存开销。

对于处理器之间的数据结构,须要保证消息长度不会因不一样编译平台或处理器而致使消息结构体长度发生变化,使用一字节对齐方式对消息结构进行紧缩;为保证处理器之间的消息数据结构的内存访问效率,采用字节填充的方式本身对消息中成员进行四字节对齐。

数据结构的成员位置要兼顾成员之间的关系、数据访问效率和空间利用率。顺序安排原则是:四字节的放在最前面,两字节的紧接最后一个四字节成员,一字节紧接最后一个两字节成员,填充字节放在最后。

举例以下:

3.1.3.3 排查对齐问题

若是出现对齐或者赋值问题可查看:

编译器的字节序大小端设置;

处理器架构自己是否支持非对齐访问;

若是支持看设置对齐与否,若是没有则看访问时须要加某些特殊的修饰来标志其特殊访问操做。

3.1.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)。

下面具体针对MS VC/C++ 6.0编译器介绍下如何修改编译器默认对齐值。

VC/C++ IDE环境中,可在[Project]|[Settings],C/C++选项卡Category的Code Generation选项的Struct Member Alignment中修改,默认是8字节。

VC/C++中的编译选项有/Zp[1|2|4|8|16],/Zpn表示以n字节边界对齐。n字节边界对齐是指一个成员的地址必须安排在成员的尺寸的整数倍地址上或者是n的整数倍地址上,取它们中的最小值。亦即:min(sizeof(member), n)。实际上,1字节边界对齐也就表示结构成员之间没有空洞。

/Zpn选项应用于整个工程,影响全部参与编译的结构体。在Struct member alignment中可选择不一样的对齐值来改变编译选项。

在编码时,可用#pragma pack动态修改对齐值。具体语法说明见附录5.3节。

自定义对齐值后要用#pragma pack()来还原,不然会对后面的结构形成影响。

【例3】分析以下结构体C:

变量b自身对齐值为1,指定对齐值为2,因此有效对齐值为1,假设C从0x0000开始,则b存放在0x0000,符合0x0000%1= 0;变量a自身对齐值为4,指定对齐值为2,因此有效对齐值为2,顺序存放在0x0002~0x0005四个连续字节中,符合0x0002%2=0。变量c的自身对齐值为2,因此有效对齐值为2,顺序存放在0x0006~0x0007中,符合0x0006%2=0。因此从0x0000到0x00007共八字节存放的是C的变量。C的自身对齐值为4,因此其有效对齐值为2。又8%2=0,C只占用0x0000~0x0007的八个字节。因此sizeof(struct C) = 8。

注意,结构体对齐到的字节数并不是彻底取决于当前指定的pack值,以下:

另外,GNU GCC编译器中按1字节对齐可写为如下形式:

此时sizeof(struct C)的值为7。

3.2 栈内存对齐

在VC/C++中,栈的对齐方式不受结构体成员对齐选项的影响。老是保持对齐且对齐在4字节边界上。

【例4】

结果以下:

能够看出都是对齐到4字节。而且前面的char和short并无被凑在一块儿(成4字节),这和结构体内的处理是不一样的。

至于为何输出的地址值是变小的,这是由于该平台下的栈是倒着“生长”的。

3.3 位域对齐

3.3.1 位域定义

有些信息在存储时,并不须要占用一个完整的字节,而只需占几个或一个二进制位。例如在存放一个开关量时,只有0和1两种状态,用一位二进位便可。为了节省存储空间和处理简便,C语言提供了一种数据结构,称为“位域”或“位段”。

位域是一种特殊的结构成员或联合成员(即只能用在结构或联合中),用于指定该成员在内存存储时所占用的位数,从而在机器内更紧凑地表示数据。每一个位域有一个域名,容许在程序中按域名操做对应的位。这样就可用一个字节的二进制位域来表示几个不一样的对象。

位域定义与结构定义相似,其形式为:

其中位域列表的形式为:

位域的使用和结构成员的使用相同,其通常形式为:

位域容许用各类格式输出。

位域在本质上就是一种结构类型,不过其成员是按二进位分配的。位域变量的说明与结构变量说明的方式相同,可先定义后说明、同时定义说明或直接说明。

位域的使用主要为下面两种状况:

①当机器可用内存空间较少而使用位域可大量节省内存时。如把结构做为大数组的元素时。

②当须要把一结构体或联合映射成某预约的组织结构时。如须要访问字节内的特定位时。

3.3.2 对齐准则

位域成员不能单独被取sizeof值。下面主要讨论含有位域的结构体的sizeof。

C99规定int、unsigned int和bool能够做为位域类型,但编译器几乎都对此做了扩展,容许其它类型的存在。位域做为嵌入式系统中很是常见的一种编程工具,优势在于压缩程序的存储空间。

其对齐规则大体为:

(1)若是相邻位域字段的类型相同,且其位宽之和小于类型的sizeof大小,则后面的字段将紧邻前一个字段存储,直到不能容纳为止;

(2)若是相邻位域字段的类型相同,但其位宽之和大于类型的sizeof大小,则后面的字段将重新的存储单元开始,其偏移量为其类型大小的整数倍;

(3)若是相邻的位域字段的类型不一样,则各编译器的具体实现有差别,VC6采起不压缩方式,Dev-C++和GCC采起压缩方式;

(4)若是位域字段之间穿插着非位域字段,则不进行压缩;

(5)整个结构体的总大小为最宽基本类型成员大小的整数倍,而位域则按照其最宽类型字节数对齐。

【例5】

位域类型为char,第1个字节仅能容纳下element1和element2,因此element1和element2被压缩到第1个字节中,而element3只能从下一个字节开始。所以sizeof(BitField)的结果为2。

【例6】

因为相邻位域类型不一样,在VC6中其sizeof为6,在Dev-C++中为2。

【例7】

非位域字段穿插在其中,不会产生压缩,在VC6和Dev-C++中获得的大小均为3。

【例8】

位域中最宽类型int的字节数为4,所以结构体按4字节对齐,在VC6中其sizeof为16。

3.3.3 注意事项

关于位域操做有几点须要注意:

(1)位域的地址不能访问,所以不容许将&运算符用于位域。不能使用指向位域的指针也不能使用位域的数组(数组是种特殊指针)。

例如,scanf函数没法直接向位域中存储数据:

intmain(void){structBitField1tBit;scanf("%d", &tBit.element2);//error: cannot take address of bit-field 'element2'return0;}

可用scanf函数将输入读入到一个普通的整型变量中,而后再赋值给tBit.element2。

(2)位域不能做为函数返回的结果。

(3)位域以定义的类型为单位,且位域的长度不可以超过所定义类型的长度。例如定义int a:33是不容许的。

(4)位域能够不指定位域名,但不能访问无名的位域。

位域能够无位域名,只用做填充或调整位置,占位大小取决于该类型。例如,char :0表示整个位域向后推一个字节,即该无名位域后的下一个位域从下一个字节开始存放,同理short :0和int :0分别表示整个位域向后推两个和四个字节。

当空位域的长度为具体数值N时(如int :2),该变量仅用来占位N位。

【例9】

结构体大小为3。由于element1占3位,后面要保留6位而char为8位,因此保留的6位只能放到第2个字节。一样element3只能放到第3字节。

长度为0的位域告诉编译器将下一个位域放在一个存储单元的起始位置。如上,编译器会给成员element1分配3位,接着跳过余下的4位到下一个存储单元,而后给成员element3分配5位。故上面的结构体大小为2。

(5)位域的表示范围。

位域的赋值不能超过其能够表示的范围;

位域的类型决定该编码能表示的值的结果。

对于第二点,若位域为unsigned类型,则直接转化为正数;若非unsigned类型,则先判断最高位是否为1,若为1表示补码,则对其除符号位外的全部位取反再加一获得最后的结果数据(原码)。如:

(6)带位域的结构在内存中各个位域的存储方式取决于编译器,既可从左到右也可从右到左存储。

【例10】在VC6下执行下面的代码:

输入i值为11,则输出为i = 11, cba = -2 -1 -1。

Intel x86处理器按小字节序存储数据,因此bits中的位域在内存中放置顺序为ccba。当num.i置为11时,bits的最低有效位(即位域a)的值为1,a、b、c按低地址到高地址分别存储为十、一、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)尽管使用位域能够节省内存空间,但却增长了处理时间。当访问各个位域成员时,须要把位域从它所在的字中分解出来或反过来把一值压缩存到位域所在的字位中。

四,总结

让咱们回到引言部分的问题。

缺省状况下,C/C++编译器默认将结构、栈中的成员数据进行内存对齐。所以,引言程序输出就变成c1 -> 0, s -> 2, c2 -> 4, i -> 8。

编译器将未对齐的成员向后移,将每个都成员对齐到天然边界上,从而也致使整个结构的尺寸变大。尽管会牺牲一点空间(成员之间有空洞),但提升了性能。

也正是这个缘由,引言例子中sizeof(T_ FOO)为12,而不是8。

总结说来,就是:

(1)在结构体中,综合考虑变量自己和指定的对齐值;

(2)在栈上,不考虑变量自己的大小,统一对齐到4字节。

其实作为一个编程学习者,有一个学习的氛围跟一个交流圈子特别重要这里我推荐一个C语言C++交流QQ群1108152000,无论你是小白仍是转行人士欢迎入驻,你们一块儿交流成长。

微信公众号:C语言编程学习基地

相关文章
相关标签/搜索