深入理解Linux内核-内存管理-进程地址空间

概述

内核中通过这些函数获得动态内存:
__get_free_pagesalloc_pageskmem_cache_allockmallocvmallocvmalloc_32

使用上述简单方法基于以下两个原因:
(1).内核是操作系统中优先级最高的成分。如某个内核函数请求动态内存,则必定有正当的理由发出请求,因此,没道理试图推迟这个请求。
(2).内核信任自己。所有的内核函数都假定没错误,故内核函数不必插入针对编程错误的任何保护措施。

给用户态进程分配内存时,情况完全不同:
(1).进程对动态内存的请求被认为是不紧迫的。如进程的可执行文件被装入时,进程不一定立即对所有的代码页进行访问。如进程调malloc获得请求的动态内存时,不意味着进程很快会访问所有所获得的内存。故,一般来说,内核总是尽量推迟给用户态进程分配动态内存。
(2).由于用户进程是不可信任的,故,内核必须能随时准备捕获用户态进程引起的所有寻址错误。

当用户态进程请求动态内存时,并没有获得请求的页框,而仅仅获得对一个新的线性地址区间的使用权,而这一线性地址区间就成为进程地址空间的一部分。这一区间叫"线性区"。

进程的地址空间

进程的地址空间由允许进程使用的全部线性地址组成。每个进程所看到的线性地址集合是不同的,一个进程所使用的地址与另外一个进程所使用的地址之间没什么关系。内核可通过增加或删除某些线性地址区间来动态地修改进程的地址空间。

内核通过所谓线性区的资源来表示线性地址区间,线性区是由起始线性地址,长度和一些访问权限来描述的。
为效率起见,起始地址和线性区的长度必须是4096的倍数,以便每个线性区所识别的数据完全填满分配给它的页框。
下面是进程获得新线性区的一些典型情况:
(1).当用户在控制台输入一条命令时,shell进程创建一个新的进程去执行这个命令。结果是,一个全新的地址空间(一组线性区)分配给了新进程。
(2).正在运行的进程有可能决定装入一个完全不同的程序。这种情况下,进程标识符仍保持不变,可是在装入这个程序以前所使用的线性区却被释放,并有一组新的线性区被分配给这个进程
(3).正在运行的进程可能对一个文件(或它的一部分)执行"内存映射"。这种情况下,内核给这个进程分配一个新的线性区来映射这个文件。
(4).进程可能持续向它的用户态堆栈增加数据,直到映射这个堆栈的线性区用完。这种情况下,内核也许会决定扩展这个线性区的大小。
(5).进程可能创建一个IPC共享线性区来与其他合作进程共享数据。此情况下,内核给这个进程分配一个新的线性区以实现这个方案
(6).进程可能通过调类似malloc这样的函数扩展自己的动态区。结果是,内核可能决定扩展给这个堆所分配的线性区。

系统调用说明
brk改变进程堆的大小
execve装入一个新的可执行文件,从而改变进程的地址空间
_exit结束当前进程并撤销它的地址空间
fork创建一个新进程,并为它创建新的地址空间
map,map2为文件创建一个内存映射,从而扩大进程的地址空间
mremap扩大或缩小线性区
remap_file_pages为文件创建非线性映射
munmap撤销对文件的内存映射,从而缩小进程的地址空间
shmat创建一个共享线性区
shmdt撤销一个共享线性区

确定一个进程当前所拥有的线性区(即进程的地址空间)是内核的基本任务,因为这可以让缺页异常处理程序有效地区分引发这个异常处理程序的两种不同类型的无效线性地址:
(1).由编程错误引发的无效线性地址
(2).由缺页引发的无效线性地址;

即使这个线性地址属于进程的地址空间,但对应于这个地址的页框仍有待分配。
从进程观点看,后一种地址不是无效的。内核要利用这种缺页以实现请求调页:内核通过提供页框来处理这种缺页,并让进程继续运行。

内存描述符

与进程地址空间有关的全部信息都包含在一个叫内存描述符的数据结构中,这个结构的类型为mm_struct,进程描述符的mm字段就指向这个结构。

类似字段说明
struct vm_area_struct*mmap指向线性区对象的链表头
struct rb_rootmm_rb指向线性区对象的红黑树的根
struct vm_area_struct*mmap_cache指向最后一个引用的线性区对象
unsigned long(*)()get_unmapped_area在进程地址空间中搜索有效线性地址区间的方法
void (*)()unmap_area释放线性地址区间时调用的方法
unsigned longmmap_base标识第一个分配的匿名线性区或文件内存映射的线性地址
unsigned longfree_area_cache内核从这个地址开始搜索进程地址空间中线性地址的空闲区间
pdt_t*pgd指向页全局目录
atomic_tmm_users次使用计数器
atomic_tmm_count主使用计数器
intmap_count线性区的个数
struct rw_semaphoremmap_sem线性区的读/写信号量
spinlock_tpage_table_lock线性区的自旋锁和页表的自旋锁
struct list_headmmlist指向内存描述符链表中的相邻元素
unsigned longstart_code可执行代码的起始地址
unsigned longend_data可执行代码的最后地址
unsigned longstart_brk堆的起始地址
unsigned longbrk堆的当前最后地址
unsigned longstart_stack用户态堆栈的起始地址
unsigned longarg_start命令行参数的起始地址
unsigned longarg_end命令行参数的最后地址
unsigned longenv_start环境变量的起始地址
unsigned longenv_end环境变量的最后地址
unsigned longrss分配给进程的页框数
unsigned longanon_rss分配给匿名内存映射的页框数
unsigned longtotal_vm进程地址空间的大小
unsigned longlocked_vm"锁住"而不能换出的页的个数
unsigned longshared_vm共享文件内存映射中的页数
unsigned longexec_vm可执行内存映射中的页数
unsigned longstack_vm用户态堆栈中的页数
unsigned longreserved_vm在保留区中的页数或在特殊线性区中的页数
unsigned longdef_flags线性区默认的访问标志
unsigned longnr_ptes进程的页表数
unsigned long[]saved_auxv开始执行ELF程序时使用
unsigned intdumpable表示是否可产生内存信息转储的标志
cpumask_tcpu_vm_mask用于懒惰TLB交换的位掩码
mm_context_tcontext指向有关特定体系结构信息的表(如x86上的LDT地址)
unsigned longswap_token_time进程在这个时间将有资格获得交换标记
charrecent_pagein如最近发生了主缺页,设置该标志
intcore_waiters正在把进程地址空间的内存转储到core文件中的轻量级进程的数量
struct completion*core_startup_done指向创建内存转储文件时的补充原语
struct completioncore_done创建内存转储文件时使用的补充原语
rwlock_tioctx_list_lock用于保护异步I/O上下文链表的锁
struct kioctx*ioctx_list异步I/O上下文链表
struct kioctxdefault_kioctx默认的异步I/O上下文
unsigned longhiwater_rss进程所拥有的最大页框数
unsigned longhiwater_vm进程线性区中的最大页数

所有的内存描述符存放在一个双向链表中,每个描述符在mmlist字段存放链表相邻元素的地址。链表的第一个元素是init_mmmmlistinit_mm是初始化阶段进程0所使用的内存描述符。mmlist_lock保护多处理器系统对链表的同时访问。

mm_users字段存放共享mm_struct数据结构的轻量级进程的个数,叫做次使用计数器。mm_count字段是内存描述符的主使用计数器,在mm_users次使用计数器中的所有用户在mm_count中只作为一个单位。每当mm_count递减时,内核要检查它是否变为0,如是就解除这个内存描述符。

考虑一个内存描述符由两个轻量级进程共享。它的mm_users字段通常是2,而mm_count字段通常是1。如把内存描述符暂时借给一个内核线程,则,内核就增加mm_count。这样,即使两个轻量级进程都死亡,且mm_users变为0,则个内存描述符也不被释放,直到内核线程使用完。因为mm_count仍大于0

如内核想确保内存描述符在一个长操作的中间不被释放,则应增加mm_users,而非mm_count字段的值。最终的结果是相同的,因为mm_users的增加确保了mm_count不变为0,即使拥有这个内存描述符的所有轻量级进程全部死亡。

mm_alloc用来获得一个新的内存描述符。由于这些描述符被保存在slab分配器高速缓存中。故,mm_allockmem_cache_alloc来初始化新的内存描述符,并把mm_countmm_users字段都置为1

mmput递减内存描述符的mm_users字段。如该字段变为0,这个函数就释放局部描述符表,线性区描述符,由内存描述符所引用的页表,并调mmdrop。后者把mm_count1,如该字段变为0,就释放mm_struct

内核线程的内存描述符

内核线程仅仅运行在内核态,它们永不会访问低于TASK_SIZE的线性地址。大于TASK_SIZE线性地址的相应页表项都应该总是相同的,因此,一个内核线程到底用什么样的页表集根本没关系。为避免无用的TLB和高速缓存刷新,内核线程使用一组最近运行的普通进程的页表。结果,在每个进程描述符中包含mmactive_mm

进程描述符中的mm指向进程所拥有的内存描述符,active_mm指向进程运行时所使用的内存描述符。

对普通进程,两者值相同。对内核线程,mm总是NULLactive_mm为前一运行线程的active_mm。只要处于内核态的一个进程为"高端"线性地址(高于TASK_SIZE)修改了页表项,那么它就也应当更新系统中所有进程页表集合中的相应表项。一旦内核态的一个进程进行了设置,则映射应对内核态的所有其他进程都有效。触及所有进程的页表集合是相当费时的操作,因此,Linux才有一种延迟方式。每当一个高端地址被重新映射时(一般通过vmalloc,vfree),内核就更新被定位在swapper_pg_dir主内核页全局目录中的常规页表集合。这个页全局目录由主内存描述符的pgd字段指向,而主内存描述符存放于init_mm变量。

线性区

Linux通过类型为vm_area_struct的对象实现线性区

类型字段说明
struct mm_struct*vm_mm指向线性区所在的内存描述符
unsigned longvm_start线性区内的第一个线性地址
unsigned longvm_end线性区之后的第一个线性地址
struct vm_area_struct*vm_next进程拥有的线性区链表中的下一个线性区
pgprot_tvm_page_prot线性区中页框的访问许可权
unsigned longvm_flags线性区的标志
struct rb_nodevm_rb用于红-黑树的数据
unionshared链接到反映射所使用的数据结构
struct list_headanon_vma_node指向匿名线性区链表的指针
struct anon_vma*anon_vma指向anon_vma数据结构的指针
struct vm_operations_struct*vm_ops指向线性区的方法
unsigned longvm_pgoff在映射文件中的偏移量。对匿名页,它等于0或vm_start/PAGE_SIZE
struct file*vm_file指向映射文件的文件对象
void*vm_private_data指向内存区的私有数据
unsigned longvm_truncate_count释放非线性文件内存映射中的一个线性地址区间时使用

每个线性区描述符表示一个线性地址区间。vm_start字段包含区间的第一个线性地址,vm_end字段包含区间之外的第一个线性地址。vm_end - vm_start表示线性区的长度。vm_mm字段指向拥有这个区间的进程的mm_struct

进程所拥有的线性区从不重叠,且内核尽力把新分配的线性区与紧邻的现有线性区合并。如两个相邻区的访问权限匹配,就能合并在一起。vm_ops字段指向vm_operations_struct数据结构,该结构中存放的是线性区的方法。

方法说明
open当把线性区增加到进程所拥有的线性区集合时调用
close当从进程所拥有的线性区集合删除线性区时调用
nopage当进程试图访问RAM中不存在的一个页,但该页的线性地址属于线性区时,由缺页异常处理程序调用
populate设置线性区的线性地址(预缺页)所对应的页表项时调用。主要用于非线性文件内存映射

线性区数据结构

进程所拥有的所有线性区是通过一个简单链表链接在一起。链表中的线性区是按内存地址升序排列的;每两个线性区可由未用的内存地址隔开。每个vm_area_struct元素的vm_next字段指向链表的下一个元素。

内核通过进程的内存描述符的mmap字段来查找线性区,其中mmap字段指向链表中的第一个线性区描述符。内存描述符的map_count字段存放进程所拥有的线性区数目。默认下,一个进程可最多拥有65536个不同的线性区,系统管理员可通过写/proc/sys/vm/max_map_count文件来修改这个限定值。

内核频繁执行的一个操作就是查找包含指定线性地址的线性区。由于,链表是经过排序的。故,只要在指定线性地址之后找到一个线性区,搜索就可结束。仅当进程线性区非常少时,使用这种链表才是方便的。比如说,只有一二十个线性区。在链表中查找元素,插入元素,删除元素涉及许多操作,这些操作所花费的时间与链表的长度成线性比例。

尽管多数的Linux进程使用的线性区非常少,但诸如面向对象的数据库,或malloc的专用调试器那样过于庞大的大型应用程序可能由成百上千的线性区。此情况下,线性区链表的管理变得非常低效。与内存相关的系统调用的性能就降低到令人无法忍受的地步。故,Linux 2.6把内存描述符存放在叫红-黑树的数据结构中。

在红-黑树中,每个元素(或节点)通常有两个孩子:左孩子,右孩子。树中的元素被排序,对每个节点NN的左子树上的所有元素都排在N之前。相反,N的右子树上的所有元素都排在N之后;节点的关键字被写入节点内部。此外,红-黑树必须满足下列规则:
(1).每个节点必须或为黑或为红
(2).树的根必须为黑
(3).红节点的孩子必须为黑
(4).从一个节点到后代叶子节点的每个路径都包含相同数量的黑节点。
统计黑节点个数时,空指针也算作黑节点。这4条规则确保有n个内部节点的任何红-黑树其高度最多为2*log(n+1)

在红-黑树中搜索一个元素因此变得非常高效,因为其操作的执行时间与树大小的对数成线性比例。即,双倍的线性区个数只多增加一次循环。在红-黑树中插入和删除一个元素也是高效的,算法可很快便利树以确定插入元素的位置或删除元素的位置。任何新节点必须作为一个叶子插入并着成红色。如操作违背了上述规则,就需移动或重新着色。

为了存放进程的线性区,Linux既使用了链表,也使用了红-黑树。这两种数据结构包含指向同一线性区描述符的指针,插入或删除一个线性区描述符时,内核通过红-黑树搜索前后元素,用搜索结果快速更新链表而不用扫描链表。

链表的头由内存描述符的mmap字段所指向。任何线性区对象都在vm_next字段存放指向链表下一元素的指针。红-黑树首部由内存描述符的mm_rb字段所指向。任何线性区对象都在类型为rb_nodevm_rb字段中。存放节点颜色及指向双亲,左孩子,右孩子的指针。一般,红-黑树用来确定含有指定地址的线性区,链表通常在扫描整个线性区集合时来使用。红黑树可以和链表同时服务于存储一类元素的容器。插入,删除时,先在红黑树搜索前后元素。前后元素已知下链表插入,删除复杂度为O(1)。整体遍历元素时,链表比红黑树更有优势。

线性区访问权限

用页这个术语既表示一组线性地址,又表示这组地址中所存放的数据。我们把介于0~4095之间的线性地址区间称为第0页,介于4096~8191之间的线性地址区间称为第1页,以此类推。因此,每个线性区都由一组号码连续的页构成。注意,用页表示线性区域的刻度,用页框表示物理内存区域的刻度。

几类访问标志:
(1).每个页表项中存放的标志,如:Read/WritePresentUser/Supervisor
80x86硬件用来检查能否执行所请求的寻址类型;
(2).页框描述符flags字段中的一组标志由Linux用于许多不同的目的。
(3).vm_area_struct描述符的vm_flags用于为线性区内的页提供标志信息。
一些标志给内核提供有关这个线性区全部页的信息,如它们含什么内容,进程访问每个页的权限。
另外的标志描述线性区自身,如它应如何增长。

标志名收纳
VM_READ页是可读的
VM_WRITE页是可写的
VM_EXEC页是可执行的
VM_SHARED页可由几个进程共享
VM_MAYREAD可设置VM_READ标志
VM_MAYWRITE可设置VM_WRITE标志
VM_MAYEXEC可设置VM_EXEC标志
VM_MAYSHARE可设置VM_SHARE标志
VM_GROWSDOWN线性区可向低地址扩展
VM_GROWSUP线性区可向高地址扩展
VM_SHM线性区用于IPC的共享内存
VM_DENYWRITE线性区映射一个不能打开用于写的文件
VM_EXECUTABLE线性区映射一个可执行文件
VM_LOCKED线性区中的页被锁住,且不能换出
VM_IO线性区映射设备的I/O地址空间
VM_SEQ_READ应用程序顺序地访问页
VM_RAND_READ应用程序以真正的随机顺序访问页
VM_DONTCOPY当创建一个新进程时不拷贝线性区
VM_DONTEXPAND通过mremap禁止线性区扩展
VM_RESERVED线性区是特殊的,因此它的页不能被交换出去
VM_ACCOUNT创建IPC共享线性区时检查是否有足够的空闲内存用于映射
VM_HUGETLB通过扩展分页机制处理线性区中的页
VM_NONLINEAR线性区实现非线性文件映射

线性区描述符所包含的页访问权限可以任意组合。如,存在这样一种可能性,允许一个线性区中的页可执行但不可以读取。为了有效地实现这种保护方案,与线性区的页相关的访问权限(读,写,执行)必须被复制到相应的所有表项中,以便由分页单元直接执行检查。即,页访问权限表示何种类型的访问应产生一个缺页异常。Linux委派缺页处理程序查找导致缺页的原因。因为,缺页处理程序实现了许多页处理策略。

页表标志的值存放在vm_area_struct描述符的vm_page_prot字段。当增加一个页时,内核根据vm_page_prot字段的值设置相应页表项中的标志。然而,不能把线性区的访问权限直接转换成页保护位:
(1).某些情况下,即使由相应线性区描述符的vm_flags所指定的某个页的访问权限允许对该页进行访问,但,对该页的访问还是应产生一个缺页异常。如,本章后面"写时复制"。内核可能决定把属于两个不同进程的两个完全一样的可写私有页存入同一页框;这种情况下,无论哪一个进程试图改动这个页都应当产生一个异常。
(2).80x86处理器的页表仅有两个保护位,即Read/WriteUser/Supervisor。一个线性区所包含的任何一个页的User/Supervisor需总是置为1,因为用户态进程需总能访问其中的页。
(3).启用PAE的新近Intel Pentium 4,在所有64位页表项中支持NX标志。
如内核没被继续编译成支持PAE,则Linux采取如下规则以克服80x86微处理器的硬件限制:
a.读访问权限总是隐含着执行访问权限,反之亦然。
b.写访问权限总是隐含着读访问权限。反之,如内核编程成支持PAE,且CPUNX标志(No Execute)。

Linux就采取不同的规则:
a.执行访问权限总是隐含着读访问权限
b.写访问权限总是隐含着读访问权限

为做到在"写时复制"中适当地推迟页框的分配,只要相应的页不是由多个进程所共享,则这种页框应是写保护的。
故,要根据以下规则精简由读,写,执行,共享访问的16中可能组合
(1).如页有写,共享。则Read/Write设置为1
(2).如页有读或执行,但既没写,也没共享访问权限。则,Read/Write置为0
(3).如支持NX,且也没执行访问权限,则NX置为1
(4).如页没任何访问权限,则Present置为0
以便每次访问都产生一个缺页异常。为了把这种情况与真正的页框不存在情况区分,Linux还把Pagesize置为1
访问权限的每种组合对应的精简后的保护位存放在protection_map

线性区的处理

对控制内存处理所用的数据结构和状态信息有基本理解后,看一组对线性区描述符进行操作的底层函数。这些函数应被看作简化了do_mapdo_unmap实现的辅助函数。这两个函数将在后面"分配线性地址区间","释放线性地址区间"中描述。它们分别扩大或缩小进程的地址空间。它们并不接受线性区描述符作为参数,而是用一个线性地址区间的起始地址,长度,访问权限作为参数。

查找给定地址的最近邻区:find_vma

参数:
进程内存描述符的地址mm
线性地址addr

它查找线性区的vm_end大于addr的第一个线性区的位置,并返回这个线性区描述符的地址。
如没这样的线性区存在,就返回一个NULL

注意, 由find_vma所选择的线性区并不一定要包含addr,因为addr可能位于任何线性区之外。

每个内存描述符包含一个mmap_cache字段,这个字段保存进程最后一次引用线性区的描述符地址。引进这个附加字段是为了减少查找一个给定线性地址所在线性区而花费的时间。程序中引用地址的局部性使下面这种情况出现的可能性很大:如检查的最后一个地址属于某一给定的线性区,则下一个要检查的线性地址也属于这一个线性区。故,该函数一开始就检查由mmap_cache所指定的线性区是否包含addr。如是,就返回这个线性区描述符的指针

vma = mm->mmap_cache;
if(vma && vma->vm_end > addr && vma->vm_start <= addr)
	return vma;

否则,必须扫描进程的线性区,并在红-黑树中查找线性区

rb_node = mm->mm_rb.rb_node;
vma = NULL;
while(rb_node)
{
	vma_tmp = rb_entry(rb_node, struct vm_area_struct, vm_rb);
	if(vma_tmp->vm_end > addr)
	{
		vma = vma_tmp;
		if(vma_tmp->vm_start <= addr)
			break;
		rb_node = rb_node->rb_left;
	}
	else
		rb_node = rb_node->rb_right;
}

if(vma)
	mm->mmap_cache = vma;
return vma;

函数用红rb_entry从指向红黑树的一个节点的指针导出相应线性区描述符的地址。

find_vma_prev,把函数中选中的前一个线性区描述符的指针赋给附加参数ppre
find_vma_prepare确定新叶子节点在与给定线性地址对应的红-黑树中的位置,返回前一个线性区的地址和要插入的叶子节点的父节点的地址。

查找一个与给定的地址区间相重叠的线性区:find_vma_intersection

参数:
mm指向进程的内存描述符,
线性地址start_addrend_addr指定这个区间

vma = find_vma(mm, start_addr);
if(vma && end_addr <= vma->vm_start)
	vma = NULL;
return vma;

如没这样的线性区存在,就返回一个NULL。如find_vma返回一个有效的地址,但所找到的线性区是从这个线性地址区间的末尾开始的,vma就置为NULL

查找一个空闲的地址区间:get_unmapped_area

参数:
len,指定区间的长度,
addr,非空的addr指定必须从哪个地址开始查找。
返回值:
如查找成功,返回这个新区间的起始地址;否则,返回错误码-ENOMEM

addr不等于NULL,就检查所指定的地址是否在用户态空间并与页边界对齐。函数根据线性地址区间是否应用于文件内存映射或匿名内存映射,调两个方法(get_unmapped_area文件操作和内存描述符的get_unmapped_area)中的一个。

前一种情况下,函数执行get_unmapped_area文件操作。第二种情况下,函数执行内存描述符的get_unmapped_area。根据进程的线性区类型,由函数arch_get_unmapped_areaarch_get_unmapped_area_topdown实现get_unmapped_area

通过系统调用map,每个进程可获得两种不同形式的线区:
一种从线性地址0x40000000开始并向高端地址增长,
另一种正好从用户态堆栈开始并向低端地址增长。

在分配从低端地址向高端地址移动的线性区时使用arch_get_unmapped_area

if(len > TASK_SIEZ)
	return -ENOMEM;
addr = (addr + 0xfff) & 0xfffff000;
if(addr & addr + len <= TASK_SIZE)
{
	vma = find_vma(current->mm, addr);
	if(!vma || addr + len <= vma->vm_start)
		return addr;
}

start_addr = addr = mm->free_area_cache;
for(vma = find_vma(current->mm, addr); ; vma = vma->vm_next)
{
	if(addr + len > TASK_SIZE)
	{
		if(start_addr == (TASK_SIZE/3 + 0xfff) & 0xfffff000)
			return -ENOMEM;
		start_addr = addr = (TASK_SIZE/3 + 0xfff) & 0xfffff000;// 这是允许的最低起始线性地址
		vma = find_vma(current->mm, addr);
	}
	
	if(!vma || addr + len <= vma->vm_start)
	{
		mm->free_area_cache = addr + len;
		return addr;// 返回线性地址是满足分配要求线性区(尚未分配)的起始地址
	}
	
	addr = vma->vm_end;
}

函数先检查区间的长度是否在用户态下线性地址区间的限长TASK_SIZE之内。
addr不为0,函数就试图从addr开始分配区间。为安全,函数把addr值调整为4KB倍数。
addr等于0或前面的搜索失败,arch_get_unmapped_area就扫描用户态线性地址空间以查找一个可包含新区的足够大的线性地址范围。但任何已有的线性区都不包括这个地址范围。

为提高搜索速度,让搜索从最近被分配的线性区后面的线性地址开始,把内存描述符的字段mm->free_area_cache初始化为用户态线性地址空间的三分之一,并在以后创建新线性区时对它更新。如找不到一个合适的线性地址范围,就从用户态线性地址空间的三分之一的开始处重新开始搜索。其实,用户态线性地址空间的三分之一是为有预定义起始线性地址的线性区(典型的是可执行文件的正文段,数据段,bss段)而保留的。

函数调find_vma以确定搜索起点后第一个线性区终点的位置。三种情况:
(1).如所请求的区间大于正待扫描的线性地址空间部分(addr+len>TASK_SIZE),函数就从用户态地址空间的三分之一处重新开始搜索,如已完成第二次搜索,就返回-ENOMEM
(2).刚扫描过的线性区后面的空闲区没足够的大小,vma != NULL && vma->vm_start < addr + len此时,继续考虑下一个线性区。
(3).如以上两情况都没发生,则找到一个足够大的空闲区。函数返回addr

向内存描述符链表中插入一个线性区:insert_vm_struct

参数:
mm,指定进程内存描述符的地址,
vmp指定要插入的vm_area_struct对象的地址,线性区对象的vm_startvm_end必须已经初始化过。

函数调find_vma_prepare在红-黑树mm->mm_rb中查找vma应位于何处。然后,insert_vm_struct又调vma_link

vma_link
(1).在mm->mmap所指向的链表中插入线性区。
(2).在红-黑树mm->mm_rb中插入线性区。
(3).如线性区是匿名的,就把它插入以相应的anon_vma数据结构作为头节点的链表中。
(4).如线性区包含一个内存映射文件,则执行相关任务。
(5).递增mm->map_count

__vma_unlink
参数:
为一个内存描述符地址mm
两个线性区对象地址vmaprev。两个线性区都应属于mmprev应在线性区的排序中位于vma之前。
过程:
该函数从内存描述符链表和红-黑树中删除vma
mm->mmap_cache(存放刚被引用的线性区)字段指向刚被删除的线性区,则还要对mm->mmap_cache进行更新。

分配线性地址区间

do_mmap
功能:
为当前进程创建并初始化一个新的线性区

参数:
fileoffser,如新的线性区把一个文件映射到内存,则使用文件描述符指针file和文件偏移量offset
addr,这个线性地址指定从何处开始查找一个空闲的区间。
len,线性地址区间的长度。
prot,这个线性区所包含页的访问权限。可能的标志有PROT_READPROT_WRITEPROT_EXECPROT_NONE。前三个标志与标志VM_READWM_WRITEVM_EXEC意义一样。PROT_NONE表示进程没以上三个访问权限中任意一个。
flag,指定线性区的其他标志MAP_GROWSDOWNMAP_LOCKEDMAP_DENYWRITEMAP_EXECUTEABLEMAP_SHAREDMAP_PRIVATEMAP_FIXEDMAP_ANONYMOUSMAP_NORESERVEMAP_POPULATEMAP_NONBLOCK
一些标志的解释:
MAP_FIXED:区间的起始地址必须由参数addr指定。
MAP_ANONYMOUS:没有文件与这个线性区相关联。
MAP_POPULATE:函数应为线性区建立的映射提前分配需要的页框,该标志对映射文件的线性区和IPC共享的线性区有意义。
MAP_NONBLOCK:只在MAP_POPULATE置位时才有意义,提前分配页框时,函数肯定不阻塞。

do_mmapoffset的值进行一些初步检查,然后执行do_mmap_pgoff
本节假设新的线性地址区间映射的不是磁盘文件,这里仅对实现匿名线性区的do_mmap_pgoff进行说明。
(1).检查参数的值是否正确,所提的请求是否能被满足。尤其检查:
(1.1).线性地址区间的长度为0或包含的地址大于TASK_SIZE
(1.2).进程已映射了过多的线性区,即,mm内存描述符的map_count字段的值超过了允许的最大值。
(1.3).flag参数指定新线性地址区间的页必须被锁在RAM中,但不允许进程创建上锁的线性区,或进程加锁页的总数超过了保存在进程描述符signal->rlim[RLIMIT_MEMLOCK].rlim_cur字段的阈值。

以上任一情况成立,则do_mmap_pgoff终止并返回一个负值、如线性地址区间的长度为0,则函数不执行任何操作就返回。
(2).调get_unmapped_area获得新线性区的线性地址区间
(3).通过把存放在protflags参数中的值进行组合来计算新线性区描述符的标志

vm_flags = calc_vm_prot_bits(prot, flags) | calc_vm_flag_bits(prot, flags) | mm->def_flags | VM_MAYREAD | VM_MAYWRITE | VM_MAYEXEC;
if(flags & MAP_SHARED)
	vm_flags |= VM_SHARED | VM_MAYSHARE;

只有在prot中设置了相应的PROT_READPROT_WRITEPROT_EXEC标志,calc_vm_prot_bits才在vm_flags中设置VM_READVM_WRITEVM_EXEC
只有在flags设置了相应的MAP_GROWSDOWNMAP_DENYWRITEMAP_EXECUTABLEMAP_LOCKEDcalc_vm_flag_bits才在vm_flags中设置VM_GROWSDOWNVN_DENYWRITEVM_EXECUTABLEVM_LOCKED
vm_flags中还有几个标志被置为1VM_MAYREADVM_MAYWRITEVM_MAYEXEC。在mm_def_flags中所有线性区的默认标志,及如线性区的页与其他进程共享时的VM_SHAREDVM_MAYSHARE
(4).调find_vma_prepare确定处于新区间之前的线性区对象的位置,及在红-黑树中新线性区的位置

for(;;)
{
	vma = find_vma_prepare(mm, addr, &prev, &rb_link, &rb_parent);
	if(!vma || vma->vm_start >= addr + len)
		break;
	if(do_munmap(mm, addr, len))// 返回非0表示操作执行失败
		return -ENOMEM;
}

find_vma_prepare也检查是否还存在与新区间重叠的线性区。这情况发生在函数返回一个非空的地址,这个地址指向一个线性区,该区的起始位置位于新区间结束地址之前的时候。此情况下,do_mmap_pgoffdo_munmap删除新的区间,然后重复整个步骤。
(5).检查插入新的线性区是否引起进程地址空间的大小超过存放在进程描述符signal->rlim[RLIMIT_AS].rlim_cur字段中的阈值。如是,就返回错误码-ENOMEM。这个检查只在这里进行,不在第一步与其他检查一起进行。
(6).如在flags参数中没设置MAP_NORESERVE,新的线性区包含私有可写页,且没足够的空闲页框,则返回出错码-ENOMEM;这最后一个检查由security_vm_enough_memory实现。
(7).如新区间是私有的,且映射的不是磁盘上的一个文件,则调vma_merge检查前一个线性区是否可以这样的方式进行扩展来包含新的区间。前一个线性区须与在vm_flags中存放标志的那些线性区有相同的标志。如前一个线性区可扩展,则vma_merge试图把它与随后的线性区合并。一旦扩展前一线性区成功,跳12
(8).调slab分配函数kmem_cache_alloc为新线性区分配一个vm_area_struct
(9).初始化新的线性区对象

vma->vm_mm = mm;
vma->vm_start = addr;
vma->vm_end = addr + len;
vma->vm_flags = vm_flags;
vma->vm_page_prot = protection_map[vm_flags & 0x0f];
vma->vm_ops = NULL;
vma->vm_pgoff = pgoff;
vma->vm_file = NULL;
vma->vm_private_data = NULL;
vma->vm_next = NULL;
INIT_LIST_HEAD(&vma->shared);

(10).如MAP_SHARED被设置,则该线性区是一个共享匿名区:调shmem_zero_setup对它进行初始化,共享匿名区主要用于进程间通信
(11).调vma_link把新线性区插入到线性区链表和红-黑树
(12).增加存放在内存描述符total_vm字段中的进程地址空间大小
(13).如设置了VM_LOCKED,就调make_pages_present连续分配线性区 的所有页,并把它们锁在RAM

if(vm_flags & VM_LOCKED)
{
	mm->locked_vm += len >> PAGE_SHIFT;
	make_pages_present(addr, addr + len);
}

make_pages_present按如下方式调get_user_pages

write = (vma->vm_flags & VM_WRITE) != 0;
get_user_pages(current, current->mm, addr, len, write, 0, NULL, NULL);

get_user_pagesaddraddr+len之间页的所有起始线性地址上循环;对其中的每个页,该函数调follow_page检查在当前页表中是否有物理页的映射。如没这样的物理页存在,则get_user_pageshandle_mm_fault,后一个函数分配一个页框并根据内存描述符的vm_flags设置它的页表项。
(14).函数通过返回新线性区的线性地址而终止

释放线性地址区间

do_munmap
功能:
释放线性地址区间

参数:
进程描述符的地址mm
地址区间的起始地址start
它的长度len

split_vma
功能:
把与线性地址区间交叉的线性区划分成两个较小的区,一个在线性地址区间外部,另一个在区间的内部。

参数:
内存描述符指针mm
线性区描述符指针vma
表示区间与线性区之间交叉点的地址addr
表示区间与线性区之间交叉点在区间起始处还是结束处的标志new_below

步骤:
(1).调kmem_cache_alloc获得线性区描述符vm_area_struct。把它的地址存在新的局部变量中,如没可用的空闲空间,就返回-ENOMEM
(2).用vma描述符的字段值初始化新描述符的字段
(3).如标志new_below0,说明线性地址区间的起始地址在vma线性区的内部。因此须把新线性区放在vma线性区之后,函数把new->vm_startvma->vm_end赋值为addr
(4).如new_below等于1,说明线性地址区间的结束地址在vma线性区的内部。故需把新线性区放在vma线性区的前面,所以,函数把字段new->vm_endvm->vm_start都赋值为addr
(5).如定义了新线性区的open,执行它
(6).把新线性区描述符链接到线性区链表mm->mmap和红黑树mm->mm_rb,函数还要根据线性区vma的最新大小对红-黑树进行调整
(7).返回0

unmap_region
作用:
遍历线性区链表并释放它们的页框

参数:
内存描述符指针mm
指向第一个被删除线性区描述符的指针vma
指向进程链表中vma前面的线性区的指针prev
地址start
地址end

步骤:
(1).调lru_add_drain
(2).调tlb_gather_mmu初始化每CPU变量mmu_gathersmmu_gathers依赖于体系结构:通常该变量应存放成功更新进程页表项所需的所有信息。在80x86体系结构中,tlb_gather_mmu只是简单地把内存描述符指针mm的值赋给本地CPUmmu_gathers
(3).把mmu_gathers变量的地址存在局部变量tlb
(4).调unmap_vmas扫描线性地址空间的所有页表项:如只有一个有效CPU,函数就调free_swap_and_cache反复释放相应页框。否则, 函数就把相应页描述符的指针保存在局部变量mmu_gathers
(5).调free_pgtables(tlb, prev, start, end)回收上一步已清空的进程页表
(6).调tlb_finish_mmu(tlb, start, end)结束unmap_region的工作。

tlb_finish_mmu(tlb, start, end)
(1).调flush_tlb_mm刷新TLB
(2).在多处理器系统中,调free_pages_and_swap_cache释放页框,这些页框的指针已经集中存放在mmu_gather中了。

do_munmap
第一阶段,扫描进程所拥有的线性区链表,把包含在进程地址空间的线性地址区间中的所有线性区从链表中解除链接;
第二阶段,更新进程的页表,把第一阶段找到并标识出的线性区删除。

(1).对参数值检查。
(2).确定要删除的线性地址区间之后第一个线性区mpnt位置,如有这样的线性区

mpnt = find_vma_prev(mm, start, &prev);

(3).如没这样的线性区,也没与线性区间重叠的线性区,就什么都不做

end = start + len;
if(!mpnt || mpnt->vm_start >= end)
	return 0;

(4).如线性区的起始地址在线性区mpnt内,就调split_vma把线性区mpnt分成两个较小的区:一个区在线性地址区间外,另一个在区间内

if(start > mpnt->vm_start)
{
	if(split_vma(mm, mpnt, start, 0))
		return -ENOMEM;
	prev = mpnt;
}

更新局部变量prev,以前它存储的是指向线性区mpnt前面一个线性区的指针,现在让它指向mpnt,即指向线性地址区间外部的那个新线性区。这样prev仍指向要删除的第一个线性区前面的那个线性区
(5).如线性区的结束地址在一个线性区内部,就再次调split_vma把最后重叠的那个线性区划分成两个较小的区:一个在线性地址区间内,另一个在区间外

last = find_vma(mm, end);
if(last && end > last->vm_start)
{
	if(split_vma(mm, last, start, end, 1))
		return -ENOMEM;
}

(6).更新mpnt值,使它指向线性地址区间的第一个线性区。如prev为NULL,就从mm->mmap获得第一个线性区的地址

mpnt = prev ? prev->vm_next : mm->mmap;

(7).调detach_vmas_to_be_unmapped从进程的线性地址空间中删除位于线性地址区间中的线性区。

vma = mpnt;
insertion_point = (prev ? &prev->vm_next : &mm->mmap);
do
{
	rb_erase(&vma->vm_rb, &mm->mm_rb);
	mm->map_count--;
	tail_vma = vma;
	vma = vma->next;
} while(vma && vma->start < end);
*insertion_point = vma;
tail_vma->vm_next = NULL;
mm->map_cache = NULL;

要删除的线性区的描述符存放在一个排序好的链表中,局部变量mpnt指向该链表的头
(8).获得mm->page_table_lock
(9).调unmap_region清除与线性地址区间对应的页表项并释放相应的页框

unmap_region(mm, mpnt, prev, start, end);

(10).释放mm->page_table_lock
(11).释放7步建立链表时收集的线性区描述符

do
{
	struct vm_area_struct* next = mpnt->vm_next;
	unmap_vma(mm, mpnt);
	mpnt = next;
} while(mpnt != NULL);

对在链表中的所有线性区调unmap_vma,它本质上执行下述:
a.更新mm->total_vmmm->locked_vm
b.执行内存描述符的mm->unmap_area。根据进程线性区的不同类型可选择arch_unmap_areaarch_unmap_area_topdown中的一个来实现mm->unmap_area。如必要,在两种情况下都要更新mm->free_area_cache
c.调线性区的close
d.如线性区是匿名的,则函数把它从mm->anon_vma所指向的匿名线性区链表中删除
e.调kmem_cache_free释放线性区描述符
f.返回0

缺页异常处理程序

1.处理地址空间以外的错误地址

address不属于进程的地址空间,则do_page_fault继续执行bad_area处语句。

bad_area:
	up_read(&tsk->mm->mmap_sem);
bad_area_nosemaphore:
	if(error_code & 4)
	{
		tsk->thread.cr2 = address;
		tsk->thread.error_code = error_code | (address >= TASK_SIZE);
		tsk->thread.trap_no = 14;
		info.si_signo = SIGSEGV;
		info.si_errno = 0;
		info.si_addr = (void*)address;
		force_sig_info(SIGSEGV, &info, tsk);
		return;
	}	

如错误发生在用户态,则发送一个SIGSEGV信号给currentforce_sig_info确信进程不忽略或阻塞SIGSEGV信号,并通过info局部变量传递附加信息的同时把该信号发送给用户态进程;info.si_code字段已被置为SEGV_MAPERR或置为SEGV_ACCERR
如异常发生在内核态(error_code的第2位被清0),仍有两种可选的情况:
(1).异常的引起是由于把某个线性地址作为系统调用的参数传递给内核
(2).异常是因一个真正的内核缺陷所引起

no_context:
	if((fixup = search_exception_table(regs->eip)) != 0)
	{
		regs->eip = fixup;
		return;
	}

在第一种情况中,代码跳到一段"修正代码"处。这段代码的典型操作是向当前进程发SIGSEGV信号,或用一个适当的出错码终止系统调用处理程序。
第二种情况中,函数把CPU寄存器和内核态堆栈的全部转储打印到控制台,并输出到一个系统消息缓冲区,然后调do_exit杀死当前进程。这就是所谓按所显示的消息命名的"内核漏洞"错误。这些输出值可由内核编程高手用于推测引发此错误的条件,进而发现并纠正错误。

2.处理地址空间内的错误地址

addr地址属于进程的地址空间,则do_page_fault转到good_area标记处的语句执行。

good_area:
	info.si_code = SEGV_ACCERR;
	write = 0;
	if(error_code & 2)
	{
		if(!(vma->vm_flags & VM_WRITE))
			goto bad_area;
		write++;
	}
	else
		if((error_code & 1) || !(vma->vm_flags & (VM_READ | VM_EXEC)))
			goto bad_area;

如异常由写访问引起,检查这个线性区是否可写。如不可写,跳到bad_area;如可写,把write局部变量置为1
如异常由读或执行访问引起,函数检查这一页是否已经存在于RAM
(权限引起)在存在的情况下,异常发生是由于进程试图访问用户态下的一个有特权的页框,故函数跳到bad_area
NotExist引起)在不存在的情况下,函数还将检查这个线性区是否可读或可执行。如这个线性区的访问权限与引起异常的访问类型相匹配,则调handle_mm_fault分配一个新的页框

survive:
	ret = handle_mm_fault(tsk->mm, vma, address, write);
	if(ret == VM_FAULT_MINOR || ret == VM_FAULT_MAJOR)
	{
		if(ret == VM_FAULT_MINOR)
			tsk->min_flt++;
		else
			tsk->maj_flt++;
		up_read(&tsk->mm->mmap_sem);
		return;
	}

handle_mm_fault成功给进程分配一个页框,则返回VM_FAULT_MINORVM_FAULT_MAJOR
VM_FAULT_MINOR表示在没阻塞当前进程的情况下处理了缺页。这种缺页叫次缺页;
VM_FAULT_MAJOR表示缺页迫使当前进程睡眠,阻塞当前进程的缺页叫主缺页;

函数也返回VM_FAULT_OOM(没有足够的内存)或VM_FAULT_SIGBOS(其他任何错误);
handle_mm_fault返回值VM_FAULT_SIGBUS,则向进程发SIGBUS

if(ret == VM_FAULT_SIGBUS)
{
do_sigbus:
	up_read(&tsk->mm->mmap_sem);
	if(!(error_code & 4))
		goto no_context;
	tsk->thread.cr2 = address;
	tsk->thread.error_code = error_code;
	tsk->thread.trap_no = 14;
	info.si_signo = SIGBUS;
	info.si_errno = 0;
	info.si_code = BUS_ADRERR;
	info.si_addr = (void*)address;
	force_sig_info(SIGBUS, &info, tsk);
}

handle_mm_fault不分配新页框,就返回VM_FAULT_OOM,此时内核通常杀死当前进程。
如当前进程是init进程,则只是把它放在运行队列的末尾并调用调度程序。一旦init恢复执行,则handle_mm_fault又执行。

if(ret == VM_FAULT_OOM)
{
out_of_memory:
	up_read(&tsk->mm->mmap_sem);
	if(tsk->pid != 1)
	{
		if(error_code & 4)
			do_exit(SIGKILL);
		goto no_context;
	}
	yield();
	down_read(&tsk->mm->mmap_sem);
	goto survive;
}

handle_mm_fault
参数:
mm,执行异常发生时在CPU上运行的进程的内存描述符
vma,执行引起异常的线性地址所在线性区的描述符
address,引起异常的线性地址
write_access,如tsk试图向address写,则置为1;如tsk试图在address读或执行,则置为0

函数首先检查用来映射address的页中间目录和页表是否存在。即使address属于进程的地址空间,相应的页表也可能还没被分配。故,在做别的事情前先执行分配页目录和页表的任务。

pgd = pgd_offset(mm, address);
spin_lock(&mm->page_table_lock);
pud = pud_alloc(mm, pgd, address);
if(pud)
{
	pmd = pmd_alloc(mm, pud, address);
	if(pmd)
	{
		pte = pte_alloc_map(mm, pmd, address);
		if(pte)
			return handle_pte_fault(mm, vma, address, write_access, pte, pmd);
	}
}
spin_unlock(&mm->page_table_lock);
return VM_FAULT_OOM;

pgd局部变量包含引用address的页全局目录项。如需要的话,调pud_allocpmd_alloc分别分配一个新的页上级目录和页中间目录;如需要,调pte_alloc_map分配一个新的页表;如这两步都成功, pte局部变量所指向的页表项就是引用address的表项。然后调handle_pte_fault检查address地址所对应的页表项,并决定如何为进程分配一个新页框:
(1).如被访问的页不存在,即这个页还没被存放在任何一个页框中,则,内核分配一个新的页框并适当地初始化。这种技术称为请求调页
(2).如被访问的页存在但标记为只读,即它已经被存放在一个页框中,则内核分配一个新的页框,并把旧页框的数据拷贝到新页框来初始化它的内容。这种技术称为写时复制

请求调页
它把页框的分配推迟到不能再推迟为止。即一直推迟到进程要访问的页不在RAM中,由此引起一个缺页异常;
请求调页背后的动机是:进程开始执行时并不访问其地址空间中的全部地址。事实上,一部分地址也许永远不会被进程使用。
此外,程序的局部性原理保证了在程序执行的每个阶段,真正引用的进程页只有一小部分。因此,临时用不着的页所在的页框可由其他进程来使用。故,对全局分配来说,请求调页是首选的它增加了系统中空闲页框的平均数,从而更好地利用空闲内存;从另一个观点,在RAM总数保持不变下,请求调页从总体上能使系统有更大的吞吐量

为这一切优点付出的代价是系统额外的开销,由请求调页所引发的每个"缺页"异常必须由内核处理,这将浪费CPU的时钟周期。局部性原理保证了一旦进程开始在一组页上运行,在接下来相当长的一段时间内它会一直停留在这些页上而不去访问其他的页这样,就可认为"缺页"异常是一种稀有事件。

被访问的页不在主存中,其原因或者是进程从没访问过该页,或是内核已经回收了相应的页框;这两种情况下,缺页处理程序必须为进程分配新的页框;如何初始化这个页框取决于是哪一种页及页以前是否被进程访问过。特殊情况下:
(1).这个页从未被进程访问到且没映射磁盘文件,或页属于线性磁盘文件的映射。内核能识别这些情况,因为页表相应的表项被填充为0,即pte_none宏返回1
(2).页属于非线性磁盘文件的映射。内核能识别这种情况,因为Present标志被清0,且Dirty被置1。即pte_file返回1
(3).进程已访问过这个页,但其内容被临时保存在磁盘上。内核能识别这种情况,因为相应的表项没被填充为0,但PresentDirty被清0

故,handle_pte_fault通过检查address对应的页表项能区分三种情况

entry = *pte;
if(!pte_present(entry))// P是0
{
	if(pte_none(entry))// 其余位也是0--no_page
		return do_no_page(mm, vma, address, write_access, pte, pmd);
	if(pte_file(entry))// Dirty是1-file_page
		return do_file_page(mm, vma, address, write_access, pte, pmd);
	return do_swap_page(mm, vma, address, pte, pmd, entry, write_access);// Dirty是0。swap_page
}

在情况1下,当页从未被访问或页线性地映射磁盘文件时则调do_no_page
有两种方法装入所缺的页。这取决于这个页是否被映射到一个磁盘文件。
该函数通过检查vma线性区描述符的nopage字段来确认。如页被映射到一个文件,nopage就指向一个函数,该函数把所缺的页从磁盘装入RAM。因此,可能的情况是:
(1).vma->vm_ops->nopage字段不为NULL。此情况下,线性区连续映射磁盘文件。nopage指向装入页的函数。(用磁盘文件内容填充页框。)
(2).vma->vm_opsNULLvma->vm_ops->nopageNULL。这情况下,线性区没映射磁盘文件,即它是一个匿名映射。故,do_no_pagedo_anonymous_page获得一个新的页框。(仅仅分配页框,不填充,或填充0)

if(!vma->vm_ops || !vma->vm_ops->nopage)
	return do_anonymous_page(mm, vma, page_table, pmd, write_access, address);

do_anonymous_page分别处理写请求,读请求

if(write_access)
{
	pte_unmap(page_table);// 这里的page_table是pte_t表示一个页表项
	spin_unlock(&mm->page_table_lock);
	page = alloc_page(GFP_HIGHUSER | __GFP_ZERO);
	spin_lock(&mm->page_table_lock);
	
	page_table = pte_offset_map(pmd, addr);
	mm->rss++;
	entry = maybe_mkwrite(pte_mkdirty(mk_pte(page, vma->vm_page_prot)), vma);
	lru_cache_add_active(page);
	SetPageReferenced(page);
	set_pte(page_table, entry);
	pte_unmap(page_table);
	
	spin_unlock(&mm->page_table_lock);
	return VM_FAULT_MINOR;
}

pte_unmap的第一次执行释放一种临时内核映射,它映射了在调handle_pte_fault之前由pte_offset_map所建立页表项的高端内存物理地址;pte_offset_mappte_unmap对获取和释放同一个临时内核映射。

临时内核映射需在调alloc_page之前释放,因为这个函数可能阻塞当前进程。函数递增内存描述符的rss字段以记录分配给进程的页框总数,相应的页表项设置为页框的物理地址;页表框被标记为既脏又可写的。lru_cache_add_active把新页框插入与交换相关的数据结构中。(匿名页框是可以被交换到磁盘的)

当处理读访问时,页的内容是无关紧要的,因为进程第一次对它访问。给进程一个填充为0的页要比给它一个由其他进程填充了信息的旧页更安全。Linux在请求调页方面做的更深入些。没必要立即给进程分配一个填充为0的新页框。我们可给它一个现有的称为零页的页,这样可进一步推迟页框的分配;零页在内核初始化期间被静态分配,并存放在empty_zero_page。因此,用零页的物理地址设置页表项。

entry = pte_wrprotect(mk_pte(virt_to_page(empty_zero_page), vma->vm_page_prot));
set_pte(page_table, entry);
spin_unlock(&mm->page_table_lock);
return VM_FAULT_MINOR:

由于这个页被标记为不可写,故如进程试图写这个页,则写时复制被激活。当且仅当此时,进程才获得一个属于自己的页并对它进行写操作。

写时复制
第一代Unix发出fork系统调用时,内核原样复制父进程的整个地址空间并把复制的那一份分配给子进程。这种行为非常耗时,它需要:
(1).为子进程的页表分配页框
(2).为子进程的页分配页框
(3).初始化子进程的页表
(4).把父进程的页复制到子进程相应的页中

现在的Unix内核(包括Linux)采用一种更有效的方法:写时复制;
父进程和子进程共享页框而不是复制页框。只要页框被共享,它们就不能被修改;无论父进程还是子进程何时试图写一个共享的页框,就产生一个异常;这时内核就把这个页复制到一个新的页框并标记为可写,原来的页框仍是写保护的;当其他进程试图写入时,内核检查写进程是否是这个页框的唯一属主。如是,就把这个页框标记为对这个进程是可写的

页描述符的_count用于跟踪共享相应页框的进程数目,只要进程释放一个页框或在它上面执行写时复制。它的_count就减小,只有当_count变为-1时,这个页框才被释放。

我们讲述Linux如何实现写时复制。

handle_pte_fault:
handle_pte_fault确定缺页异常由访问内存中现有的一个页而引起时,它执行

if(pte_present(entry))
{
	if(write_access)
	{
		if(!pte_write(entry))
			return do_wp_page(mm, vma, address, pte, pmd, entry);
		entry = pte_mkdirty(entry);
	}
	entry = pte_mkyong(entry);
	set_pte(pte, entry);
	flush_tbl_page(vma, address);
	pte_unmap(pte);
	spin_unlock(&mm->page_table_lock);
	return VM_FAULT_MINOR;
}

handle_pte_fault与体系结构无关,它考虑任何违背页访问权限的可能。
80x86体系结构上,如页是存在的,则访问权限是写允许的而页框是写保护的。故,总是要调do_wp_pagedo_wp_page先获取与缺页异常相关的页框描述符。接下来,确定页的复制是否真正必要。如仅有一个进程拥有这个页,则写时复制不必应用,进程应自由写该页。具体说,函数读取页描述符的_count,如它等于0,写时复制就不必。实际上,检查稍微复杂些,因为当页插入到交换高速缓存且设置了页描述符的PG_private时,_count也增加。不过,写时复制不进行时,就把该页框标记为可写的

set_pte(page_table, maybe_mkwrite(pte_mkyong(pte_mkdirty(pte)), vma));
flush_tlb_page(vma, address);
pte_unmap(page_table);
spin_unlock(&mm->page_table_lock);
return VM_FAULT_MINOR;

如两个或多个进程通过写时复制共享页框,则函数就把旧页框的内容复制到新分配的页框。为避免竞争条件,在开始复制操作前调get_pageold_page使用计数加1

old_page = pte_page(pte);
pte_unmap(page_table);
get_page(old_page);
spin_unlock(&mm->page_table_lock);
if(old_page == virt_to_page(empty_zero_page))
	new_page = alloc_page(GFP_HIGUUSER | __GFP_ZERO);
else
{
	new_page = alloc_page(GFP_HIGHUSER);
	vfrom = kmap_atomic(old_page, KM_USER0);
	vto = kmap_atomic(new_page, KM_USER1);
	copy_page(vto, vfrom);
	kunmap_atomic(vfrom, KM_USER0);
	kunmap_atomic(vto, KM_USER0)
}

如旧页框是零页,就在分配新的页框时(__GFP_ZERO)把它填充为0。否则,使用copy_page复制页框内容。因为页框的分配可能阻塞进程,故函数检查自从函数开始执行以来是否已修改了页表项。如是,新的页框被释放。old_page的使用计数器减少,结束。如所有事情进展顺利, 则新页框的物理地址最终被写进页表项,且相应的tlb寄存器无效

spin_lock(&mm->page_table_lock);
entry = maybe_mkwrite(pte_mkdirty(mk_pte(new_page, vma->vm_page_prot)), vma);
set_pte(page_table, entry);
flush_tlb_page(vma, address);
lru_cache_add_active(new_page);
pte_unmap(page_table);
spin_unlock(&mm->page_table_lock);

lru_cache_add_active把新页框插入到与交换相关的数据结构中。最后,do_wp_pageold_page的使用计数器减少两次。第一次减少是取消复制页框内容之前进行的安全性增加,第二次的减少是反映当前进程不再拥有该页框这一事实

处理非连续内存区访问:
内核在更新非连续内存区对应的页表项时是非常懒惰的。事实上,vmallocvfree只把自己限制在更新主内核页表。一旦内核初始化阶段结束,任何进程或内核线程便都不直接使用主内核页表。因此,考虑内核态进程对非连续内存区的第一次访问,当把线性地址转换为物理地址时,CPU的内存管理单元遇到空的页表项并产生一个缺页。但缺页异常处理程序认识这种特殊情况,因为异常发生在内核态且产生缺页的线性地址大于TASK_SIZE。故,do_page_fault检查相应的主内核页表项

vmalloc_fault:
	asm("movl %%cr3,%0":"=r"(pgd_paddr));
	pgd = pgd_index(address) + (pgd_t*)__va(pgd_paddr);
	pgd_k = init_mm.pgd + pgd_index(address);
	if(!pgd_present(*pgd_k))
		goto no_context;
	pud = pud_offset(pgd, address);
	pud_k = pud_offset(pgd_k, address);
	if(!pud_present(*pud_k))
		goto no_context;
	pmd = pmd_offset(pud, offset);
	pmd_k = pmd_offset(pud_k, address);
	if(!pmd_present(*pmd_k))
		goto no_context;
	set_pmd(pmd, *pmd_k);
	pte_k = pte_offset_kernel(pmd_k, address);
	if(!pte_present(*pte_k))
		goto no_context;
	return;

把存放在cr3寄存器中的当前进程页全局目录的物理地址赋给局部变量pgd_paddr,把与pgd_paddr相应的线性地址赋给局部变量pgd,且把主内核页全局目录的线性地址赋给pgd_k局部变量。如产生缺页的线性地址所对应的主内核页全局目录项为空,则函数跳到标号为no_context代码处。否则,函数检查与错误线性地址相对应的主内核页上级目录项和主内核页中间目录项。如它们中有一个为空,就再次跳到no_context处。否则,就把主目录项复制到进程页中间目录的相应项中。随后,对主页表项重复上述整个操作

缺页异常主程序

如前,Linux的缺页异常处理程序必须区分以下两种情况:
由编程错误所引起的异常,
由引用属于进程地址空间但还尚未分配物理页框的页所引起的异常。

线性区描述符可让缺页异常处理程序非常有效的完成它的工作。do_page_fault80x86上的缺页异常中断服务程序,它把引起缺页的线性地址和当前进程的线性区相比较,从而能选择适当方法处理这个异常。

1.地址属于进程的地址空间?
1.1.是,访问类型与线性区的访问权限匹配?
1.1.1.是,合法访问。分配一个新的页面
1.1..2.否,非法访问。发送一个SIGSEGV信号
1.2.否,异常发生在用户态?
1.2.1.是,非法访问,发送一个SIGSEGV信号
1.2.2.否,内核错误,杀死进程

实际中,情况更复杂。因为缺页处理程序必须处理多种分得更细的特殊情况,它们不宜在总体方案中列出来,还必须区分许多种合理的访问。标识符vmalloc_faultgood_areabad_areano_context是出现在do_page_fault中的标记,它们有助于你理清流程图中的块与代码中特定行之间的关系。

do_page_fault接收参数:
(1).pt_regs结构的地址regs,结构包含当异常发生时的微处理器寄存器的值
(2).三位的error_code,当异常发生时由控制单元压入栈中。这些位有以下含义:
(2.1).如第0位被清0,则异常由访问一个不存在的页引起,否则,如第0位被设置,则异常由无效的访问权限引起
(2.2).如第1位被清0,则异常由读访问或执行访问所引起;如该位被设置,则异常由写访问所引起
(2.3).如第2位被清0,则异常发生在处理器处于内核态时,否则, 异常发生在处理器处于用户态时。

do_page_fault的第一步操作是读取引起缺页的线性地址。异常发生时,CPU控制单元把这个值存放在cr2控制寄存器中

asm("movl %%cr2, %0":"=r"(address));
if(regs->eflags & 0x00020200)
	local_irq_enable();
tsk = current;

这个线性地址保存在address
如缺页发生之前或CPU运行在虚拟8086模式时,打开了本地中断,则该函数还要确保本地中断打开,并把指向current进程描述符的指针保存在tsk局部变量中。

do_page_fault首先检查引起缺页的线性地址是否属于第4GB

info.si_code = SEGV_MAPERR;
if(address >= TASK_SIZE)
{
	if(!(error_code & 5))
		goto vmalloc_fault;
	goto bad_area_nosemaphore;
}

如发生了由于内核试图访问不存在的页框引起的异常,就跳转去执行vmalloc_fault。该部分代码处理可能由于在内核态访问非连续内存区而引起的缺页。否则,就跳转去执行bad_area_nosemaphore。接下来,缺页处理程序检查异常发生时是否内核正在执行一些关键例程或正在运行内核线程

if(in_atomic() || !tsk->mm)
	goto bad_area_nosemaphore;

如缺页发生在下面任何一种情况下,则in_atomic产生等于1的值
(1).内核正在执行中断处理程序或可延迟函数
(2).内核正在禁用内核抢占的情况下执行临界区代码,如缺页的确发生在中断处理程序,可延迟函数,临界区,或内核线程中。do_page_fault就不会试图把这个线性地址与current的线性区做比较。内核线程从来不使用小于TASK_SIZE的地址。
同样,中断处理程序,可延迟函数,临界区代码(这三者也在内核代码段)也不应使用小于TASK_SIZE的地址,因为这可能导致当前进程的阻塞。

我们假定缺页没发生在中断处理程序,可延迟函数,临界区或内核线程中。于是,函数必须检查进程所拥有的线性区以决定引起缺页的线性地址是否包含在进程的地址空间中,为此,必须获得进程的mmap_sem读写信号量。

if(!down_read_trylock(&tsk->mm->mmap_sem))
{
	if((error_code & 4) == 0 && !search_exception_table(regs->eip))
		goto bad_area_nosemaphore;
	down_read(&tsk->mm->mmap_sem);
}

如内核bug和硬件故障有可能被排除,则当缺页发生时,当前进程就还没为写而获得信号量mmap_sem。尽管如此,do_page_fault还是想确定的确没获得这个信号量。因为如果不是这样就会发生死锁。
所以,函数用down_read_trylock而不是down_read
如这个信号量被关闭且缺页发生在内核态,do_page_fault就要确定异常发生的时候,是否正使用作为系统调用参数被传递给内核的线性地址。此时,因为每个系统调用服务例程都小心地避免在访问用户态地址空间以前为写而获得mmap_sem信号量,故do_page_fault确信mmap_sem信号量由另外一个进程占有了,从而do_page_fault一直等到该信号量被释放。否则,如缺页是由于内核bug或严重的硬件故障引起的,就跳到bad_area_nosemaphore标记处。假设已为读而获得了mmap_sem信号量。现在,do_page_fault开始搜索错误线性地址所在的线性区

vma = find_vma(tsk->mm, address);
if(!vma)
	goto bad_area;
if(vma->vm_start <= address)
	goto good_area;

vmaNULL,说明address之后没线性区,因此这个错误的地址肯定是无效的,另一方面,如在address之后结束的第一个线性区包含address,则函数跳到标记为good_area的代码处。

如两个if都不满足,函数已确定address没包含在任何线性区中。可它还必须执行进一步的检查,由于这个错误地址可能是由pushpusha指令在进程的用户态堆栈上的操作所引起的。解释下栈如何映射到线性区上的。
每个向低地址扩展的栈所在的区,它的VM_GROWSDOWN标志被设置,这样,当vm_start字段的值可能被减小的时候,而vm_end保持不变。这种线性区的边界包括,但不严格限定用户态堆栈当前的大小。这种细微差别主要基于:
(1).线性区的大小是4KB的倍数,栈的大小是任意的
(2).分配给一个线性区的页框在这个线性区被删除前永远不被释放。尤其是,一个栈所在线性区的vm_start字段的值只能减少,永远不能增加。甚至进程执行一系列pop指令时,这个线性区的大小仍保持不变

当进程填满分配给它的堆栈的最后一个页框后,进程如何引起一个"缺页"异常。push引用了这个线性区以外的一个地址(即引用一个不存在的页框)。这种异常不是由程序错误引起, 它必须由缺页处理程序单独处理

if(!(vma->vm_flags & VM_GROWSDOWN))
	goto bad_area;
if(error_code & 4 && address + 32 < regs->esp)
	goto bad_area;
if(expand_stack(vma, address))
	goto bad_area;
goto good_area;

如线性区的VM_GROWSDOWN被设置,且异常发生在用户态,函数就检查address是否小于regs->esp栈指针。几个与栈相关的汇编语言指令只有在访问内存之后才执行减esp寄存器的操作,所以允许进程有32字节的后备区间。如这个地址足够高,则代码调expand_stack函数检查是否允许进程既扩展它的栈也扩展它的地址空间。如一切都可以,就把vma的vm_start设为address,且返回0。否则,返回-ENOMEM

只要线性区的VM_GROWSDOWN标志被设置,但异常不是发生在用户态,上述代码就跳过容错检查。这些条件意味着内核正访问用户态的栈,意味着这段代码总是应运行expand_stack–缺页异常能看懂每处处理,但整理的显得杂乱。应该由自己结合源码进行更好的整理输出。

创建和删除进程的地址空间

1.创建进程的地址空间

第三章的cloneforkvfork已经提到,当创建一个新的进程时内核调copy_mm,这个函数通过建立新进程的所有页表和内存描述符来创建进程的地址空间。通常,每个进程有自己的地址空间,但轻量级进程可通过调clone来创建。这些轻量级进程共享同一地址空间,即允许它们对同一组页进行寻址。

按前面写时复制,传统的进程继承父进程的地址空间,只要页是只读的,就依然共享它们,当其中一个进程试图对某个页写时,这个页就被复制一份。一段时间后,所创建的进程通常获得与父进程不一样的完全属于自己的地址空间。
轻量级进程使用父进程的地址空间,Linux实现轻量级进程很简单,即不复制父进程地址空间,创建轻量级进程比创建普通进程相应快的多,且只要父进程和子进程谨慎地协调它们的访问,就认为页的共享是有益的。如通过clone已经创建了新进程且flag参数的CLONE_VM被设置,则copy_mm把父进程地址空间给子进程

if(clone_flags & CLONE_VM)
{
	atomic_inc(&current->mm->mm_users);
	spin_unlock_wait(&current->mm->page_table_lock);
	tsk->mm = current->mm;
	tsk->active_mm = current->mm;
	return 0;
}

如其他CPU持有进程页表自旋锁,就调spin_unlock_wait保证在释放锁前,缺页处理程序不会结束。
实际上,这个自旋锁除了保护页表外,还需禁止创建新的轻量级进程,因为它共享current->mm描述符。
如没设置CLONE_VMcopy_mm就需创建一个新的地址空间,这个函数分配一个新的内存描述符,把它的地址存放在新进程描述符tskmm中,并把current->mm的内容复制到tsk->mm。然后,改变新进程描述符的一些字段

tsk->mm = kmem_cache_alloc(mm_cachep, SLAB_KERNEL);
memcpy(tsk->mm, current->mm, sizeof(*tsk->mm));
atomic_set(&tsk->mm->mm_users, 1);
atomic_set(&tsk->mm->mm_count, 1);
init_rwsem(&tsk->mm->mmap_sem);
tsk->mm->core_waiters = 0;
tsk->mm->page_table_lock = SPIN_LOCK_UNLOCKED;
tsk->mm->ioctx_list_lock = RW_LOCK_UNLOCKED;
tsk->mm->ioctx_list = NULL;
tsk->mm->default_kioctx = INIT_KIOCTX(tsk->mm->default_kioctx, *tsk->mm);
tsk->mm->free_area_cache = (TASK_SIZE/3 + 0xfff) & 0xfffff000;
tsk->mm->pgd = pgd_alloc(tsk->mm);
tsk->mm->def_flags = 0;

pgd_alloc为新进程分配页全局目录。然后,调依赖于体系结构的init_new_context。对80x86,函数检查当前进程是否拥有定制的局部描述符表。如是,init_new_context复制一份current的局部描述符表并把它插入tsk的地址空间。最后,调dup_mmap既复制父进程的线性区,也复制父进程的页。dup_mmap把新内存描述符tsk->mm插入到内存描述符的全局链表中。然后,从current->mm->mmap所指向的线性区开始扫描父进程的线性区链表。它复制遇到的每个vm_area_struct线性区描述符,把复制品插入到子进程的线性区链表和红-黑树

在插入一个新的线性区描述符后,如需要,dup_mmap立即调copy_area_range创建必要的页表来映射这个线性区所包含的一组页且初始化新页表的表项,尤其是,与私有的,可写的页(VM_SHARED关闭,VM_MAYWRITE打开)所对应的任一页框都标记为对父子进程是只读的,以便这种页框能用写时复制机制进行处理。

2.删除进程的地址空间

进程结束时,调exit_mm释放进程的地址空间

mm_release(tsk, tsk->mm);
if(!(mm != tsk->mm))
	return;
down_read(&mm->mmap_sem);

mm_release唤醒在tsk->vfork_done补充原语上睡眠的任一进程。典型地,只当现有进程通过vfork被创建时,相应的等待队列才为非空,如正被终止的进程不是内核线程,exit_mm就需释放内存描述符和所有相关的数据结构。首先,它检查mm->core_waiters是否被置位。如是,进程把内存的所有内存转储到一个转储文件。为避免转储文件混乱,用mm->core_donemm->core_startup_done补充原语使共享同一个内存描述符mm的轻量级进程的执行串行化。

函数递增内存描述符的主使用计数器,重新设置进程描述符的mm,使处理器处于懒惰TLB模式

atomic_inc(&mm->mm_count);
spin_lock(tsk->alloc_lock);
tsk->mm = NULL;
up_read(&mm->map_sem);
enter_lazy_tlb(mm, current);
spin_unlock(tsk->alloc_lock);
mmput(mm);

最后,调mmput释放局部描述符表,线性区描述符,页表。因为,exit_mm已经递增了主使用计数器,所以并不释放内存描述符本身。当要把正在被终止的进程从本地CPU撤销时,将由finish_task_switch释放内存描述符

堆的管理

每个Unix进程都有一个特殊的线性区,这个线性区就是堆,堆用于满足进程的动态内存请求。内存描述符的start_brkbrk分别限定了这个区的开始地址,结束地址。

进程可用下面API来请求和释放动态内存:
malloc(size),分配成功时,返回所分配内存单元第一个字节的线性地址
calloc(n, size),请求含n个大小为size的元素的一个数组。分配成功时,数组元素初始化为0,返回首个元素线性地址
realloc(pte, size),分配新线性区域,将pte老区域内容拷贝到新区域起始部分
free(addr),释放由malloccalloc分配的起始地址为addr的线性区
brk(addr),直接修改堆的大小,addr指定current->mm->brk新值,返回值是线性区新的结束地址
sbrk(incr),incr指定是增加还是减少以字节为单位的堆大小

brk是唯一以系统调用的方式实现的函数。
其他所有函数都是使用brkmmap系统调用实现的c语言库函数。
用户态进程调brk时,内核执行sys_brk(addr)
函数先验证addr参数是否位于进程代码所在的线性区,如是,立即返回。因为堆不能与进程代码所在的线性区重叠。

mm = current->mm;
down_write(&mm->mmap_sem);
if(addr < mm->end_code)
{
out:
	up_write(&mm->mmap_sem);
	return mm->brk;
}

由于brk系统调用作用于某一个线性区,它分配和释放完整的页;
故,函数把addr值调整为PAGE_SIZE的倍数。然后,把调整的结果与内存描述符的brk字段值比较:

newbrk = (addr + 0xfff) & 0xfffff000;
oldbrk = (mm->brk + 0xfff) & 0xfffff000;
if(oldbrk == newbrk)
{
	mm->brk = addr;
	goto out;
}

如进程请求缩小堆,则sys_brkdo_munmap完成这项任务,然后返回

if(addr <= mm->brk)
{
	if(!do_munmap(mm, newbrk, oldbrk-newbrk))// 撤销内存映射允许在大的映射内撤销局部
		mm->brk = addr;
	goto out;
}

如进程请求扩大堆,则sys_brk先检查是否允许进程这样做。
如进程企图分配在其限制范围外的内存,函数并不多分配内存,只简单返回mm->brk原有值

rlim = current->signal->rlim[RLIMIT_DATA].rlim_cur;
if(rlim < RLIM_INFINITY && addr - mm->start_data > rlim)
	goto out;

函数检查扩大后的堆是否和进程的其他线性区重叠。如是,不做任何事情就返回

if(find_vma_intersection(mm, oldbrk, newbrk + PAGE_SIZE))
	goto out;

如一切都顺序,则调do_brk,如它返回oldbrk,则分配成功且sys_brk返回addr的值。否则,返回旧的mm->brk

if(do_brk(oldbrk, newbrk-oldbrk) == oldbrk)// 执行扩展现有映射区域。在现有线性区随后,再次申请线性区。并触发合并。
	mm->brk = addr;
goto out;

do_brk实际上仅处理匿名线性区的do_mmap的简化版。可认为它的调用等价于

do_mmap(NULL, oldbrk, newbrk-oldbrk, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_FIXED|MAP_PRIVATE, 0);

do_brkdo_mmap稍快,因为前者假定线性区不映射磁盘上的文件,从而避免了检查线性区对象的几个字段

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

raindayinrain

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值