内存对齐

2019-01-10

为什么要进行内存对齐

在计算机组成原理中我们学到:一块内存芯片一般只提供 8 位数据线,要进行 16 位数据的读写可采用奇偶分体来组织管理多个芯片,32 位也类似:

align

这样,连续的四个字节会分布在不同的芯片上,送入地址 0,我们可将第 0、1、2、3 四个字节一次性读出组成一个 32 位数,送入地址 4(每个芯片接收到的地址是1),可一次性读出 4、5、6、7 四个字节。

但是如果要读 1、2、3、4 四个字节,就麻烦了,有的 CPU 直接歇菜了:我处理不了!但 Intel 的 CPU 走的是复杂指令集路线,岂能就此认输,它通过两次内存读,然后进行拼接合成我们想要的那个 32 位数,而这一切是在比机器码更低级的微指令执行阶段完成的,所以 movl 1, %eax 会不出意外地读出 1、2、3、4 四个字节到 eax,证据如下(mem.c):

#include <stdio.h>
char a[]={0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88};
int main()
{
	int *p = (int*)(a + 1);
	int ans = *p;
	
	printf("*p:\t%p\n", ans);
	printf("a:\t%p\n", a);
	printf("p:\t%p\n", p);
	return 0;
}

该程序的运行结果如下:

[lqy@localhost temp]$ gcc -o mem mem.c
[lqy@localhost temp]$ ./mem
*p:	0x55443322
a:	0x80496a8
p:	0x80496a9
[lqy@localhost temp]$ 

可看出程序确实从一个未对齐到 4 字节的地址(0x80496a9)后读出了 4 个字节,从汇编可看出确实是 1 条 mov 指令读出来的:

movl	$a, %eax
addl	$1, %eax
movl	%eax, 28(%esp)	# 初始化指针 p
movl	28(%esp), %eax
movl	(%eax), %eax	# 这里读出了 0x55443322
movl	%eax, 24(%esp)	# 初始化 ans

虽然 Intel 的 CPU 能这样处理,但还是要浪费点时间不是,所以 C 程序还是要采取措施避免这种情况的发生,那就是内存对齐。

内存对齐的结果

内存对齐的完整描述你还是去百度吧,这里我只是含糊地介绍一下:

  1. 保证最大类型对齐到它的 size
  2. 尽量不浪费空间

比如:

struct A{
	char a;
	int c;
};

它的大小为 8,c 的内部偏移为 4, 这样就可以一次性读出 c 了。

再如:

struct B{
	char a;
	char b;
	int c;
};

它的大小还是 8,第 2 条起作用了!

关闭内存对齐

讲到内存对齐,估计大家最期待的一大快事就是怎么关闭它(默认是开启的),毕竟 Intel CPU 如此强大,关闭了也没事。

关闭它也甚是简单,添加预处理指令 #pragma pack(1) 就行,windows linux 都管用:

#include <stdio.h>
#pragma pack(1)
struct _A{
    char c;
    int i;
};
//__attribute__((packed));

typedef struct _A A;

int main()
{
	printf("%d\n", sizeof(A));
	return 0;
}

gcc 中更常见的是使用 __attribute__((packed)),这个属性只解除对一个结构体的内存对齐,而 #pragma pack(1) 解除了整个 C源文件 的内存对齐,所以有时候 __attribute__((packed)) 显得更为合理。

什么时候可能需要注意或者关闭内存对齐呢? 我想大概是这两种情况:

  • 结构化文件的读写
  • 网络数据传输

另一个浪费内存的家伙

说到内存对齐,我想起了另一个喜欢浪费内存的家伙:参数对齐(我瞎编的名字,C 标准中或许有明确规定)。 看下面这个程序:

#include <stdio.h>
typedef unsigned char u_char;
u_char add(u_char a, u_char b)
{
	return (u_char)(a+b);
}

int main()
{
	u_char a=1, b=2;
	
	printf("ans:%d\n", add(a, b));
	return 0;
}

你说 add 函数的参数会占几个字节呢?2个?4个? 结果是 8 个……

“可恨”的是,这个家伙浪费内存的行为却被所有编译器纵容,我们无法追究它的责任(应该是为了方便计算参数位置而规定的)。