再说内存
离我的上一篇BLOG已经时隔一个月有余,项目忙是一方面原因,最主要的还是自己没什么“收获”。在最近的项目中总是和内存打交道,时间长了,便有了些许问题,原本我就不是不求甚解者,遂趁此机会又复习了些内存相关资料。
其实下面的话题都是源于在实际项目中碰到的问题,我们通过推敲一句话来开始吧!
1、推敲一句话
在《C专家编程》一书中,有这样的说法“Malloced memory is always aligned appropriately for the largest size of atomic access on a machine”,中文版中翻译为“被分配的内存总是经过对齐,以适合机器上最大尺寸的原子访问”。这句话我也不只看过一遍,不过仅在这次我对它作了认真的推敲,其实整篇也都是围绕着这句话写的。
a) 对齐:数据项仅能存储在地址是该数据项整数倍的内存位置上。在不同的平台上对内存对齐的要求不同,比如在Windows平台上数据项也可以存储在非对齐的内存地址上。但在Sun SPARC平台上如果数据地址没对齐,对它的访问就会带来严重后果(CORE DUMP)。稍后继续详说。
b) 最大尺寸:在32为平台上一般为8字节,在64位平台上为16字节。
c) 原子访问:原子,顾名思义,不可拆分的(严格意义上,在科学界原子并不是最小的粒子,是可拆分的,但在我们这里它就是不可拆分的)。原子访问就是不能被中断的访问。
2、虚拟内存与Cache
太古老的就不说了,我们从“虚拟内存”和Cache说起,为什么莫名其妙的谈到“虚拟内存”和Cache了呢?其实真正需要内存地址对齐的就是这“两位”。虚拟内存技术和Cache的出现追其原因都是因为人们的“物质财富”拮据 — 内存条太贵。虚拟内存允许你拿硬盘做内存,这样一来就满足了应用程序对内存地址空间的旺盛需求问题,但随之而来的是大量的磁盘操作,使数据的访问速度下降了。人们遂在CPU内加了Cache。
1) 虚拟内存管理以“页”作为基本传输和保护单位在“物理内存”和“硬磁盘”之间倒腾数据。每页大小是固定的,一般为4KB一页。一旦确定了页的大小那么就相当于给了各原子数据项一个不成文的建议:“你们最好不要跨页存储”。什么是“跨页存储”?举个跨页存储例子如下:有这么一个原子类型为int的变量n = 1,其在进程地址空间的存储方式如下:(按照big-endian,高位存在低地址上)
+ .. + 0×0000 (0000)<——–第一页
| |
| .. |
+——-+
| |
+——-+ 0x0ffd
| 0 |
+——-+ 0x0ffe
| 0 |
+——-+ 0x0fff (4095)
| 0 |
+——-+ 0×1000 (4096)<——– 第二页
| 1 |
+——-+ 0×1001 (4097)
| |
| .. |
上图中变量n的存储空间横跨两个内存“页”。那么为什么最好不要跨页存储呢?如上例一旦int n跨页存储,那么程序在访问该变量的时候就必须通过两次换页才能完整的访问该数据,这照比不跨页存储的数据多了一次换页的工作。像这样如果不做约束的话,那么像n这样的数据将会到处都是,那么将会系统的性能很差。在Sun SPARC平台上像这样跨页存储会导致BUS ERROR,在Windows平台上也会带来性能上的下降。相反如果每个数据都是按照页边界对齐的话就不会带来上面的问题。
2) 除了虚拟内存的机制,Cache也是一个要求内存对齐的重要原因。一个Cache由若干Cache Line组成,每个Line中包括一个标签(tag)和一个数据块(Cache Block)组成,CPU在读写Cache时一般是按照Cache Line整行读写的,Cache结构如下图:
tag | block
| 0 8 16 31
—–+—————+—————+—————
| 32 | | Line 0
—–+—————+—————+—————
| 64 | | Line 1
—–+—————+—————+—————
同虚拟内存一样,原子类型数据项不应该跨Cache Line边界存储。具体不详述了。
3、编译器甜头
如果上述问题都让应用程序员自己来解决的话,那就太困难了。庆幸的是我们尝到了“编译器甜头”,编译器通过自动分配和填充数据(在内存中)来进行对齐。对数据进行对齐可以迫使每个内存访问局限在一个cache line或一个单独的页面(page)内。这样就极大简化了cache controller和MMC这样的硬件设计和实现,并且大大提高了访存速度。总结一下:要求数据对齐,从另一个角度说就是不允许原子类型数据项跨越页面或者Cache边界。
4、常见操作未对齐内存的问题
在Sun的Solaris SPARC Runtime Check文档中列出了常见的几种操作未对齐内存的问题:(在Sun SPARC Solaris 9下测试,GCC 3.4.2)
1) 未对齐读(Misaligned Read)
例子:
int j;
char *s = "hello world";
int *i = (int *)&s[1];
j = *i; /* Dump Core */
分析:s指向的字符串中每个字符地址仅仅能满足1字节对齐,而读该数据项时却要求8字节对齐(GCC默认),&s[1]显然不满足对齐要求,运行出Core。
2) 未对齐写(Misaligned Write)
例子:
char *s = "hello world";
int *i = (int *)&s[1];
*i = 'm' ; /* Dump Core */
分析:s指向的字符串中每个字符地址仅仅能满足1字节对齐,而上面程序却向该地址写数据时却要求8字节对齐(GCC默认),&s[1]显然不满足对齐要求,运行出Core。
3) 未对齐Free
例子:
char *ptr = (char *)malloc(4);
ptr++;
free(ptr); /* Misaligned free */
分析:略
5、什么时候需要考虑到内存对齐
考虑内存对齐的一个典型情况就是“在异构平台间以C结构体的方式进行数据交互”。举个简单的例子:“现需要在Windows平台将一个test_t类型的数据写入一个二进制文件并将该二进制文件在Linux平台下解析,在不指定对齐系数的前提下:Windows平台默认对齐系数为8,而Linux平台默认对齐系数为4”。(暂不考虑字节序的影响)
假设test_t结构如下:
struct test_t {
double d;
char c;
};
在Windows平台下将一个test_t写入二进制文件,由于对齐后test_t大小为16,所以该二进制文件大小为16;将该文件传到Linux上并解析,由于Linux上对齐后的test_t大小为12,导致Linux上的程序验证该二进制文件的完整性失败,解析失败。解决方案:在两个平台使用相同的对齐系数。如对其系数为8:
#pragma pack(8)
struct test_t {
double d;
char c;
};
#pragma pack()
这样两边就能完美对正了。
评论