当前位置: 首页 > 科技观察

C语言对齐问题,包括结构体、栈内存和位域对齐

时间:2023-03-12 18:28:44 科技观察

介绍考虑如下结构体定义:假设这个结构体的成员在内存中排列紧凑,c1的起始地址为0,那么的地址s为1,c2的地址为3,i的地址为4。现在,我们写一个简单的程序:运行后输出:为什么会这样?这是字节对齐引起的问题。本文在参考大量资料的基础上,详细介绍了常见的字节对齐问题。由于撰写时间较早,大部分资料来源已不再提供,敬请谅解。1、什么是字节对齐在现代计算机中,内存空间是按字节划分的,理论上可以从任意起始地址访问任意类型的任意变量。但在实际中,访问特定类型的变量时,往往是在特定的内存地址进行访问,这就需要各种类型的数据在空间上按照一定的规则进行排列,而不是按顺序一个一个地存储,这就是结盟。二、对齐的原因及作用不同的硬件平台对存储空间的处理有很大差异。有些平台只能从特定地址访问特定类型的数据,不允许在内存中任意存放。例如,Motorola68000处理器不允许在其地址中存储16位字,否则会触发异常,因此在该架构下编程必须保证字节对齐。但最常见的情况是,如果数据存储没有按照平台要求对齐,会造成访问效率的损失。例如,32位的Intel处理器通过总线访问(包括读写)内存数据。每个总线周期从偶地址开始访问32位内存数据,内存数据以字节为单位存储。如果一个32位的数据没有存放在能被4字节整除的内存地址,那么处理器访问它需要2个总线周期,显然访问效率下降很多。因此,可以通过合理的内存对齐来提高访问效率。为了使CPU能够快速访问数据,数据的起始地址应该具有“对齐”特性。例如,4字节数据的起始地址应该位于4字节的边界上,即起始地址可以被4整除。此外,合理使用字节对齐也可以有效节省存储空间。但要注意,在32位机器上使用1字节或2字节对齐会减慢变量访问速度。因此需要考虑处理器类型。还应考虑编译器的类型。VC/C++和GNUGCC默认为4字节对齐。3、对齐的分类和准则主要是根据IntelX86架构来介绍结构对齐和栈内存对齐。位字段本质上是结构类型。对于IntelX86平台,无论是结构变量还是简单类型变量,每次内存分配都应该从4的整数倍的地址开始分配。3.1结构对齐在C语言中,结构是一种复合数据类型,其组成元素可以是基本数据类型(如int、long、float等)的变量,也可以是一些复合数据类型(如数组、结构体)body、union等)数据单元。编译器根据其自然对齐方式为结构的每个成员分配空间。成员按照声明的顺序依次存储在内存中,第一个成员的地址与整个结构的地址相同。字节对齐的问题主要针对结构。3.1.1简单例子看一个简单的例子(32位,X86处理器,GCC编译器):【例子1】让结构体定义如下:已知32位上每个数据类型的长度machine是:char是1个字节,short是2个字节,int是4个字节,long是4个字节,float是4个字节,double是8个字节。那么上面两个结构体的大小是多少呢?结果是:sizeof(strcutA)的值为8;sizeof(structB)的值为12。结构A包含一个4字节的int数据,一个1字节的char数据和一个2字节的short数据;乙是一样的。按理说A和B的大小应该都是7字节。出现上述结果的原因是编译器需要在空间上对齐数据成员。3.1.2对齐准则首先看四个重要的基本概念:(1)数据类型本身的对齐值:char类型数据本身的对齐值为1字节,short类型数据为2字节,int/float类型是4字节Byte,double类型是8字节。(2)结构或类的自对齐值:其成员中自对齐值最大的值。(3)指定对齐值:#pragmapack(value)时指定对齐值value。(4)数据成员、结构和类的有效对齐值:自对齐值与指定对齐值中的较小者,即有效对齐值=min{自对齐值,当前指定包值}。基于以上数值,方便讨论具体数据结构的成员与自身的对齐方式。其中,有效对齐值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(structB)=12。编译器之所以在后面加2个字节,是为了达到结构体数组的访问效率。试想如果你定义一个结构体B的数组,那么第一个结构体的起始地址为0没有问题,但是第二个结构体呢?根据数组的定义,数组中的所有元素都是相邻的。如果我们不把结构体的大小补成4的整数倍,那么下一个结构体的起始地址就是0x0000A,显然不能满足结构体的地址对齐。因此,该结构应补充为有效对齐大小的整数倍。其实char/short/int/float/double等现有类型的自对齐值也是基于数组来考虑的,但是因为这些类型的长度是已知的,所以它们的自对齐值也是已知。上面的概念很容易理解,但我个人更喜欢下面的对齐指南。结构体字节对齐的细节与具体的编译器实现有关,但一般来说,满足三个条件:(1)结构体变量的首地址可以被其最宽的基本类型成员的大小整除;(2)结构中每个成员相对于结构首地址的偏移量(offset)是该成员大小的整数倍。如有必要,编译器将在成员之间添加填充字节(内部添加);(3)结构的总大小该大小是结构中最宽的基本类型成员大小的整数倍。如有必要,编译器将在最后一个成员之后添加填充字节{trailingpadding}。上述规则的描述如下:(1)编译器在为结构体开辟空间时,首先在结构体中寻找最宽的基本数据类型,然后寻找内存地址可以被该结构体整除的位置基本数据类型,作为结构体的起始地址。将这个最宽的原始数据类型的大小作为上面介绍的对齐取模。(2)在为结构体成员开辟空间之前,编译器首先检查预开空间的首地址相对于结构体首地址的偏移量是否为该成员大小的整数倍,如果是,则存储该成员,否则,在该成员与前一个成员之间填充一定数量的字节,以满足整数倍的要求,即将预开空间的首地址向后移动若干字节。(3)结构的总大小包括填充字节。最后一个成员除了满足以上两项外,还必须满足第三项。否则最后必须补几个字节才能满足本项的要求。【例2】假设4字节对齐,下面程序的输出是什么?执行后输出如下:我们来详细分析一下:首先,chara占1个字节,没有问题。短b本身占用2个字节。根据上面的规则2,b和a之间需要填充1个字节。charc占用1个字节,没问题。intd本身占用4个字节,根据指南2,d和c之间需要填充3个字节。字符[3];本身占用3个字节,根据原则3,需要在其后增加1个字节。因此,sizeof(T_Test)=1+1+2+1+3+4+3+1=16字节。3.1.3对齐隐患3.1.3.1数据类型转换代码中的很多对齐隐患都是隐含的。比如在强制类型转换的情况下:最后两段代码,从奇数边界访问unsignedshort类型变量显然不符合对齐要求。在X86上,类似的操作只会影响效率;但可能会导致MIPS或SPARC错误,因为它们需要字节对齐。又如3.1.1节中的结构体structB,定义如下函数:如果在函数体中直接访问p->a,则很可能出现异常。因为MIPS认为a是一个int,它的地址应该是4的倍数,但是p->a的地址很可能不是4的倍数,如果p的地址不在对齐边界上,可能会有问题.比如p来自一个跨CPU的数据包(多种数据类型的数据按顺序放在一个数据包中传输),或者p是通过指针移位计算出来的。的。因此,需要特别注意跨CPU数据的接口函数对接口输入数据的处理,指针移位转换成结构体指针进行访问时的安全性。解决方法如下:定义一个该结构的局部变量,使用memmove将数据copy进去。注意:如果能确定p的起始地址没问题,则不需要这样做;如果你不能确定(例如跨CPU输入数据时要小心,或者指针移位操作得到的数据),你需要这样做。使用#pragmapack(1)将STRUCT_T定义为1字节对齐。3.1.3.2处理器间数据通信处理器间通过消息(C/C++结构)进行通信时,需要注意字节对齐和字节顺序。大多数编译器都提供内存对齐选项供用户使用。这样用户就可以根据处理器的情况选择不同的字节对齐方式。例如C/C++编译器提供的#pragmapack(n)n=1,2,4等,让编译器在当生成目标文件的可整除内存地址。但是,在不同的编译平台或处理器上,字节对齐会导致消息结构的长度发生变化。编译器为了字节对齐可能会填充消息结构,不同的编译平台可能会填充不同的形式,这大大增加了处理器之间数据通信的风险。以32位处理器为例,提出了几种内存对齐方法来解决上述问题。对于本地使用的数据结构,为了提高内存访问效率,采用四字节对齐;同时为了减少内存开销,合理安排结构体成员的位置,减少四字节对齐带来的成员间空隙,减少内存占用。高架。对于处理器之间的数据结构,需要保证消息的长度不会因为编译平台或处理器的不同而发生变化,使用单字节对齐来压缩消息结构;消息数据结构的内存访问效率采用字节填充,将消息中的成员用四个字节对齐。数据结构成员的位置应该考虑成员之间的关系、数据访问效率和空间利用率。顺序排列原则是:四字节成员在前,二字节成员在最后一个四字节成员之后,一字节成员在最后一个两字节成员之后,填充字节为放在最后。示例如下:3.1.3.3排查对齐问题如果存在对齐或赋值问题,您可以检查:编译器的字节顺序设置;处理器架构本身是否支持未对齐访问;如果支持,检查是否对齐;如果不是,检查它访问的时候,需要做一些特殊的修改,标记它的特殊访问操作。3.1.4改变对齐方式主要是改变C编译器默认的字节对齐方式。默认情况下,C编译器根据其自然对接条件为每个变量或数据单元分配空间。一般情况下,可以通过以下方法改变默认的对接条件:使用伪指令#pragmapack(n):C编译器会按照n字节对齐;使用伪指令#pragmapack():取消自定义字段对齐。此外,还有如下几种方式(GCC特有的语法):__attribute((aligned(n))):将作用结构体成员对齐到n字节的自然边界上。如果结构中任意成员的长度大于n,则按照最大成员的长度对齐。__attribute__((packed)):取消编译时结构体的优化对齐,按照实际占用字节数对齐。【注意】__attribute__机制是GCC的一大特色,可以设置函数属性(FunctionAttribute)、变量属性(VariableAttribute)和类型属性(TypeAttribute)。下面具体介绍MSVC/C++6.0编译器修改编译器默认对齐值的方法。在VC/C++IDE环境下,可以在【项目】|【设置】C/C++选项卡Category的CodeGeneration选项的StructMemberAlignment中修改,默认为8字节。VC/C++中的编译选项为/Zp[1|2|4|8|16],/Zpn表示对齐n字节边界。N字节边界对齐是指成员的地址必须排列在成员大小的整数倍的地址或n的整数倍的地址,取小者。即:min(sizeof(member),n)。事实上,在1字节边界上对齐意味着结构成员之间没有空洞。/Zpn选项适用于整个项目,影响编译中涉及的所有结构。在Struct成员对齐中,可以选择不同的对齐值来改变编译选项。编码时,可以使用#pragmapack动态修改对齐值。具体语法说明见附录5.3节。自定义对齐后,使用#pragmapack()恢复,否则会影响后续结构。【例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。因此,C变量中存放了0x0000到0x00007共8个字节。C自身的对齐是4,所以它的有效对齐是2。而8%2=0,C只占0x0000~0x0007这8个字节。所以sizeof(structC)=8。注意结构对齐的字节数并不完全取决于当前指定的pack值,如下:另外,在GNUGCC编译器中对齐1字节可以是写成如下形式:此时sizeof(structC)的值为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、unsignedint、bool可以作为位域类型,但几乎所有的编译器都对此进行了扩展,允许其他类型的存在。位域作为嵌入式系统中非常常用的编程工具,其优点是可以压缩程序的存储空间。对齐规则大致如下:(1)如果相邻的位域字段类型相同,且其位宽之和小于该类型的sizeof,则后一个字段紧挨着前一个字段存储直到无法容纳为止;(2)如果相邻的位域字段类型相同,但其位宽之和大于该类型的sizeof大小,则后面的字段将从一个新的存储单元开始,其偏移量为整数其类型大小的倍数;(3)如果同一个位域字段的类型不同,各个编译器的具体实现不同。VC6采用未压缩方式,Dev-C++和GCC采用压缩方式;(4)如果位域字段之间穿插有非位域域,(5)整个结构的总大小是最宽基本类型成员大小的整数倍,位域按照其最宽类型的字节数。【例5】位域类型为char,第一个字节只能容纳element1和element2,所以element1和element2压缩到第一个字节,element3只能从下一个字节开始。所以sizeof(BitField)的结果为2。【例6】由于相邻位域的类型不同,sizeof在VC6中为6,在Dev-C++中为2。【例7】非位域字段穿插其中,不会发生压缩。在VC6和Dev-C++中得到的size是3。[例8]位域中最宽类型int的字节数是4,所以结构体是4字节对齐的,它的sizeof在VC6中是16。3.3.3注意事项位域的操作有几点需要注意:(1)位域的地址是不可访问的,所以位域不能使用&运算符。您不能使用指向位域或位域数组的指针(数组是一种特殊的指针)。例如scanf函数不能直接在位域中存储数据:intmain(void){structBitField1tBit;scanf("%d",&tBit.element2);//error:cannottakeaddressofbit-field'element2'return0;}可用scanf函数将输入读入一个普通整型变量,然后赋值给tBit.element2。(2)位域不能作为函数返回的结果。(3)位域的单位是定义类型,位域的长度不能超过定义类型的长度。例如,不允许定义inta:33。(4)位域不能指定位域名,但不能访问未命名的位域。位域可以没有位域,仅用于填充或调整位置。地方的大小取决于类型。例如char:0表示将整个位域后推一个字节,即从下一个字节开始存储未命名位域之后的下一个位域。同样,short:0和int:0代表整个位域向后推两个和四个字节。当空间字段的长度为特定值N(如int:2)时,这个变量只用来占用N位。【例9】结构体大小为3,因为element1占3位,后面保留6位,而char是8位,所以保留的6位只能放在第二个字节。同样,element3只能放在第3个字节。长度为0的位域告诉编译器将下一个位域放在内存单元的开头。如上,编译器会分配3位给成员element1,然后将剩下的4位跳过到下一个存储单元,再分配5位给成员element3。所以上述结构体的大小为2。(5)位域的表示范围。对位域的赋值不能超出它所能表示的范围;位域的类型决定了该编码可以表示的值的结果。对于第二点,如果位域是无符号类型,则直接转为正数;如果不是无符号类型,先判断最高位是否为1,如果为1,则表示补码,然后将除符号位外的所有位反相加一,得到最终的结果数据(原码)。例如:(6)带位域的结构体的每个位域在内存中的存储方式取决于编译器,可以是从左到右存储,也可以是从右到左存储。【例10】在VC6下执行如下代码:输入i值为11,则输出i=11,cba=-2-1-1。Intelx86处理器以littleendian顺序存储数据,所以bits中的位域在内存中是按照ccba的顺序放置的。当num.i的值为11时,bits的最低位(即位域a)的值为1,a、b、c分别存储为10、1、1(二进制)从低地址到高地址。但是为什么最后的打印结果是a=-1而不是1呢?因为a中定义的signedchar类型是有符号数,所以即使a只有1位,仍然需要进行符号扩展。1作为补码存在,对应原码-1。如果定义a、b、c的类型为unsignedchar,则可以得到cba=211。1011是11的二进制数。注:在C语言中,不同成员使用公共存储区的数据结构类型称为联合(或union)。联合的足迹大小由类型的最大成员决定。联合在定义、规范和用法上类似于结构。(7)位域的实现会因编译器而异,位域的使用会影响程序的可移植性。所以除非必要,否则最好不要使用位域。(8)使用位域虽然可以节省内存空间,但增加了处理时间。在访问每个位域成员时,需要将位域从它所在的字中分解出来,或者反过来将一个值压缩存储到位域所在的字bit中。四、结论让我们回到引言部分的问题。默认情况下,C/C++编译器默认对栈中的结构体和成员数据进行内存对齐。因此,preamble输出变为c1->0,s->2,c2->4,i->8。编译器将未对齐的成员移回,将每个成员对齐到自然边界,这也导致整体结构增长在尺寸方面。提高性能,尽管以牺牲一点空间(成员之间的孔)为代价。正是由于这个原因,引入示例中的sizeof(T_FOO)为12,而不是8。总结起来就是:(1)在结构体中综合考虑了变量本身和指定的对齐值;(2)在栈上,不管变量本身的大小,统一对齐到4字节。