Linux中的内存管理模型浅析 (上)【内存分配主文】

在weibo上看到梁大的这个贴子:



实际上这是一个内存方面的问题。要想研究这个问题,首先我们要将题目本身搞明白。由于我对Linux内核比较熟而对Windows的内存模型几乎毫不了解,因此在这篇文章中针对Linux环境对这个问题进行探讨。

在Linux的世界中,从大的方面来讲,有两块内存,一块叫做内核空间,Kernel Space,另一块叫做用户空间,即User Space。它们是相互独立的,Kernel对它们的管理方式也完全不同。

首先我们要知道,现代操作系统一个重要的任务之一就是管理内存。所谓内存,就是内存条上一个一个的真正的存储单元,实实在在的电子颗粒,这里面通过电信号保存着数据。

Linux Kernel为了使用和管理这些内存,必须要给它们分成一个一个的小块,然后给这些小块标号。这一个一个的小块就叫做Page,标号就是内存地址,Address。

Linux内核会负责管理这些内存,保证程序可以有效地使用这些内存。它必须要能够管理好内核本身要用的内存,同时也要管理好在Linux操作系统上面跑的各种程序使用的内存。因此,Linux将内存划分为Kernel Space和User Space,对它们分别进行管理。

只有驱动模块和内核本身运行在Kernel Space当中,因此对于这道题目,我们主要进行考虑的是User Space这一块。

在Linux的世界中,Kernel负责给用户层的程序提供虚地址而不是物理地址。举个例子:A手里有20张牌,将它们命名为1-20。这20张牌要分给两个人,每个人手里10张。这样,第一个人拿到10张牌,将牌编号为1-10,对应A手里面的1-10;第二个人拿到10张牌,也给编号为1-10,对应A的11-20。

这里面,第二个人手里的牌,他自己用的时候编号是1-10,但A知道,第二个人手里的牌在他这里的编号是11-20。

在这里面,A的角色就是Linux内核;他手里的编号,1-20,就是物理地址;两个人相当于两个进程,它们对牌的编号就是虚地址;A要负责给两个人发牌,这就是内存管理。

了解了这些概念以后,我们来看看kernel当中具体的东西,首先是mm_struct这个结构体:

C代码   收藏代码
  1. struct mm_struct {  
  2.         struct vm_area_struct * mmap;           /* list of VMAs */  
  3.         struct rb_root mm_rb;  
  4.         struct vm_area_struct * mmap_cache;     /* last find_vma result */  
  5.         ...  
  6.         unsigned long start_code, end_code, start_data, end_data;  
  7.         unsigned long start_brk, brk, start_stack;  
  8.         ...  
  9. };  


mm_struct负责描述进程的内存。相当于发牌人记录给谁发了哪些牌,发了多少张,等等。那么,内存是如何将内存进行划分的呢?也就是说,发牌人手里假设是一大张未裁剪的扑克纸,他是怎样将其剪成一张一张的扑克牌呢?上面的vm_area_struct就是基本的划分单位,即一张一张的扑克牌:

C代码   收藏代码
  1. struct vm_area_struct * mmap;  


这个结构体的定义如下:

C代码   收藏代码
  1. struct vm_area_struct {  
  2.         struct mm_struct * vm_mm;       /* The address space we belong to. */  
  3.         unsigned long vm_start;         /* Our start address within vm_mm. */  
  4.         unsigned long vm_end;           /* The first byte after our end address 
  5.                                            within vm_mm. */  
  6.         ....  
  7.         /* linked list of VM areas per task, sorted by address */  
  8.         struct vm_area_struct *vm_next;  
  9.         ....  
  10. }  


这样,内核就可以记录分配给用户空间的内存了。

Okay,了解了内核管理进程内存的两个最重要的结构体,我们来看看用户空间的内存模型。

Linux操作系统在加载程序时,将程序所使用的内存分为5段:text(程序段)、data(数据段)、bss(bss数据段)、heap(堆)、stack(栈)。


text segment(程序段)

text segment用于存放程序指令本身,Linux在执行程序时,要把这个程序的代码加载进内存,放入text segment。程序段内存位于整个程序所占内存的最上方,并且长度固定(因为代码需要多少内存给放进去,操作系统是清楚的)。

data segment(数据段)

data segment用于存放已经在代码中赋值的全局变量和静态变量。因为这类变量的数据类型(需要的内存大小)和其数值都已在代码中确定,因此,data segment紧挨着text segment,并且长度固定(这块需要多少内存也已经事先知道了)。


bss segment(bss数据段)

bss segment用于存放未赋值的全局变量和静态变量。这块挨着data segment,长度固定。

heap(堆)

这块内存用于存放程序所需的动态内存空间,比如使用malloc函数请求内存空间,就是从heap里面取。这块内存挨着bss,长度不确定。

stack(栈)

stack用于存放局部变量,当程序调用某个函数(包括main函数)时,这个函数内部的一些变量的数值入栈,函数调用完成返回后,局部变量的数值就没有用了,因此出栈,把内存让出来给另一个函数的变量使用(程序在执行时,总是会在某一个函数调用里面)。

我们看一个图例说明:



为了更好的理解内存分段,可以撰写一段代码:

C代码   收藏代码
  1. #include <stdio.h>  
  2.   
  3. // 未赋值的全局变量放在dss段  
  4. int global_var;  
  5.   
  6. // 已赋值的全局变量放在data段  
  7. int global_initialized_var = 5;  
  8.   
  9. void function() {    
  10.    int stack_var; // 函数中的变量放在stack中  
  11.   
  12.    // 放在stack中的变量  
  13.    // 显示其所在内存地值  
  14.    printf("the function's stack_var is at address 0x%08x\n", &stack_var);  
  15. }  
  16.   
  17.   
  18. int main() {  
  19.    int stack_var; // 函数中的变量放在stack中  
  20.      
  21.    // 已赋值的静态变量放在data段  
  22.    static int static_initialized_var = 5;  
  23.      
  24.    // 未赋值的静态变量放在dss段  
  25.    static int static_var;  
  26.      
  27.    int *heap_var_ptr;  
  28.   
  29.    // 由malloc在heap中分配所需内存,  
  30.    // heap_var_ptr这个指针指向这块  
  31.    // 分配的内存  
  32.    heap_var_ptr = (int *) malloc(4);  
  33.   
  34.    // 放在data段的变量  
  35.    // 显示其所在内存地值  
  36.    printf("====IN DATA SEGMENT====\n");  
  37.    printf("global_initialized_var is at address 0x%08x\n", &global_initialized_var);  
  38.    printf("static_initialized_var is at address 0x%08x\n\n", &static_initialized_var);  
  39.   
  40.    // 放在bss段的变量  
  41.    // 显示其所在内存地值  
  42.    printf("====IN BSS SEGMENT====\n");  
  43.    printf("static_var is at address 0x%08x\n", &static_var);  
  44.    printf("global_var is at address 0x%08x\n\n", &global_var);  
  45.   
  46.    // 放在heap中的变量  
  47.    // 显示其所在内存地值  
  48.    printf("====IN HEAP====\n");  
  49.    printf("heap_var is at address 0x%08x\n\n", heap_var_ptr);  
  50.   
  51.    // 放在stack中的变量  
  52.    // 显示其所在内存地值  
  53.    printf("====IN STACK====\n");  
  54.    printf("the main's stack_var is at address 0x%08x\n", &stack_var);  
  55.    function();   
  56. }  


编译这个代码,看看执行结果:



理解了进程的内存空间使用,我们现在可以想想,这几块内存当中,最灵活的是哪一块?没错,是Heap。其它几块都由C编译器编译代码时预处理,相对固定,而heap内存可以由malloc和free进行动态的分配和销毁。

有关malloc和free的使用方法,在本文中我就不再多说,这些属于基本知识。我们在这篇文章中要关心的是,malloc是如何工作的?实际上,它会去调用mmap(),而mmap()则会调用内核,获取VMA,即前文中看到的vm_area。这一块工作由c库向kernel发起请求,而由kernel完成这个请求,在kernel当中,有vm_operations_struct进行实际的内存操作:

C代码   收藏代码
  1. struct vm_operations_struct {  
  2.         void (*open)(struct vm_area_struct * area);  
  3.         void (*close)(struct vm_area_struct * area);  
  4.         ...  
  5. };  


可以看到,kernel可以对VMA进行open和close,即收发牌的工作。理解了malloc的工作原理,free也不难了,它向下调用munmap()。

下面是mmap和munmap的函数定义:

C代码   收藏代码
  1. void *  
  2. mmap(void *addr, size_t len, int prot, int flags, int fd, off_t offset);  


这里面,addr是希望能够分配到的虚地址,比如:我希望得到一张牌,做为我手里编号为2的那张。需要注意的是,mmap最后分配出来的内存地址不一定是你想要的,可能你请求一张编号为2的扑克,但发牌人控制这个编号过程,他会给你一张在你手里编号为3的扑克。

prot代表对进程对这块内存的权限:

C代码   收藏代码
  1. PROT_READ 是否可读  
  2. PROT_WRITE 是否可写  
  3. PROT_EXEC IP指针是否可以指向这里进行代码的执行  
  4. PROT_NONE 不能访问  


flags代表用于控制很多的内存属性,我们一会儿会用到,这里不展开。

fd是文件描述符。我们这里必须明白一个基本原理,任何硬盘上面的数据,都要读取到内存当中,才能被程序使用,因此,mmap的目的就是将文件数据映射进内存。因此,要在这里填写文件描述符。 如果你在这里写-1,则表示不映射任何文件数据,只是在内存里面要上这一块空间,就是malloc对mmap的使用方法。

offset是文件的偏移量,比如:从第二行开始映射。 文件映射(即内存映射文件),不是这篇文章关心的内容,不展开。

okay,了解了mmap的用法,下面看看munmap:

C代码   收藏代码
  1. int  
  2. munmap(void *addr, size_t len);  


munmap很简单,告诉它要还回去的内存地址(即哪张牌),然后告诉它还回去的数量(多少张),其实更准确的说:尺寸。


现在让我们回到题目上来,如何部分地回收一个数组中的内存?我们知道,使用malloc和free是无法完成的:

C代码   收藏代码
  1. #include <stdlib.h>  
  2. int main() {  
  3.         int *p = malloc(12);  
  4.         free(p);  
  5.         return 0;  
  6. }  


因为无论是malloc还是free,都需要我们整体提交待分配和销毁的全部内存。于是自然而然想到,是否可以malloc分配内存后,然后使用munmap来部分地释放呢?下面是一个尝试:

C代码   收藏代码
  1. #include <sys/mman.h>  
  2. #include <stdio.h>  
  3. #include <stdlib.h>  
  4.   
  5. int main() {  
  6.     int *arr;  
  7.     int *p;  
  8.     p = arr = (int*) malloc(3 * sizeof(int));  
  9.     int i = 0;  
  10.       
  11.     for (i=0;i<3;i++) {  
  12.         *p = i;  
  13.         printf("address of arr[%d]: %p\n", i, p);  
  14.         p++;  
  15.     }  
  16.       
  17.     printf("munmap: %d\n", munmap(arr, 3 * sizeof(int)));  
  18. }  


运行这段代码输出如下:



注意到munmap调用返回-1,说明内存释放未成功,这是由于munmap处理的内存地址必须页对齐(Page Aligned)。在Linux下面,kernel使用4096 byte来划分页面,而malloc的颗粒度更细,使用8 byte对齐,因此,分配出来的内存不一定是页对齐的。为了解决这个问题,我们可以使用memalign或是posix_memalign来获取一块页对齐的内存:

C代码   收藏代码
  1. #include <sys/mman.h>    
  2. #include <stdio.h>    
  3. #include <stdlib.h>    
  4.     
  5. int main() {    
  6.     void *arr;    
  7.     printf("posix_memalign: %d\n", posix_memalign(&arr, 4096, 4096));  
  8.     printf("address of arr: %p\n", arr);    
  9.     printf("munmap: %d\n", munmap(arr, 4096));    
  10. }   


运行上述代码得结果如下:



可以看到,页对齐的内存资源可以被munmap正确处理(munmap返回值为0,说明执行成功)。

仔细看一下被分配出来的地址:

Bash代码   收藏代码
  1. 0x7fe09b804000  


转换到10进制是:140602658275328

试试看是否能被4096整除:140602658275328 / 4096 = 34326820868

可以被整除,验证了分配出来的地址是页对齐的。

接下来,我们试用一下mmap,来分配一块内存空间:

C代码   收藏代码
  1. mmap(NULL, 3 * sizeof(int), PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0)  


注意上面mmap的使用方法。其中,我们不指定虚地址,让内核决定内存地址,也就是说,我们要是要一张牌,但不关心给牌编什么号。然后PROT_READ|PROT_WRITE表示这块内存可读写,接下来注意flags里面有MAP_ANONYMOUS,表示这块内存不用于映射文件。下面是完整代码:

C代码   收藏代码
  1. #include <sys/mman.h>  
  2. #include <stdio.h>  
  3. #include <stdlib.h>  
  4.   
  5. int main() {  
  6.     int *arr;  
  7.     int *p;  
  8.     p = arr = (int*) mmap(NULL, 3 * sizeof(int), PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0);  
  9.     int i = 0;  
  10.       
  11.     for (i=0;i<3;i++) {  
  12.         *p = i;  
  13.         printf("address of arr[%d]: %p\n", i, p);  
  14.         p++;  
  15.     }  
  16.       
  17.     printf("munmap: %d\n", munmap(arr, 3 * sizeof(int)));  
  18. }  


运行结果如下:



注意munmap返回值为0,说明内存释放成功了。因此,验证了mmap分配出来的内存是页对齐的。

okay,了解了所有这些背景知识, 我们现在应该对给内存打洞这个问题有一个思路了。我们可以创建以Page为基本单元的内存空间,然后用munmap在上面打洞(即最小的一个洞为一个page大小)。下面是实验代码:

C代码   收藏代码
  1. #include <sys/mman.h>    
  2. #include <stdio.h>    
  3. #include <stdlib.h>    
  4.     
  5. int main() {    
  6.     void *arr;    
  7.     printf("posix_memalign: %d\n", posix_memalign(&arr, 4096, 3 * 4096));  
  8.     printf("address of arr: %p\n", arr);   
  9.     printf("address of arr[4096]: %p\n", &arr[4096]);   
  10.     printf("munmap: %d\n", munmap(&arr[4096], 4096));    
  11. }    


我们申请了3*4096 byte的空间,也就是3页的内存,然后通过munmap,在中间这页上开个洞 。运行上面的代码,结果如下:



看到munmap的返回为0,说明内存释放成功,我们在arr数组上成功地开了一个洞。

这种方法,最大的局限在于,你操作的内存必须是page对齐的。如果想要更细颗粒度的打洞,纯靠User Space的API调用是不行的,需要在Kernel Space直接操作进程的VMA结构体来实现。实现思路如下:

1. 通过kernel提供的page map映射,找到要释放的内存虚地址所对应的物理地址。
2. 撰写一个内核模块,帮助你user space的程序来将实际的物理内存放回free list。

我在本文的下篇中,将详细介绍Kernel Space和User Space的结合编码,实现更细颗粒度的内存操作。

参考资料

Experiments with the Linux Kernel: Process Segments

How to find the physical address of a variable from user-space in Linux?

Simplest way to get physical address from the logical one in linux kernel module

Page Map

anon_mmap.c

Mmap

mmap()--Memory Map a File

C_dynamic_memory_allocation

What are the differences between "brk()" and "mmap()"?

How to guarantee alignment with malloc and or new?

Understanding Memory Pages and Page Alignment

评论
6 楼 allwefantasy 2012-10-08  
亲,你之后发博文的时候 勾选一下推荐到 '首页'贝,这样编辑才能将你的文章放到ITEYE首页
5 楼 阿男bluedash 2012-10-07  
ziyoudefeng 写道
程序内存模型是不是反了?

环境变量和命令行参数
stack
...

BSS
data segment
text segment

环境变量地址最高,text segment最低?不然理解不了http://dl.iteye.com/upload/attachment/0074/6636/e7b6a0e9-43e9-3100-8a95-8c39bb4e0abf.png这张图。里面大小关系看不懂,怎么0x6ea3eb4c > 0x6ea3eb68?


是的,谢谢勘误!:-)
4 楼 阿男bluedash 2012-10-07  
askoliver 写道
0x0ee40060 应该是与 0x0ee4005c 挨着,不是与0x0ee40058,并且差4个字节。


是的,谢谢勘误!
3 楼 askoliver 2012-10-07  
0x0ee40060 应该是与 0x0ee4005c 挨着,不是与0x0ee40058,并且差4个字节。
2 楼 ziyoudefeng 2012-10-07  
程序内存模型是不是反了?

环境变量和命令行参数
stack
...

BSS
data segment
text segment

环境变量地址最高,text segment最低?不然理解不了http://dl.iteye.com/upload/attachment/0074/6636/e7b6a0e9-43e9-3100-8a95-8c39bb4e0abf.png这张图。里面大小关系看不懂,怎么0x6ea3eb4c > 0x6ea3eb68?
1 楼 dalviker 2012-10-07  
学习并收藏,期待下一篇。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值