目录
查找一个与给定的地址区间相重叠的线性区:find_vma_intersection
向内存描述符链表中插入一个线性区:insert_vm_struct
概述
我们在之前更加底层的层面看到了:内核中通过这些函数获得动态内存: __get_free_pages
,alloc_pages
,kmem_cache_alloc
,kmalloc
,vmalloc
,vmalloc_32
。
使用上述简单方法基于以下两个原因:
内核是操作系统中优先级最高的成分。如某个内核函数请求动态内存,则必定有正当的理由发出请求,因此,没道理试图推迟这个请求。
内核信任自己。所有的内核函数都假定没错误,故内核函数不必插入针对编程错误的任何保护措施。
给用户态进程分配内存时,情况完全不同:
进程对动态内存的请求被认为是不紧迫的。如进程的可执行文件被装入时,进程不一定立即对所有的代码页进行访问。如进程调
malloc
获得请求的动态内存时,不意味着进程很快会访问所有所获得的内存。故一般来说,内核总是尽量推迟给用户态进程分配动态内存。由于用户进程是不可信任的,故内核必须能随时准备捕获用户态进程引起的所有寻址错误。
当用户态进程请求动态内存时,并没有获得请求的页框,而仅仅获得对一个新的线性地址区间的使用权,而这一线性地址区间就成为进程地址空间的一部分。这一区间叫"线性区"。
进程的地址空间
进程的地址空间由允许进程使用的全部线性地址组成。每个进程所看到的线性地址集合是不同的,一个进程所使用的地址与另外一个进程所使用的地址之间没什么关系。内核可通过增加或删除某些线性地址区间来动态地修改进程的地址空间。
内核通过所谓线性区的资源来表示线性地址区间,线性区是由起始线性地址,长度和一些访问权限来描述的。 为效率起见,起始地址和线性区的长度必须是4096
的倍数,以便每个线性区所识别的数据完全填满分配给它的页框。 下面是进程获得新线性区的一些典型情况:
当用户在控制台输入一条命令时,
shell
进程创建一个新的进程去执行这个命令。结果是,一个全新的地址空间(一组线性区)分配给了新进程。正在运行的进程有可能决定装入一个完全不同的程序。这种情况下,进程标识符仍保持不变,可是在装入这个程序以前所使用的线性区却被释放,并有一组新的线性区被分配给这个进程
正在运行的进程可能对一个文件(或它的一部分)执行"内存映射"。这种情况下,内核给这个进程分配一个新的线性区来映射这个文件。
进程可能持续向它的用户态堆栈增加数据,直到映射这个堆栈的线性区用完。这种情况下,内核也许会决定扩展这个线性区的大小。
进程可能创建一个IPC共享线性区来与其他合作进程共享数据。此情况下,内核给这个进程分配一个新的线性区以实现这个方案
进程可能通过调类似
malloc
这样的函数扩展自己的动态区。结果是,内核可能决定扩展给这个堆所分配的线性区。
系统调用 | 说明 |
---|---|
brk | 改变进程堆的大小 |
execve | 装入一个新的可执行文件,从而改变进程的地址空间 |
_exit | 结束当前进程并撤销它的地址空间 |
fork | 创建一个新进程,并为它创建新的地址空间 |
map,map2 | 为文件创建一个内存映射,从而扩大进程的地址空间 |
mremap | 扩大或缩小线性区 |
remap_file_pages | 为文件创建非线性映射 |
munmap | 撤销对文件的内存映射,从而缩小进程的地址空间 |
shmat | 创建一个共享线性区 |
shmdt | 撤销一个共享线性区 |
确定一个进程当前所拥有的线性区(即进程的地址空间)是内核的基本任务,因为这可以让缺页异常处理程序有效地区分引发这个异常处理程序的两种不同类型的无效线性地址:
由编程错误引发的无效线性地址
由缺页引发的无效线性地址;
即使这个线性地址属于进程的地址空间,但对应于这个地址的页框仍有待分配。从进程观点看,后一种地址不是无效的。内核要利用这种缺页以实现请求调页:内核通过提供页框来处理这种缺页,并让进程继续运行。(也就是说我们需要区分这两种情况才是!)
内存描述符
与进程地址空间有关的全部信息都包含在一个叫内存描述符的数据结构中,这个结构的类型为mm_struct
,进程描述符的mm
字段就指向这个结构。
类似 | 字段 | 说明 |
---|---|---|
struct vm_area_struct* | mmap | 指向线性区对象的链表头 |
struct rb_root | mm_rb | 指向线性区对象的红黑树的根 |
struct vm_area_struct* | mmap_cache | 指向最后一个引用的线性区对象 |
unsigned long(*)() | get_unmapped_area | 在进程地址空间中搜索有效线性地址区间的方法 |
void (*)() | unmap_area | 释放线性地址区间时调用的方法 |
unsigned long | mmap_base | 标识第一个分配的匿名线性区或文件内存映射的线性地址 |
unsigned long | free_area_cache | 内核从这个地址开始搜索进程地址空间中线性地址的空闲区间 |
pdt_t* | pgd | 指向页全局目录 |
atomic_t | mm_users | 次使用计数器 |
atomic_t | mm_count | 主使用计数器 |
int | map_count | 线性区的个数 |
struct rw_semaphore | mmap_sem | 线性区的读/写信号量 |
spinlock_t | page_table_lock | 线性区的自旋锁和页表的自旋锁 |
struct list_head | mmlist | 指向内存描述符链表中的相邻元素 |
unsigned long | start_code | 可执行代码的起始地址 |
unsigned long | end_data | 可执行代码的最后地址 |
unsigned long | start_brk | 堆的起始地址 |
unsigned long | brk | 堆的当前最后地址 |
unsigned long | start_stack | 用户态堆栈的起始地址 |
unsigned long | arg_start | 命令行参数的起始地址 |
unsigned long | arg_end | 命令行参数的最后地址 |
unsigned long | env_start | 环境变量的起始地址 |
unsigned long | env_end | 环境变量的最后地址 |
unsigned long | rss | 分配给进程的页框数 |
unsigned long | anon_rss | 分配给匿名内存映射的页框数 |
unsigned long | total_vm | 进程地址空间的大小 |
unsigned long | locked_vm | "锁住"而不能换出的页的个数 |
unsigned long | shared_vm | 共享文件内存映射中的页数 |
unsigned long | exec_vm | 可执行内存映射中的页数 |
unsigned long | stack_vm | 用户态堆栈中的页数 |
unsigned long | reserved_vm | 在保留区中的页数或在特殊线性区中的页数 |
unsigned long | def_flags | 线性区默认的访问标志 |
unsigned long | nr_ptes | 进程的页表数 |
unsigned long[] | saved_auxv | 开始执行ELF程序时使用 |
unsigned int | dumpable | 表示是否可产生内存信息转储的标志 |
cpumask_t | cpu_vm_mask | 用于懒惰TLB交换的位掩码 |
mm_context_t | context | 指向有关特定体系结构信息的表(如x86上的LDT地址) |
unsigned long | swap_token_time | 进程在这个时间将有资格获得交换标记 |
char | recent_pagein | 如最近发生了主缺页,设置该标志 |
int | core_waiters | 正在把进程地址空间的内存转储到core文件中的轻量级进程的数量 |
struct completion* | core_startup_done | 指向创建内存转储文件时的补充原语 |
struct completion | core_done | 创建内存转储文件时使用的补充原语 |
rwlock_t | ioctx_list_lock | 用于保护异步I/O上下文链表的锁 |
struct kioctx* | ioctx_list | 异步I/O上下文链表 |
struct kioctx | default_kioctx | 默认的异步I/O上下文 |
unsigned long | hiwater_rss | 进程所拥有的最大页框数 |
unsigned long | hiwater_vm | 进程线性区中的最大页数 |
所有的内存描述符存放在一个双向链表中,每个描述符在mmlist
字段存放链表相邻元素的地址。链表的第一个元素是init_mm
的mmlist
,init_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_alloc
调kmem_cache_alloc
来初始化新的内存描述符,并把mm_count
和mm_users
字段都置为1
。
mmput
递减内存描述符的mm_users
字段。如该字段变为0
,这个函数就释放局部描述符表,线性区描述符,由内存描述符所引用的页表,并调mmdrop
。后者把mm_count
减1
,如该字段变为0
,就释放mm_struct
。
内核线程的内存描述符
内核线程仅仅运行在内核态,它们永不会访问低于TASK_SIZE
的线性地址。大于TASK_SIZE
线性地址的相应页表项都应该总是相同的,因此,一个内核线程到底用什么样的页表集根本没关系。为避免无用的TLB
和高速缓存刷新,内核线程使用一组最近运行的普通进程的页表。结果,在每个进程描述符中包含mm
和active_mm
。
进程描述符中的mm
指向进程所拥有的内存描述符,active_mm
指向进程运行时所使用的内存描述符。
对普通进程,两者值相同。对内核线程,mm
总是NULL
。active_mm
为前一运行线程的active_mm
。只要处于内核态的一个进程为"高端"线性地址(高于TASK_SIZE
)修改了页表项,那么它就也应当更新系统中所有进程页表集合中的相应表项。一旦内核态的一个进程进行了设置,则映射应对内核态的所有其他进程都有效。触及所有进程的页表集合是相当费时的操作,因此,Linux
才有一种延迟方式。每当一个高端地址被重新映射时(一般通过vmalloc,vfree
),内核就更新被定位在swapper_pg_dir
主内核页全局目录中的常规页表集合。这个页全局目录由主内存描述符的pgd
字段指向,而主内存描述符存放于init_mm
变量。
线性区
Linux
通过类型为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 | 线性区的标志 |
struct rb_node | vm_rb | 用于红-黑树的数据 |
union | shared | 链接到反映射所使用的数据结构 |
struct list_head | anon_vma_node | 指向匿名线性区链表的指针 |
struct anon_vma* | anon_vma | 指向anon_vma数据结构的指针 |
struct vm_operations_struct* | vm_ops | 指向线性区的方法 |
unsigned long | vm_pgoff | 在映射文件中的偏移量。对匿名页,它等于0或vm_start/PAGE_SIZE |
struct file* | vm_file | 指向映射文件的文件对象 |
void* | vm_private_data | 指向内存区的私有数据 |
unsigned long | vm_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
把内存描述符存放在叫红-黑树的数据结构中。
红黑树小论
在红-黑树中,每个元素(或节点)通常有两个孩子:左孩子,右孩子。树中的元素被排序,对每个节点N
,N
的左子树上的所有元素都排在N
之前。相反,N
的右子树上的所有元素都排在N
之后;节点的关键字被写入节点内部。此外,红-黑树必须满足下列规则:
每个节点必须或为黑或为红
树的根必须为黑
红节点的孩子必须为黑
从一个节点到后代叶子节点的每个路径都包含相同数量的黑节点。
统计黑节点个数时,空指针也算作黑节点。这4
条规则确保有n
个内部节点的任何红-黑树其高度最多为2*log(n+1)
。
在红-黑树中搜索一个元素因此变得非常高效,因为其操作的执行时间与树大小的对数成线性比例。即,双倍的线性区个数只多增加一次循环。在红-黑树中插入和删除一个元素也是高效的,算法可很快便利树以确定插入元素的位置或删除元素的位置。任何新节点必须作为一个叶子插入并着成红色。如操作违背了上述规则,就需移动或重新着色。
为了存放进程的线性区,Linux
既使用了链表,也使用了红-黑树。这两种数据结构包含指向同一线性区描述符的指针,插入或删除一个线性区描述符时,内核通过红-黑树搜索前后元素,用搜索结果快速更新链表而不用扫描链表。
链表的头由内存描述符的mmap
字段所指向。任何线性区对象都在vm_next
字段存放指向链表下一元素的指针。红-黑树首部由内存描述符的mm_rb
字段所指向。任何线性区对象都在类型为rb_node
的vm_rb
字段中。存放节点颜色及指向双亲,左孩子,右孩子的指针。一般,红-黑树用来确定含有指定地址的线性区,链表通常在扫描整个线性区集合时来使用。红黑树可以和链表同时服务于存储一类元素的容器。插入,删除时,先在红黑树搜索前后元素。前后元素已知下链表插入,删除复杂度为O(1)
。整体遍历元素时,链表比红黑树更有优势。
线性区访问权限
用页这个术语既表示一组线性地址,又表示这组地址中所存放的数据。我们把介于0~4095
之间的线性地址区间称为第0
页,介于4096~8191
之间的线性地址区间称为第1
页,以此类推。因此,每个线性区都由一组号码连续的页构成。注意,用页表示线性区域的刻度,用页框表示物理内存区域的刻度。
几类访问标志:
每个页表项中存放的标志,如:
Read/Write
,Present
或User/Supervisor
。 由80x86
硬件用来检查能否执行所请求的寻址类型;页框描述符
flags
字段中的一组标志由Linux
用于许多不同的目的。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
字段的值设置相应页表项中的标志。然而,不能把线性区的访问权限直接转换成页保护位:
某些情况下,即使由相应线性区描述符的
vm_flags
所指定的某个页的访问权限允许对该页进行访问,但,对该页的访问还是应产生一个缺页异常。如,本章后面"写时复制"。内核可能决定把属于两个不同进程的两个完全一样的可写私有页存入同一页框;这种情况下,无论哪一个进程试图改动这个页都应当产生一个异常。80x86
处理器的页表仅有两个保护位,即Read/Write
和User/Supervisor
。一个线性区所包含的任何一个页的User/Supervisor
需总是置为1
,因为用户态进程需总能访问其中的页。启用
PAE
的新近Intel Pentium 4
,在所有64
位页表项中支持NX
标志。如内核没被继续编译成支持
PAE
,则Linux
采取如下规则以克服80x86
微处理器的硬件限制:读访问权限总是隐含着执行访问权限,反之亦然。
写访问权限总是隐含着读访问权限。反之,如内核编程成支持
PAE
,且CPU
有NX
标志(No Execute
)。
Linux
就采取不同的规则:执行访问权限总是隐含着读访问权限
写访问权限总是隐含着读访问权限
为做到在"写时复制"(Copy on Write
)中适当地推迟页框的分配,只要相应的页不是由多个进程所共享,则这种页框应是写保护的。故要根据以下规则精简由读,写,执行,共享访问的16
中可能组合
如页有写,共享。则
Read/Write
设置为1
。如页有读或执行,但既没写,也没共享访问权限。则,
Read/Write
置为0
。如支持
NX
,且也没执行访问权限,则NX
置为1
。如页没任何访问权限,则
Present
置为0
。以便每次访问都产生一个缺页异常。为了把这种情况与真正的页框不存在情况区分,Linux
还把Pagesize
置为1
。访问权限的每种组合对应的精简后的保护位存放在
protection_map
。
线性区的处理
对控制内存处理所用的数据结构和状态信息有基本理解后,看一组对线性区描述符进行操作的底层函数。这些函数应被看作简化了do_map
和do_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_addr
和end_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_area
或arch_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
以确定搜索起点后第一个线性区终点的位置。三种情况:
如所请求的区间大于正待扫描的线性地址空间部分(
addr+len>TASK_SIZE
),函数就从用户态地址空间的三分之一处重新开始搜索,如已完成第二次搜索,就返回-ENOMEM
。刚扫描过的线性区后面的空闲区没足够的大小,
vma != NULL && vma->vm_start < addr + len
此时,继续考虑下一个线性区。如以上两情况都没发生,则找到一个足够大的空闲区。函数返回
addr
。
向内存描述符链表中插入一个线性区:insert_vm_struct
参数:
mm
,指定进程内存描述符的地址,vmp
指定要插入的vm_area_struct
对象的地址,线性区对象的vm_start
和vm_end
必须已经初始化过。
函数调find_vma_prepare
在红-黑树mm->mm_rb
中查找vma
应位于何处。然后,insert_vm_struct
又调vma_link
。
vma_link:
在
mm->mmap
所指向的链表中插入线性区。在红-黑树
mm->mm_rb
中插入线性区。如线性区是匿名的,就把它插入以相应的
anon_vma
数据结构作为头节点的链表中。如线性区包含一个内存映射文件,则执行相关任务。
递增
mm->map_count
。
__vma_unlink: 参数:
为一个内存描述符地址
mm
,两个线性区对象地址
vma
和prev
。两个线性区都应属于mm
,prev
应在线性区的排序中位于vma
之前。
过程: 该函数从内存描述符链表和红-黑树中删除vma
, 如mm->mmap_cache
(存放刚被引用的线性区)字段指向刚被删除的线性区,则还要对mm->mmap_cache
进行更新。
分配线性地址区间
do_mmap:
功能:
为当前进程创建并初始化一个新的线性区
参数:
file
和offser
,如新的线性区把一个文件映射到内存,则使用文件描述符指针file
和文件偏移量offset
。addr
,这个线性地址指定从何处开始查找一个空闲的区间。len
,线性地址区间的长度。prot
,这个线性区所包含页的访问权限。可能的标志有PROT_READ
,PROT_WRITE
,PROT_EXEC
和PROT_NONE
。前三个标志与标志VM_READ
,WM_WRITE
及VM_EXEC
意义一样。PROT_NONE
表示进程没以上三个访问权限中任意一个。flag
,指定线性区的其他标志MAP_GROWSDOWN
,MAP_LOCKED
,MAP_DENYWRITE
和MAP_EXECUTEABLE
,MAP_SHARED
和MAP_PRIVATE
,MAP_FIXED
,MAP_ANONYMOUS
,MAP_NORESERVE
,MAP_POPULATE
,MAP_NONBLOCK
。
一些标志的解释:
flag | 说明 |
---|---|
MAP_FIXED |
区间的起始地址必须由参数addr 指定。 |
MAP_ANONYMOUS |
没有文件与这个线性区相关联。 |
MAP_POPULATE |
函数应为线性区建立的映射提前分配需要的页框,该标志对映射文件的线性区和IPC 共享的线性区有意义。 |
MAP_NONBLOCK |
只在MAP_POPULATE 置位时才有意义,提前分配页框时,函数肯定不阻塞。 |
do_mmap
对offset
的值进行一些初步检查,然后执行do_mmap_pgoff
。本节假设新的线性地址区间映射的不是磁盘文件,这里仅对实现匿名线性区的do_mmap_pgoff
进行说明。
检查参数的值是否正确,所提的请求是否能被满足。尤其检查:
线性地址区间的长度为
0
或包含的地址大于TASK_SIZE
。进程已映射了过多的线性区,即,
mm
内存描述符的map_count
字段的值超过了允许的最大值。flag参数指定新线性地址区间的页必须被锁在
RAM
中,但不允许进程创建上锁的线性区,或进程加锁页的总数超过了保存在进程描述符signal->rlim[RLIMIT_MEMLOCK].rlim_cur
字段的阈值。
以上任一情况成立,则
do_mmap_pgoff
终止并返回一个负值、如线性地址区间的长度为0
,则函数不执行任何操作就返回。调
get_unmapped_area
获得新线性区的线性地址区间通过把存放在
prot
和flags
参数中的值进行组合来计算新线性区描述符的标志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_READ
,PROT_WRITE
和PROT_EXEC
标志,calc_vm_prot_bits
才在vm_flags
中设置VM_READ
,VM_WRITE
,VM_EXEC
;只有在flags设置了相应的MAP_GROWSDOWN
,MAP_DENYWRITE
,MAP_EXECUTABLE
和MAP_LOCKED
,calc_vm_flag_bits
才在vm_flags
中设置VM_GROWSDOWN
,VN_DENYWRITE
,VM_EXECUTABLE
和VM_LOCKED
。 在vm_flags
中还有几个标志被置为1
:VM_MAYREAD
,VM_MAYWRITE
,VM_MAYEXEC
。在mm_def_flags
中所有线性区的默认标志,及如线性区的页与其他进程共享时的VM_SHARED
和VM_MAYSHARE
。调
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_pgoff
调do_munmap
删除新的区间,然后重复整个步骤。检查插入新的线性区是否引起进程地址空间的大小超过存放在进程描述符
signal->rlim[RLIMIT_AS].rlim_cur
字段中的阈值。如是,就返回错误码-ENOMEM
。这个检查只在这里进行,不在第一步与其他检查一起进行。若在
flags
参数中没设置MAP_NORESERVE
,新的线性区包含私有可写页,且没足够的空闲页框,则返回出错码-ENOMEM
;这最后一个检查由security_vm_enough_memory
实现。如新区间是私有的,且映射的不是磁盘上的一个文件,则调
vma_merge
检查前一个线性区是否可以这样的方式进行扩展来包含新的区间。前一个线性区须与在vm_flags
中存放标志的那些线性区有相同的标志。如前一个线性区可扩展,则vma_merge
试图把它与随后的线性区合并。一旦扩展前一线性区成功,跳12
。调
slab
分配函数kmem_cache_alloc
为新线性区分配一个vm_area_struct
初始化新的线性区对象
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);
如
MAP_SHARED
被设置,则该线性区是一个共享匿名区:调shmem_zero_setup
对它进行初始化,共享匿名区主要用于进程间通信调
vma_link
把新线性区插入到线性区链表和红-黑树增加存放在内存描述符
total_vm
字段中的进程地址空间大小如设置了
VM_LOCKED
,就调make_pages_present
连续分配线性区 的所有页,并把它们锁在RAM
中if(vm_flags & VM_LOCKED) { mm->locked_vm += len >> PAGE_SHIFT; make_pages_present(addr, addr + len); } 12345 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_pages
在addr
和addr+len
之间页的所有起始线性地址上循环;对其中的每个页,该函数调follow_page
检查在当前页表中是否有物理页的映射。如没这样的物理页存在,则get_user_pages
调handle_mm_fault
,后一个函数分配一个页框并根据内存描述符的vm_flags
设置它的页表项。函数通过返回新线性区的线性地址而终止
释放线性地址区间
do_munmap: 功能:
释放线性地址区间
参数:
进程描述符的地址
mm
,地址区间的起始地址
start
,它的长度
len
。
split_vma: 功能:
把与线性地址区间交叉的线性区划分成两个较小的区,一个在线性地址区间外部,另一个在区间的内部。
参数:
内存描述符指针
mm
,线性区描述符指针
vma
,表示区间与线性区之间交叉点的地址
addr
,表示区间与线性区之间交叉点在区间起始处还是结束处的标志
new_below
。
步骤:
调
kmem_cache_alloc
获得线性区描述符vm_area_struct
。把它的地址存在新的局部变量中,如没可用的空闲空间,就返回-ENOMEM
。用
vma
描述符的字段值初始化新描述符的字段如标志
new_below
为0
,说明线性地址区间的起始地址在vma
线性区的内部。因此须把新线性区放在vma
线性区之后,函数把new->vm_start
和vma->vm_end
赋值为addr
。如
new_below
等于1
,说明线性地址区间的结束地址在vma
线性区的内部。故需把新线性区放在vma
线性区的前面,所以,函数把字段new->vm_end
和vm->vm_start
都赋值为addr
。如定义了新线性区的
open
,执行它把新线性区描述符链接到线性区链表
mm->mmap
和红黑树mm->mm_rb
,函数还要根据线性区vma
的最新大小对红-黑树进行调整返回0
unmap_region:
作用:
遍历线性区链表并释放它们的页框
参数:
内存描述符指针
mm
,指向第一个被删除线性区描述符的指针
vma
,指向进程链表中vma前面的线性区的指针
prev
,地址
start
,地址
end
。
步骤:
调
lru_add_drain
调
tlb_gather_mmu
初始化每CPU
变量mmu_gathers
。mmu_gathers
依赖于体系结构:通常该变量应存放成功更新进程页表项所需的所有信息。在80x86
体系结构中,tlb_gather_mmu
只是简单地把内存描述符指针mm
的值赋给本地CPU
的mmu_gathers
把
mmu_gathers
变量的地址存在局部变量tlb
调
unmap_vmas
扫描线性地址空间的所有页表项:如只有一个有效CPU
,函数就调free_swap_and_cache
反复释放相应页框。否则, 函数就把相应页描述符的指针保存在局部变量mmu_gathers
调
free_pgtables(tlb, prev, start, end)
回收上一步已清空的进程页表调
tlb_finish_mmu(tlb, start, end)
结束unmap_region
的工作。
tlb_finish_mmu(tlb, start, end):
调
flush_tlb_mm
刷新TLB
在多处理器系统中,调
free_pages_and_swap_cache
释放页框,这些页框的指针已经集中存放在mmu_gather
中了。
do_munmap: 第一阶段,扫描进程所拥有的线性区链表,把包含在进程地址空间的线性地址区间中的所有线性区从链表中解除链接; 第二阶段,更新进程的页表,把第一阶段找到并标识出的线性区删除。
步骤如下:
对参数值检查。
确定要删除的线性地址区间之后第一个线性区
mpnt
位置,如有这样的线性区
mpnt = find_vma_prev(mm, start, &prev); 1
如没这样的线性区,也没与线性区间重叠的线性区,就什么都不做
end = start + len; if(!mpnt || mpnt->vm_start >= end) return 0; 123
如线性区的起始地址在线性区
mpnt
内,就调split_vma
把线性区mpnt
分成两个较小的区:一个区在线性地址区间外,另一个在区间内
if(start > mpnt->vm_start) { if(split_vma(mm, mpnt, start, 0)) return -ENOMEM; prev = mpnt; }
更新局部变量prev
,以前它存储的是指向线性区mpnt
前面一个线性区的指针,现在让它指向mpnt
,即指向线性地址区间外部的那个新线性区。这样prev
仍指向要删除的第一个线性区前面的那个线性区
如线性区的结束地址在一个线性区内部,就再次调
split_vma
把最后重叠的那个线性区划分成两个较小的区:一个在线性地址区间内,另一个在区间外
last = find_vma(mm, end); if(last && end > last->vm_start) { if(split_vma(mm, last, start, end, 1)) return -ENOMEM; } 123456
更新
mpnt
值,使它指向线性地址区间的第一个线性区。如prev为NULL,就从mm->mmap
获得第一个线性区的地址
mpnt = prev ? prev->vm_next : mm->mmap; 1
调
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; 123456789101112
要删除的线性区的描述符存放在一个排序好的链表中,局部变量mpnt
指向该链表的头
获得
mm->page_table_lock
调
unmap_region
清除与线性地址区间对应的页表项并释放相应的页框
unmap_region(mm, mpnt, prev, start, end); 1
释放
mm->page_table_lock
释放
7
步建立链表时收集的线性区描述符
do { struct vm_area_struct* next = mpnt->vm_next; unmap_vma(mm, mpnt); mpnt = next; } while(mpnt != NULL); 123456
对在链表中的所有线性区调unmap_vma
,它本质上执行下述:
更新
mm->total_vm
和mm->locked_vm
执行内存描述符的
mm->unmap_area
。根据进程线性区的不同类型可选择arch_unmap_area
或arch_unmap_area_topdown
中的一个来实现mm->unmap_area
。如必要,在两种情况下都要更新mm->free_area_cache
。调线性区的
close
如线性区是匿名的,则函数把它从
mm->anon_vma
所指向的匿名线性区链表中删除调
kmem_cache_free
释放线性区描述符返回
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
信号给current
,force_sig_info
确信进程不忽略或阻塞SIGSEGV
信号,并通过info
局部变量传递附加信息的同时把该信号发送给用户态进程;info.si_code
字段已被置为SEGV_MAPERR
或置为SEGV_ACCERR
; 如异常发生在内核态(error_code
的第2
位被清0
),仍有两种可选的情况:
异常的引起是由于把某个线性地址作为系统调用的参数传递给内核
异常是因一个真正的内核缺陷所引起
no_context: if((fixup = search_exception_table(regs->eip)) != 0) { regs->eip = fixup; return; } 123456
在第一种情况中,代码跳到一段"修正代码"处。这段代码的典型操作是向当前进程发
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_MINOR
或VM_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_alloc
和pmd_alloc
分别分配一个新的页上级目录和页中间目录;如需要,调pte_alloc_map
分配一个新的页表;如这两步都成功, pte
局部变量所指向的页表项就是引用address
的表项。然后调handle_pte_fault
检查address
地址所对应的页表项,并决定如何为进程分配一个新页框:
如被访问的页不存在,即这个页还没被存放在任何一个页框中,则,内核分配一个新的页框并适当地初始化。这种技术称为请求调页
如被访问的页存在但标记为只读,即它已经被存放在一个页框中,则内核分配一个新的页框,并把旧页框的数据拷贝到新页框来初始化它的内容。这种技术称为写时复制
请求调页:
它把页框的分配推迟到不能再推迟为止。即一直推迟到进程要访问的页不在RAM
中,由此引起一个缺页异常;请求调页背后的动机是:进程开始执行时并不访问其地址空间中的全部地址。事实上,一部分地址也许永远不会被进程使用。
此外,程序的局部性原理保证了在程序执行的每个阶段,真正引用的进程页只有一小部分。因此,临时用不着的页所在的页框可由其他进程来使用。故对全局分配来说,请求调页是首选的它增加了系统中空闲页框的平均数,从而更好地利用空闲内存;从另一个观点,在RAM
总数保持不变下,请求调页从总体上能使系统有更大的吞吐量
为这一切优点付出的代价是系统额外的开销,由请求调页所引发的每个"缺页"异常必须由内核处理,这将浪费CPU
的时钟周期。局部性原理保证了一旦进程开始在一组页上运行,在接下来相当长的一段时间内它会一直停留在这些页上而不去访问其他的页这样,就可认为"缺页"异常是一种稀有事件。
被访问的页不在主存中,其原因或者是进程从没访问过该页,或是内核已经回收了相应的页框;这两种情况下,缺页处理程序必须为进程分配新的页框;如何初始化这个页框取决于是哪一种页及页以前是否被进程访问过。特殊情况下:
这个页从未被进程访问到且没映射磁盘文件,或页属于线性磁盘文件的映射。内核能识别这些情况,因为页表相应的表项被填充为0,即
pte_none
宏返回1
。页属于非线性磁盘文件的映射。内核能识别这种情况,因为
Present
标志被清0
,且Dirty
被置1
。即pte_file
返回1
进程已访问过这个页,但其内容被临时保存在磁盘上。内核能识别这种情况,因为相应的表项没被填充为
0
,但Present
,Dirty
被清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
。因此,可能的情况是:
vma->vm_ops->nopage
字段不为NULL
。此情况下,线性区连续映射磁盘文件。nopage
指向装入页的函数。(用磁盘文件内容填充页框。)vma->vm_ops
为NULL
或vma->vm_ops->nopage
为NULL
。这情况下,线性区没映射磁盘文件,即它是一个匿名映射。故do_no_page
调do_anonymous_page
获得一个新的页框。(仅仅分配页框,不填充,或填充0)
if(!vma->vm_ops || !vma->vm_ops->nopage) return do_anonymous_page(mm, vma, page_table, pmd, write_access, address); 12
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_map
和pte_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
系统调用时,内核原样复制父进程的整个地址空间并把复制的那一份分配给子进程。这种行为非常耗时,它需要:
为子进程的页表分配页框
为子进程的页分配页框
初始化子进程的页表
把父进程的页复制到子进程相应的页中
现在的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_page
。do_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_page
把old_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); 1234567
lru_cache_add_active
把新页框插入到与交换相关的数据结构中。最后,do_wp_page
把old_page
的使用计数器减少两次。第一次减少是取消复制页框内容之前进行的安全性增加,第二次的减少是反映当前进程不再拥有该页框这一事实
处理非连续内存区访问:
内核在更新非连续内存区对应的页表项时是非常懒惰的。事实上,vmalloc
和vfree
只把自己限制在更新主内核页表。一旦内核初始化阶段结束,任何进程或内核线程便都不直接使用主内核页表。因此,考虑内核态进程对非连续内存区的第一次访问,当把线性地址转换为物理地址时,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_contex
t处。否则,就把主目录项复制到进程页中间目录的相应项中。随后,对主页表项重复上述整个操作
缺页异常主程序
如前,Linux的缺页异常处理程序必须区分以下两种情况: 由编程错误所引起的异常, 由引用属于进程地址空间但还尚未分配物理页框的页所引起的异常。
线性区描述符可让缺页异常处理程序非常有效的完成它的工作。do_page_fault
是80x86
上的缺页异常中断服务程序,它把引起缺页的线性地址和当前进程的线性区相比较,从而能选择适当方法处理这个异常。
if(地址属于进程的地址空间) if(访问类型与线性区的访问权限匹配) 合法访问。分配一个新的页面 else 非法访问。发送一个SIGSEGV信号 else if(异常发生在用户态) 非法访问,发送一个SIGSEGV信号 else 内核错误,杀死进程
实际中,情况更复杂。因为缺页处理程序必须处理多种分得更细的特殊情况,它们不宜在总体方案中列出来,还必须区分许多种合理的访问。标识符vmalloc_fault
,good_area
,bad_area
和no_context
是出现在do_page_fault
中的标记,它们有助于你理清流程图中的块与代码中特定行之间的关系。
do_page_fault
接收参数:
pt_regs
结构的地址regs
,结构包含当异常发生时的微处理器寄存器的值三位的
error_code
,当异常发生时由控制单元压入栈中。这些位有以下含义:如第
0
位被清0
,则异常由访问一个不存在的页引起,否则,如第0
位被设置,则异常由无效的访问权限引起如第
1
位被清0
,则异常由读访问或执行访问所引起;如该位被设置,则异常由写访问所引起如第
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
首先检查引起缺页的线性地址是否属于第4
个GB
:
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
的值
内核正在执行中断处理程序或可延迟函数
内核正在禁用内核抢占的情况下执行临界区代码,如缺页的确发生在中断处理程序,可延迟函数,临界区,或内核线程中。
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;
如vma
为NULL
,说明address
之后没线性区,因此这个错误的地址肯定是无效的,另一方面,如在address
之后结束的第一个线性区包含address
,则函数跳到标记为good_area
的代码处。
如两个if
都不满足,函数已确定address
没包含在任何线性区中。可它还必须执行进一步的检查,由于这个错误地址可能是由push
或pusha
指令在进程的用户态堆栈上的操作所引起的。解释下栈如何映射到线性区上的。 每个向低地址扩展的栈所在的区,它的VM_GROWSDOWN
标志被设置,这样,当vm_start
字段的值可能被减小的时候,而vm_end
保持不变。这种线性区的边界包括,但不严格限定用户态堆栈当前的大小。这种细微差别主要基于:
线性区的大小是4KB的倍数,栈的大小是任意的
分配给一个线性区的页框在这个线性区被删除前永远不被释放。尤其是,一个栈所在线性区的
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.创建进程的地址空间
之前的clone
,fork
,vfork
已经提到,当创建一个新的进程时内核调copy_mm
,这个函数通过建立新进程的所有页表和内存描述符来创建进程的地址空间。通常,每个进程有自己的地址空间,但轻量级进程可通过调clone
来创建。这些轻量级进程共享同一地址空间,即允许它们对同一组页进行寻址。
按前面写时复制,传统的进程继承父进程的地址空间,只要页是只读的,就依然共享它们,当其中一个进程试图对某个页写时,这个页就被复制一份。一段时间后,所创建的进程通常获得与父进程不一样的完全属于自己的地址空间。
轻量级进程使用父进程的地址空间,Linux
实现轻量级进程很简单,即不复制父进程地址空间,创建轻量级进程比创建普通进程相应快的多,且只要父进程和子进程谨慎地协调它们的访问,就认为页的共享是有益的。如通过clone
已经创建了新进程且flag
参数的CLONE_VM
被设置,则copy_mm
把父进程地址空间给子进程
if(clone_flags & CLONE_VM) { atomic_inc(¤t->mm->mm_users); spin_unlock_wait(¤t->mm->page_table_lock); tsk->mm = current->mm; tsk->active_mm = current->mm; return 0; }
如其他CPU
持有进程页表自旋锁,就调spin_unlock_wait
保证在释放锁前,缺页处理程序不会结束。
实际上,这个自旋锁除了保护页表外,还需禁止创建新的轻量级进程,因为它共享current->mm
描述符。
如没设置CLONE_VM
,copy_mm
就需创建一个新的地址空间,这个函数分配一个新的内存描述符,把它的地址存放在新进程描述符tsk
的mm
中,并把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_struc
t线性区描述符,把复制品插入到子进程的线性区链表和红-黑树
在插入一个新的线性区描述符后,如需要,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_done
和mm->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); 1234567
最后,调mmput
释放局部描述符表,线性区描述符,页表。因为,exit_mm
已经递增了主使用计数器,所以并不释放内存描述符本身。当要把正在被终止的进程从本地CPU
撤销时,将由finish_task_switch
释放内存描述符
堆的管理
每个Unix
进程都有一个特殊的线性区,这个线性区就是堆,堆用于满足进程的动态内存请求。内存描述符的start_brk
和brk
分别限定了这个区的开始地址,结束地址。
进程可用下面API来请求和释放动态内存: malloc(size)
,分配成功时,返回所分配内存单元第一个字节的线性地址 calloc(n, size)
,请求含n
个大小为size
的元素的一个数组。分配成功时,数组元素初始化为0
,返回首个元素线性地址 realloc(pte, size)
,分配新线性区域,将pte老区域内容拷贝到新区域起始部分 free(addr)
,释放由malloc
或calloc
分配的起始地址为addr
的线性区 brk(addr)
,直接修改堆的大小,addr
指定current->mm->brk
新值,返回值是线性区新的结束地址 sbrk(incr)
,incr指定是增加还是减少以字节为单位的堆大小
brk
是唯一以系统调用的方式实现的函数。 其他所有函数都是使用brk
和mmap
系统调用实现的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_brk
调do_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_brk
比do_mmap
稍快,因为前者假定线性区不映射磁盘上的文件,从而避免了检查线性区对象的几个字段
Reference
感谢:深入理解Linux内核-进程-进程调度_sched_setscheduler-CSDN博客大佬,帮我省了码很多字的烦恼()