C语言字节对齐

本文详细介绍了C语言中的字节对齐概念,解释了字节对齐在提高CPU数据访问效率方面的重要性。通过实例分析了结构体对齐规则,包括结构体的成员对齐和整体对齐,并探讨了栈内存对齐和位域对齐的处理方式。文章还提醒读者注意字节对齐可能导致的问题及解决方案,以及不同编译器对位域的处理差异,强调了位域使用时的注意事项和可能影响程序可移植性的问题。
摘要由CSDN通过智能技术生成

参考来源

文章主要参考自以下文章,进行整合补充:
C语言内存对齐详解
C语言字节对齐问题详解
什么是字节对齐,为什么需要字节对齐

字节对齐概念

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

  为了使CPU能够对变量进行快速的访问,变量的起始地址应该具有某些特性,即所谓的”对齐”,比如4字节的int型,其起始地址应该位于4字节的边界上,即起始地址能够被4整除,也即对齐跟数据在内存中的位置有关。如果一个变量的内存地址正好位于它长度的整数倍,他就被称做自然对齐。

  比如在32位cpu下,假设一个整型变量的地址为0x00000004(为4的倍数),那它就是自然对齐的,而如果其地址为0x00000002(非4的倍数)则是非对齐的。

  现代计算机中内存空间都是按照byte划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但实际情况是在访问特定类型变量的时候经常在特定的内存地址访问,这就需要各种类型数据按照一定的规则在空间上排列,而不是顺序的一个接一个的排放,这就是对齐。

字节对齐的作用

  需要字节对齐的根本原因在于CPU访问数据的效率问题。例:假设一个处理器总是从存储器中取出4个字节,如果0x02~0x05存了一个int,读取这个int就需要先读0x01~0x04,留下0x02~0x04的内容,再读0x05~0x08,留下0x05的内容,两部分拼接起来才能得到那个int的值,这样读一个int就要两次内存访问,效率就低了。

  而如果变量在自然对齐位置上,则只要一次就可以取出数据。一些系统对对齐要求非常严格,比如sparc系统或者MIPS架构下,如果取未对齐的数据会发生错误,而在x86上就不会出现错误,只是效率下降。

  为使CPU能够对数据进行快速访问,数据的起始地址应具有“对齐”特性。比如4字节数据的起始地址应位于4字节边界上,即起始地址能够被4整除。
  另外字节对齐的作用不仅是便于cpu快速访问,同时合理的利用字节对齐可以有效地节省存储空间

分类

结构体对齐

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

简单示例

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

 struct A{
     int    a;
     char   b;
     short  c;
 };
struct B{
     char   b;
     int    a;
     short  c;
};

  已知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字节。之所以出现上述结果,就是因为编译器要对数据成员在空间上进行对齐。

struct A:
在这里插入图片描述
struct B:
在这里插入图片描述
对于结构体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。所以0x000A ~ 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);如果使用pragma pack(n) 有效对齐值指定为n,则成员的对齐值为 min(n,自身对齐值) 。例如:n为4,那么double对齐值也会以4来计算,小于等于4的类型则不变。

  3. 结构体的总大小为结构体最宽基本类型成员大小的整数倍,如有需要编译器会在最末一个成员之后加上填充字节{trailing padding}。如果使用pragma pack(n) 有效对齐值指定为n,则总大小为 min(n,最大成员对齐值) 的整数倍。

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

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

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

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

示例

示例1
struct A
{
    char a;
    double b;
};

struct B
{
	double a;
    char b;
}

  此时,结构体大小都为16。
  结构体A中a的地址在 0x00000x0001 ~ 0x0007 为自动填充,b的地址在 0x0008 ~ 0x000F,整体大小是最大成员对齐值8(double)的整数倍。
  结构体B中a的地址在 0x0000~0x00070x0008是b的地址, 0x0009 ~ 0x000F为自动填充,整体大小是最大成员对齐值8(double)的整数倍。

示例2
#pragma pack(4)
struct A
{
    char a;
    double b;
};

struct B
{
	double a;
    char b;
}

  此时,结构体大小都为12。
  此时double的对齐值以及结构体整体的对齐值都应当为 有效对齐值 = min(n,double自身对齐值) = min(4,8)= 4
  结构体A中a的地址在 0x00000x0001 ~ 0x0003 为自动填充,b的地址在 0x0004 ~ 0x000B,整体大小是有效对齐值(4)的整数倍。
  结构体B中a的地址在 0x0000~0x00070x0008是b的地址, 0x0009 ~ 0x000B为自动填充,整体大小是有效对齐值(4)的整数倍。

隐患

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

struct B{
     char   b;
     int    a;
     short  c;
};
void Func(struct B *p){
     //Code
}

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

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

解决方式如下:

  1. 定义一个此结构的局部变量,用memmove方式将数据拷贝进来。
void Func(struct B *p){
    struct B tData;
    memmove(&tData, p, sizeof(struct B));
    //此后可安全访问tData.a,因为编译器已将tData分配在正确的起始地址上
}

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

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

栈内存对齐

所谓的栈内存对齐,就是程序在声明变量时,内存是在栈上进行开辟的,这时,这些变量也要遵循内存对齐的原则,在VC/C++中,栈的对齐方式不受结构体成员对齐选项的影响。总是保持对齐且对齐在4字节边界上。

位域对齐

  有些信息在存储时,并不需要占用一个完整的字节,而只需占几个或一个二进制位。例如在存放一个开关量时,只有0和1两种状态,用一位二进位即可。为了节省存储空间和处理简便,C语言提供了一种数据结构,称为“位域”或“位段”。
  位域是一种特殊的结构成员或联合成员(即只能用在结构或联合中),用于指定该成员在内存存储时所占用的位数,从而在机器内更紧凑地表示数据。每个位域有一个域名,允许在程序中按域名操作对应的位。这样就可用一个字节的二进制位域来表示几个不同的对象。
其对齐规则大致为:

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

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

  3. 如果相邻的位域字段的类型不同,则各编译器的具体实现有差异,VC6采取不压缩方式,Dev-C++和GCC采取压缩方式;

  4. 如果位域字段之间穿插着非位域字段,则不进行压缩;

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

示例1:

 struct BitField{
    char element1  : 1;
    char element2  : 4;
    char element3  : 5;
};

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

示例2:

struct BitField1{
     char element1   : 1;
     short element2  : 5;
     char element3   : 7;
};

由于相邻位域类型不同,在VC6中其sizeof为6,在Dev-C++中为2。
示例3:

struct BitField2{
     char element1  : 3;
     char element2  ;
     char element3  : 5;
};

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

struct StructBitField{
    int element1   : 1;
    int element2   : 5;
    int element3   : 29;
    int element4   : 6;
    char element5  :2;
    char stelement;  //在含位域的结构或联合中也可同时说明普通成员
};

位域中最宽类型int的字节数为4,因此结构体按4字节对齐,在VC6中其sizeof为16。
注意事项:

  1. 位域的地址不能访问,因此不允许将&运算符用于位域。不能使用指向位域的指针也不能使用位域的数组(数组是种特殊指针)。
    可用scanf函数将输入读入到一个普通的整型变量中,然后再赋值给tBit.element2。

  2. 位域不能作为函数返回的结果。

  3. 位域以定义的类型为单位,且位域的长度不能够超过所定义类型的长度。例如定义int a:33是不允许的。

  4. 位域可以不指定位域名,但不能访问无名的位域。

  5. 位域的表示范围。

    • 位域的赋值不能超过其可以表示的范围;
    • 位域的类型决定该编码能表示的值的结果。
  6. 带位域的结构在内存中各个位域的存储方式取决于编译器,既可从左到右也可从右到左存储。

  7. 位域的实现会因编译器的不同而不同,使用位域会影响程序可移植性。因此除非必要否则最好不要使用位域。

  8. 尽管使用位域可以节省内存空间,但却增加了处理时间。当访问各个位域成员时,需要把位域从它所在的字中分解出来或反过来把一值压缩存到位域所在的字位中。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值