标签 内存对齐 下的文章

再说内存

离我的上一篇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()
这样两边就能完美对正了。

也谈内存对齐

在最近的项目中,我们涉及到了“内存对齐”技术。对于大部分程序员来说,“内存对齐”对他们来说都应该是“透明的”。“内存对齐”应该是编译器的“管辖范围”。编译器为程序中的每个“数据单元”安排在适当的位置上。但是C语言的一个特点就是太灵活,太强大,它允许你干预“内存对齐”。如果你想了解更加底层的秘密,“内存对齐”对你就不应该再透明了。

一、内存对齐的原因
大部分的参考资料都是如是说的:
1、平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
2、性能原因:数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。

二、对齐规则
每个特定平台上的编译器都有自己的默认“对齐系数”(也叫对齐模数)。程序员可以通过预编译命令#pragma pack(n),n=1,2,4,8,16来改变这一系数,其中的n就是你要指定的“对齐系数”。

规则:
1、数据成员对齐规则:结构(struct)(或联合(union))的数据成员,第一个数据成员放在offset为0的地方,以后每个数据成员的对齐按照#pragma pack指定的数值和这个数据成员自身长度中,比较小的那个进行。
2、结构(或联合)的整体对齐规则:在数据成员完成各自对齐之后,结构(或联合)本身也要进行对齐,对齐将按照#pragma pack指定的数值和结构(或联合)最大数据成员长度中,比较小的那个进行。
3、结合1、2颗推断:当#pragma pack的n值等于或超过所有数据成员长度的时候,这个n值的大小将不产生任何效果。

三、试验
我们通过一系列例子的详细说明来证明这个规则吧!
我试验用的编译器包括GCC 3.4.2和VC6.0的C编译器,平台为Windows XP + Sp2。

我们将用典型的struct对齐来说明。首先我们定义一个struct:
#pragma pack(n) /* n = 1, 2, 4, 8, 16 */
struct test_t {
 int a;
 char b;
 short c;
 char d;
};
#pragma pack(n)
首先我们首先确认在试验平台上的各个类型的size,经验证两个编译器的输出均为:
sizeof(char) = 1
sizeof(short) = 2
sizeof(int) = 4

我们的试验过程如下:通过#pragma pack(n)改变“对齐系数”,然后察看sizeof(struct test_t)的值。

1、1字节对齐(#pragma pack(1))
输出结果:sizeof(struct test_t) = 8 [两个编译器输出一致]
分析过程:
1) 成员数据对齐
#pragma pack(1)
struct test_t {
 int a;  /* 长度4 < 1 按1对齐;起始offset=0 0%1=0;存放位置区间[0,3] */
 char b;  /* 长度1 = 1 按1对齐;起始offset=4 4%1=0;存放位置区间[4] */
 short c; /* 长度2 > 1 按1对齐;起始offset=5 5%1=0;存放位置区间[5,6] */
 char d;  /* 长度1 = 1 按1对齐;起始offset=7 7%1=0;存放位置区间[7] */
};
#pragma pack()
成员总大小=8

2) 整体对齐
整体对齐系数 = min((max(int,short,char), 1) = 1
整体大小(size)=$(成员总大小) 按 $(整体对齐系数) 圆整 = 8 /* 8%1=0 */ [注1]

2、2字节对齐(#pragma pack(2))
输出结果:sizeof(struct test_t) = 10 [两个编译器输出一致]
分析过程:
1) 成员数据对齐
#pragma pack(2)
struct test_t {
 int a;  /* 长度4 > 2 按2对齐;起始offset=0 0%2=0;存放位置区间[0,3] */
 char b;  /* 长度1 < 2 按1对齐;起始offset=4 4%1=0;存放位置区间[4] */
 short c; /* 长度2 = 2 按2对齐;起始offset=6 6%2=0;存放位置区间[6,7] */
 char d;  /* 长度1 < 2 按1对齐;起始offset=8 8%1=0;存放位置区间[8] */
};
#pragma pack()
成员总大小=9

2) 整体对齐
整体对齐系数 = min((max(int,short,char), 2) = 2
整体大小(size)=$(成员总大小) 按 $(整体对齐系数) 圆整 = 10 /* 10%2=0 */

3、4字节对齐(#pragma pack(4))
输出结果:sizeof(struct test_t) = 12 [两个编译器输出一致]
分析过程:
1) 成员数据对齐
#pragma pack(4)
struct test_t {
 int a;  /* 长度4 = 4 按4对齐;起始offset=0 0%4=0;存放位置区间[0,3] */
 char b;  /* 长度1 < 4 按1对齐;起始offset=4 4%1=0;存放位置区间[4] */
 short c; /* 长度2 < 4 按2对齐;起始offset=6 6%2=0;存放位置区间[6,7] */
 char d;  /* 长度1 < 4 按1对齐;起始offset=8 8%1=0;存放位置区间[8] */
};
#pragma pack()
成员总大小=9

2) 整体对齐
整体对齐系数 = min((max(int,short,char), 4) = 4
整体大小(size)=$(成员总大小) 按 $(整体对齐系数) 圆整 = 12 /* 12%4=0 */

4、8字节对齐(#pragma pack(8))
输出结果:sizeof(struct test_t) = 12 [两个编译器输出一致]
分析过程:
1) 成员数据对齐
#pragma pack(8)
struct test_t {
 int a;  /* 长度4 < 8 按4对齐;起始offset=0 0%4=0;存放位置区间[0,3] */
 char b;  /* 长度1 < 8 按1对齐;起始offset=4 4%1=0;存放位置区间[4] */
 short c; /* 长度2 < 8 按2对齐;起始offset=6 6%2=0;存放位置区间[6,7] */
 char d;  /* 长度1 < 8 按1对齐;起始offset=8 8%1=0;存放位置区间[8] */
};
#pragma pack()
成员总大小=9

2) 整体对齐
整体对齐系数 = min((max(int,short,char), 8) = 4
整体大小(size)=$(成员总大小) 按 $(整体对齐系数) 圆整 = 12 /* 12%4=0 */

5、16字节对齐(#pragma pack(16))
输出结果:sizeof(struct test_t) = 12 [两个编译器输出一致]
分析过程:
1) 成员数据对齐
#pragma pack(16)
struct test_t {
 int a;  /* 长度4 < 16 按4对齐;起始offset=0 0%4=0;存放位置区间[0,3] */
 char b;  /* 长度1 < 16 按1对齐;起始offset=4 4%1=0;存放位置区间[4] */
 short c; /* 长度2 < 16 按2对齐;起始offset=6 6%2=0;存放位置区间[6,7] */
 char d;  /* 长度1 < 16 按1对齐;起始offset=8 8%1=0;存放位置区间[8] */
};
#pragma pack()
成员总大小=9

2) 整体对齐
整体对齐系数 = min((max(int,short,char), 16) = 4
整体大小(size)=$(成员总大小) 按 $(整体对齐系数) 圆整 = 12 /* 12%4=0 */

四、结论
8字节和16字节对齐试验证明了“规则”的第3点:“当#pragma pack的n值等于或超过所有数据成员长度的时候,这个n值的大小将不产生任何效果”。另外内存对齐是个很复杂的东西,上面所说的在有些时候也可能不正确。呵呵^_^

[注1]
什么是“圆整”?
举例说明:如上面的8字节对齐中的“整体对齐”,整体大小=9 按 4 圆整 = 12
圆整的过程:从9开始每次加一,看是否能被4整除,这里9,10,11均不能被4整除,到12时可以,则圆整结束。

相关文章:
1. 也谈内存对齐(续)
2. 三谈内存对齐-背后的故事
3. 四谈内存对齐

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats