本文首发于 2014-07-21 15:32:28
1. 引言
考虑下面的结构体定义:
1 | typedef struct{ |
假设这个结构体的成员在内存中是紧凑排列的,且 c1 的起始地址是 0,则 s 的地址就是 1,c2 的地址是 3,i 的地址是 4。
现在,我们编写一个简单的程序:
1 | int main(void){ |
运行后输出:
1 | c1 -> 0, s -> 2, c2 -> 4, i -> 8 |
为什么会这样?这就是字节对齐导致的问题。
本文在参考诸多资料的基础上,详细介绍常见的字节对齐问题。因成文较早,资料来源大多已不可考,敬请谅解。
2. 什么是字节对齐
现代计算机中,内存空间按照字节划分,理论上可以从任何起始地址访问任意类型的变量,但实际上在访问特定类型变量时经常在特定的内存地址访问,这就需要各种类型数据按照一定的规则在空间上排列,而不是一个接一个地顺序存放,这就是对齐。
3. 对齐的原因和作用
- 不同硬件平台对存储空间的处理上存在很大的不同。某些平台对特定类型的数据只能从特定地址开始存取,而不允许其在内存中任意存放。例如 Motorola 68000 处理器不允许 16 位的字存放在奇地址,否则会触发异常,因此在这种架构下编程必须保证字节对齐。
- 如果不按照平台要求对数据存放进行对齐,会带来存取效率上的损失。比如 32 位的 Intel 处理器通过总线访问(包括读和写)内存数据。每个总线周期从偶地址开始访问 32 位内存数据,内存数据以字节为单位存放。如果一个 32 位的数据没有存放在 4 字节整除的内存地址处,那么处理器就需要 2 个总线周期对其进行访问,显然访问效率下降很多。因此,通过合理的内存对齐可以提高访问效率。 为使 CPU 能够对数据进行快速访问,数据的起始地址应具有“对齐”特性。比如 4 字节数据的起始地址应位于 4 字节边界上,即起始地址能够被 4 整除。
- 合理利用字节对齐还可以有效地节省存储空间。但要注意,在 32 位机中使用 1 字节或 2 字节对齐,反而会降低变量访问速度,因此,需要考虑处理器类型。同时,还应考虑编译器的类型,
在VC/C++和GNU GCC中都是默认是4字节对齐
。
4. 对齐的分类和准则
本小节主要基于 Intel X86 架构介绍结构体对齐和栈内存对齐,位域本质上为结构体类型。
对于 Intel X86 平台,每次分配内存应该是从 4 的整数倍地址开始分配,无论是对结构体变量还是简单类型的变量。
4.1. 结构体对齐
在 C 语言中,结构体是种复合数据类型,其构成元素既可以是基本数据类型(int、long、float 等)的变量,也可以是一些复合数据类型(数组、结构体、联合等)的数据单元。编译器为结构体的每个成员按照其自然边界(alignment)分配空间。各成员按照它们被声明的顺序在内存中顺序存储,第一个成员的地址和整个结构的地址相同。
字节对齐的问题主要就是针对结构体。
4.1.1. 简单示例
先看个简单的例子(32 位,X86 处理器,GCC 编译器):
【例 1】假设结构体定义如下:
1 | struct A{ |
已知 32 位机器上各数据类型的长度为:char 为 1 字节、short 为 2 字节、int 为 4 字节、long 为 4 字节、float 为 4 字节、double 为 8 字节。那么上面两个结构体大小如何呢?
结果是:sizeof(strcut A)值为 8;sizeof(struct B)的值却是 12。
结构体 A 和 B 中字段一样,包含一个 4 字节的 int 数据,一个 1 字节 char 数据和一个 2 字节 short 数据,只是顺序不同。按理说 A 和 B 大小应该都是 7 字节,之所以出现上述结果,就是因为编译器要对数据成员在空间上进行对齐。
4.1.2. 对齐准则
先来看四个重要的基本概念:
数据类型自身的对齐值:char 型数据自身对齐值为 1 字节,short 型数据为 2 字节,int/float 型为 4 字节,double 型为 8 字节。
结构体或类的自身对齐值:
其成员中自身对齐值最大的那个值
。指定对齐值:
#pragma pack (value)
指定对齐值 value。数据成员、结构体和类的有效对齐值:自身对齐值和指定对齐值中较小者,即
有效对齐值=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 等已有类型的自身对齐值也是基于数组考虑的,只是因为这些类型的长度已知,所以他们的自身对齐值也就已知。
上面的概念非常便于理解,不过个人还是更喜欢下面的对齐准则。
结构体字节对齐的细节和具体编译器实现相关,但一般而言满足三个准则:
- 结构体变量的首地址能够被其最宽基本类型成员的大小所整除;
- 结构体每个成员相对结构体首地址的偏移量(offset)都是成员大小的整数倍,如有需要编译器会在成员之间加上填充字节(internal adding);
- 结构体的总大小为结构体最宽基本类型成员大小的整数倍,如有需要编译器会在最末一个成员之后加上填充字节{trailing padding}。
对于以上规则的说明如下:
- 第一条:编译器在给结构体开辟空间时,首先找到结构体中最宽的基本数据类型,然后寻找内存地址能被该基本数据类型所整除的位置,作为结构体的首地址。
将这个最宽的基本数据类型的大小作为上面介绍的对齐模数
。 - 第二条:为结构体的一个成员开辟空间之前,
编译器首先检查预开辟空间的首地址相对于结构体首地址的偏移是否是本成员大小的整数倍
,若是,则存放本成员,反之,则在本成员和上一个成员之间填充一定的字节,以达到整数倍的要求,也就是将预开辟空间的首地址后移几个字节。 - 第三条:
结构体总大小是包括填充字节
,最后一个成员满足上面两条以外,还必须满足第三条,否则就必须在最后填充几个字节以达到本条要求。
【例 2】假设 4 字节对齐,以下程序的输出结果是多少?
1 | /* OFFSET宏定义可取得指定结构体某成员在结构体内部的偏移 */ |
执行后输出如下:
1 | Size = 16 |
下面来具体分析:
首先 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 字节。
4.1.3. 对齐的隐患
4.1.3.1. 数据类型转换
代码中关于对齐的隐患,很多是隐式的。例如,在强制类型转换的时候:
1 | int main(void){ |
最后两句代码,从奇数边界去访问 unsigned short 型变量,显然不符合对齐的规定。在X86上,类似的操作只会影响效率;但在MIPS或者SPARC上可能导致error,因为它们要求必须字节对齐
。
又如对于 3.1.1 节的结构体 struct B,定义如下函数:
1 | void Func(struct B *p){ |
在函数体内如果直接访问 p->a,则很可能会异常。因为 MIPS 认为 a 是 int,其地址应该是 4 的倍数,但 p->a 的地址很可能不是 4 的倍数。
如果 p 的地址不在对齐边界上就可能出问题,比如 p 来自一个跨 CPU 的数据包(多种数据类型的数据被按顺序放置在一个数据包中传输),或 p 是经过指针移位算出来的。因此要特别注意跨 CPU 数据的接口函数对接口输入数据的处理,以及指针移位再强制转换为结构指针进行访问时的安全性。
解决方式如下:
- 定义一个此结构的局部变量,用
memmove
方式将数据拷贝进来。
1 | void Func(struct B *p){ |
注意:如果能确定 p 的起始地址没问题,则不需要这么处理;如果不能确定(比如跨 CPU 输入数据、或指针移位运算出来的数据),则需要这样处理。 2. 用#pragma pack (1)
将 STRUCT_T 定义为 1 字节对齐方式。
4.1.3.2. 处理器间数据通信
处理器间通过消息(对于 C/C++而言就是结构体)进行通信时,需要注意字节对齐以及字节序的问题。
大多数编译器提供一些内存选项供用户使用。这样用户可以根据处理器的情况选择不同的字节对齐方式。例如:C/C++编译器提供的#pragma pack(n) n=1,2,4
等,让编译器在生成目标文件时,使内存数据按照指定的方式排布在1,2,4等字节整除的内存地址处
。
然而在不同编译平台或处理器上,字节对齐会造成消息结构长度的变化。编译器为了使字节对齐可能会对消息结构体进行填充,不同编译平台可能填充为不同的形式,大大增加处理器间数据通信的风险。
下面以 32 位处理器为例,提出一种内存对齐方法以解决上述问题。
对于本地使用的数据结构,为提高内存访问效率,采用 4 字节对齐方式;同时为了减少内存的开销,合理安排结构体成员的位置,减少 4 字节对齐导致的成员之间的空隙,降低内存开销。
对于处理器之间的数据结构,需要保证消息长度不会因不同编译平台或处理器而导致消息结构体长度发生变化,使用 1 字节对齐方式对消息结构进行紧缩;为保证处理器之间的消息数据结构的内存访问效率,采用字节填充的方式自己对消息中成员进行 4 字节对齐。
数据结构的成员位置要兼顾成员之间的关系、数据访问效率和空间利用率。顺序安排原则是:4 字节的放在最前面,2 字节的紧接最后一个 4 字节成员,1 字节紧接最后一个 2 字节成员,填充字节放在最后。
举例如下:
1 | typedef struct tag_T_MSG{ |
4.1.3.3. 排查对齐问题
如果出现对齐或者赋值问题,可查看:
- 编译器的字节序大小端设置;
- 处理器架构本身是否支持非对齐访问;
如果支持,则看是否设置对齐;
如果没有,则看访问时是否需要加某些特殊的修饰来标志其特殊访问操作。
4.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)。
在编码时,可用#pragma pack
动态修改对齐值。具体语法说明见附录 5.3 节。
自定义对齐值后要用#pragma pack()
来还原,否则会对后面的结构造成影响。
【例 3】分析如下结构体 C:
1 |
|
变量 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 共 8 字节存放的是 C 的变量。
C 的自身对齐值为 4,所以其有效对齐值为 2。又 8%2=0,C 只占用 0x0000~0x0007 的八个字节。所以 sizeof(struct C)=8。
注意:结构体对齐到的字节数并非完全取决于当前指定的 pack 值,例如:
1 |
|
虽然#pragma pack(8)
,但依然按照 2 字节对齐,所以 sizeof(struct D) 的值为 6。所以,对齐到的字节数=min{当前指定的 pack 值,最大成员大小}。
另外,GNU GCC 编译器中按 1 字节对齐可写为以下形式:
1 |
|
此时 sizeof(struct C) 的值为 7。
4.2. 栈内存对齐
在 VC/C++中,栈的对齐方式不受结构体成员对齐选项的影响,总是保持对齐在 4 字节边界上。
【例 4】分析栈内存对齐方式:
1 |
|
结果如下:
1 | a address: 0xbfc4cfff |
可以看出都是对齐到 4 字节,并且前面的 char 和 short 并没有被凑在一起(成 4 字节),这和结构体内的处理是不同的。
至于为什么输出的地址值是变小的,这是因为该平台下的栈是倒着“生长”的。
4.3. 位域对齐
4.3.1. 位域定义
有些信息在存储时,并不需要占用一个完整的字节,而只需占几个或一个二进制位。例如在存放一个开关量时,只有 0 和 1 两种状态,用一位二进位即可。为了节省存储空间和处理简便,C 语言提供了一种数据结构,称为位域或位段。
位域是一种特殊的结构成员或联合成员(即只能用在结构或联合中),用于指定该成员在内存存储时所占用的位数,从而在机器内更紧凑地表示数据。每个位域有一个域名,允许在程序中按域名操作对应的位,这样就可用一个字节的二进制位域来表示几个不同的对象。
位域定义与结构定义类似,其形式为:
1 | struct 位域结构名 |
其中位域列表的形式为:
1 | 类型说明符位域名:位域长度 |
位域的使用和结构成员的使用相同,其一般形式为:
1 | 位域变量名.位域名 |
位域允许用各种格式输出。
位域在本质上就是一种结构类型,不过其成员是按二进位分配的。位域变量的说明与结构变量说明的方式相同,可先定义后说明、同时定义说明或直接说明。
位域的使用主要为下面两种情况:
- 当机器可用内存空间较少而使用位域可大量节省内存时。例如:把结构作为大数组的元素时。
- 当需要把一结构体或联合映射成某预定的组织结构时。例如:需要访问字节内的特定位时。
4.3.2. 对齐准则
位域成员不能单独被取sizeof值
。下面主要讨论含有位域的结构体的 sizeof。
C99 规定 int、unsigned int 和 bool 可以作为位域类型,但编译器几乎都对此作了扩展,允许其它类型的存在。位域作为嵌入式系统中非常常见的一种编程工具,优点在于压缩程序的存储空间。
其对齐规则大致为:
- 如果相邻位域字段的类型相同,且其位宽之和小于类型的 sizeof 大小,则后面的字段将紧邻前一个字段存储,直到不能容纳为止;
- 如果相邻位域字段的类型相同,但其位宽之和大于类型的 sizeof 大小,则后面的字段将从新的存储单元开始,其偏移量为其类型大小的整数倍;
- 如果相邻的位域字段的类型不同,则各编译器的具体实现有差异,VC6 采取不压缩方式,Dev-C++和 GCC 采取压缩方式;
- 如果位域字段之间穿插着非位域字段,则不进行压缩;
- 整个结构体的总大小为最宽基本类型成员大小的整数倍,而位域则按照其最宽类型字节数对齐。
【例 5】
1 | struct BitField{ |
位域类型为 char,第 1 个字节仅能容纳下 element1 和 element2,所以 element1 和 element2 被压缩到第 1 个字节中,而 element3 只能从下一个字节开始。因此 sizeof(BitField) 的结果为 2。
【例 6】
1 | struct BitField1{ |
由于相邻位域类型不同,在 VC6 中其 sizeof 为 6,在 Dev-C++中为 2。
【例 7】
1 | struct BitField2{ |
非位域字段穿插在其中,不会产生压缩,在 VC6 和 Dev-C++中得到的大小均为 3。
【例 8】
1 | struct StructBitField{ |
位域中最宽类型 int 的字节数为 4,因此结构体按 4 字节对齐,在 VC6 中其 sizeof 为 16。
4.3.3. 注意事项
关于位域操作有几点需要注意:
1)位域的地址不能访问,因此不允许将&运算符用于位域
。不能使用指向位域的指针也不能使用位域的数组(数组是种特殊指针)。例如,scanf 函数无法直接向位域中存储数据:
1 | int main(void){ |
可用 scanf 函数将输入读入到一个普通的整型变量中,然后再赋值给 tBit.element2。
2)位域不能作为函数返回的结果
。
3)位域以定义的类型为单位,且位域的长度不能够超过所定义类型的长度
。例如:定义 int a:33 是不允许的。
4)位域可以不指定位域名,但不能访问无名的位域
。
位域可以无位域名,只用作填充或调整位置,占位大小取决于该类型。例如,
char :0 表示整个位域向后推一个字节
,即该无名位域后的下一个位域从下一个字节开始存放,同理short :0
和int :0
分别表示整个位域向后推两个和四个字节。当空位域的长度为具体数值 N 时(如 int :2),该变量仅用来占位 N 位。
【例 9】
1 | struct BitField3{ |
结构体大小为 3。因为 element1 占 3 位,后面要保留 6 位而 char 为 8 位,所以保留的 6 位只能放到第 2 个字节。同样 element3 只能放到第 3 字节。
1 | struct BitField4{ |
长度为 0 的位域告诉编译器将下一个位域放在一个存储单元的起始位置。如上,编译器会给成员 element1 分配 3 位,接着跳过余下的 4 位到下一个存储单元,然后给成员 element3 分配 5 位。所以,上面的结构体大小为 2 。
5)位域的表示范围:
- 位域的赋值不能超过其可以表示的范围。
- 位域的类型决定该编码能表示的值的结果。
对于第二点,若位域为 unsigned 类型,则直接转化为正数;若非 unsigned 类型,则先判断最高位是否为 1,若为 1,则表示补码,则对其除符号位外的所有位取反再加一得到最后的结果数据(原码)。例如:
1 | unsigned int p:3 = 111; //p表示7 |
6)带位域的结构在内存中各个位域的存储方式取决于编译器,既可从左到右也可从右到左存储。
【例 10】在 VC6 下执行下面的代码:
1 | int main(void){ |
输入 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)尽管使用位域可以节省内存空间,但却增加了处理时间。当访问各个位域成员时,需要把位域从它所在的字中分解出来或反过来把一值压缩存到位域所在的字位中。
5. 总结
让我们回到引言部分的问题。
缺省情况下,C/C++编译器默认将结构、栈中的成员数据进行内存对齐。因此,引言程序输出就变成”c1 -> 0, s -> 2, c2 -> 4, i -> 8”。
编译器将未对齐的成员向后移,将每一个都成员对齐到自然边界上,从而也导致整个结构的尺寸变大。尽管会牺牲一点空间(成员之间有空洞),但提高了性能。
也正是这个原因,引言例子中 sizeof(T_ FOO)为 12,而不是 8。
总结说来,就是:在结构体中,综合考虑变量本身和指定的对齐值;在栈上,不考虑变量本身的大小,统一对齐到 4 字节。
说明: 本文转载自 https://www.cnblogs.com/clover-toeic/p/3853132.html
欢迎关注我的微信公众号【数据库内核】:分享主流开源数据库和存储引擎相关技术。
标题 | 网址 |
---|---|
GitHub | https://dbkernel.github.io |
知乎 | https://www.zhihu.com/people/dbkernel/posts |
思否(SegmentFault) | https://segmentfault.com/u/dbkernel |
掘金 | https://juejin.im/user/5e9d3ed251882538083fed1f/posts |
CSDN | https://blog.csdn.net/dbkernel |
博客园(cnblogs) | https://www.cnblogs.com/dbkernel |