嵌入式C语言自我修养 07:地址对齐那些事儿

7.1 属性声明:aligned

GNU C 通过 attribute 来声明 aligned 和 packed 属性,指定一个变量或类型的对齐方式。这两个属性用来告诉编译器:在给变量分配存储空间时,要按指定的地址对齐方式给变量分配地址。如果你想定义一个变量,在内存中以8字节地址对齐,就可以这样定义。

int a __attribute__((aligned(8));

通过 aligned 属性,我们可以直接显式指定变量 a 在内存中的地址对齐方式。aligned 有一个参数,表示要按几字节对齐,使用时要注意地址对齐的字节数必须是2的幂次方,否则编译就会出错。

什么是数据对齐

一般情况下,当我们定义一个变量,编译器会按照默认的地址对齐方式,来给该变量分配一个存储空间地址。如果该变量是一个 int 型数据,那么编译器就会按4字节或4字节的整数倍对齐;如果该变量是一个 short 型数据,那么编译器就会按2字节或2字节的整数倍边界对齐;如果是一个 char 类型的变量,那么编译器就会按照1字节对齐。

 int a = 1;
 int b = 2;
 char c1 = 3;
 char c2 = 4;
 int main(void)
 {
     printf("a: %p\n",&a);
     printf("b: %p\n",&b);
     printf("c1:%p\n",&c1);
     printf("c2:%p\n",&c2);
     return 0;
 }

在上面的程序中,我们分别定义2个 int 型变量,2个 char 型变量,然后分别打印它们的地址,运行结果如下。

a:  00402000
b:  00402004
c1: 00402008
c2: 00402009

通过运行结果我们可以看到,对于 int 型数据,其在内存中的地址都是以4字节或4字节整数倍对齐的。而 char 类型的数据,其在内存中是以1字节对齐的。变量 c2 就直接分配到了 c1 变量的下一个存储单元,不用像 int 数据那样考虑4字节对齐。接下来,我们修改一下程序,指定变量 c2 按4字节对齐。

 int a = 1;
 int b = 2;
 char c1 = 3;
 char c2 __attribute__((aligned(4))) = 4;
 int main(void)
 {
     printf("a: %p\n",&a);
     printf("b: %p\n",&b);
     printf("c1:%p\n",&c1);
     printf("c2:%p\n",&c2);
     return 0;
 }

运行结果如下。

a:  00402000
b:  00402004
c1: 00402008
c2: 0040200C

通过运行结果可以看到,字符变量 c2 由于使用 aligned 属性声明按照4字节边界对齐,所以编译器不可能再给其分配 0x00402009 这个地址,因为这个地址不是4字节对齐的。编译器空出3个字节单元,直接从 0x0040200C 这个地址上给变量 c2 分配存储空间。

为什么要数据对齐?

通过 aligned 这个属性声明,我们虽然可以显式指定变量的地址对齐方式,但是也会因边界对齐造成一定的内存空洞,浪费一定的内存空间。比如在上面这个程序中,0x00402009~0x0040200b 这三个地址空间的存储单元就没有被使用。

既然地址对齐会造成一定的内存空洞,那我们为什么还要按照这种对齐方式去存储数据呢?一个主要原因就是,这种对齐设置可以简化 CPU 和内存 RAM 之间的接口和硬件设计。比如一个32位的计算机系统,CPU 读取内存时,硬件设计上可能只支持4字节或4字节倍数对齐的地址访问,CPU 每次往内存 RAM 读写数据时,一个周期可以读写4个字节。如果我们把一个数据放在4字节对齐的地址上,那么CPU一次就可以把数据读写完毕;如果我们把一个 int 型数据放在一个非4字节对齐的地址上,那 CPU 就要分2次才能把这个4字节大小的数据读写完毕。

为了配合计算机的硬件设计,编译器在编译程序时,对于一些基本数据类型,比如 int、char、short、float 等,会按照其数据类型的大小进行地址对齐,按照这种地址对齐方式分配的存储地址,CPU 一次就可以读写完毕。虽然边界对齐会造成一些内存空洞,浪费一些内存单元,但是在硬件上的设计却大大简化了。这也是编译器给我们定义的变量分配地址时,不同类型变量按不同字节数地址对齐的原因。

除了 int、char、short、float 这些基本类型数据,对于一些复合类型数据,也要满足地址对齐要求。

7.2 结构体的对齐

结构体作为一种复合数据类型,编译器在给一个结构体变量分配存储空间时,不仅要考虑结构体内各个基本成员的地址对齐,还要考虑结构体整体的对齐。为了结构体内的成员地址对齐,编译器可能会在结构体内填充一些空间;为了结构体整体对齐,编译器可能会在结构体的末尾填充一些空间。

接下来,我们定义一个结构体,结构体内定义 int、char 和 short 三种成员,并打印结构体的大小和各个成员的地址。

 struct data{
     char a;
     int b ;
     short c ;
 }
 int main(void)
 {
     struct data s;
     printf("size:%d\n",sizeof(s));
     printf("a:%p\n",&s.a);
     printf("b:%p\n",&s.b);
     printf("c:%p\n",&s.c);
 }

程序运行结果如下。

size: 12
 &s.a: 0028FF30
 &s.b: 0028FF34
 &s.c: 0028FF38

我们可以看到,因为结构体的成员 b 需要4字节对齐,编译器在给成员 a 分配完空间后,接着会空出3个字节,在满足4字节对齐的 0x0028FF34 地址处才给成员 b 分配存储空间。接着是 short 类型的成员 c 占据2字节的存储空间。三个结构体成员一共占据4+4+2=10字节的存储空间,根据结构体的对齐规则,结构体的整体对齐要向结构体所有成员中最大对齐字节数或其整数倍对齐,或者说结构体的整体长度要为其最大成员字节数的整数倍,如果不是整数倍要补齐。因为结构体最大成员 int 为4个字节,或者说按4字节的整数倍对齐,所以结构体的长度要为4的整数倍,要在结构体的末尾补充2个字节,所以最后结构体的 size 为12个字节。

结构体成员中,不同的排放顺序,可能也会导致结构体的整体长度不一样,我们修改一下上面的程序。

struct data{
     char a;
     short b ;
     int c ;
 };
 int main(void)
 {
     struct data s;
     printf("size: %d\n",sizeof(s));
     printf("&s.a: %p\n",&s.a);
     printf("&s.b: %p\n",&s.b);
     printf("&s.c: %p\n",&s.c);
 }

程序运行结果如下。

size: 8
 &s.a: 0028FF30
 &s.b: 0028FF32
 &s.c: 0028FF34

我们调整了一些成员顺序,你会发现,char 型变量 a 和 short 型变量 b,分配在了结构体的前4个字节存储空间中,而且都满足各自的地址对齐,整个结构体大小是8字节,只造成一个字节的内存空洞。我们继续修改程序,让 short 型的变量 b 按4字节对齐:

struct data{
     char a;
     short b __attribute__((aligned(4)));
     int c ;
 };

程序运行结果如下。

 size: 12
 &s.a: 0028FF30
 &s.b: 0028FF34
 &s.c: 0028FF38

你会发现,结构体的大小又重新变为12个字节。这是因为,我们显式指定 short 变量以4字节地址对齐,导致变量 a 的后面填充了3个字节空间。int 型变量 c 也要4字节对齐,所以变量 b 的后面也填充了2个字节,导致整个结构体的大小为12字节。

我们不仅可以显式指定结构体内某个成员的地址对齐,也可以指定整个结构体的对齐方式。

struct data{
     char a;
     short b;
     int c ;
 }__attribute__((aligned(16)));

程序运行结果如下。

 size: 16
 &s.a: 0028FF30
 &s.b: 0028FF32
 &s.c: 0028FF34

在这个结构体中,各个成员一共占8个字节。通过前面学习我们知道,整个结构体的对齐只要是最大成员对齐字节数的整数倍即可。所以这个结构体整体就以8字节对齐,结构体的整体长度为8字节。但是我们在这里,显式指定结构体整体以16字节对齐,所以编译器就会在这个结构体的末尾填充8个字节以满足16字节对齐的要求,导致结构体的总长度变为16字节。

7.3 思考:编译器一定会按照我们指定的大小对齐吗?

通过 aligned 属性,我们可以显式指定一个变量的对齐方式,那么,编译器就一定会按照我们指定的大小对齐吗?非也!

我们通过这个属性声明,其实只是建议编译器按照这种大小地址对齐,但不能超过编译器允许的最大值。一个编译器,对每个基本数据类型,都有默认的最大边界对齐字节数。如果你超过了,不好意思,我不奉陪,编译器只能按照它规定的最大对齐来给你的变量分配地址。

 char c1 = 3;
 char c2 __attribute__((aligned(16))) = 4 ;
 int main(void)
 {
     printf("c1:%p\n",&c1);
     printf("c2:%p\n",&c2);
     return 0;
 }

在这个程序中,我们指定 char 型的变量 c2 以16字节对齐,然后运行结果为:

c1:00402000
c2:00402010

我们可以看到,编译器给 c2 分配的地址就是16字节地址对齐的,如果我们继续修改 c2 变量按32字节对齐,你会发现程序的运行结果不再会有变化,编译器还会分配一个16字节对齐的地址,因为已经超过编译器允许的最大值了。

7.4 属性声明:packed

aligned 属性一般用来增大变量的地址对齐,元素之间因为地址对齐会造成一定的内存空洞。而 packed 属性则与之相反,用来减少地址对齐,用来指定变量或类型使用最可能小的地址对齐方式。

struct data{
     char a;
     short b __attribute__((packed));
     int c __attribute__((packed));
 };
 int main(void)
 {
     struct data s;
     printf("size: %d\n",sizeof(s));
     printf("&s.a: %p\n",&s.a);
     printf("&s.b: %p\n",&s.b);
     printf("&s.c: %p\n",&s.c);
 }

在这个程序中,我们将结构体的成员 b 和 c 使用 packed 属性声明,就是告诉编译器,尽量使用最可能小的地址对齐给它们分配地址,尽可能地减少内存空洞。程序的运行结果如下。

 size: 7
 &s.a: 0028FF30
 &s.b: 0028FF31
 &s.c: 0028FF33

通过结果我们看到,结构体内各个成员地址的分配,使用最小1字节的对齐方式,导致整个结构体的大小只有7个字节。

这个特性在底层驱动开发中还是非常有用的。比如,你想定义一个结构体,封装一个 IP 控制器的各种寄存器。在 ARM 芯片中,每一个控制器的寄存器地址空间一般是连续存在的。如果考虑数据对齐,结构体内有空洞,这样就跟实际连续的寄存器地址不一致了,使用 packed 就可以避免这个问题,结构体的每个成员都紧挨着依次分配存储地址,这样就避免了各个成员元素因地址对齐而造成的内存空洞。

 struct data{
     char a;
     short b ;
     int c ;
 }__attribute__((packed));

我们对整个结构体添加 packed 属性,和分别对每个成员添加 packed 属性,效果是一样的。修改结构体后,程序的运行结果跟上面程序运行结果相同——结构体的大小为7,结构体内各成员地址相同。

7.5 Linux内核中 aligned、packed 属性声明

在 Linux 内核中,我们经常看到 aligned 和 packed 一起使用,即对一个变量或类型同时使用 aligned 和 packed 属性声明。这样做的好处是,既避免了结构体内因地址对齐产生的内存空洞,又指定了整个结构体的对齐方式。

 struct data{
     char a;
     short b ;
     int c ;
 }__attribute__((packed,aligned(8)));
 int main(void)
 {
     struct data s;
     printf("size: %d\n",sizeof(s));
     printf("&s.a: %p\n",&s.a);
     printf("&s.b: %p\n",&s.b);
     printf("&s.c: %p\n",&s.c);
 }

程序运行结果如下。

 size: 8
 &s.a: 0028FF30
 &s.b: 0028FF31
 &s.c: 0028FF33

在这个程序中,结构体 data 虽然使用 packed 属性声明,整个长度变为7,但是我们同时又使用了 aligned(8) 指定其按8字节地址对齐,所以编译器要在结构体后面填充1个字节,这样整个结构体的大小就变为8字节,按8字节地址对齐。

本教程根据 C语言嵌入式Linux高级编程视频教程 第05期 改编,电子版书籍可加入QQ群:475504428 下载,更多嵌入式视频教程,可关注:
微信公众号:宅学部落(armlinuxfun)
51CTO学院-王利涛老师:http://edu.51cto.com/sd/d344f

原文地址:http://blog.51cto.com/zhaixue/2348617

时间: 2024-08-01 10:13:16

嵌入式C语言自我修养 07:地址对齐那些事儿的相关文章

嵌入式C语言自我修养 06:U-boot镜像自拷贝分析:section属性

6.1 GNU C 的扩展关键字:attribute GNU C 增加一个 atttribute 关键字用来声明一个函数.变量或类型的特殊属性.声明这个特殊属性有什么用呢?主要用途就是指导编译器在编译程序时进行特定方面的优化或代码检查.比如,我们可以通过使用属性声明指定某个变量的数据边界对齐方式. attribute 的使用非常简单,当我们定义一个函数.变量或类型时,直接在它们名字旁边添加下面的属性声明即可: __atttribute__((ATTRIBUTE)) 这里需要注意的是:attrib

嵌入式C语言自我修养 04:Linux 内核第一宏:container_of

4.1 typeof 关键字 ANSI C 定义了 sizeof 关键字,用来获取一个变量或数据类型在内存中所占的存储字节数.GNU C 扩展了一个关键字 typeof,用来获取一个变量或表达式的类型.这里使用关键字可能不太合适,因为毕竟 typeof 还没有被写入 C 标准,是 GCC 扩展的一个关键字.为了方便,我们就姑且称之为关键字吧. 通过使用 typeof,我们可以获取一个变量或表达式的类型.所以 typeof 的参数有两种形式:表达式或类型. int i ; typeof(i) j

嵌入式C语言自我修养 03:宏构造利器 - 语句表达式

3.1 基础复习:表达式.语句和代码块 表达式 表达式和语句是 C 语言中的基础概念.什么是表达式呢?表达式就是由一系列操作符和操作数构成的式子.操作符可以是 C 语言标准规定的各种算术运算符.逻辑运算符.赋值运算符.比较运算符等.操作数可以是一个常量,也可以是一个变量.表达式也可以没有操作符,单独的一个常量甚至是一个字符串,也是一个表达式.下面的字符序列都是表达式: 2 + 3 2 i = 2 + 3 i = i++ + 3 "wit" 表达式一般用来数据计算或实现某种功能的算法.表

嵌入式C语言自我修养 09:链接过程中的强符号和弱符号

9.1 属性声明:weak GNU C 通过 attribute 声明weak属性,可以将一个强符号转换为弱符号. 使用方法如下. void __attribute__((weak)) func(void); int num __attribte__((weak); 编译器在编译源程序时,无论你是变量名.函数名,在它眼里,都是一个符号而已,用来表征一个地址.编译器会将这些符号集中,存放到一个叫符号表的 section 中. 在一个软件工程项目中,可能有多个源文件,由不同工程师开发.有时候可能会遇

嵌入式C语言自我修养 10:内联函数探究

10.1 属性声明:noinline & always_inline 这一节,接着讲 attribute 属性声明,attribute可以说是 GNU C 最大的特色.我们接下来继续讲一下跟内联函数相关的两个属性:noinline 和 always_inline.这两个属性的用途是告诉编译器:编译时,对我们指定的函数内联展开或不展开.它们的使用方法如下. static inline __attribute__((noinline)) int func(); static inline __att

嵌入式C语言自我修养 13:总结

13.1 总结 前面12节的课程,主要针对 Linux 内核中 GNU C 扩展的一些常用 C 语言语法进行了分析.GNU C 的这些扩展语法,主要用来完善 C 语言标准和编译优化.而通过 C 标准的发展过程我们又发现,对于一些编译器扩展的一些特性,或者其它编程语言(如:C++)中的好的特性和语法,C 标准也会适时地吸收进来,作为新的 C 语言标准. 在 GNU C 的这些扩展语法中,attribute 和宏定义是两大特色.在嵌入式底层系统中,尤其是 Linux 内核和 U-boot 中,大量使

嵌入式C语言自我修养 05:零长度数组

5.1 什么是零长度数组 顾名思义,零长度数组就是长度为0的数组. ANSI C 标准规定:定义一个数组时,数组的长度必须是一个常数,即数组的长度在编译的时候是确定的.在ANSI C 中定义一个数组的方法如下: int a[10]; C99 新标准规定:可以定义一个变长数组. int len; int a[len]; 也就是说,数组的长度在编译时是未确定的,在程序运行的时候才确定,甚至可以由用户指定大小.比如,我们可以定义一个数组,然后在程序运行时才指定这个数组的大小,还可以通过输入数据来初始化

嵌入式C语言自我修养 11:有一种函数,叫内建函数

11.1 什么是内建函数 内建函数,顾名思义,就是编译器内部实现的函数.这些函数跟关键字一样,可以直接使用,无须像标准库函数那样,要 #include 对应的头文件才能使用. 内建函数的函数命名,通常以 __builtin 开头.这些函数主要在编译器内部使用,主要是为编译器服务的.内建函数的主要用途如下. 用来处理变长参数列表: 用来处理程序运行异常: 程序的编译优化.性能优化: 查看函数运行中的底层信息.堆栈信息等: C 标准库函数的内建版本. 因为内建函数是编译器内部定义,主要由编译器相关的

嵌入式C语言自我修养 08:变参函数的格式检查

8.1 属性声明:format GNU 通过 attribute 扩展的 format 属性,用来指定变参函数的参数格式检查. 它的使用方法如下: __attribute__(( format (archetype, string-index, first-to-check))) void LOG(const char *fmt, ...) __attribute__((format(printf,1,2))); 我们经常实现一些自己的打印调试函数.这些打印函数往往是变参函数,那编译器编译程序时