本文对Linux内存管理中的copy_page_tables源码进行理解。Linus认为下面copy_page_tables函数是内存管理部分最难的之一。这个函数在父进程创建子进程的过程中使用,父进程要负责设置子进程的代码段、数据段(线性空间),然后为子进程拥有的线性地址空间创建对应的页目录项和页表,使得子进程能够进行内存寻址。copy_page_tables的工作就是通过复制父进程的页表来创建子进程的页表,并设置相应的页目录项。
代码
1 | /* |
参数
函数的参数是从fork->copy_process->copy_mem中传递过来的old_data_base,new_data_base,data_limit。
- 其中old_data_base是父进程局部描述符表LDT中数据段的基地址(线性地址空间)
- new_data_base为即将创建的子进程在线性地址空间中的基地址,由于整个4G线性空间是等分的,目前支持的是64个进程,则4G/64=64MB,即每个进程分配64MB的线性地址空间,因此对于一个任务号为nr的进程,即对应的线性地址空间起始地址为nr*64MB,
- data_limit为父进程的局部描述符表LDT中数据段描述符中的段限长。
检查线性地址对齐
1 | if ((from&0x3fffff) || (to&0x3fffff)) //4M对齐检测,否则die |
32位线性地址拆分成3部分来看,10 | 10 | 12, 前十位对应页目录项,中间10位对应页表项,最后12位对应页内偏移。0x3fffff:最后12位全为1,相当于4MB,也就是一个页表管辖4MB的地址空间(4MB线性地址空间<->4MB物理地址空间)。上述代码也就是判断末尾的22位是否全为0,也就是说任意进程的线性地址空间必须是从0x000000开始的4MB的整数倍的线性地址。
通过线性地址得到页目录项的物理地址
1 | from_dir = (unsigned long *) ((from>>20) & 0xffc); // _pg_dir = 0,通过线性地址得到页目录项物理地址 |
这句话的目的是通过线性地址得到页目录项的物理地址。页目录表共1K项,因此只要10位进行索引,因此页目录号索引=from>>22。又因为1个页目录项占4B,则页目录项相对物理地址=页目录号<<2, 例如页目录号为1,则页目录项相对物理地址为4B。又因为页目录的基址为0x000000,因此页目录项相对物理地址=页目录项绝对物理地址。故【(线性地址>>22)<<2】得到的就是页目录项的物理地址。【(线性地址>>22)<<2】这句话实际上就等价于【(线性地址>>20) & 0xffc】, 也就是把最后2位清空。因此任何一个线性地址(前10位)唯一确定一个页目录项。2】这句话实际上就等价于【(线性地址>2】得到的就是页目录项的物理地址。【(线性地址>
计算要复制的页表数
1 | size = ((unsigned) (size+0x3fffff)) >> 22; //计算页表数 |
size的单位是B,是父进程数据段对应的段限长,这个段限长首先限制的是线性地址空间寻址大小,例如进程0的数据段限长为640KB,因此其线性地址空间为0~640KB,同时也间接得限制了物理地址空间的寻址大小,因为任何物理地址都需要有与之相映射的线性地址,对于0进程而言,映射的物理内存是物理内存起始开始的640KB的空间。而对于一般的进程,线性地址空间为64M,对应的最大物理内存空间也是64M,一个页目录项或者说一个页表管辖4MB的物理内存空间,因此一个进程线性地址空间最多可以拥有16个页目录项,也即16个页表。因此,页表数=(size+4M/4M),相当于求需要多少个页表来管辖,也就是copy_page_tables要复制多少个页表。有多少个页表,则就有多少个对应的页表项。
根据页目录项物理地址得到页表物理地址
1 | from_page_table = (unsigned long *) (0xfffff000 & *from_dir);//根据页目录项地址得到页表物理地址 |
from_dir是页目录项的地址,取*号,相当于取页目录项的内容,因为页目录项指向页表,也即页目录项的内容就是页表的物理地址。因为get_free_page返回的物理地址=相对物理地址<<12+LOW_MEM(每页大小为2^12=4KB),因此将低12位清零&fffff000得到的就是页面的物理地址,页表也是占用1页面大小,因此这里得到的就是页表的物理地址,同时也是第一项页表项的物理地址,最后12位实际上用于存储页表的属性。至于为什么只用20位存储,因为目前使用的是16M物理内存,16MB/4KB=2^24/2^12=2^12页数,20位足够用了,实际上20位可以索引最多4G的物理空间。
拷贝页表项
1 | if (!(to_page_table = (unsigned long *) get_free_page())) |
首先为子进程页表(1个页表占4K)存储申请一个页面的物理内存,get_free_page()。然后执行*to_dir = ((unsigned long) to_page_table) | 7,将子进程页表存到相应的页目录项当中,页目录项也是前20位代表页表的地址,后12位代表页表对应的页面的属性,因此把最后3位设置成用户权、可读写、存在,相当于设置了页表对应页面的属性。如果是0进程则只拷贝160个页表项,否则全部拷贝1K个页表项,接着for循环拷贝每一个页表项,对于未使用的页表项不进行拷贝。this_page &= ~2其中2=010,~2=101,代表用户,只读,存在。因为此时父子进程共享了物理页,因此需要对物理页的引用次数+1。可以看到这里面索引的计算,索引=(物理地址- LOW_MEM)/4KB。
另外还要强调一点,此时并没有把页表对应的页面和用户进程的线性地址空间进行映射。某一个页面和进程的线性地址空间映射是指,将这个页面的物理地址存入该进程线性地址空间所对应的页表的某个页表项当中,这样进程才有权限对该页面进行访问。 具体映射函数是put_page(page, address)用来完成物理页面与一个线性地址页面的映射,从而将一个线性地址空间内的页面落实到物理地址空间内,其中page就是某个物理页面的物理地址,address是要映射的进程的线性地址。具体过程是,首先会根据该线性地址前10位得到页目录项,其次页目录项取*号得到的就是页表的物理地址,这个页表也就是这个进程拥有的,然后根据线性地址接下来的10位,得到相对于这个页表的位置索引,最后把这个地址存入位置索引对应的页表项。之所以说,并没有把页表对应的页面和用户进程的线性地址空间进行映射,是因为并没有把页表对应的物理地址放入这个页表的某个页表项当中。如果要映射,相当于把页表自己的物理地址放入页表自己的某个页表项当中,只有这样,才能说将页表映射到了进程的线性地址空间,也只有这样,用户进程才有权限访问该页表本身。
总之,用户进程只能访问其线性地址空间所对应的页表(前10位找到页目录项,页目录项中的内容就是该线性地址所对应的页表的物理地址),中的页表项所指向的物理内存页面。而不能访问内核管理进程所使用的页表本身,也就是用户进程读写不了页表的页表项,但可以读写页表项中指向的物理页面。而内核将所有物理内存16MB和自己的16MB线性地址空间全部映射了,也就是将16MB物理内存所有页面的物理地址全部存入16MB线性地址空间所对应的4个内核页表(4M对应一个页表)的页表项当中了,当然也包括内核为进程分配的页表对应的页面的物理地址,因此内核有权限读写管理所有进程的页表,即进程的页表位于内核的线性地址空间。
物理页面和进程线性地址的映射函数如下:
1 | // put_page用来完成物理页面与一个线性地址页面的映射 |
最后还要强调一下如下代码,
1 | if (this_page > LOW_MEM) { //如果是主内存区,1MB以内不参与用户分页管理,1MB以上mem_map才管 |
对于这段代码的理解:
对于内核空间,不使用写时复制。一个页面被多个进程共享,每当一个进程产生一次写保护错误,内核将给进程分配一个新的物理页面,将共享页面的内容复制过来,新的页面将设置为可读写,而共享页面仍然是只读的,只是共享计数减小了。当其他共享进程都产生了一次写保护错误后,共享页面的共享计数减成了1,其实就是被一个进程独占了,但此时该共享页面仍然是只读的,如果独占它的进程对它进行写操作仍然会产生写保护出错。为什么不在共享计数减成了1之后就将共享页面置为可写呢?原因很简单,因为系统并不知道最后是哪个页表项指向这个共享页,如果要把它查找出来会有很大的系统开销,这是中断处理程序应当尽量避免的,所以采用了以逸待劳的办法。如果当初共享的页面不属于主内存块,在共享时就没有作共享计数的处理,就不存在共享计数的问题,直接复制就可以了。
但是,对于内核空间来说,却是不同的—内核空间不使用写时复制机制!这也是Linus在其内核代码注释中提到的,对于内核空间来说,还是以特殊的进程0和进程1来说。进程0是系统中第一个手工创建的程序,其特殊在其地址空间属于内核空间,也就是说,进程0是内核空间的物理页面。系统通过fork函数产生了进程1,此时进程0和1共享内核物理页面(假设为KM)。但是其特殊就特殊在在fork时,内核针对内核空间是特殊对待的。也就是说,只有对于非内核地址空间的页面,才会将被fork共享的页面所对应的页表项设为只读,从而在最后一次写操作的时候,将源页面释放。而对于内核地址空间的地址共享,只将进程1的页目录的属性设置为只读,而源目录表项(进程0)依然是可读写的。这就导致进程1fork进程0而产生之后,只有进程1对共享的物理页面(内核地址空间)进行写操作的时候才会产生写时复制,为进程1在主内存中申请一页新的物理页面作为独属于进程1的物理页面。而进程0对其共享内存的写操作不会引起写时复制,即KM就好像独属于进程0一样。
参考
[对Linux0.11 “内核空间不使用写时复制机制” 本质理解][http://blog.csdn.net/yihaolovem/article/details/37958351]
[Linux-0.11内核源码分析系列:内存管理copy_page_tables()函数分析][http://blog.csdn.net/linpeng12358/article/details/41441993]