一.概述
当内核函数请求内存时,内核不会对该请求进行推迟,会立刻得到。而对于用户态进程,内核会认位其请求不是十分紧迫的(因为进程开始运行时并不会立刻访问其地址空间中的全部内存),且内核不信任用户代码,需要捕获并处理用户态进程引起的寻址错误,因而当用户进程请求动态内存时,并未获得请求的页框,而仅仅获得对一个新的线性地址区间的使用权,即这一线性地址成为进程地址空间的一部分,真正分配页框会推迟到进程要访问的页未对应页框时引发缺页异常。
二.进程地址空间及其结构
1.进程地址空间概念
进程的地址空间由允许进程使用的全部线性地址组成,内核可以通过增加和删除线性地址区间来动态修改进程的地址空间。每个线性地址区间在内核中是通过线性区结构进行描述的,其包括该线性地址区间的起始地址,长度和一些访问权限。综上所述:进程地址空间即一组可用的
以下是进程获取新线性区的典型情况:
- 当创建一个新进程程执行某个程序时,一个全新的进程地址空间(即一组线性区)会分配给进程。比如:fork + exec,则会释放旧的进程地址空间,然后分配新的进程地址空间。
- 当一个进程对一个文件进行存储映射时,内核会为这个进程分配一个新的线性区来映射这个文件。
- 当进程创建一个IPC共享内存区与其它进程进行通信时,内核会为进程分配一个新的线性区进行映射。
- 当进程的堆栈线性区用完时,内核会扩展该线性区的大小。
2.进程地址空间在内核中的结构
Linux中每个进程在内核中都有一个与之对应的进程描述符(task_struct),进程描述符中的内存描述符字段(struct mm_struct *mm)便存储了进程的地址空间。内存描述符结构内字段较多,其中用链表和红黑树两种数据结构维护了该进程的所有线性区。而每个线性区由一个线性区结构(vm_area_struct)描述,该结构不仅描述了线性区的起止地址,还描述了当应用于存储映射等时的一些信息(如在映射文件中的偏移,指向映射文件的文件对象的指针),并且通过list_head和rb_node对象将自己链入链表与红黑树中。内存描述符与线性区描述符的结构如下:
// 内存描述符
struct mm_struct {
struct vm_area_struct * mmap; // 指向线性区对象链表的表头
struct rb_root mm_rb; // 指向线性区对象红黑树的根
...
pgd_t pgd; // 指向页全局目录,用于将线性地址映射到物理地址
...
unsigned arg_start // 命令行参数的起始地址
unsigned arg_start // 命令行参数的最后地址
unsigned env_start // 环境变量的起始地址
unsigned env_start // 环境变量的最后地址
...
};
// 线性区描述符
struct vm_area_struct {
struct mm_struct * vm_mm; // 回指内存描述符
unsigned long vm_start; // 线性区的起始线性地址
unsigned long vm_end; // 线性区的末尾线性地址
struct vm_area_struct *vm_next; // 该进程下一个线性区
pgprot_t vm_page_prot; // 线性区对应页框的访问权限
unsigned long vm_flags; /* Flags, listed below. */
struct rb_node vm_rb; // 将自己链入红黑树
struct list_head anon_vma_node; // 将自己链入链表
struct anon_vma *anon_vma; /* Serialized by page_table_lock */
/* Function pointers to deal with this struct. */
struct vm_operations_struct * vm_ops;
/* Information about our backing store: */
unsigned long vm_pgoff; // 在映射文件中的偏移量
struct file * vm_file; // 指向映射文件的文件对象
void * vm_private_data; /* was vm_pte (shared mem) */
unsigned long vm_truncate_count;/* truncate_count or restart_addr */
...
};
结构示意图如下(红黑树未画出):
3.再谈存储映射过程
当我们使用void* mmap(void *addr/*建议地址*/, size_t len/*要映射的地址长度*/, int prot/*对映射区的权限*/, int flag, int fd/*文件描述符*/, off_t off/*文件中要映射区的偏移*/)进程存储映射时,内核首先会根据用户是否设置了建议地址来查找空闲的线性地址区间,若找到了,则创建线性区对象,并将其加入进程地中空间(即链入该进程的内存描述符中的链表和红黑树)。在创建线性区对象时,会根据映射区的长度len进行创建(当然内核可能会进行一些调整),之后将访问权限及文件映射偏移填入线性区对象,并将文件对象指针指向文件描述符fd所指向的文件对象,当用户之后向该块内存去写数据或读数据时,内核便可通过线性区对象内的文件对象指针及映射偏移来读写文件中的相应字节。
三.缺页异常处理程序
1.引起缺页异常的原因
缺页异常主要由两种情况引起:1)由编程错误引起的异常。2)由于引用属于进程地址空间但尚未分配物理页框的页所引起(由于内核推迟分配物理页导致)。
2.缺页异常处理程序
当出现缺页异常时,会产生一个缺页中断,之后会调用缺页处理程序处理以上两种缺页错误,对于编程错误引起的异常,会发送SIGSEGV信号给进程,对于属于进程空间但未分配物理页的情况会为其分配物理页。
3.请求调页
“请求调页”是一种动态内存分配技术,它把页框的分配推迟到不能再推迟为止,即一致推迟到进程要访问的页不再RAM中时为止,由此引发一个缺页异常。这样做的原因是进程运行时并不访问其地址空间的全部地址,有一部分地址可能永远不会访问。再者根据程序的局部性原理程序运行的每个阶段,真正引用的页只是一小部分,因此暂时不用的页框可以由其它进程使用。“请求调页”技术增加了系统空闲页框的平均数,从而能更好的利用空闲内存,在RAM总量不变的情况下,“请求调页”技术更好的利用了内存。
但这也带来了一个缺点,即系统的额外开销,浪费了CPU的时钟周期。
【注】:被访问的页不存在RAM中的原因可能是进程从未访问过该页,或内核已经回收了该页框(对于如何回收暂不太了解)。
4.写时复制
写时复制技术即当进程创建一个子进程时,并不立刻复制一份父进程的地址空间给内核,而是暂时共享父进程的地址空间(深度复制父进程的地址空间到子进程中),并将页框标记为只读。当父或子进程写某个页框时,会产生一个异常,之后内核便会将该页的内容复制到一个新的页框中,并标记为可写,原页框仍不可写,直至写进程是该页框的唯一属主。
上述为创建一个普通进程(fork),但当fork一个轻量级进程时(也就是Linux中的线程),并步复制父进程的地址空间,而是使用同一个内存描述符(childTsk->mm = parentTsk->mm,注意仅仅复制了指针)。这也是创建线程程更快的原因之一