前言
本文是基于Linux0.11源码来叙述该功能。本文就不贴Linux0.11的源码了,仅介绍一下逻辑,需要源码的可以在oldlinux.org上自行下载。
页异常介绍
当CPU开启页表功能后,若出现页访问权限不足或者页不存在,便会触发页异常,异常就是所谓的中断,在异常中断处理程序处理完后,返回原点重新执行先前触发异常的指令。
页异常功能
可能有人会认为,页异常发生后,系统不应该panic了吗?实际并非如此,Linux0.11下的页异常设计非常巧妙,利用页异常做到了进程的写时复制(页写保护异常),应用程序的代码或数据拷贝及堆栈的延伸分配(缺页异常),这一切都是被设计好的,并非如字面想象中的是一种故障异常。
页异常入口
函数set_trap_gate(14,&page_fault)
将中断服务程序地址填入中断描述符表第14项,异常发生时,CPU会寻找中断描述符第14项,并进入该地址处理异常。
页异常流程
- 异常发生瞬间,CPU会将错误码push到内核堆栈当中并将触发页异常的地址(逻辑地址)存入CR2寄存器,错误码用于区分是缺页异常(1)或是写保护异常(0)。
- 随后CPU会跳转到
page_fault
函数处理异常,将数据段切为内核段后,根据堆栈中的错误码判断是缺页异常还是写保护异常,将错误码和CR2的异常地址写入堆栈作为调用参数,如果是缺页异常则执行do_no_page
,否则是写保护异常执行do_wp_page
。
缺页异常
do_no_page
函数为缺页异常的主要处理程序,执行的操作如下:
-
计算了发生异常的地址的所在页
address&0xfffff000
,并将该地址转换为在文件中的地址(存在段基址),需要减去段基址,基址存于current->start_code
。Linux0.11中应用程序的起始地址为0,但在linux当中的逻辑地址空间中,并不是0起始而是0+段基址,其中0是偏移地址,CPU识别的是逻辑地址(逻辑地址=段基址+偏移地址),每个进程的段基址是64M的倍数(linux0.11中允许有64个进程,每个进程的逻辑地址占用64M,64M*64=4G),那么减去这个段基址后,就可以获得这个程序在磁盘中的实际地址(方便用于读取可执行文件),这个tmp
在程序中称为逻辑地址的偏移地址所在页的指针。address &= 0xfffff000; tmp = address - current->start_code;
-
如果当前进程并非应用程序(即
current->executable
应用程序文件节点为空,内核进程)或异常地址所在页高于data段,那么申请一个空页,将空页置入页表,返回(这种情况一般是应用程序访问bss段或堆区或栈区占用新页需要申请物理内存,见下图所示)。
-
函数
share_page(tmp)
,遍历进程列表,如果有其他进程(后面称为进程A)与当前进程是同一个可执行文件产生(例如可执行文件a.out里面fork出了一个子进程或a.out被N个进程分别执行)那么执行try_to_share()
函数。例如下图所示,进程A、进程B及进程C都是由a.out生成,他们共用同一个文件节点(同一代码),进程A、B、C都会被轮询并作为参数执行try_to_share
。
try_to_share()
检查进程A是否曾经复制过出当前异常的页,如果复制过,那么将进程A的页表项复制到当前进程的页表项当中(可以理解为两个进程的同一偏移地址映射到同一物理地址),并且剥夺其写权限(因为进程是独立的资源并不共享,因此要写的时候会产生写保护异常,写保护异常会复制出同样的页,而后便可以修改数据,异常后两个进程的同一偏移地址映射到不同的物理地址,这就是写时复制),并且将页map表的引用计数mem_map[phys_addr]++
(代表有几个进程在使用该页面)。 成功share_page
分享页表的话,则页异常处理完毕,直接返回,否则继续处理。以下是try_to_share
源码及注释,配合这段文字去看。static int try_to_share(unsigned long address, struct task_struct * p) { unsigned long from; unsigned long to; unsigned long from_page; unsigned long to_page; unsigned long phys_addr; from_page = to_page = ((address>>20) & 0xffc);//取偏移地址的页目录项地址 from_page += ((p->start_code>>20) & 0xffc);//源页目录项地址:偏移地址加上进程A段基址,即逻辑地址 to_page += ((current->start_code>>20) & 0xffc);//目的页目录项地址:偏移地址加上当前进程段基址,即逻辑地址 /* is there a page-directory at from? */ from = *(unsigned long *) from_page;//取源页目录项 if (!(from & 1))//确认该页目录项是否存在 return 0;//不存在则返回,无法共享 from &= 0xfffff000;//取源目录项的页表地址 from_page = from + ((address>>10) & 0xffc);//源页表项地址 phys_addr = *(unsigned long *) from_page;//取源页表项值 /* is the page clean and present? */ if ((phys_addr & 0x41) != 0x01)//如果源页表项值不是clean或无效 return 0;//返回 phys_addr &= 0xfffff000;//屏蔽标志位,获得页所在的物理地址 if (phys_addr >= HIGH_MEMORY || phys_addr < LOW_MEM) return 0; to = *(unsigned long *) to_page;//取目的页目录项值 if (!(to & 1))//页目录项是否无效 if (to = get_free_page())//无效则分配新页表,获取页 *(unsigned long *) to_page = to | 7;//填充目的页目录项,指向新页表 else oom();//无法获取页,宕机 to &= 0xfffff000;//取目的页表地址值 to_page = to + ((address>>10) & 0xffc);//目的页表项地址 if (1 & *(unsigned long *) to_page)//目的页表项如果无效 panic("try_to_share: to_page already exists");//宕机 /* share them: write-protect */ *(unsigned long *) from_page &= ~2;//源页表项取消写权限 *(unsigned long *) to_page = *(unsigned long *) from_page;//目的页表项=源页表项,映射向同一物理地址 invalidate();//同步 phys_addr -= LOW_MEM;//物理地址减去低端地址 phys_addr >>= 12;//计算出mem_map索引 mem_map[phys_addr]++;//记录该物理地址页引用计数+1 return 1;//返回1,代表share成功 }
-
如果没能共享到其他进程的页,
get_free_page()
从物理内存中申请新的页,计算异常地址处于可执行文件的哪一个block(1个block为1024KB,exec可执行头部占用1个block,因此代码由第二个block开始存储这都是gcc编译程序自动规划好的),根据可执行文件节点获取到该block处于硬盘中的逻辑块号(一个逻辑块可以索引1个block),使用bread_page(参数1:新分配的页基址, 参数2:当前可执行所处的块设备号, 参数3:逻辑块数组)函数从硬盘中读取出一页(4096KB),最后使用put_page()
将该page放入页表映射到发生异常的逻辑地址所在页。逻辑如下图所示。
-
中断处理返回,此时程序指针会指向异常地址重新执行,而此刻,因为页表中已经映射了相应的物理页,所以不会产生异常,程序正常执行。
缺页异常的好处:
- 节省可执行文件加载时间,倘若可执行文件被执行后将整个可执行文件拷贝到内存当中,这个过程是很费时且浪费CPU资源的,缺页异常可以在用到的时候才进行复制,从而做到我需要,我复制。
- 节省内存,可执行文件每次执行并不一定所有的代码都一定会被执行到,因此避免了一些不必要的复制,同样做到了我需要,我复制。
写保护异常
do_wp_page
函数为写保护异常的主要处理程序,执行的操作如下:
-
先计算出发生异常的地址(逻辑地址)的页表项地址,作为参数调用
un_wp_page()
void do_wp_page(unsigned long error_code,unsigned long address) { un_wp_page((unsigned long *) (((address>>10) & 0xffc) + (0xfffff000 & *((unsigned long *) ((address>>20) &0xffc))))); }
-
根据页表项地址取出其值,即得到发生异常的物理页地址old_page,如果物理页地址old_page高于
LOW_MEM
并且只有当前进程占用(这种情况),给予该物理页对应的页表项写权限,重载页表后退出中断处理,一般这种情况成立的话是由于另一个进程已经复制了新的页(另一个进程先于当前进程进行了写时复制操作,所以当前进程不需要复制,因为他独享该物理页所以直接开启权限即可)。... old_page = 0xfffff000 & *table_entry; if (old_page >= LOW_MEM && mem_map[MAP_NR(old_page)]==1) { *table_entry |= 2; invalidate(); return; } ...
-
如果Step2中的条件未成立,那么分配一个新的物理页new_page,如果发生异常的物理页地址old_page大于
LOW_MEM
(内核代码及所使用的数据,如显存),那么将该物理页的引用计数减一(就是代表不共享物理页了,因为进程资源不共享,所以这个时候不能共享了,得分家),将新的物理页new_page的放入页表(异常地址所对应的页表项)并将读/写位与有效位置位,重载页表,并将old_page的整页内容复制到new_page之中。下图可以直观地看到,两个进程在其中一个进程需要写时,复制了新的物理页,这样就不会污染另一个进程的数据了。
执行写保护异常(写时复制)好处是:
- 节省CPU资源,避免在fork的时候占用CPU去进行复制
- 节省物理内存资源,避免不必要的复制,例如应用程序的代码是不会改变的,改变的只有数据(甚至有些数据从头到尾都不会被写),如果一开始
fork()
时将代码也复制会造成物理内存的浪费。
总结
可以看到页异常巧妙地用在了应用程序的执行上,做到了能共享则先共享,若有需要,再进行复制,避免了不必要的浪费。