- 论坛徽章:
- 0
|
C语言字节对齐
更多资料请访问我的博客blog.sina.com.cn/ifreecoding
论坛排版不好并且长度不够,请下载PDF文档阅读
C语言字节对齐.pdf
(262.64 KB, 下载次数: 246)
字节对齐的由来
程序在运行时会将数据临时存放在内存中,芯片内核需要对这些数据进行计算,不断的读取内存以获得数据,并将计算结果写入内存。计算机体系经过若干年的发展,最终确定了以8bits作为其基本的存储单元——byte(字节),这是每个地址所对应的最小访问单元,在C语言中对应一个char型的变量。
下图为芯片内核访问内存的示意图。芯片内核通过控制总线控制内存的动作,通过地址总线告知内存地址,数据总线上出现交互的数据。
图1 访问内存示意图
假设上图是8位机的示意图,那么数据总线的宽度是8bits,由8根数据线组成,这样芯片内核与内存之间一次就可以同时交换8个bits的数据,正好是一个字节。图中右侧的每个小格子代表一个存储地址,对应一个字节。
下面通过一段C语言代码来具体看看芯片内核与内存之间的数据交互过程。
char data[2];
data[0] = 2;
data[1] = data[0] + 1;
第一行代码定义了2个字节的数组data。假设data数组被编译到地址0x100,那么data[0]这个字节就被存储在地址为0x100的内存空间,data[1]这个字节就被存储在地址为0x101的内存空间。
第二行对应的硬件动作是将数据2存入到data[0]中,也就是将数据2存入到内存中的0x100地址,执行这条语句时,芯片内核对控制总线、地址总线和数据总线进行操作,控制总线上出现写信号,地址总线上出现数据0x100,数据总线上出现数据0x02。此时内存就知道需要将数据2写入到地址0x100中,完成一次写操作。
第三行先读出data[0]中的数据,芯片内核将控制总线置为读信号,将地址总线置为0x100,此时,内存就会从其内部取出0x100地址中的数据,也就是数据2,2将出现在数据总线上,此时芯片内核就会通过数据总线读取到data[0]中的数据了。接下来芯片内核计算2+1=3,需要将数字3写入到data[1]中,芯片内核将控制总线置为写信号,将地址总线置为0x101,将数据总线置为3,内存接收到这些信号后,就会将数据3存入到其内部0x101地址中,完成本次操作。
从上述介绍的过程可以看出,芯片内核与存储芯片之间每次操作可以传递1个字节的数据,如果要传递多个字节的数据就需要重复这个过程,这受限于数据总线的宽度。
计算机技术在不断的发展,在8bits数据总线之后又相继出现了16bits、32bits乃至64bits数据总线,它们分别对应于我们所谓的8位机、16位机、32位机以及64位机。对于16位机一次可以交互2个字节的数据,32位机一次可以交互4个字节的数据,64位机一次可以交互8个字节的数据,可以看出总线的带宽增加了,速度成倍提高。
以32位机为例,我们在访问0地址时,可以一次访问4个字节的数据,这4个字节的数据占用了4个内存地址,也就是说访问0地址时同时可以访问0、1、2、3这4个地址,访问4地址时可以同时访问4、5、6、7这4个地址。我们不难得出这样的结论:在地址总线上只要出一个地址,就可以连同访问这个地址及其后面的3个地址中的数据,这4个地址正好可以组成一个32bits的数据,通过访问数据总线一次即可获得,而对这个地址的要求就是:需要4字节对齐(对于64位机则需要8字节对齐)。在芯片设计时遵循了这个要求,地址总线上只需要出现0、4、8……这样4的整数倍的地址就可以同时访问连续4个字节的内存空间,这就是字节对齐的根源——是由硬件决定的!为了配合硬件的4字节对齐访问,软件的编译器链接器也对软件做了限制,需要4字节对齐访问。
有关计算机的设计五花八门,上述有关控制总线、地址总线、数据总线的介绍只是原理性的介绍,不同芯片在具体实现时会有所不同。
字节对齐规则
我们在写代码时一般并不会指定变量存放在内存中的地址,这是由编译器链接器决定的,而编译器链接器则遵循了4字节对齐的原则,以32位机为例,其规则是1字节长度的变量可以被编译链接到任何地址,2字节长度类型的变量被编译链接到2的整数倍的地址,4字节长度类型的变量被编译链接到4的整数倍的地址。因此,取signed/unsigned char类型变量的地址,它可以是任意地址。取signed/unsigned short int类型变量的地址,它一定是2的整数倍。取signed/unsigned int,signed/unsigned long类型变量的地址,它一定是4的整数倍。
C语言的结构体类型由多种基本类型组成,比较利于讨论字节对齐的问题,下面我们将以结构体为例讲解字节对齐规则。以下例子除特殊说明外,均是在X86 32位CPU,VC2010环境下测试。
例1:
typedef struct example1
{
char a;
}EXAMPLE1;
结构体EXAMPLE1比较简单,它其实就是一个char型,它的长度sizeof(EXAMPLE1)为1。
例2:
typedef struct example2
{
char a;
short b;
}EXAMPLE2;
结构体EXAMPLE2中包含了2个变量,其中char型a的长度为1,short型b的长度为2,但结构体EXAMPLE2的整体长度sizeof(EXAMPLE2)却为4,而不是1+2=3,这种现象就是字节对齐造成的。
为了方便观察结构体中变量相对结构体头的偏移地址,我们定义如下的宏:
#define OFFSET(s, e) ((unsigned int)(&((s*)0)->e))
其中s为结构体类型,e为结构体中的变量,OFFSET返回的就是结构体中的变量e相对于结构体s的偏移地址。通过该结构就可以看出结构体在内存中的分布。
求得结构体EXAMPLE2的数据如下:
sizeof(EXAMPLE2) 4
OFFSET(EXAMPLE2, a) 0
OFFSET(EXAMPLE2, b) 2
画出结构体EXAMPLE2在内存中分布如下:
a
b b
其中每个格子代表一个字节,a和b之间灰色背景的格子是编译器为了字节对齐而保留的一个字节空间。为什么会保留一个字节的空间呢,这是因为结构体的对齐长度必须是其内部变量类型中最长的对齐长度,也就是说存放结构体的起始地址必须是其内部变量类型中最长的对齐长度的整数倍。结构体EXAMPLE2中变量a的对齐长度是1,变量b的对齐长度是2,因此EXAMPLE2存放的地址必须是2的整数倍。变量a可以存放在任何地址,因此存放在EXAMPLE2开始的第一个字节,这个字节所在的地址是2的整数倍,接下来的字节(灰色)所在的地址不是2的整数倍,而变量b又只能存放在2的整数倍地址,因此a和b之间只好空出1个字节,这就使结构体EXAMPLE2的长度变为4了。
例3:
typedef struct example3
{
char a;
short b;
int c;
}EXAMPLE3;
在结构体EXAMPLE2的基础上再增加一个int变量c构造成结构体EXAMPLE3,按照例2中介绍的方法分析一下结构体EXAMPLE3的长度。
EXAMPLE3中最长对齐长度的变量是c,4个字节,因此EXAMPLE3开始的地址必须是4的整数倍。变量a是1个字节,存放在EXAMPLE3开始的第一个字节。变量b是2个字节,需要在a之后空出1个字节,才能存放在2字节对齐的地址。变量c是4个字节,需要存放在4字节对齐的地址,前面的变量a、保留字节和变量b之后已经是4字节对齐的地址了,因此变量c可以直接存放在变量b之后。
按照上面的分析,我们可以画出EXAMPLE3在内存中的分布示意图:
a b b
c c c c
可以看到EXAMPLE3占有8个字节。我们再使用sizeof和OFFSET计算EXAMPLE3的数据进行验证,如下:
sizeof(EXAMPLE3) 8
OFFSET(EXAMPLE3, a) 0
OFFSET(EXAMPLE3, b) 2
OFFSET(EXAMPLE3, c) 4
例4:
typedef struct example4
{
char a;
char b;
short c;
int d;
}EXAMPLE4;
在结构体EXAMPLE3的基础上再增加一个char的变量构造成结构体EXAMPLE4,EXAMPLE4比EXAMPLE3多了一个char型变量,那么EXAMPLE4是否会比EXAMPLE3长1个字节?
EXAMPLE4中最长的对齐长度的变量是d,4个字节,因此EXAMPLE4开始的地址必须是4的整数倍。变量a是1个字节,存放在EXAMPLE4开始的第一个字节。变量b是1个字节,对字节对齐没有要求,直接存放在a后面。变量c是2个字节,在a、b之后已经是2字节对齐的地址了,因此c可以直接存放在b之后,对齐到2个字节。变量d是4个字节,在a、b、c之后已经是4字节对齐的地址了,因此d可以直接存放在c之后,对齐到4个字节。
按照上面的分析,我们可以画出EXAMPLE4在内存中的分布示意图:
a b c c
d d d d
可以看到EXAMPLE4虽然比EXAMPLE3多了一个变量,但与EXAMPLE3一样同样占有8个字节。我们再使用sizeof和OFFSET计算EXAMPLE3的数据进行验证,如下:
sizeof(EXAMPLE4) 8
OFFSET(EXAMPLE4, a) 0
OFFSET(EXAMPLE4, b) 1
OFFSET(EXAMPLE4, c) 2
OFFSET(EXAMPLE4, d) 4
例5:
typedef struct example5
{
short a;
char b;
}EXAMPLE5;
再来看EXAMPLE5,按照上面介绍的规则你是否会认为它的长度是3?
EXAMPLE5在内存中分布示意图如下:
a a
b
结构体不但要保证其存放的地址需要对齐到其内部变量类型中最长对齐长度的长度的整数倍,其长度也要保证是其内部变量类型中最长的对齐长度的整数倍。EXAMPLE5中最长的对齐长度变量是a,2个字节,因此它也必须是2字节的整数倍,所以在b之后需要填充1个字节。因此sizeof(EXAMPLE5)为4。
例6:
typedef struct example6
{
char a;
int b;
short c;
}EXAMPLE6;
按照前面介绍的方法可以得知EXAMPLE6的长度是12,在内存中分布示意图如下:
a
b b b b
c c
EXAMPLE6的数据如下:
sizeof(EXAMPLE6) 12
OFFSET(EXAMPLE6, a) 0
OFFSET(EXAMPLE6, b) 4
OFFSET(EXAMPLE6, c) 8
例7:
typedef struct example7_1
{
char a;
int b;
char c;
}EXAMPLE7_1;
typedef struct example7_2
{
short a;
EXAMPLE7_1 b;
char c;
}EXAMPLE7_2;
当一个结构体被包含在另外一个结构体中时,我们仍可以使用上面的方法进行分析。
先来看被包含的结构体EXAMPLE7_1,它按照4字节对齐,长度是12,它的内存分布示意图如下:
a
b b b b
c
对于结构体EXAMPLE7_2,short型为2字节对齐,EXAMPLE7_1型被看做一个整体,为4字节对齐,char型为1字节对齐,因此结构体EXAMPLE7_2也需要4字节对齐,可以得出EXAMPLE7_2的内存分布示意图如下:
a a
b.a
b.b b.b b.b b.b
b.c
c
由于EXAMPLE7_1作为一个整体存在,其内部的char型变量b.a并不会直接接在变量a后面,char型变量c也不会直接接在EXAMPLE7_2内部的b.c之后。由于EXAMPLE7_2是4字节对齐的,因此变量c之后需要保留3个字节对齐到4字节。
例8:
typedef struct example8_1
{
char a;
short b;
}EXAMPLE8_1;
typedef struct example8_2
{
char a;
EXAMPLE8_1 b;
char c;
}EXAMPLE8_2;
再来看一下例8这个例子,EXAMPLE8_1按照2字节对齐,长度是4,它的内存分布示意图如下:
a
b b
对于结构体EXAMPLE8_2,char型为1字节对齐,EXAMPLE8_1型为2字节对齐,因此结构体EXAMPLE8_2也需要2字节对齐。在EXAMPLE8_2中将EXAMPLE8_1看做一个整体,可以得出EXAMPLE8_2的内存分布示意图如下:
a
b.a
b.b b.b
c
由于EXAMPLE8_1作为一个整体存在,其内部的char型变量b.a并不会直接接在变量a后面。由于EXAMPLE8_2是2字节对齐的,因此变量c之后需要保留1个字节对齐到2字节。
上面我们了解了字节对齐的规则,是以32位机为例的。8位机中硬件一次所能操作的最大长度是1个字节,多个字节的操作也是由单个字节组成的,因此8位机没有字节对齐的概念。例如过去所广泛使用的8位单片机,它的int型是2个字节,long型是4个字节,但受硬件限制在硬件操作时都是按字节操作的。
理解了这一点,下面的结构体在8位机上的结果也就不意外了:
例9:
typedef struct example9
{
char a;
int b;
long c;
}EXAMPLE9;
sizeof(EXAMPLE9)为7。
非字节对齐访问
你是否会想到,如果访问非字节对齐的地址会怎么样?来看下面这个例子:
有一个char型的数组array[5],要求将array[0]~array[3]和array[1]~array[4]分别组成2个int型变量,array中存放的数是按照与处理器大小端相同的模式存放的。
如果处理器是小端模式的话,我们可以使用下面的代码完成:
例10
char array[5] = {1, 2, 3, 4, 5};
int result1, result2;
result1 = array[0] | ((int)array[1] << | ((int)array[2] << 16) | ((int)array[3] << 24);
result2 = array[1] | ((int)array[2] << | ((int)array[3] << 16) | ((int)array[4] << 24);
如果处理器是大端模式的话,我们可以使用下面的代码完成:
char array[5] = {1, 2, 3, 4, 5};
int result1, result2;
result1 = array[3] | ((int)array[2] << | ((int)array[1] << 16) | ((int)array[0] << 24);
result2 = array[4] | ((int)array[3] << | ((int)array[2] << 16) | ((int)array[1] << 24);
除了上面的方法,我们还可以使用指针来实现。先定义一个int*型的指针p,将指针p指向需要转换为int型变量的地址,然后通过*p就可以读出这个变量的值了,可以使用下面的代码实现:
例11
char array[5] = {1, 2, 3, 4, 5};
int* p;
int result1, result2;
p = (int*)&array[0];
result1 = *p;
p = (int*)&array[1];
result2 = *p;
这种方法很简洁,也不需要考虑处理器大小端,得到的结果与例10中的结果完全相同。但这段程序在某些ARM处理器上运行就会出现错误,这其中原因就是由于字节没有对齐造成的:指向int型变量的指针p本该访问4字节对齐的地址,但本例中它却访问了非4字节对齐的地址。
这个例子在某些ARM处理器上虽然会出问题,但在X86处理器及另外一些ARM处理器上却可以正常运行。这是因为后者在硬件设计上支持了非字节对齐的访问——非字节对齐的硬件访问仍可以得到正确的结果。
ARM7、ARM9、ARM11(对应ARMv6以下的架构)处理器以及一些其它处理器需要保证硬件字节对齐访问,否则它就会出错。而Cortex系列的ARM处理器(对应ARMv7架构)以及X86处理器以及一些其它处理器则支持硬件的非字节对齐访问,即使硬件进行了非字节对齐的访问也可以得到正确的结果。
虽说X86处理器及ARMv7架构的处理器硬件可以进行非字节对齐访问,但在它们上面运行的软件仍遵循“字节对齐规则”。
是不是感觉有些迷糊?现在我们总结一下。
在ARMv6架构以下以及一些其它的处理器上,严格遵循字节对齐规则,不仅是硬件遵循字节对齐规则——非字节对齐的硬件访问将产生错误,而且编译器链接器也遵循字节对齐规则——在没有字节对齐的变量间采用保留字节填充,保证分配给变量的地址能字节对齐。如果我们在编写软件时强制进行非字节对齐的访问,绕过了软件字节对齐规则,那么这个非字节对齐的访问就会使它的硬件产生一个非字节对齐的错误。
而在X86以及ARMv7架构等一些其它处理器上,硬件不仅支持字节对齐访问,也支持非字节对齐访问,非字节对齐的硬件访问也可以得到正确的结果,但非字节对齐的硬件访问效率较低,相对字节对齐的硬件访问非字节对齐的硬件访问则需要更多的硬件访问周期组合在一起才能完成一次非字节对齐的访问操作。在软件层次上,编译器链接器遵循了字节对齐的规则,保证分配给变量的地址能字节对齐,相比非字节对齐的地址可以实现更快的访问速度。但如果我们在编写软件时强制进行非字节对齐访问,绕过了软件字节对齐规则,那么也是可以的,硬件会正确的执行这次访问,但效率要低一些。
虽然在软件层次上使用非字节对齐访问可能会有这样或那样的问题,但在某些情况下,软件使用非字节对齐的访问会更方便,就比如说例11这个例子。但在上面的介绍里说明这个例子在ARMv6以下的ARM处理器以及一些其它处理器上运行会出错,如何解决呢?
编译器链接器一般都会提供一些非字节对齐的用法,比如说如果希望在ARMv6以下处理器上运行例11这段程序,如果是在KEIL开发环境下使用RealView 编译器,那么只需要在声明变量p时,在前面加一个“__packed”就可以解决这个问题,实现在ARMv6以下处理器上的非字节对齐硬件访问,如下:
__packed int* p;
“__packed”为何会这样神奇,难道它会改变硬件时序?当然不会是这样,__packed的作用是告诉编译器,int*型变量p需要按1字节对齐访问,而不是4字节,这样编译器在编译时,发现只要是有使用变量p的地方,软件都需要使用字节访问,而不是4字节对齐访问,使用4次字节访问,再将这4次访问的结果拼合成一个4字节的数据。这样就在软件层次上使用字节访问来规避硬件上的非字节对齐访问,这就是其中的奥秘!
例如,对于例11中下面这条语句:
result1 = *p;
在使用__packed定义变量p的情况下,使用4次字节访问分别取出array[0]~ array[3]这4个字节(由于是字节访问,因此不涉及字节对齐的问题),然后再使用例10中的方法将这4个字节的数据组合成一个4字节的数据放入到result1变量中,这样就规避了硬件非字节访问带来的问题。这个字节访问并组合成int型数据的过程是由编译器编译出的代码来实现,而例10的这个过程则需要程序员自己编写代码来实现。
我们再来看一下下面例12的例子,这是我们在编写消息收发通信时经常会遇到的需要使用非字节对齐的例子。
我们在使用编写设备接收消息的程序时,一般是先将接收到的消息存放到一个字节数组缓冲中,然后再对数组中的数据进行解析。比如说在一个char型数组array中已经保存了一组接收到的数据,现在需要解析这些数据,这些数据的格式依次为1个char型的变量a,1个int型的变量b,1个short型的变量c,按小端字节序存放,在数组中分布的示意图如下,要求解析出这3个变量a,b,c的数值。
a b b b b c c
我们可以使用下面的这段代码实现:
例12
char array[7] = {1, 2, 3, 4, 5, 6, 7}; //假设接收到的数据是1, 2, 3, 4, 5, 6, 7
char a;
int b;
short c;
/* 解析出3个变量的数值 */
a = array[0];
b = array[1] | ((int)array[2] << | ((int)array[3] << 16) | ((int)array[4] << 24);
c = array[5] | ((short)array[6] << ;
我相信大部分人都会使用上面的这种方式实现,至少我见过的甚至工作了很多年的人,几乎都是用这种方式实现的。这种实现方式虽然简单,但可读性、可修改性、可维护性却是最差的。
下面我们使用一种较好的方法——结构体指针来实现。先构造一个与数组中变量类型相同的结构体,再将结构体的指针指向数组,那么直接使用结构体中的变量即可读出数组中相关的数据。
我们仿照数组中连续存放的3个类型的变量构建一个结构体,如下:
typedef struct example12
{
char a;
int b;
short c;
}EXAMPLE12;
这个结构体中包含的变量类型虽然符合要求,但由于字节对齐的限制,这个结构体的内存分布示意图如下:
a
b b b b
c c
变量并不是连续存放的,这与数组array在内存中的分布并不相同。但如果这个结构体可以以非字节对齐方式存在,去掉其中保留的填充字节,那么就与数组array在内存中的分布相同了。为此,我们在VC2010环境下可以使用#pragma pack伪指令来实现非字节对齐,代码如下:
例13
#pragma pack(push)
#pragma pack(1)
typedef struct example13
{
char a;
int b;
short c;
}EXAMPLE13;
#pragma pack(pop)
char array[7] = {1, 2, 3, 4, 5, 6, 7}; //假设接收到的数据是1, 2, 3, 4, 5, 6, 7
char a;
int b;
short c;
EXAMPLE13* str;
/* 将结构体指针指向存储数据的空间 */
str = (EXAMPLE13*)array;
/* 解析出3个变量的数值 */
a = str->a;
b = str->b;
c = str->c;
其中#pragma pack(push)的作用是保存前面的字节对齐规则,#pragma pack(1)表示以后的字节对齐规则都是以1字节对齐,#pragma pack(pop)表示恢复保存的字节对齐规则。由于定义结构体EXAMPLE13的地方使用的是1字节对齐,因此结构体EXAMPLE13就会以1字节对齐,它的内存分布示意图如下,去掉了为4字节对齐而填充的保留字节:
a b b b b c c
经过如此处理,例13中的程序就可以正确的转换数据了,其结果与例12一样。
例13在X86处理器上使用非字节对齐访问与例11在ARMv6以下处理器上使用非字节对齐访问的过程是有区别的。由于X86处理器支持硬件的非对齐访问,因此例13中非4字节对齐访问int型变量时,编译器仍使用1个4字节访问指令来完成。而ARMv6以下处理器不支持硬件的非对齐访问,因此例11中非4字节对齐访问int型变量时,编译器会使用4个字节访问指令来完成,然后再将这4个字节拼凑成1个int型数据。
尽管例13的程序看起来要比例12复杂一些,但读起来要清晰很多,尤其是当需求修改时会发现非常方便。比如说需要调换一下array中各个变量的顺序,内存分布示意图改为如下顺序:
c c a b b b b
对于例12来说,程序需要做很多修改,需要仔细的核对每一个变量的字节组合,而对于例13来说,只需要修改结构体定义即可,程序部分不用做任何修改:
#pragma pack(push)
#pragma pack(1)
typedef struct example13
{
short c;
char a;
int b;
}EXAMPLE13;
#pragma pack(pop)
如果增加了一个char型变量e并修改了变量存放的顺序,如下:
a c c e b b b b
这也只需要简单的修改结构体即可,如下:
#pragma pack(push)
#pragma pack(1)
typedef struct example13
{
char a;
short c;
char e;
int b;
}EXAMPLE13;
#pragma pack(pop)
如果这个结构非常复杂又在很多地方使用,那么例12这种写法将会非常难改,而例13这种写法只需要简单的修改结构体即可。
非字节对齐的方法
接下来,我们了解一下让编译器链接器非字节对齐的方法。
目前,我使用过3种修改非字节对齐的方法,当你需要使用非字节对齐时,可以根据编译器选择所能使用的方法。一种是在RealView上使用“__packed”,一种是在GNU上使用“__attribute__((packed))”,另外一种是在VC上使用“#pragma pack”。至于这3种方法是否与编译器一一对应,是否有更多的方法,我无从所知,我记得我刚参加工作时项目中的代码好像在GNU上也使用过“#pragma pack”。这并不是本文档关注的重点,当我们实在弄不清该如何实现非字节对齐时,可以查阅所使用的编译器的支持文档,查找它所支持的非字节对齐的方式,实在不行,做几个实验看看结果就知道了。
下面简单介绍一下这3种方法:
如果使用的是KEIL开发环境下的RealView 编译器,那么我们可以使用“__packed”实现非字节对齐,只要在定义变量的前面加上“__packed”,例如:
__packed int* p;
这表示变量p是非字节对齐的变量,它按照1字节对齐。__packed修饰的变量只能对齐到1字节。
未完,长度不够,请下载PDF文档阅读
这篇文档拖拖拉拉的写了4个多月,为了能说的更清楚让大家看的更明白,真的费了不少力气,挺不容易的。其中有些内容涉及到处理器内部机制,处理器架构又千差万别,我没有能力找到一个全面的权威说明,因此错误也许在所难免。如有问题请到我的博客反馈,我将尽力修正 blog.sina.com.cn/ifreecoding |
|