蘑菇先生学习记

Linux内存管理之copy_page_tables源码理解

  本文对Linux内存管理中的copy_page_tables源码进行理解。Linus认为下面copy_page_tables函数是内存管理部分最难的之一。这个函数在父进程创建子进程的过程中使用,父进程要负责设置子进程的代码段、数据段(线性空间),然后为子进程拥有的线性地址空间创建对应的页目录项和页表,使得子进程能够进行内存寻址。copy_page_tables的工作就是通过复制父进程的页表来创建子进程的页表,并设置相应的页目录项。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/* 
* copy_page_tables()函数只被fork函数调用
* 拷贝只是拷贝页表,页表是管理4M地址的,所以按照4M对齐
* 不拷贝物理页内容,当发生写时拷贝才会拷贝页表所管理的物理页内容
* 对于进程0和1,只拷贝前160页共640Kb,出于效率考虑
* 0-1M作为内核驻留地址区域,禁止写覆盖
* 参数from,to是0-4G线性地址,size是字节为单位
*/
int copy_page_tables(unsigned long from,unsigned long to,long size)
{
unsigned long * from_page_table; //用于管理源页表
unsigned long * to_page_table; //用于管理目的页表
unsigned long this_page; //用于保存页表
unsigned long * from_dir, * to_dir; //用于管理源页目录项,目的页目录项
unsigned long nr; //用于保存页表项个数

if ((from&0x3fffff) || (to&0x3fffff)) //4M对齐检测,否则die
panic("copy_page_tables called with wrong alignment");
from_dir = (unsigned long *) ((from>>20) & 0xffc); /* _pg_dir = 0 */
//源页目录项
to_dir = (unsigned long *) ((to>>20) & 0xffc); //目的页目录项
size = ((unsigned) (size+0x3fffff)) >> 22; //页表项个数是字节数除以4M
for( ; size-->0 ; from_dir++,to_dir++) {
if (1 & *to_dir) //最后一位P位,如果目的页目录项已经被使用,die
panic("copy_page_tables: already exist");
if (!(1 & *from_dir)) //最后一位代表P位,如果源页目录项未使用,跳过,不拷贝
continue;
from_page_table = (unsigned long *) (0xfffff000 & *from_dir);//取源页表地址
if (!(to_page_table = (unsigned long *) get_free_page()))
return -1; /* Out of memory, see freeing */ //取空闲物理页为to_page_table赋值
//如果没有空闲物理页,die
*to_dir = ((unsigned long) to_page_table) | 7; //将页表存进相应页目录项,
//7表示可读写
//想一下常用的chmod 777 anyfile
nr = (from==0)?0xA0:1024; //如果是0地址,只拷贝160页,否则拷贝1024页
//一个页目录表管理1024个页目录项
//一个页表管理1024个页表项
//一个页表项管理有4K物理地址

for ( ; nr-- > 0 ; from_page_table++,to_page_table++) {
this_page = *from_page_table; //从源页表中取源页表项
if (!(1 & this_page)) //如果源页表项未被使用,跳过
continue;
this_page &= ~2; //目的页表项读写位,
//设置为只读
*to_page_table = this_page; //将源页表项存进目的页表项
if (this_page > LOW_MEM) { //如果是主内存区
*from_page_table = this_page;//源页表项也要设置为只读
this_page -= LOW_MEM; //取相对主内存的偏移地址
this_page >>= 12; //取主内存管理数组索引
mem_map[this_page]++; //物理页引用次数加1
}
}
}
invalidate(); //刷新高速缓存
return 0; //返回0表示成功
}

参数

  函数的参数是从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
2
if ((from&0x3fffff) || (to&0x3fffff))    //4M对齐检测,否则die  
panic("copy_page_tables called with wrong alignment");

  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位)唯一确定一个页目录项

计算要复制的页表数

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
if (!(to_page_table = (unsigned long *) get_free_page()))      
return -1; /* Out of memory, see freeing */ //取空闲物理页为to_page_table赋值
*to_dir = ((unsigned long) to_page_table) | 7; //将页表存进相应页目录项,//7表示可读写
nr = (from==0)?0xA0:1024; //如果是0地址,只拷贝160页,否则拷贝1024页
//一个页目录表管理1024个页目录项
//一个页表管理1024个页表项
//一个页表项管理有4K物理地址
for ( ; nr-- > 0 ; from_page_table++,to_page_table++) {
this_page = *from_page_table; //从源页表中取源页表项
if (!(1 & this_page)) //如果源页表项未被使用,跳过
continue;
this_page &= ~2; //目的页表项读写位, 设置为只读
*to_page_table = this_page; //将源页表项存进目的页表项
if (this_page > LOW_MEM) { //如果是主内存区,1MB以内不参与用户分页管理,1MB以上mem_map才管
*from_page_table = this_page;//源页表项也要设置为只读
this_page -= LOW_MEM; //取相对主内存的偏移地址
this_page >>= 12; //取主内存管理数组索引
mem_map[this_page]++; //物理页引用次数加1
}
}

  首先为子进程页表(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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// put_page用来完成物理页面与一个线性地址页面的映射
// page为物理地址,address为线性地址
unsigned long put_page(unsigned long page,unsigned long address)
{
unsigned long tmp, *page_table;
/* NOTE !!! This uses the fact that _pg_dir=0 */
// 要映射的线性地址只能在主内存区,你不能给内存起始1M范围内映射线性地址
// 因为LOW_MEM内是给内核用的,且不属于分页内存范围。
if (page < LOW_MEM || page >= HIGH_MEMORY)
printk("Trying to put page %p at %p\n",page,address);
// (page-LOW_MEM)>>12得到page这个物理地址对应的页号
// mem_map[(page-LOW_MEM)>>12] !=1是检查这个page地址对应的物理内存页面
// 是不是已经注册过的页面(用get_free_page函数申请,申请时会再mem_map中
// 注册这个页面,也就是把这个页面对应的mem_map项加1。所以此处==1就代表
// 这个物理页面是刚申请好备用的,只有这种物理页面才能被映射到一个线性地
// 址。所以这里!=1就要警告)
if (mem_map[(page-LOW_MEM)>>12] != 1)
printk("mem_map disagrees with %p at %p\n",page,address);
// page_table得到address线性地址页面在页目录表中对应的页目录项的地址,这个
// 指针用来得到页表基地址,然后寻址就进行到了页表级。再配合address的次10位
// 就能寻址到address对应的内存页面基地址了。最后12bit用来在页面内寻址。
page_table = (unsigned long *) ((address>>20) & 0xffc);
// 检验address对应的页目录表项内容,即页表地址是不是存在的,即有没有对应的物理页面存储这个页表
//若页表存在,则直接把页表地址给page_table;若页表不存在,则调用get_free_page函数在mem_map
// 数组内申请一个空闲页面,并将这个空闲页面设置为用户级、只读、存在,再将
// 新申请并处理好的页面地址赋值给page_table
if ((*page_table) & 1)
page_table = (unsigned long *) (0xfffff000 & *page_table);
else {
if (!(tmp=get_free_page()))
return 0;
*page_table = tmp|7;
page_table = (unsigned long *) tmp;
}
// 上面已经得到了一个有效地page_table了,现在就要将page这个物理页面和address
// 这个线性地址处的页面挂接起来了。挂接的方法就是在page_table中,将address对应
// 的页表项中的内容(即address这个地址所在的页面基地址)设置为page这个物理地址
// |7没什么好说的,这里值得关注的是page_table可以当做数组来用,page_table本来就是个指针,
// 而1024个页表项也和数组结构一样的,用数组的方式来访问也是可行的。(相当于指针加偏移)
// 再说明下(address>>12)&0x3ff,这个操作实际是取出address中间10位表示在页表内的偏移值(下标索引)。
// 这样用这个偏移值结合page_table这个页表首地址,就能找到address所在的页面基地址了。
//相当于*(page_table+4*((address>>12) & 0x3ff))=page|7;每个页表项占4B
page_table[(address>>12) & 0x3ff] = page | 7;
/* no need for invalidate */
return page;
}

  最后还要强调一下如下代码,

1
2
3
4
5
6
if (this_page > LOW_MEM) {       //如果是主内存区,1MB以内不参与用户分页管理,1MB以上mem_map才管  
*from_page_table = this_page;//源页表项也要设置为只读
this_page -= LOW_MEM; //取相对主内存的偏移地址
this_page >>= 12; //取主内存管理数组索引
mem_map[this_page]++; //物理页引用次数加1
}

  对于这段代码的理解:
  对于内核空间,不使用写时复制。一个页面被多个进程共享,每当一个进程产生一次写保护错误,内核将给进程分配一个新的物理页面,将共享页面的内容复制过来,新的页面将设置为可读写,而共享页面仍然是只读的,只是共享计数减小了。当其他共享进程都产生了一次写保护错误后,共享页面的共享计数减成了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]

坚持原创技术分享,您的支持将鼓励我继续创作!