对齐问题主要有3点:变量对齐、结构对齐和数据对齐。前两点是编译器决定的变量映射和结构布局。最后一点与CPU的架构(CISC/RISC)有关。 在大多数情况下,对齐是编译器和CPU的事情,和程序员没什么关系。但在某些情况下,程序员又必须考虑对齐问题,否则会有一些麻烦。
0 约定和预备知识
0.1 地址边界
如果把字节看作小房子,内存就是顺序排列的小房子。每个小房子都有一个顺序编号的门牌号码,例如:0,1,2,...,0xffffffff。我们 把这个门牌号码称作地址。本文将2的整数倍的地址记作2n边界,将4的整数倍的地址记作4n边界,依此类推。显然每个地址都是1n边界,每个4n边界都是 2n边界,每个8n边界都是4n边界。
所谓“对齐”就是把变量放在什么样的地址边界上,例如:1n边界,2n边界,还是4n边界。
0.2 变量的分类
分类源自角度。有多少角度,就有多少分类。最近经常被迫收听“One World, One dream”,其实在我看来,每个生命都有独一无二的梦想,何况国家。如果狗熊有宗教信仰,它心目中的上帝应该是一只相貌儒雅的狗熊吧。
0.2.1 基本类型和复合类型
从构成看,变量可以分为基本类型的变量和复合类型的变量。基本类型就是语言内部支持的简单类型,例如char, short, int, double等。复合类型由基本类型组成,例如结构。本文将基本类型的变量记作基本变量,将复合类型的变量记作复合变量或结构变量。
基本变量的长度目前有1、2、4、8字节。以后可能会有更大的基本变量。嵌入式环境通常不支持浮点,常见的长度是1、2、4字节。
0.2.2 变量的地址
从地址看,变量可以分成有确定地址的变量和没有确定地址的变量。所谓“有确定地址”就是指在程序运行前就有确定的地址。而“没有确定地址”的变量,它们的地址是在运行时确定的。
全局变量和静态变量都有确定地址。局部变量和动态分配的变量没有确定地址。本文将有确定地址的变量记作有址变量。
1 变量对齐
1.1 没有确定地址的变量
局部变量是从堆栈分配的,编译器通常会保证每个局部变量的地址都在4n边界上。
动态分配的变量是从堆上分配。堆的实现与标准库和操作系统有关。在一些简单的嵌入式系统中,我们需要自己实现动态内存分配,这时我们要保证每次分配的内存块地址都在4n边界上,以避免后面谈到的数据对齐问题。
1.2 有确定地址的变量
有址变量的地址是在链接时确定的。编译器通常有设置变量对齐方式的编译选项,我们通常使用该选项的默认值。在默认情况下,编译器会按照默认方式对齐放置有址变量。
所谓按“按默认方式对齐”,就是将长度为1的基本变量放在1n边界上。将长度为2的基本变量放在2n边界上。将长度为4的基本变量放在4n边界上,依此类推。
每个结构变量总是由一个个基本变量构成。结构变量按照该结构中最长的基本变量对齐。如果某个结构基本变量的最大长度是1,编译器就可以把这个结构放在1n边界上。如果某个结构基本变量的最大长度是4,编译器就应该把这个结构放在4n边界上。
那么结构中的成员变量又是怎样对齐的?
1.3 变量对齐会带来什么麻烦?
我在变量对齐问题上吃过一次亏,可以作为本节的一个例子。不过要理解这个例子,读者必须知道ARM CPU的一个特点:就是长度为m的基本变量必须放在mn边界上,否则读写时会发生数据访问错误,其中m=2或4。这就是第3节要介绍的数据对齐。
事情是这样,我定义了几个缓冲区(大数组),然后动态分配这些内存。我的错误在于将这些数组定义为字节数组。我的分配算法是按块分配,每个数据块的大小都是4的整数倍。读者能猜到错误产生的原因了吗?
由于我把缓冲区定义为字节数组,编译器就可以把它们放在1n边界。如果缓冲区的起始地址是奇数地址,从缓冲区分配的内存块的起始地址都是奇数地址。 如果这些内存块被用于需要按2或4字节对齐的变量,读写时就会发生数据访问错误。如果编译器恰好把这些缓冲区放在4n边界上,问题就不会暴露出来。所以前 一次编译可能是好的,但是下一次编译就会发生莫名其妙的错误。调试程序与侦破案件差不多,离犯罪现场越远的凶手就越难发现。在我透过各种表象找到根源之前,吃点苦头是难免的。
解决问题的方法很简单,将缓冲区定义为unsignedint(下文记作uint32)的数组,编译器自然会把它们放到4n边界。在嵌入式系统中,我们经常要为任务定义堆栈。这些堆栈通常都是uint32类型的数组。你知道为什么要把它们定义成uint32数组,而不能定义成字节数组了吗?
2 结构对齐
2.1 基本长度
为了描述方便,我们定义一个基本长度的概念。一个基本变量的基本长度就是它的长度,一个结构变量的基本长度就是结构成员中基本变量的最大长度。前面说过:在默认情况下,结构变量就是按照其基本长度对齐的。
2.2 对齐
在默认情况下,可以认为结构的成员按照默认方式对齐,即长度为m的基本变量放在mn边界上,其中m=1,2,4或8。因为要把成员对齐,结构的各成员间就可能出现填充字节,结构的大小可能大于各成员大小之和。
例如:
typedef struct St1Tag {
char ch1;
int num1;
short sh1;
short sh2;
char ch2;
} St1;
这个结构的基本长度是4,所以这个结构的变量要放在4n边界。成员num1的基本长度为4,所以也要放在4n边界。成员ch1从4n边界开始,只占1个字节,所以在ch1和num1之间有3个填充字节。
在对齐时,编译器会将结构长度取整到基本长度的整数倍。这样以该结构为基本类型的数组既可以连续排列,每个元素又可以对齐放置。所以,sizeof(St1)的值是16,在St1的最后一个成员ch2后面还有3个填充字节。 |