关于 C 语言内存对齐的思考

内存对齐又是 C 语言的经典问题了...

intro

首先应该明确的是内存对齐(Memory alignment)是一个概念,不能把它仅仅局限在 C 语言中(尽管总是会出现在 C 语言面试题中😂),它其实是一种和硬件相关的计算机系统结构设计思想,本质就是空间换时间
其次,与其说内存对齐是一个很难理解的概念,不如说是一个很麻烦的概念。换句话说,只要对齐的过程理清楚了,就不难了。
当然了,本篇文章不会深入的讨论这些理论,还是重点介绍 C 语言中和内存对齐相关的知识。

struct

结构体(struct)是一种构造类型,其对齐规则如下:

  1. 结构体中,第一个数据成员放在 offset 为 0 的地方,以后每个数据成员按其类型大小和默认对齐参数(32 位系统默认按照 4 字节对齐,64 位系统默认按照 8 字节对齐)中较小的一个对齐,且需要满足addr % size == 0,其中addr是当前数据成员的起始地址,size是当前数据成员的对齐值,否则补空直至满足条件。
  2. 结构体本身也需要对齐,最终sizeof的结果必须是系统默认的对齐长度和成员中最长类型二者之中最小值的整数倍。
  3. 如果存在结构体嵌套,则内层的结构体成员默认按照规则 1 进行对齐。

接下来,通过一些的例子来理解这些规则。

eg1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>

struct A {
char c1;
short s;
};

int main() {
printf("sizeof(struct A) = %d\n", sizeof(struct A));
struct A a;
printf("&a.c1 = %p\n", &a.c1);
printf("&a.s = %p\n", &a.s);

return 0;
}
/*
out:
sizeof(struct A) = 4
&a.c1 = 000000834ddffbcc
&a.s = 000000834ddffbce
*/

这个例子按照规则 1 理解即可。

eg2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>

struct A {
char c1;
short s;
char c2;
};

int main() {
printf("sizeof(struct A) = %d\n", sizeof(struct A));
struct A a;
printf("&a.c1 = %p\n", &a.c1);
printf("&a.s = %p\n", &a.s);
printf("&a.c2 = %p\n", &a.c2);
return 0;
}
/*
out:
sizeof(struct A) = 6
&a.c1 = 000000016c7ffb3a
&a.s = 000000016c7ffb3c
&a.c2 = 000000016c7ffb3e
*/

这个例子需要用规则 1 和规则 2 来理解,c1只需要一个字节,但s需要 2 个字节,而000000016c7ffb3b % 2 != 0,所以补 1 个字节,故s000000016c7ffb3c开始存;对应的,c2按照顺序存储在000000016c7ffb3e,但由于此时结构体的总大小是 5,整个结构体的大小必须要是 2(这个结构体的对齐值)的倍数,所以又补齐了 1 个字节,最终的大小就是 6。

eg3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <stdio.h>

struct A {
char c1;
int i;
double d;
char c2;
};

struct B {
int a;
char c1;
struct A sa;
char c2;
};

int main() {
printf("sizeof(struct A) = %d\n", sizeof(struct A));
printf("sizeof(struct B) = %d\n", sizeof(struct B));
struct B b;
printf("&b.a = %p\n", &b.a);
printf("&b.c1 = %p\n", &b.c1);
printf("&b.sa = %p\n", &b.sa);
printf("&b.sa.c1 = %p\n", &b.sa.c1);
printf("&b.sa.i = %p\n", &b.sa.i);
printf("&b.sa.d = %p\n", &b.sa.d);
printf("&b.sa.c2 = %p\n", &b.sa.c2);
printf("&b.c2 = %p\n", &b.c2);
return 0;
}
/*
out:
sizeof(struct A) = 24
sizeof(struct B) = 40
&b.a = 0000003dd87ff6a0
&b.c1 = 0000003dd87ff6a4
&b.sa = 0000003dd87ff6a8
&b.sa.c1 = 0000003dd87ff6a8
&b.sa.i = 0000003dd87ff6ac
&b.sa.d = 0000003dd87ff6b0
&b.sa.c2 = 0000003dd87ff6b8
&b.c2 = 0000003dd87ff6c0
*/

这个例子需要用规则 1、规则 2 和规则 3 来理解。首先在struct A中,b.sa.c2放在第 17 个地址处,为了满足规则 3,又补了 7 个字节,所以sizeof(struct A)最终结果是 24;而b.sa.c1b.sa.i只需要满足addr % size == 0(规则 1)即可;最后在struct B中,b.c2放在第 33 个地址处,为了满足规则 2,又补了 7 个字节。

union

union关键字中,其实没有“对齐”这个概念,但是有“内存”的概念(实际上,C 语言总是与内存联系紧密),相比于struct而言,union关键字的内存规则要简单上许多。另外,之所以会把union的内容放在内存对齐这里,也是为了与struct进行对比,更容易熟悉二者的联系与差别。

union的规则如下:

  1. union中能够定义多个成员,整个union的大小由最大的成员的大小决定
  2. union成员共享同一块大小的内存,一次仅仅能使用当中的一个成员
  3. 对某一个成员赋值,会覆盖掉其它成员的值。这是由于这些变量共享一块内存,但当成员所占字节数不一致时,仅仅会覆盖对应字节上的值,比如对char成员赋值并不会把整个int成员覆盖掉,因为char只占一个字节,而int占四个字节
  4. union的存放顺序是全部成员都从低地址开始存放的

还是直接看代码来理解:

eg4
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>

union A {
char c;
short i;
};

union B {
int i;
float f;
double d;
};

int main() {
printf("sizeof(union A) = %d\n", sizeof(union A));
printf("sizeof(union B) = %d\n", sizeof(union B));
return 0;
}
/*
out:
sizeof(union A) = 2
sizeof(union B) = 8
*/

从输出结果看,union的规则很容易理解,这里不再赘述。

bit field

写到这里,不得不再提一下位域(也叫位字段,bit field)。
位域其实就是人为划分好的二进制位区域,并且每个区域都有域名和具体的位数,比如下面这种形式:

eg5
1
2
3
4
5
6
struct {
unsigned int a: 1;
char b: 2;
char c: 1;
int i: 1;
} b;

以上是用struct关键字声明位域,需要指出的是位域中无法使用浮点型关键字声明位域名,如float f: 1;double d: 2;等。
如果需要某些位(bit)在特定位置,可以用未命名的字段进行分隔,比如:

eg7
1
2
3
4
5
struct {
unsigned int a: 32;
char : 2;
char c: 1;
} b;

当然了,未命名的位域字段是无法使用的。
最后,在举一个位域的实际例子,计算 16 进制数相加:

eg8
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>
#include <stdint.h>

union {
struct {
uint16_t i;
uint16_t j;
} x;
uint32_t y;
} a;

int main() {
a.y = 0x11223344;
printf("%x\n", a.x.i + a.x.j);
return 0;
}
/*
out:
4466
*/

在上面的代码中,xy共用一块 4 字节内存,对y赋值也相当于对x的成员ij赋值,所以后面直接相加,就可以将两个十六进制数相加。

Summary

有关 C 语言内存对齐的一些思考,到这里就暂时结束了。实际上,个人认为在 C 语言中,掌握对应规则,知道有这个“特性”存在,并能善于利用即可。真碰到了笔试题时,一个一个算,其实没有太大意义😶。因为本身 C 语言就提供了自定义内存对齐值的功能,比如:

1
#pragma pack(n)

这个编译指令可以指定有效对齐值为 n。
也可以指定某个结构体不进行内存对齐,如:

eg9
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>
#include <stdlib.h>

struct A {
int a;
double d;
char c;
}__attribute__((packed));

int main() {
printf("sizeof(struct A) = %d\n", sizeof(struct A));
return 0;
}
/*
out:
sizeof(struct A) = 13
*/

此时结构体的大小,就是其成员大小之和。
另外,还有一个原因是 C11 提供了查看对齐值的宏_Alignof(需要包含头文件stdalign.h,且在 C++ 中是alignof运算符),以上面的代码为例,可以得到:

eg10
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <stdalign.h>

struct A {
int a;
double d;
char c;
}__attribute__((packed));

int main() {
printf("sizeof(struct A) = %d\n", sizeof(struct A));
printf("%d\n", _Alignof(struct A));
return 0;
}
/*
out:
sizeof(struct A) = 13
1
*/

从输出结果也可以看出,指定结构不对齐其实就是按 1 字节进行对齐😂。
总而言之,对于内存对齐,只要掌握分析的方法,再掌握分析的思路,并熟练灵活运用就可以了,死记硬背过考试题、面试题,真的不是好事情...
好了,又扯了这么多,关于内存对齐,暂时就到这里了。
PS:写了好几天,终于勉强算是完成了,累😴。


Buy me a coffee ? :)
0%