memory descriptor(内存描述符)
与进程地址空间相关的全部信息都包含在内存描述符中。其类型是mm_struct,记录在task_struct的mm成员中。
1. 有一个mmlist的链表链接所有的mm_struct。表头是init_mm(进程0的mm)。
2. mm_users和mm_count
当一个内存描述符有两个轻量级进程共享,此时mm_users为2.而mm_count为1。如果把内存描述符暂时借给一个内核线程,mm_count就加一。内存描述符当mm_count为0时释放。
3. mm_alloc()函数用来获得一个新的内存描述符,这些描述符是由slab分配器管理的,它调用kmem_cache_alloc(),并把mm_count和mm_users置为1。
内核线程的内存描述符
内核线程运行在普通线程中,所以其没有特殊的mm_struct,而是借用用户进程的,在task_struct中有两种内存描述符指针:mm和active_mm。对于用户进程这两个是一致的。对于内核进程而言,它的mm总是为NULL,active_mm被初始化为前一个运行进程的active_mm。
Question:只要处于内核态的一个进程为“高端”线性地址(>0xc0000000)修改了页表项,它就应当更新系统中所有进程页表集合中的相应表项。而修改所有的进程的页表项是非常费时的,如何解决这个问题?
Answer: Linux采用一种延迟方式。当高端地址被重新映射时,内核只更新主内存描述符的pgd指向的页全局目录swapper_pg_dir。主内存描述符在init_mm变量中,是给swapper内核进程使用的(即是进程0)。具体实现在缺页异常处理程序中说明。
memory region(线性区)
内核使用了一种新的资源来实现对进程动态内存的推迟分配,这就是线性区。线性区是用来表示线性地址区间的,线性区有起始线性地址,长度和一些存取权限来描述。线性区的类型为vm_area_struct:
1. 线性区合并与删除
进程所拥有的线性区不会重叠,并且内核尽力把新分配的线性区与紧邻的现有线性区合并(它们权限要相同)。
会出现以下几种情况:
2. vm_ops
vm_area_struct中的vm_ops变量指向线性区的方法,只有三个方法被定义:open,close,nopage。
open和close分别是添加和删除进程的线性区。而nopage是当进程试图访问RAM中不存在的一个页,但该页的线性地址属于线性区时由缺页异常程序调用。其格式为:
struct page * (*nopage)(struct vm_area_struct * area, unsigned long address, int unused);
3. 线性区的数据结构
进程所拥有的所有线性区是通过一个简单的链表链接在一起的。如下图所示:
vm_next指向链表的下一个元素:
mm_struct的mmap变量指向第一个线性区描述符。
map_count变量存放进程所拥有的线性区数目,一个进程最多拥有MAX_MAP_COUNTD=65536个不同的线性区。
4. 查找线性区
由于有时线性区链表会非常大,这时一个个去遍历链表就会带来非常低效的性能。内核用红黑树来检索线性区,这样就可以在log(n)的时间复杂度中完成操作。红黑树的根节点由mm_struct的mm_rb变量指向。在vm_area_struct的vm_rb变量中保存其在树中的的颜色,父节点和左右子节点。
5. 线性区存储权限
(1)线性区标志vm_flags
这里存放着有关这个线性区全部页的信息,例如它们包含什么内容,每个页的权限和线性区如何增长。
(2)线性区页表标志vm_page_prot
增加一个页时,内核根据vm_page_prot的值设置相应页页表项的标志。这里要注意的是由于要完成copy_on_write功能,并且页表的User/Supervisor标志必须总为1,因为用户态进程必须总能访问其中的页。
linux采取以下两种规则:(a)读总是隐含执行权限 (b)写总是隐含读权限
这样就把由read,write,exec和共享存取权限所产生的16种可能被精简为3种:
(1)页有写和共享两种权限,Read/Write=1
(2)页有读或执行权限,但既没有写也没有共享存取权限,Read/Write=0
(3)页没有任何存取权限,Present=0,以便每次访问都产生一个缺页中断。不过这里值得注意的是,如何把写时复制和真正页框不存在的情况区分出来,Linux通过把Page size位置置为1。Tricky?是的,这里钻了i386的一个小空子就是芯片在页目录项中检查Page size位,而不在页表项中检查。
6. 线性区的处理
mmap_cahce保存进程最后一次引用线性区的描述符地址。这是为了减少查找线性区的时间做的优化。也就是说线性区的查找会先查找这个描述符,如果不是再从红黑树中查找。
内核提供了如下几种函数去处理线性区:
(1)find_vma() 查找线性区的vm_end大于addr的第一个线性区的位置。
(2)find_vma_intersection() 查找一个与给定地址区间相重叠的线性区
(3)arch_get_unmapped_area() 寻找一个空闲的地址空间
(4)insert_vm_struct() 向内存描述符链表插入一个线性区
7. 分配线性地址区间
do_mmap()
do_munmap()
两步走。
Page Fault Handler
1. 非连续区地址
在kernel mode下对非连续区地址的访问,就会发生进程的第4GB的地址空间与swap_pg_dir不一致的情况,如果错误是由这种情况引起的就会把swap_pg_dir的页表项复制过来。
2. 用户态堆栈
由于堆栈的大小是不固定的,所以当一个push或者pusha指令就可能访问超过线性区的地址,所以这里就需要缺页处理程序单独处理。
3. demand paging(请求调页)
demand paging是一种动态内存分配技术,它把页框的分配推迟到不能再推迟为止。请求调页会发生在以下两种情况下(页表的present都为0):
1) 进程从来没有访问过这个页。页表项被填充为0,也就是pte_none宏返回1。调用do_no_page()函数,有两种方法装入所缺页,这取决于这个页是否被映射到一个磁盘文件。
是否映射磁盘文件是由vma(线性区对象)的nopage函数来确定。
(1) 当vma->vm_ops->nopage != NULL,线性区映射磁盘文件,通过调用nopage函数来把文件读到内存中。
(2) 当vma->vm_ops->nopage == NULL,线性区没有映射磁盘文件,do_no_page()调用do_anonymous_page()来获得一个新的页框。
这个函数根据write和read请求选择了不同的策略,首先当请求是read时,由于进程是第一次对它访问,给进程一个填充为0的页要比给它一个由其他进程使用过的旧业更为安全,所以没有必要立即给进程分配一个填充为0的新页框。内核把已有的zero page分配出去,这样可以进一步推迟页框的分配。由于这个页被标志为不可写,如果进程试图写这个页的时候会使用copy_on_write机制。当请求是write时就调用alloc_page()来分配一个新的页框。
2) 页被swap到磁盘上了。页表项不为0。
4. copy on write(写时复制)
当页表的present=1,write=0时,对该页框的写操作就会引起缺页中断,此时调用do_wp_page()来实现copy_on_write。分两种情况
1) 当页描述符的count=1时,表示只有一个进程使用这个页,就没有必要复制了。
2) 当页描述符的count>1时,就把旧页框的内容复制到新分配的页框。
Process Address Space Layout
我们可以通过pmap工具或者直接查看/proc来看到进程的地址空间:
如上图所示,一开始是libc的code,data,bss段,接下来是ld的code,data,bss段,然后是a.out程序的code,data,bass段。接下来是heap和stack段。这里的每个区间都是由一个vm_area_struct对象相对应。
brk() && sbrk()
brk()会调用系统调用sys_brk(addr),其功能是修改堆的大小,addr指定新的heap的末尾地址。而旧的heap的末尾地址存放在mm->brk中。brk()会调用do_brk(oldbrk,newbrk)来扩展heap的vm_area_struct,do_brk相当于是do_mmap的简化版本(其中去除了映射文件的部分),相当于:
do_mmap(NULL, oldbrk, newbrk-oldbrk, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_FIXED|MAP_PRIVATE, 0);
sbrk(incr)类似于,不过incr参数指定增加还是减少以字节为单位的堆大小。如果是减少incr就为负数。不过sbrk不是系统调用,它只是调用了brk()来实现的。
malloc() && free()
malloc是libc库的函数,对于操作系统来说,有一个heap的vm_area_struct,其中包含了一串线性地址空间。而对这组空间的管理就会交给malloc和free函数。操作系统只提供了brk()和sbrk()来控制heap的线性区(sbrk()也是libc库,brk()是系统调用)。就是说libc可以通过brk来增大或者减少heap的大小,不过如何管理这部分空间,就是由malloc和free自己实现了。
我们可以做一个实验:
#include <stdio.h> #include <malloc.h> int main(int argc, char **argv) /* arguments aren't used */ { int i; int size = 1024 * 16; void* pList[128]; for(i = 0; i < 128; ++i) { printf("malloc: %d\n",size); void* p = malloc(size); pList[i] = p; } sleep(8); for(i = 0; i < 128; ++i) { printf("free: %d\n",size); void* p = pList[i]; free(p); } while (1) {} }
当上面的代码不断malloc,此时heap的大小就不断增加。而当所有的指针被free掉之后heap就回到原来的值了。不过这里有一个问题就是heap的vm_area_struct是允许空洞的。比如我们malloc了128个指针,然后释放前面的64个,而后面64个没有释放,那heap的大小也不能够改动。
由于malloc是调用brk(),也就是说,实际上malloc只是分配了进程的线性地址,而没有分配物理地址,只有当实际用到的时候才会通过缺页中断来把物理页框分配出来。
malloc && free算法
A Lab to implement malloc: