实际上这是一个内存方面的问题。要想研究这个问题,首先我们要将题目本身搞明白。由于我对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这个结构体:
-
struct mm_struct {
-
struct vm_area_struct * mmap;
-
struct rb_root mm_rb;
-
struct vm_area_struct * mmap_cache;
-
...
-
unsigned long start_code, end_code, start_data, end_data;
-
unsigned long start_brk, brk, start_stack;
-
...
-
};
mm_struct负责描述进程的内存。相当于发牌人记录给谁发了哪些牌,发了多少张,等等。那么,内存是如何将内存进行划分的呢?也就是说,发牌人手里假设是一大张未裁剪的扑克纸,他是怎样将其剪成一张一张的扑克牌呢?上面的vm_area_struct就是基本的划分单位,即一张一张的扑克牌:
-
struct vm_area_struct * mmap;
这个结构体的定义如下:
-
struct vm_area_struct {
-
struct mm_struct * vm_mm;
-
unsigned long vm_start;
-
unsigned long vm_end;
-
-
....
-
-
struct vm_area_struct *vm_next;
-
....
-
}
这样,内核就可以记录分配给用户空间的内存了。
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函数)时,这个函数内部的一些变量的数值入栈,函数调用完成返回后,局部变量的数值就没有用了,因此出栈,把内存让出来给另一个函数的变量使用(程序在执行时,总是会在某一个函数调用里面)。
我们看一个图例说明:
为了更好的理解内存分段,可以撰写一段代码:
-
#include
-
-
-
int global_var;
-
-
-
int global_initialized_var = 5;
-
-
void function() {
-
int stack_var;
-
-
-
-
printf("the function's stack_var is at address 0x%08x\n", &stack_var);
-
}
-
-
-
int main() {
-
int stack_var;
-
-
-
static int static_initialized_var = 5;
-
-
-
static int static_var;
-
-
int *heap_var_ptr;
-
-
-
-
-
heap_var_ptr = (int *) malloc(4);
-
-
-
-
printf("====IN DATA SEGMENT====\n");
-
printf("global_initialized_var is at address 0x%08x\n", &global_initialized_var);
-
printf("static_initialized_var is at address 0x%08x\n\n", &static_initialized_var);
-
-
-
-
printf("====IN BSS SEGMENT====\n");
-
printf("static_var is at address 0x%08x\n", &static_var);
-
printf("global_var is at address 0x%08x\n\n", &global_var);
-
-
-
-
printf("====IN HEAP====\n");
-
printf("heap_var is at address 0x%08x\n\n", heap_var_ptr);
-
-
-
-
printf("====IN STACK====\n");
-
printf("the main's stack_var is at address 0x%08x\n", &stack_var);
-
function();
-
}
编译这个代码,看看执行结果:
理解了进程的内存空间使用,我们现在可以想想,这几块内存当中,最灵活的是哪一块?没错,是Heap。其它几块都由C编译器编译代码时预处理,相对固定,而heap内存可以由malloc和free进行动态的分配和销毁。
有关malloc和free的使用方法,在本文中我就不再多说,这些属于基本知识。我们在这篇文章中要关心的是,malloc是如何工作的?实际上,它会去调用mmap(),而mmap()则会调用内核,获取VMA,即前文中看到的vm_area。这一块工作由c库向kernel发起请求,而由kernel完成这个请求,在kernel当中,有vm_operations_struct进行实际的内存操作:
-
struct vm_operations_struct {
-
void (*open)(struct vm_area_struct * area);
-
void (*close)(struct vm_area_struct * area);
-
...
-
};
可以看到,kernel可以对VMA进行open和close,即收发牌的工作。理解了malloc的工作原理,free也不难了,它向下调用munmap()。
下面是mmap和munmap的函数定义:
-
void *
-
mmap(void *addr, size_t len, int prot, int flags, int fd, off_t offset);
这里面,addr是希望能够分配到的虚地址,比如:我希望得到一张牌,做为我手里编号为2的那张。需要注意的是,mmap最后分配出来的内存地址不一定是你想要的,可能你请求一张编号为2的扑克,但发牌人控制这个编号过程,他会给你一张在你手里编号为3的扑克。
prot代表对进程对这块内存的权限:
-
PROT_READ 是否可读
-
PROT_WRITE 是否可写
-
PROT_EXEC IP指针是否可以指向这里进行代码的执行
-
PROT_NONE 不能访问
flags代表用于控制很多的内存属性,我们一会儿会用到,这里不展开。
fd是文件描述符。我们这里必须明白一个基本原理,任何硬盘上面的数据,都要读取到内存当中,才能被程序使用,因此,mmap的目的就是将文件数据映射进内存。因此,要在这里填写文件描述符。如果你在这里写-1,则不映射任何文件数据,只是在内存里面要上这一块空间,这就是malloc对mmap的使用方法。
offset是文件的偏移量,比如:从第二行开始映射。文件映射,不是这篇文章关心的内容,不展开。
okay,了解了mmap的用法,下面看看munmap:
-
int
-
munmap(void *addr, size_t len);
munmap很简单,告诉它要还回去的内存地址(即哪张牌),然后告诉它还回去的数量(多少张),其实更准确的说:尺寸。
现在让我们回到题目上来,如何部分地回收一个数组中的内存?我们知道,使用malloc和free是无法完成的:
-
#include
-
int main() {
-
int *p = malloc(12);
-
free(p);
-
return 0;
-
}
因为无论是malloc还是free,都需要我们整体提交待分配和销毁的全部内存。于是自然而然想到,是否可以malloc分配内存后,然后使用munmap来部分地释放呢?下面是一个尝试:
-
#include
-
#include
-
#include
-
-
int main() {
-
int *arr;
-
int *p;
-
p = arr = (int*) malloc(3 * sizeof(int));
-
int i = 0;
-
-
for (i=0;i<3;i++) {
-
*p = i;
-
printf("address of arr[%d]: %p\n", i, p);
-
p++;
-
}
-
-
printf("munmap: %d\n", munmap(arr, 3 * sizeof(int)));
-
}
运行这段代码输出如下:
注意到munmap调用返回-1,说明内存释放未成功,这是由于munmap处理的内存地址必须页对齐(Page Aligned)。在Linux下面,kernel使用4096 byte来划分页面,而malloc的颗粒度更细,使用8 byte对齐,因此,分配出来的内存不一定是页对齐的。为了解决这个问题,我们可以使用memalign或是posix_memalign来获取一块页对齐的内存:
-
#include
-
#include
-
#include
-
-
int main() {
-
void *arr;
-
printf("posix_memalign: %d\n", posix_memalign(&arr, 4096, 4096));
-
printf("address of arr: %p\n", arr);
-
printf("munmap: %d\n", munmap(arr, 4096));
-
}
运行上述代码得结果如下:
可以看到,页对齐的内存资源可以被munmap正确处理(munmap返回值为0,说明执行成功)。仔细看一下被分配出来的地址:
转换到10进制是:140602658275328
试试看是否能被4096整除:140602658275328 / 4096 = 34326820868
可以被整除,验证了分配出来的地址是页对齐的。
接下来,我们试用一下mmap,来分配一块内存空间:
-
mmap(NULL, 3 * sizeof(int), PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0)
注意上面mmap的使用方法。其中,我们不指定虚地址,让内核决定内存地址,也就是说,我们要是要一张牌,但不关心给牌编什么号。然后PROT_READ|PROT_WRITE表示这块内存可读写,接下来注意flags里面有MAP_ANONYMOUS,表示这块内存不用于映射文件。下面是完整代码:
-
#include
-
#include
-
#include
-
-
int main() {
-
int *arr;
-
int *p;
-
p = arr = (int*) mmap(NULL, 3 * sizeof(int), PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0);
-
int i = 0;
-
-
for (i=0;i<3;i++) {
-
*p = i;
-
printf("address of arr[%d]: %p\n", i, p);
-
p++;
-
}
-
-
printf("munmap: %d\n", munmap(arr, 3 * sizeof(int)));
-
}
运行结果如下:
注意munmap返回值为0,说明内存释放成功了。因此,验证了mmap分配出来的内存是页对齐的。
okay,了解了所有这些背景知识,我们现在应该对给内存打洞这个问题有一个思路了。我们可以创建以Page为基本单元的内存空间,然后用munmap在上面打洞。下面是实验代码:
-
#include
-
#include
-
#include
-
-
int main() {
-
void *arr;
-
printf("posix_memalign: %d\n", posix_memalign(&arr, 4096, 3 * 4096));
-
printf("address of arr: %p\n", arr);
-
printf("address of arr[4096]: %p\n", &arr[4096]);
-
printf("munmap: %d\n", munmap(&arr[4096], 4096));
-
}
我们申请了3*4096 byte的空间,也就是3页的内存,然后通过munmap,在中间这页上开个洞 。运行上面的代码,结果如下:
看到munmap的返回为0,说明内存释放成功,我们在arr数组上成功地开了一个洞。
这种方法,最大的局限在于,你操作的内存必须是page对齐的。如果想要更细颗粒度的打洞,纯靠User Space的API调用是不行的,需要在Kernel Space直接操作进程的VMA结构体来实现。实现思路如下:
1. 通过kernel提供的page map映射,找到要释放的内存虚地址所对应的物理地址。
2. 撰写一个内核模块,帮助你user space的程序来将实际的物理内存放回free list。
阅读(2322) | 评论(0) | 转发(0) |