上一节讨论了美国某著名软件企业M公司的面试题,面试题目如下:
文章发出后,引起了一些争议:有些朋友发现以上C语言程序运行时并不会崩溃,还有些则发现程序运行时确实会崩溃。这是怎么回事?同一个C语言程序还能得到不同的结果吗?的确如此,有些C语言语句在不同架构的平台下运行结果确实可能有所差异。
一个典型的例子是,long 型在某些平台(如 x86_64平台)占用 8 字节内存空间(sizeof(long)==4),而在另外一些平台(如x86平台)仅仅占用 4 字节内存空间(sizeof(long)==4)。
C标准并没有定义 long 型占多少内存空间。
耐心看完上一篇文章的朋友应该会发现,文章在最后说,在 x86_64 机器上,“long 型问题”和 “int 型问题”会得到两个不同的结果:一个运行时崩溃,另一个则可以正常运行。那为什么会这样呢?请继续往下看。
以下均是在 x86_64 平台分析的。
先看看崩溃时的情况
会崩溃的是“long 型问题”,C语言代码如下:
struct S{
long i;
long *p;
};
int main()
{
struct S s;
long *p = &s.i;
p[0] = 4;
p[1] = 3;
s.p=p;
s.p[1] = 1;
s.p[0] = 2;
return 0;
}
以上C语言程序会崩溃的原因,上一节已经较为详细的讨论过,感到陌生的朋友可以再看看上一节。关键原因就是执行
s.p[1] =1;
// 相当于执行
s.p = 1;
导致 s.p 指向了地址 1,接下来对 s.p[0] 赋值就相当于对地址 1 赋值,这当然会崩溃。不过这一切分析都是建立在 s.p[1] 等价于 s.p 之上的,为什么它俩等价呢?请看下图:
已知 s.p 指向 p,p 指向 s.i,所以 s.p 其实指向的就是结构体 s 自身。如果结构体 s 的成员 i 和成员 p 是紧密排列的,那么 s.p + 1 不就指向 s.p 了吗?此时 s.p[1] 和 s.p 自然是等价的。
请注意“紧密排列”这个词。
再来看看“int型问题”
“int型问题”对应的C语言程序如下,编译后运行不会崩溃,为什么呢?
按照上面的分析,“long型问题”的C语言程序崩溃的直接原因是 s.p[0] = 1; 向地址 1 赋值,罪魁祸首是s.p[1] = 1; 让 s.p 指向地址 1 。那如果结构体 s 的成员 i 和成员 p 不是紧密排列的,如下图:
此时 s.p+1 并不指向 s.p,执行 s.p[1] = 1; 并不会把 s.p 的值也修改了,此时执行赋值语句 s.p[0] = 2; 自然就不会崩溃了。那问题又来了,结构体 s 的成员 i 和成员 p 什么时候“紧密排列”,什么时候“不紧密排列”呢?这就涉及到C语言中数据的内存对齐问题了。
C语言中的内存对齐
那么,什么是“内存对齐”呢?在回答这个问题之前,先来看看下面这个C语言程序:
#include <stdio.h>
struct S{
int i;
int *p;
};
int main()
{
struct S s;
printf("%lu %lu %lu\n", sizeof(s.i), sizeof(s.p), sizeof(s));
return 0;
}
上面这个C语言程序非常简单,就是输出结构体 s 和它两个成员的 size,这个程序会输出什么呢?编译并执行之,得到如下结果:
# gcc t.c
# ./a.out
4 8 16
s.i 和 s.p 的 size 分别是 4 字节和 8 字节,但有些奇怪的是,结构体 s 的 size 居然是大于 4+8 的 16!怎么回事?这其实就是编译器对结构体 s 的两个成员做了“内存对齐”的缘故。
之所以要做“内存对齐”,主要是为了提升访问内存数据时的效率。在 64 位地址总线的平台上,处理器访问数据是逐 8 字节进行的(0-7, 8-15, 16-23, ...),即使是想读取单字节的 char 型数据,处理器也得一次性读取 8 字节数据。
现在假设 4 字节的 int 型变量 a 存放在内存地址 6-9 处,C语言程序若想读取 a 的数值,需要分两次先后读取 0-7 和 8-15 地址的数据,这就降低了效率。(在其他某些平台,若数据没有内存对齐,程序是无法正常运行的。)对数据做内存对齐,其实就是为了尽力减少程序读取数据时,需要访问内存的次数。
现在“结构体 s 的 size 居然是大于 4+8 的 16”就好理解了,在 x86_64 平台上,s.p 占 8 字节内存空间,为了提升效率,编译器会将其放在地址 addr 处,而 addr 必须是 8 的整数倍。但是 s.i 只占 4 字节内存空间,若将 s.p 紧跟在 s.i 之后,s.p 的地址 addr 就不是 8 的整数倍了,为了解决这个问题,编译器会在 s.i 后填充 4 字节,如下图:
这就解释了为什么 sizeof(s) 不等于 sizeof(s.i)+sizeof(s.p),也对“int型问题”为什么不会崩溃的解释做了补充。
为了加深对“内存对齐”的认识,再来看个例子,请看下面的C语言代码:
#include <stdio.h>
struct S1{
char a;
int b;
char c;
};
struct S2{
char a;
char b;
int c;
};
int main()
{
struct S1 s1;
struct S2 s2;
printf("%lu %lu\n", sizeof(s1), sizeof(s2));
return 0;
}
上面的C语言代码中,结构体 S1 和结构体 S2 的成员个数和类型都是一样的,唯一的区别就是 int 型成员的顺序不同,那么 sizeof(s1) 和 sizeof(s2) 相等吗?得到答案最简单的方法就是实际运行这个C语言程序:
# gcc t.c
# ./a.out
12 8
看来,sizeof(s1) 和 sizeof(s2) 是不相等的,那为什么呢?先来分析结构体 s1:成员 a 和 c 只占用一个字节内存空间,无论放在哪个地址都是自然对齐的。关键在于 int 型的成员 b,它占用 4 字节内存空间,为了将其放在 4 的整数倍的内存地址中,编译器需要在成员 a 后填充 3 个字节。成员 c 可以紧跟在 b 之后,但是为了兼顾结构体 s1 的内存对齐,需要在其后也填充 3 字节,即:
这么一来,sizeof(s1) 显然等于 12。
再来分析一下结构体 s2:成员 b 是 char 型的,占用 1 字节内存空间,因此可以紧跟在 a 之后,现在 a 和 b 共同占用 2 字节内存空间。而成员 c 占用 4 字节内存空间,考虑到要对其做内存对齐,需要再在 b 之后填充 2 字节,即:
这就解释了为什么 sizeof(s2) 等于 8.
小节
如果在开发C语言程序时,有内存对齐的概念,那么在定义结构体的时候就会留心成员的顺序,尽可能的减少程序对内存的占用。当然了,如果内存空间实在紧张,C语言也是有手段不让编译器做自动“内存对齐”的,至于是什么样的手段,留给读者自己思考了。