字节对齐-C语言

什么是字节对齐?

便于CPU对内存的快速操作,变量在内存中的存放不是连续的,起始地址需要遵循一定的规则。

为什么要字节对齐?

  • 如果不按照平台要求对数据存放进行对齐,会带来存取效率上的损失。比如32位的Intel处理器通过总线访问(包括读和写)内存数据。每个总线周期从偶地址开始访问32位内存数据,内存数据以字节为单位存放。如果一个32位的数据没有存放在4字节整除的内存地址处,那么处理器就需要2个总线周期对其进行访问,显然访问效率下降很多。

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

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

对齐的分类与准则

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

结构体对齐

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

示例:

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

对齐准则

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

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

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

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

示例:

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

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

以此分析3.1.1节中的结构体B:
struct B{
    char   b;
    int    a;
    short  c;
};

假设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. 结构体变量的首地址能够被其最宽基本类型成员的大小所整除;
    1. 结构体每个成员相对结构体首地址的偏移量(offset)都是成员大小的整数倍,如有需要编译器会在成员之间加上填充字节(internal adding);
    1. 结构体的总大小为结构体最宽基本类型成员大小的整数倍,如有需要编译器会在最末一个成员之后加上填充字节{trailing padding}。

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

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

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

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

示例:假设4字节对齐,以下程序的输出结果是多少?

/* OFFSET宏定义可取得指定结构体某成员在结构体内部的偏移 */
#define OFFSET(st, field)     (size_t)&(((st*)0)->field)
typedef struct{
    char  a;
    short b;
    char  c;
    int   d;
    char  e[3];
}T_Test;

int main(void){  
    printf("Size = %d\n  a-%d, b-%d, c-%d, d-%d\n  e[0]-%d, e[1]-%d, e[2]-%d\n",
           sizeof(T_Test), OFFSET(T_Test, a), OFFSET(T_Test, b),
           OFFSET(T_Test, c), OFFSET(T_Test, d), OFFSET(T_Test, e[0]),
           OFFSET(T_Test, e[1]),OFFSET(T_Test, e[2]));
    return 0;
}

执行后输出如下:

1 Size = 16
2   a-0, b-2, c-4, d-8
3   e[0]-12, e[1]-13, e[2]-14

对齐的隐患 (重点)

数据类型转换

代码中关于对齐的隐患,很多是隐式的。

示例1:在强制类型转换的时候:

int main(void){  
    unsigned int i = 0x12345678;
        
    unsigned char *p = (unsigned char *)&i;
    *p = 0x00;
    unsigned short *p1 = (unsigned short *)(p+1);
    *p1 = 0x0000;

    return 0;
}

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

示例2:强制类型转换的结构体访问

void Func(struct B *p){
    //Code
}

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

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

解决方式:

  • 定义一个此结构的局部变量,用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输入数据、或指针移位运算出来的数据要特别小心),则需要这样处理。

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

处理器间数据通信

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

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

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

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

  • 本地使用的数据结构

    • 为提高内存访问效率,采用四字节对齐方式;
    • 为了减少内存的开销,合理安排结构体成员的位置,减少四字节对齐导致的成员之间的空隙,降低内存开销。
  • 处理器之间的数据结构

  • 需要保证消息长度不会因不同编译平台或处理器而导致消息结构体长度发生变化,使用一字节对齐方式对消息结构进行紧缩;

  • 为保证处理器之间的消息数据结构的内存访问效率,采用字节填充的方式自己对消息中成员进行四字节对齐。

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

示例:

typedef struct tag_T_MSG{
    long  ParaA;
    long  ParaB;
    short ParaC;
    char  ParaD;
    char  Pad;   //填充字节
}T_MSG;

排查对齐问题:

  • 编译器的字节序大小端设置;
  • 处理器架构本身是否支持非对齐访问;
  • 如果支持看设置对齐与否,如果没有则看访问时需要加某些特殊的修饰来标志其特殊访问操作。

更改对齐方式

主要是更改C编译器的缺省字节对齐方式。

在缺省情况下,C编译器为每一个变量或是数据单元按其自然对界条件分配空间。一般地,可以通过下面的方法来改变缺省的对界条件:

  • 使用伪指令#pragma pack(n):C编译器将按照n个字节对齐;
  • 使用伪指令#pragma pack(): 取消自定义字节对齐方式。
#pragma pack(2)  //指定按2字节对齐H
struct C{ 
    char b;
    int a;
    hort c;
};
#pragma pack()   //取消指定对齐,恢复缺省对齐

注意:结构体对齐到的字节数并非完全取决于当前指定的pack值,如:

#pragma pack(8)
struct D{ 
    char b;
    short a;
    char c;
};
#pragma pack()

虽然#pragma pack(8),但依然按照两字节对齐,所以sizeof(struct D)的值为6。因为:对齐到的字节数 = min{当前指定的pack值,最大成员大小}。

gcc使用__attribute__((aligned(n))):

attribute((aligned (n))): 让所作用的结构成员对齐在n字节自然边界上。如果结构体中有成员的长度大于n,则按照最大成员的长度来对齐。
attribute ((packed)): 取消结构在编译过程中的优化对齐,按照实际占用字节数进行对齐。

栈内存对齐

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

#pragma pack(push, 1)  //后面可改为1, 2, 4, 8
struct StrtE{
    char m1;
    long m2;
};
#pragma pack(pop)

int main(void){  
    char a;
    short b;
    int c;
    double d[2];
    struct StrtE s;
        
    printf("a    address:   %p\n", &a);
    printf("b    address:   %p\n", &b);
    printf("c    address:   %p\n", &c);
    printf("d[0] address:   %p\n", &(d[0]));
    printf("d[1] address:   %p\n", &(d[1]));
    printf("s    address:   %p\n", &s);
    printf("s.m2 address:   %p\n", &(s.m2));
    return 0;
}

结果如下:

a    address:   0xbfc4cfff
b    address:   0xbfc4cffc
c    address:   0xbfc4cff8
d[0] address:   0xbfc4cfe8
d[1] address:   0xbfc4cff0
s    address:   0xbfc4cfe3  // 1字节对齐
s.m2 address:   0xbfc4cfe4

可以看出都是对齐到4字节。并且前面的char和short并没有被凑在一起(成4字节),这和结构体内的处理是不同的。
至于为什么输出的地址值是变小的,这是因为该平台下的栈是倒着“生长”的。

位域对齐

位域的定义

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

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

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


struct 位域结构名 { 
    位域列表;
};

其中位域列表的形式为:

类型说明符 位域名:位域长;

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

位域变量名.位域名

位域允许用各种格式输出。

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

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

    1. 当机器可用内存空间较少而使用位域可大量节省内存时。如把结构作为大数组的元素时。
    1. 当需要把一结构体或联合映射成某预定的组织结构时。如需要访问字节内的特定位时。

对齐准则

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

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

其对齐规则大致为:

    1. 如果相邻位域字段的类型相同,且其位宽之和小于类型的sizeof大小,则后面的字段将紧邻前一个字段存储,直到不能容纳为止;
    1. 如果相邻位域字段的类型相同,但其位宽之和大于类型的sizeof大小,则后面的字段将从新的存储单元开始,其偏移量为其类型大小的整数倍;
    1. 如果相邻的位域字段的类型不同,则各编译器的具体实现有差异,VC6采取不压缩方式,Dev-C++和GCC采取压缩方式
    1. 如果位域字段之间穿插着非位域字段,则不进行压缩;
    1. 整个结构体的总大小为最宽基本类型成员大小的整数倍,而位域则按照其最宽类型字节数对齐。

示例:

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。


gcc的__attribute__((aligned(n)))

GNU C的一大特色就是__attribute__机制。__attribute__机制可以设置函数属性(Function Attribute)、变量属性(Variable Attribute)和类型属性(Type Attribute)。

__attribute__语法格式为:attribute((attribute-list))。

attribute((aligned (n))): 让所作用的结构成员对齐在n字节自然边界上。如果结构体中有成员的长度大于n,则按照最大成员的长度来对齐。
attribute ((packed)): 取消结构在编译过程中的优化对齐,按照实际占用字节数进行对齐。

attribute((aligned(n)))对变量的影响

使用的语法如下:

int a attribute((aligned(64))) = 10;

这个修饰的影响主要是对齐,所谓对齐是存储为值的起始地址。变量a的地址&a,本来是4字节对齐,变成了64字节对齐(有的环境对最大对齐数值有限制)。64字节对齐就是a的地址的最后6位为0

sizeof(a) = 4; // a 占用的字节数还是4个字节

下面看看在使用了typedef之后对对齐和变量大小的影响:

typedef int myint attribute((aligned(64))) ;
sizeof(myint) = 4; //占用的字节数还是4个字节

这样说明myint 声明的变量按照64字节对齐,大小是4字节,这样就会有一个问题,这个变量不能定义数组:
myint myarray[2]; //这样定义编译器会报err

报错的原因是数组的存储在内存中是连续的,而myint只有4字节确要64字节对齐,这样对齐和连续就不能同时保证,就会报错。

主机字节序与网络字节序

主机字节序

主机字节序(host-byte)指的是处理器存储数据的字节顺序。对于Inter x86处理器来说,将数据的不重要的部分保存在低地址,重要的部分保存在高地址,即低地址中保存的是数据的低字节位,高地址保存的是数据的高字节位。

如果将低地址看成是“头”,主机字节序又叫做“小头”(little-endian)。

网络字节序

网络字节序(network-byte)指的是网络编程时,存储数据的字节顺序。与“主机字节序”相反,网络字节序将数据的重要部分保存在低地址,不重要的部分保存到高地址,即低地址保存的是数据的高字节位,高地址保存的是数据的低字节位。在网络编程时用到的IP地址和端口号都需要网络字节序。

参考

[原文链接](http://www.nfvschool.cn/?p=741)

posted @ 2020-03-15 06:58  可酷可乐  阅读(830)  评论(0编辑  收藏  举报