再说内存

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

也谈字节序问题

一次Sun SPARC到Intel X86的平台移植让我们的程序遭遇了“字节序问题”,既然遇到了也就不妨深入的学习一下。

一、字节序定义
字节序,顾名思义字节的顺序,再多说两句就是大于一个字节类型的数据在内存中的存放顺序(一个字节的数据当然就无需谈顺序的问题了)。

其实大部分人在实际的开发中都很少会直接和字节序打交道。唯有在跨平台以及网络程序中字节序才是一个应该被考虑的问题。

在所有的介绍字节序的文章中都会提到字节序分为两类:Big-Endian和Little-Endian。引用标准的Big-Endian和Little-Endian的定义如下:
a) Little-Endian就是低位字节排放在内存的低地址端,高位字节排放在内存的高地址端。
b) Big-Endian就是高位字节排放在内存的低地址端,低位字节排放在内存的高地址端。
c) 网络字节序:TCP/IP各层协议将字节序定义为Big-Endian,因此TCP/IP协议中使用的字节序通常称之为网络字节序。

其实我在第一次看到这个定义时就很糊涂,看了几个例子后也很是朦胧。什么高/低地址端?又什么高低位?翻阅了一些资料后略有心得。

二、高/低地址与高低字节
首先我们要知道我们C程序映像中内存的空间布局情况:在《C专家编程》中或者《Unix环境高级编程》中有关于内存空间布局情况的说明,大致如下图:
———————– 最高内存地址 0xffffffff
 | 栈底
 .
 .              栈
 .
  栈顶
———————–
 |
 |
\|/

NULL (空洞) 

/|\
 |
 |
———————–
                堆
———————–
未初始化的数据
—————-(统称数据段)
初始化的数据
———————–
正文段(代码段)
———————– 最低内存地址 0×00000000

以上图为例如果我们在栈上分配一个unsigned char buf[4],那么这个数组变量在栈上是如何布局的呢[注1]?看下图:
栈底 (高地址)
———-
buf[3]
buf[2]
buf[1]
buf[0]
———-
栈顶 (低地址)

现在我们弄清了高低地址,接着我来弄清高/低字节,如果我们有一个32位无符号整型0×12345678(呵呵,恰好是把上面的那4个字节buf看成一个整型),那么高位是什么,低位又是什么呢?其实很简单。在十进制中我们都说靠左边的是高位,靠右边的是低位,在其他进制也是如此。就拿0×12345678来说,从高位到低位的字节依次是0×12、0×34、0×56和0×78。

高低地址和高低字节都弄清了。我们再来回顾一下Big-Endian和Little-Endian的定义,并用图示说明两种字节序:
以unsigned int value = 0×12345678为例,分别看看在两种字节序下其存储情况,我们可以用unsigned char buf[4]来表示value:
Big-Endian: 低地址存放高位,如下图:
栈底 (高地址)
—————
buf[3] (0×78) — 低位
buf[2] (0×56)
buf[1] (0×34)
buf[0] (0×12) — 高位
—————
栈顶 (低地址)

Little-Endian: 低地址存放低位,如下图:
栈底 (高地址)
—————
buf[3] (0×12) — 高位
buf[2] (0×34)
buf[1] (0×56)
buf[0] (0×78) — 低位
—————
栈顶 (低地址)

在现有的平台上Intel的X86采用的是Little-Endian,而像Sun的SPARC采用的就是Big-Endian。

三、例子
测试平台: Sun SPARC Solaris 9和Intel X86 Solaris 9
我们的例子是这样的:在使用不同字节序的平台上使用相同的程序读取同一个二进制文件的内容。
生成二进制文件的程序如下:
/* gen_binary.c */
int main() {
        FILE    *fp = NULL;
        int     value = 0×12345678;
        int     rv = 0;

        fp = fopen("temp.dat", "wb");
        if (fp == NULL) {
                printf("fopen error\n");
                return -1;
        }

        rv = fwrite(&value, sizeof(value), 1, fp);
        if (rv != 1) {
                printf("fwrite error\n");
                return -1;
        }

        fclose(fp);
        return 0;
}

读取二进制文件的程序如下:
int main() {
        int             value   = 0;
        FILE         *fp     = NULL;
        int             rv      = 0;
        unsigned        char buf[4];

        fp = fopen("temp.dat", "rb");
        if (fp == NULL) {
                printf("fopen error\n");
                return -1;
        }

        rv = fread(buf, sizeof(unsigned char), 4, fp);
        if (rv != 4) {
                printf("fread error\n");
                return -1;
        }

        memcpy(&value, buf, 4); // or value = *((int*)buf);
        printf("the value is %x\n", value);

        fclose(fp);
        return 0;
}

测试过程:
(1) 在SPARC平台下生成temp.dat文件
在SPARC平台下读取temp.dat文件的结果:
the value is 12345678

在X86平台下读取temp.dat文件的结果:
the value is 78563412

(1) 在X86平台下生成temp.dat文件
在SPARC平台下读取temp.dat文件的结果:
the value is 78563412

在X86平台下读取temp.dat文件的结果:
the value is 12345678

[注1]
buf[4]在栈的布局我也是通过例子程序得到的:
int main() {
        unsigned char buf[4];

        printf("the buf[0] addr is %x\n", buf);
        printf("the buf[1] addr is %x\n”, &buf[1]);

        return 0;
}
output:
SPARC平台:
the buf[0] addr is ffbff788
the buf[1] addr is ffbff789
X86平台:
the buf[0] addr is 8047ae4
the buf[1] addr is 8047ae5

两个平台都是buf[x]所在地址高于buf[y] (x > y)。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系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