深入理解 Linux 内核—进程地址空间

释放双眼,带上耳机,听听看~!

讲述:
进程是怎样看待动态内存的。
进程空间的基本组成。
缺页异常处理程序在推迟给进程分配页框中所起的作用。
内核怎样创建和删除进程的整个地址空间。
与进程的地址空间管理有关的 API 和系统调用。

进程的地址空间

进程的地址空间由允许进程使用的全部线性地址组成。
每个进程看到的线性地址集合是不同的。
内核可通过增加或删除某些线性地址区间来动态地修改进程的地址空间。

内核通过线性区来表示线性地址区间,由起始线性地址、长度和一些访问权限描述。
为效率起见,起始地址和长度都必须是 4096 的倍数,这样线性区的数据就可以完全填满分配给它的页框。
下面是获得新线性区的一些典型情况:

  • 用户在控制台输入一条命令时,shell 进程创建一个新进程区执行该命令,一组线性区会分配给新进程。
  • 正在运行的进程可能会装入一个完全不同的程序,进程标识符保持不变,但该程序使用的线性区会替换。
  • 正在运行的进程可能对一个文件(或它的一部分)执行”内存映射“,内核会给该进程分配一个新的线性区来映射该文件。
  • 进程可能持续向它的用户态堆栈增加数据,直到映射该堆栈的线性区用完,这时,内核也许会扩展该线性区的大小。
  • 进程可能创建一个 IPC 共享线性区来与其他何作进程共享数据,这时,内核会给该进程分配一个新的线性区。
  • 进程可能通过调用类似 malloc() 的函数扩展自己的动态区(堆),这时,内核可能决定扩展分配给该堆的线性区。

确定一个进程当前所拥有的线性区(即进程的地址空间)是内核的基本任务,因为可让缺页异常处理程序有效地区分两种不同的无效线性地址:

  • 由编程错误引发的无效线性地址。
  • 由缺页引发的无效线性地址:即使该线性地址属于进程的地址空间,但对应于该地址的页框仍有待分配。

内存描述符

内存描述符包含与进程地址空间有关的全部信息,该结构类型为 mm_struct,进程描述符的 mm 字段指向它。

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

mm_users 字段存放共享 mm_struct 数据结构的轻量级进程的个数。
mm_count 字段是内存描述符的主使用计数器,值为 0 时,解除该内存描述符。
mm_users 次使用计数器中的所有用户在 mm_count 中值作为一个单位。

如果内核向确保内存描述符在一个长操作的中间不被释放,应该增加 mm_users 字段而不是 mm_count 字段的值,最终结果是相同的。

mm_alloc() 获得一个新的内存描述符。
由于内存描述符被保存在 slab 分配器高速缓存中,因此,mm_alloc() 调用 kmem_cache_alloc() 初始化新的内存描述符,并将 mm_count 和 mm_users 都设为 1。

mmput() 递减内存描述符的 mm_users 字段。
如果 mm_users 变为 0,释放局部描述符表、线性区描述符、由内存描述符所引用的页表,并调用 mmdrop()。
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 变量。

线性区

vm_area_struct 对象实现线性区。

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

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

线性区数据结构

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

内存描述符的 map_count 字段存放所有进程所拥有的线性区数目。
默认,一个进程最多可拥有 65536 个不同的线性区,可通过 /proc/sys/vm/max_map_count 文件修改该限定值。

但是,当进程的线性区非常少时,比如一二十个,使用链表才比较法方便,为提高效率,存放进程的线性区时,Linux 既使用了链表,也使用了红黑树。
这两种数据结构包含指向同一线性区描述符的指针,当插入或删除一个线性区描述符时,内核通过红黑树搜索前后元素,并用搜索结果快速更新链表而不用扫描链表。

红黑树的首部由内存描述符的 mm_rb 字段指向。
任何线性区对象都在在类型为 rb_node 的 vm_rb 字段中存放节点颜色以及指向双亲、左孩子和右孩子的指针。

一般,红黑树用来确定含有指定线性地址的线性区,而链表通常用于扫描整个线性区集合。

线性区访问权限

页和线性区之间的关系:每个线性区都由一组号码连续的页构成。

与页有关的标志:

  • 由 80×86 硬件用来检查能否指向所请求的寻址类型,如 Read/Write,Present 或 User/Supervisor。
  • 由 Linux 用于许多不同的目的,flags 字段中的一组标志。
  • 与线性区的页相关的标志,存放在 vm_area_struct 描述的 vm_flags 字段。一些标志给内核提供有关该线性区全部页的信息,如包含有什么内容,进程访问每个页的权限是什么。另外的标志描述线性区自身,如应如何增长。

线性区描述符所包含的页访问权限可任意组合。
页访问权限表示何种类型的访问应该产生一个缺页异常。

页表标志的初值存放在 vm_area_struct 描述符的 vm_page_prot 字段。

不能把线性区的访问权限直接转换成页保护位,因为

  • 某些情况下,即使 vm_flags 字段允许对该页访问,但访问时还是会产生一个缺页异常,如”写时复制“。
  • 80×86 处理器的页表仅有两个保护位,即 Read/Write 和 User/Supervisor 标志。线性区的页的 User/Supervisor 标志总为 1,因为用户态进程必须总能访问该其中的页。
  • 启用 PAE 时,所有 64 位页表项支持 NX 标志。

如果内核没有被编译成 PAE,采用以下规则克服 80×86 微处理器的硬件限制:

  • 读访问权限总是隐含着执行访问权限,反之亦然。
  • 写访问权限总是隐含着读访问权限。

但如果内核被编译成支持 PAE,且 CPU 有 NX 标志,采用不同的规则:

  • 执行访问权限总是隐含着读访问权限。
  • 写访问权限总是隐含着读访问权限。

根据以下规则精简由读、写、执行核共享访问权限的 16 种可能组合:

  • 如果页具有写和共享两种访问权限,Read/Write 位被设置为 1。
  • 如果页具有读或执行访问权限,但没有写或共享访问权限,则 Read/Write 位被清 0。
  • 如果支持 NX 位,且页没有执行访问权限,则把 NX 位设置为 1.
  • 如果页没有任何访问权限,Present 位被清 0,以便每次访问都产生一个缺页异常。但为了与真正的页框不存在的情况区分,Page size 置 1。

访问权限的每种组合所对应的精简后的保护位存放在元素个数为 16 的 protection_map 数组中。

线性区的处理

对线性区描述符进行操作的底层函数可被看作简化了的 do_map() 和 do_unmap()。
但函数所处的层次更高一些,它们的参数不是线性区描述符,而是一个线性地址区间的起始地址、长度和访问权限。

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

参数:

  • 进程内存描述符的地址 mm
  • 线性地址 addr

它查找第一个 vm_end 字段大于 addr 的线性区,并返回该线性区描述符的地址。

内存描述符的 mmap_cache 字段保存进程最后一次引用线性区的描述符地址。
该附加字段时为了减少查找一个给定线性地址所在线性区而花费的时间。


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
1// 函数一开始就检查 mmap_cache 所指定的线性区是否包含 addr
2// 如果是,就返回该线性区描述符的指针
3vma = mm->mmap_cache;
4if(vma && vma->vm_end > addr && vma->vm_start <= adddr)
5   return vma;
6
7// 否则,必须扫描进程的线性区,并在红黑树中查找线性区
8rb_node = mm->mm_rb.rb_rbnode;
9vma  = NULL;
10while(rb_node)
11{
12  // 从指向红黑树中的一个节点的指针导出相应线性区描述符的地址
13  vma_tmp = rb_entry(rb_node, struct vm_area_struct, vm_rb);  
14
15  if(vmap_tmp->vm_end > addr)
16  {
17      vma = vma_tmp;
18      if(vma_tmp->vm_start <= addr)
19          break;
20      rb_node = rb_node->rb_left;
21  }
22  else
23      rb_node = rb_node->rb_right;
24}
25if(vma)
26  mm->mmap_cache = vma;
27return vma;
28
29

find_vma_prev() 与 find_vma() 类似,但它把函数选中的前一个线性区描述符的指针赋给附加字段 ppre。

最后,find_vma_prepare() 确定新叶子节点在与给定线性地址对应的红黑树中的位置,并返回前一个线性区的地址和要插入的叶节点的父节点地址。

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

查找与给定的线性地址区间重叠的第一个线性区。

参数:

  • mm 参数指向进程的内存描述符。

  • 线性地址 start_addr 和 end_addr 指定该区间。


1
2
3
4
5
6
7
1// 如果返回一个有效的地址,但所找到的线性区位于给定线性区之后,vma 就被置为 NULL
2vma = find_vma(mm, start_addr);
3if(vma && end_addr <= vma->vmstart)
4   vma = NULL;
5return vma;
6
7

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

搜查进程的地址空间以找到一个可以使用的线性地址区间。

参数:

  • len,指定区间的长度
  • addr,从哪个地址开始查找

如果查找成功,返回新区间的起始地址;否则,返回错误码 -ENOMEM。

addr 不等于 NULL 时,检查 addr 是否在用户态空间,并与页边界对齐。
根据线性地址区间是用于文件内存映射,还是匿名内存映射,调用两个方法:

  • 前一种情况下,执行 get_unmapped_area 文件操作。
  • 后一种情况下,执行内存描述符的 get_unmapped_area 方法。

根据进程线性区类型,由 arch_get_unmapped_area() 或 arch_get_unmapped_area_topdown() 实现 get_unmapped_area方法。
通过调用 mmap(),每个进程可能获得两种不同形式的线性区:一种从线性地址 0x40000000 向高端地址增长,另一种从用户态堆栈开始向低端地址增长。

arch_get_unmapped_area() 分配从低端地址向高端地址的线性区:


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
1 // 检查区间的长度是否在用户态下线性地址区间的限长 TASK_SIZE 之内
2if(len > TASK_SIZE)
3   return -ENOMEM;
4  
5addr = (addr + 0xfff) & 0xfffff000;  // 调整为 4KB 的倍数
6if(addr && addr + len <= TASK_SIZE)
7{
8   vma = find_vma(current->mm, addr);  // 试图从 addr 开始分配区间
9
10  // 找到一个足够大的空闲区,返回 addr
11  if(!vma || addr + len <= vma->vm_start)
12      return addr;
13}
14
15// addr == NULL 或前面的搜索失败
16// 扫描用户态线性地址空间以查找一个可以包含新区的足够大的线性地址范围
17// 但任何已有的线性区都不包含该地址范围
18// 为提高搜索速度,从最近被分配的线性区后面的地址开始
19// 把内存描述符字段 mm->free_area_cache 初始化为用户态线性地址空间的三分之一(通常为 1GB)
20// 并在以后创建新线性区时对齐更新
21// 用户态线性地址空间的三分之一是为有预定义起始线性地址的线性区保留的
22start_addr = addr = mm->free_area_cache;
23for(vma = find_vma(current->mm, addr); ; vma = vma->vm_next)
24{
25  // 如果所请求的区间大于待扫描的线性地址空间部分
26  // 就从用户态地址空间的三分之一处重新搜索
27  if(addr + len > TASK_SIZE)
28  {
29      // 如果已经完成第二次搜索,返回 -ENOMEM
30      if(start_addr == (TASK_SIZE/3 + 0xfff) & 0xfffff000)
31          return -ENOMEM;
32         
33      start_addr = addr = (TASK_SIZE/3 + 0xfff) & 0xfffff000;
34      vma = find_vma(current->mm, addr);
35  }
36 
37  // 找到一个足够大的空闲区,返回 addr
38  if(!vma || addr + len <= vma->vm_start)
39  {
40      mm->free_area_cache = addr + len;
41      return addr;
42  }
43
44  // 刚刚扫描过的线性区后面的空闲区太小,继续考虑下一个线性区
45  addr = vma->vm_end;
46}
47
48

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

在线性区对象链表和内存描述符的红黑树中插入一个 vm_area_struct 结构。

参数:

  • mm 指定进程描述符地址
  • vmp 指定要插入的 vm_area_struct 对象的地址。线性区对象的 vm_start 和 vm_end 字段已经初始化过。

调用 find_vma_prepare() 在红黑树 mm->mm_rb 中查找 vma 应该位于何处。

然后,insert_vm_struct() 调用 vma_link() 执行以下操作:

  1. 在 mm->mmap 指向的链表中插入线性区。
  2. 在红黑树 mm->mmrb 中插入线性区。
  3. 如果线性区是匿名的,就把它插入以相应的 anon_vma 数据结构为头节点的链表中。
  4. mm->map_count++;

如果线性区包含一个内存映射文件,vma_link() 执行相应任务。

__vma_unlink() 参数:

  • 内存描述符地址 mm
  • 两个线性区对象地址 vma 和 prev,都属于 mm。

__vma_link() 从内存描述符链表和红黑树中删除 vma,如果 mm->mmap_cache 指向刚被删除的线性区,还需对 mm->mmap_cache 更新。

分配线性地址区间

do_mmap() 为当前进程创建并初始化一个新的线性区。
分配成功后,可与进程已有的其他线性区进行合并。

参数:

  • file、offset,如果新的线性区将把一个文件映射到内存,则使用文件描述符指针 file 和文件偏移量 offset
  • addr,从何处查找一个空闲的区间
  • len,线性地址区间的长度
  • prot,指定线性区所包含页的访问权限
  • flag,指定线性区的其他标志

do_mmap() 对 offset 进行初步检查,然后执行 do_mmap_pgoff()。
假设新的线性地址区间映射的不是磁盘文件,这里仅对匿名线性区的 do_mmap_pgoff() 函数进行说明。

  1. 检查参数的值是否正确,所提的请求是否能被满足,如果不满足,则返回一个负值。如果线性区地址区间的长度为 0,则函数不执行任何操作就返回。

尤其检查以下不能满足请求的条件:

  • 线性地址区间的长度为 0 或包含的地址大于 TASK_SIZE。
  • 进程已经映射了过多的线性区,因此 mm 内存描述符的 map_count 字段的值超过了允许的最大值。
  • flag 参数指定新线性地址区间的页必须被锁在 RAM 中,但不允许进程创建上锁的线性区,或者进程加锁页的总数超过了保存在进程描述符中的阈值 signal->rlim[RLIMIT_MEMLOCK].rlim_cur。
  1. get_unmapped_area() 获得新线性区的线性地址区间。

  2. 通过将存放在 prot 和 flags 参数中的值进行组合来计算新线性区描述符的标志:


1
2
3
4
5
1vm_flags = calc_vm_prot_bits(prot, flags) | calc_vm_flag_bits(prot, flags) | mm->def_flags | VM_MAYREAD | VM_MAYWRITE | VM_MAYEXEC;
2if(flags & MAP_SHARED)
3   vm_flags |= VM_SHARED | VM_MAYSHARE;
4
5
  1. find_vma_prepare() 确定线性区的对象的位置,应该位于新区间之前,以及新线性区在红黑树中的位置。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
1for(;;)
2{
3   vma = find_vma_prepare(mm, addr, &prev, &rb_link, &rv_parent);
4   if(!vma || vma->vm_start >= addr + len)  
5       break;
6
7   // 如果找到的线性区位于新区间结束地址之前
8   // 说明与新区间存在重叠的线性区
9   // 删除新的区间
10  if(do_munmap(mm, addr, len))  
11      return -ENOMEM;
12}
13
14
  1. 检查插入新的线性区是否导致进程地址空间的大小 (mm->total_vm<<PAGE_SHIFT)+len 超过了进程描述符 signal->rlim[RLIMIT_AS].rlim_cur 字段中的阈值。
  2. 如果 flags 参数没有设置 MAP_NORESERVE 标志,且新的线性区包含私有可写页,且没有足够的空闲页框,返回错误码 -ENOMEM;这最后一个检查由 security_vm_enough_memory() 实现。
  3. 如果新区间是私有的(没有设置 VM_SHARED),且映射的不是磁盘上的一个文件,则调用 vma_merge() 检查前一个线性区是否以此种方式扩展以包含新的区间。

前一个线性区必须前一个线性区必须与在 vm_flags 局部变量中存放标志的那些线性区具有完全相同的标志。
如果前一个线性区可以扩展,那么 vma_merge() 将它与随后的线性区合并(发生在新区间填充两个线性区之间的空洞,且三个线性区具有全部相同的标志时)。
扩展前一个线性区成功则跳到第 12 步。

  1. 调用 slab 分配函数 kem_cache_alloc() 为新的线性区分配一个 vm_area_struct 数据结构。

  2. 初始化 vma 指向的新的线性区对象:


1
2
3
4
5
6
7
8
9
10
11
12
13
1vma-&gt;vm_mm = mm;
2vma-&gt;vm_start = addr;
3vma-&gt;vm_end = addr + len;
4vma-&gt;vm_flags = vm_flags;
5vma-&gt;vm_page_prot = protection_map[vm_flags &amp;  0x0f];
6vma-&gt;vm_ops = NULL;
7vma-&gt;vm_pgoff = pgoff;
8vma-&gt;vm_file = NULL;
9vma-&gt;vm_private_data = NULL;
10vma-&gt;vm_next = NULL;
11INIT_LIST_HEAD(&amp;vma-&gt;shared);
12
13
  1. 如果 MAP_SHARED 标志被设置(以及新的线性区不映射磁盘上的文件),则该线性区是一个共享匿名区:调用 shmem_zero_setup() 对它进行初始化。共享匿名区主要用于进程通信。

  2. 调用 vma_link() 将新线性区插入到线性区链表和红黑树中。

  3. 增加存放在内存描述符 total_vm 字段中的进程地址空间的大小。

  4. 如果设置了 VM_LOCKED 标志,调用 make_pages_present() 连续分配线性区的所有页,并把它们锁在 RAM 中:


1
2
3
4
5
6
7
1if(vm_flags &amp; VM_LOCKED)
2{
3   mm-&gt;locked_vm += len &gt;&gt; PAGE_SHIFT;
4   make_pages_present(addr, addr + len);
5}
6
7

make_pages_present() 调用 get_user_pages():


1
2
3
4
1write = (vma-&gt;vm_flags &amp; VM_WRITE) != 0;
2get_user_pages(current, current-&gt;mm, addr, len, write, 0, NULL, NULL);
3
4
  • get_user_pages() 在 addr 和 addr_len 之间的页的所有起始线性地址上循环;对于每个页,调用 follow_page() 检查在当前页表中是否有到物理页的映射。如果不存在这样的物理页,则 get_user_pages() 调用 handle_mm_fault() 分配一个页框并根据内存描述符的 vm_flags 字段设置它的页表项。
  1. 返回新线性区的地址。

总结:对线性区间进行检查,获得,检查,描述,得到插入位置,检查,扩展,插入,更新,决定是否锁在 RAM 中。

释放线性地址区间

do_munmap() 从当前进程的地址空间中删除一个线性地址区间。要删除的区间可能是一个线性区,可能是线性区的一部分,可能是多个线性区。

参数:

  • 进程内存描述符的地址 mm
  • 地址区间的起始地址 start 及其长度 len

do_munmap() 函数

第一阶段,16 步,扫描进程所拥有的线性区链表,并删除包含在进程地址空间的线性地址区间。
第二阶段,7
12 步,更新进程的页表,并删除在第一阶段找到的线性区。
会用到后面要说明的 split_vma() 和 unmap_region() 函数。

执行如下步骤:

  1. 对参数进行初步检查:如果线性地址区间所含的地址大于 TASK_SIZE,或 start 不是 4096 的倍数,或线性地址区间的长度为 0,则返回错误代码 -EINVAL。

  2. 确定要删除的线性地址区间之后的第一个线性地址区 mpnt 的位置:


1
2
3
1mpnt = find_vma_prev(mm, start, &amp;prev);
2
3
  1. 如果 mpnt 不存在,或与线性地址区间不重叠,则什么都不做,因为该区间上没有线性区:


1
2
3
4
5
1end = start + len;
2if(!mpnt || mpnt-&gt;vm_start &gt;= end)
3   return 0;
4
5
  1. 如果线性区的起始地址在 mpnt 内,就调用 split_vma() 将 mpnt 划分成两个较小的区:一个区在线性地址区间外部,另一个在内部。


1
2
3
4
5
6
7
8
1if(start &gt; mpnt-&gt;vm_start)
2{
3   if(split_vma(mm, mpnt, start, 0))
4       return -ENOMEM;
5   prev = mpnt;  // prev 指向要删除的第一个线性区前面的那个线性区
6}
7
8
  1. 如果线性区的结束地址在一个线性区内部,再次调用 split_vma() 将最后重叠的那个线性区同样划分成两个较小的区。


1
2
3
4
5
6
7
8
1last  = find_vma(mm, end);
2if(last &amp;&amp; end &gt; last-&gt;vm_start))
3{
4   if(split_vma(mm, last, start, end, 1))
5       return -ENOMEM;
6}
7
8
  1. 更新 mpnt 的值,使其指向线性地址区间的第一个线性区。


1
2
3
1mpnt = prev ? prev-&gt;vm_next : mm-&gt;mmap;
2
3
  1. 调用 detach_vmas_to_be_unmapped() 从进程的线性地址空间中删除位于线性地址区间中的线性区。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
1vma = mpnt;  // 要删除的线性区的描述符放在一个排好序的链表中,mpnt 指向该链表的头
2insertion_point = (prev ? &amp;prev-&gt;vm_next : &amp;mm-&gt;mmap);
3do
4{
5   rb_erase(&amp;vma-&gt;vm_rb, &amp;mm-&gt;mm_rb);
6   mm-&gt;map_count--;
7   tail_vma = vma;
8   vma = vma-&gt;next;
9}while(vma &amp;&amp; vma-&gt;start &lt; end);
10*insertion_point = vma;
11tail_vma-&gt;vm_next = NULL;
12mm-&gt;map_cache = NULL;
13
14
  1. 获得 mm->page_table_lock 自旋锁。

  2. 调用 unmap_region() 清除与线性地址区间对应的页表项并释放相应的页表:


1
2
3
1unmap_region(mm, mpnt, prev, start, end);
2
3
  1. 释放 mm->page_table_lock 自旋锁。

  2. 释放在第 7 步建立链表时收集的线性区描述符:


1
2
3
4
5
6
7
8
1do
2{
3   struct vm_area_struct *next = mpnt-&gt;vm_next;
4   unmap_vma(mm, mpnt);
5   mpnt = next;
6}while(mpnt != NULL);
7
8
  • 对链表中的所有线性区调用 unmap_vma() 函数,本质上执行下述步骤:

a. 更新 mm->total_vm 和 mm->locked_vm 字段。
b. 执行内存描述符的 mm->unmap_area 方法。根据进程线性区的类型选择 arch_unmap_area() 或 arch_unmap_topdown(),必要时更新 mm->free_area_cache 字段。
c. 调用线性区的 close 方法。
d. 如果线性区是匿名的,则将其从 mm->anon_vma 指向的匿名线性区链表中删除。
e. 调用 kmem_cache_free() 释放线性区描述符。

  1. 成功,返回 0。

split_vma()

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

参数:

  • 内存描述符指针 mm
  • 线性区描述符指针 vma
  • 区间与线性区之间交叉点的地址 addr
  • 表示区间和线性区之间交叉点在区间起始处还是结束处的标志 new_below
  1. 调用 kmem_cache_alloc() 获得线性区描述符 vm_area_struct,并将它的地址存在新的局部变量中,如果没有可用的空闲空间,返回 -ENOMEM。
  2. 用 vma 描述符的字段值初始化新描述符的字段。
  3. 如果标志 new_below 为 0,说明线性地址区间的起始地址在 vma 线性区的内部,因此应将新线性区放在 vma 线性区之后,所以将 new->vm_start = addr; vma->vm_end = addr。
  4. 如果标志 new_below 为 1,说明线性地址区间的结束地址在 vma 线性区的内部,因此应将新线性区放在 vma 线性区之前,所以将 new->vm_start = addr; vma->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_mm() 初始化每 CPU 变量 mmu_gathers。mm_gathers 通常存放更新进程页表项所需的所有信息。80×86 体系结构中,tlb_gather_mmu() 只是把 mm 赋给本地 CPU 的 mm_gathers 变量。
  3. 把 mmu_gathers 变量的地址保存在局部变量 tlb 中。
  4. 调用 unmap_vmas() 扫描线性地址空间的所有页表项:如果只有一个有效 CPU,则调用 free_swap_and_cache() 释放相应页;否则,mm_gathers = 相应页描述符的指针。
  5. free_pgtables(tlb, prev, start, end) 回收在上一步已经清空的进程页表。
  6. tlb_finish_mmu(tlb, start, end) 结束 unmap_region() 的工作,tlb_finish_mmu(tlb, start, end) 执行下面的操作:

a. flush_tlb_mm() 刷新 TLB。
b. 多处理器系统中,free_pages_and_swap_cache() 释放页框,这些页框的指针已经集中存放在 mmu_gather 数据结构中了。1

缺页异常处理程序

Linux 的缺页异常处理程序必须区分以下两种情况:

  • 由编程错误所引起的异常
  • 引用属于进程地址空间但还尚未分配物理页框所引起的异常

线性区描述符可以让缺页异常处理程序非常有效地完成其工作。
do_page_fault() 是 80×86 上的缺页中断服务程序,它将引起缺页的线性地址和当前进程的线性区比较,从而根据图 9-4 所示的方案处理该异常。
实际情况会更复杂一些,如图 9-5 所示。

do_page_fault() 接收以下参数:

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

do_page_fault() 执行下述步骤:

读取引起缺页的线性地址 address


1
2
3
4
5
6
7
8
9
10
11
12
1// 当异常发生时,CPU 将 address 存放在 cr2 控制寄存器中
2asm(&quot;movl %%cr2, %0&quot;:&quot;=r&quot; (address));
3
4// 如果缺页发生之前或 CPU 运行在虚拟 8086 模式时本地中断是可打开的
5// 则函数还需要确保本地中断是可打开的
6if(regs-&gt;eflags &amp; 0x00020200)
7   local_irq_enable();
8
9// 将指向 current 进程描述符的指针保存在 tsk 局部变量中
10tsk = current;
11
12

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


1
2
3
4
5
6
7
8
9
1info.si_code = SEGV_MAPERR;
2if(address &gt;= TASK_SIZE)
3{
4   if(!(error_code &amp; 0x101))
5       goto vmalloc_fault;   // 处理非连续内存访问
6   goto bad_area_nosemaphore;  // 处理地址空间以外的错误地址
7}
8
9

检查异常发生时内核是否正在一些一些关键例程或正在运行内核线程。


1
2
3
4
5
6
7
8
1// in_atomic() 宏等于 1 的情况:
2// 1. 内核正在指向中断处理程序或可延迟函数
3// 2. 内核在禁用内核抢占的情况下执行临界区代码
4// 进程描述符的 mm 字段为 NULL 时,说明为内核线程
5if(in_atomic() || !tsk-&gt;mm)
6   goto bad_area_nosemaphore;
7
8

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


1
2
3
4
5
6
7
8
9
10
11
12
13
1// 如果内核 bug 和硬件故障可被排除,缺页发生时,当前进程还没有获得 mmap_sem 写信号量
2// 但还是要确定一下,真的没有获得该信号量,否则会发生死锁
3if(!down_read_trylock(&amp;tsk-&gt;mm-&gt; mmap_sem))
4{
5   // 缺页时由内核 bug 或严重的故障引起的
6   if((error_code &amp; 4) == 0 &amp;&amp; !search_exception_table(regs-&gt;eip))
7       goto bad_area_nosemaphore;  
8
9   // 获得 mmap_sem 读信号量
10  down_read(&amp;tsk-&gt;mm-&gt;mmap_sem);
11}
12
13

假设获得了 mmap_sem 读信号量,开始搜索错误线性地址所在的线性区。


1
2
3
4
5
6
7
8
1vma = find_vma(tsk-&gt;mm, address);
2if(!vma)  // 说明 address 后面没有线性区,因此这个错误的地址无效
3   goto bad_area;   // 处理地址空间以外的错误地址
4
5if(vma-&gt;vm_start &lt;= address)  // 如果在 address 之后的第一个线性区包含 address
6   goto good_area;   // 处理地址空间内的错误地址
7
8

如果两个“if”条件都不满足,则函数已确定 address 没有包含在任何线性区中,还需进一步检查,因为该错误可能是由 push 或 pusha 指令在进程的用户态堆栈上的操作引起的。

可能是 push 引用了该线性区以外的一个地址(即引用一个不存在的页框)。该异常不是由程序的错误引起的,因此必须由缺页处理程序单独处理。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
1// 向低地址扩展的栈所在的区,它的 VM_GROWSDOWN 标志被设置
2// 这样,当 vm_start 字段的值减少时,vm_end 字段的值保持不变
3if(!(vma-&gt;vm_flags &amp; VM_GROWSDOWN))
4   goto bad_area;   // 处理地址空间以外的错误地址
5
6// 如果线性区的 VM_GROWSDOWN 标志被设置,且异常发生在用户态,且 address &lt; regs-&gt;esp 栈指针
7if(error_code &amp; 4 &amp;&amp; address + 32 &lt; regs-&gt;esp)
8   goto bad_area;  // 处理地址空间以外的错误地址
9
10// 如果地址足够高(在允许范围内)
11// 则检查是否允许进程既扩展栈页扩展它的地址空间
12// 如果一切都可以,将 vma 的 vm_start 字段设为 address,并返回 0;否则,返回 -ENOMEM
13if(expand_stack(vma, address))
14  goto bad_area;
15 
16goto good_area;   // 处理地址空间内的错误地址
17
18

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

如果 address 不属于进程的地址空间,则 do_page_fault() 继续执行 bad_area 标记处的语句。
如果错误发生在用户态,则向 current 进程发生一个 SIGSEGV 信号并结束函数:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
1bad_area:
2up_read(&amp;tsk-&gt;mm-&gt;mmap_sem);
3bad_area_nosemaphore:
4if(error_code &amp; 4)  // 用户模式
5{
6   tsk-&gt;thread.cr2 = address;
7   tsk-&gt;thread.error_code = error_code | (address &gt;= TASK_SIZE);
8   tsk-&gt;thread.trap_no = 14;
9   info.si_signo = SIGSEGV;
10  info.si_errno  = 0;
11  info.si_addr = (void *)address;
12
13  // 确信进程不忽略或阻塞 SIGSEGV 信号
14  // 并通过 info 局部变量传递附加信息的同时发送给用户态进程
15  // info.si_code 字段已被设置为 SEGV_MAPERR 或 SEGV_ACCERR
16  force_sig_info(SIGSEGV, &amp;info, tsk);  
17
18  return;
19}
20
21

如果异常发生在内核态(error_code 的第 2 位被清 0),有两种情况:

  • 异常的引起是由于把某个线性地址作为系统调用的参数传递给内核。
  • 异常是因一个真正的内核缺陷引起的。

区分两种情况:


1
2
3
4
5
6
7
8
1no_context:
2if((fixup = search_exception_table(regs-&gt;eip)) != 0)
3{
4   regs-&gt;eip = fixup;
5   return;
6}
7
8

第一种情况中,代码跳到一段“修正代码”处,向当前进程发送 SIGSEGV 信号,或用一个适当的出错码终止系统调用处理程序。

第二种情况中,函数将 CPU 寄存器和内核态堆栈的所有转储打印到控制台,并输出到一个系统消息缓冲区,方便编程人员定位错误,然后调用 do_exit() 杀死当前进程。

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

如果 addr 地址属于进程的地址空间,则 do_page_fault() 转到 good_area 标记处的语句执行:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
1good_area:
2info.si_code = SEGV_ACCERR;
3write = 0;
4if(error_code &amp; 2)  // 异常由写访问引起
5{
6   // 检查该线性区是否可写
7   // 不可写,跳到 bad_area
8   // 可写, write++
9   if(!(vma-&gt;vm_flags &amp; VM_WRITE))  
10      goto bad_area;  
11  write++;
12}
13else  // 异常由读或执行访问引起
14{
15  // 检查这一页是否已存在于 RAM
16  // 存在时,异常发生是由于进程试图访问用户态下的一个有特权的页框,跳到 bad_area
17  // 不存在时,检查该线性区是否可读或可执行
18  if((error_code &amp; 1) || !(vma-&gt;vm_flags &amp; (VM_READ | VM_EXEC)))  
19      goto bad_area;  
20}
21
22

如果该线性区的访问权限与引起异常的访问类型相匹配,则调用 handle_mm_fault() 分配一个新的页框:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1survive:
2
3// 如果成功地分配给进程一个页框,则返回 VM_FAULT_MINOR 或 VM_FAULT_MAJOR
4ret = handle_mm_fault(tsk-&gt;mm, vma, address, write);  
5if(ret == VM_FAULT_MINOR || ret = VM_FAULT_MAJOR)
6{
7   if(ret == VM_FAULT_MINOR)  // 没有阻塞当前进程的情况下处理了缺页
8       tsk-&gt;min_flt++;  
9   else  // 迫使当前进程睡眠
10      tsk-&gt;maj_flt++;
11  up_read(&amp;tsk-&gt;mm-&gt;mmap_sem);
12  return;
13}
14
15

如果 handle_mm_fault() 返回 VM_FAULT_SIGBUG,则向进程发送 SIGBUS 信号:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
1if(ret == VM_FAULT_SIGBUS)
2{
3do_sigbus:
4   up_read(&amp;tsk-&gt;mm-&gt;mmap_sem);
5   if(!(error_code &amp; 4))  // 内核态
6       goto no_context;
7   tsk-&gt;thread.cr2 = address;
8   tsk-&gt;thread.error_code = error_code;
9   tsk-&gt;thread.trap_no = 14;
10  info.si_signo = SIGBUS;
11  info.si_errno = 0;
12  info.si_code = BUS_ADRERR;
13  info.si_addr = (void *)address;
14  force_sig_info(SIGBUS, &amp;info, tsk);
15}
16
17

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


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
1if(ret == VM_FAULT_OOM)
2{
3out_of_memory:
4   up_read(&amp;tsk-&gt;mm-&gt;mmap_sem);
5   if(tsk-&gt;pid != 1)
6   {
7       if(error_code &amp; 4)  // 用户模式
8           do_exit(SIGKILL);
9       goto no_context;
10  }
11  yield();
12  down_read(&amp;tsk-&gt;mm-&gt;mmap_sem);
13  goto survive;
14}
15
16

handle_mm_fault() 参数:

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

首先检查用来映射 address 的页中间目录和页表是否存在,不存在的话分配。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
1// pgd 局部变量包含了引用 address 的页全局目录项
2pgd = pgd_offset(mm, address);  
3
4spin_lock(&amp;mm-&gt;page_table_lock);
5pud = pud_alloc(mm, pgd, address);  // 分配新的页上级目录
6if(pud)
7{
8   pmd = pmd_alloc(mm, pud, address);  // 分配页中间目录
9   if(pmd)
10  {
11      pte = pte_alloc_map(mm, pmd, address);  // 分配一个新的页表
12      if(pte)
13          return handle_pte_fault(mm, vma, address, wrie_access, pte, pmd);  
14  }
15}
16spin_unlock(&amp;mm-&gt;page_table_lock);
17return VM_FAULT_OOM;
18
19

handle_pte_fault() 检查 address 地址对应的页表项,并决定如何为进程分配一个新页框:

  • 如果被访问的页不存在,即没有存放在任何一个页框中,则内核分配一个新的页框并适当初始化,称为请求调页。
  • 如果被访问的页存在但是标记为只读,即已经被存放在一个页框中,则内核分配一个新的页框,并将旧页框的数据拷贝到新页框来初始化它的内容,称为写时复制。

请求调页

是一种动态内存分配技术,它将页框的分配推迟到进程要访问的页不在 RAM 中时为止,由此引起一个缺页异常。

被访问的页可能不在主存中,原因或者是进程从没访问过该页,或者内核已经回收了相应的页框。

缺页处理程序为进程分配新的页框后,如何初始化该页框取决于哪一种页以及页是否被进程访问过。特殊情况下:

  1. 或者该页从未被进程访问到且没有映射磁盘文件,或者页映射了磁盘文件。

内核识别该种情况的依据:页表相应的表项被填充为 0,即 pte_none 宏返回 1。

  1. 页属于一个非线性磁盘文件映射。

内核识别该种情况的依据:Present 标志被清 0 且 Dirty 标志被置 1,即 pte_file 宏返回 1。

  1. 进程已经访问过该页,但其内容被临时保存在磁盘上。

内核识别该种情况的依据:相应的页表项没被填充为 0,但 Present 和 Dirty 标志被清 0。

handle_pte_fault() 通过检查 address 对应的页表项区分那三种情况:


1
2
3
4
5
6
7
8
9
10
11
12
1entry = *pte;
2if(!pte_present(entry))
3{
4   // 第一种情况
5   if(pte_none(entry))
6       return do_no_page(mm, vma, address, write_access, pte, pmd);
7   if(pte_file(entry))
8       return do_file_page(mm, vma, address, write_access, pte, pmd);
9   return do_swap_page(mm, vma, address, pte, pmd, entry, write_access);
10}
11
12

第 1 种情况下,调用 do_no_page()。有两种方法装入所缺的页,取决于该页是否被映射到一个磁盘文件。
通过检查 vma 线性区描述符的 nopage 字段来确定,如果页被映射到一个文件,nopage 就指向一个函数,该函数将所缺的页从磁盘装入到 RAM。
可能的情况是:

  • vma->vm_ops->nopage != NULL,说明线性区映射了一个磁盘文件,nopage 指向装入页的函数。

  • vma->vm_ops == NULL || vma->ops->nopage == NULL, 说明线性区没有映射磁盘文件,即它是一个匿名映射。因此,调用 do_anonymous_page() 获得一个新的页框:


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
1if(!vma-&gt;vm_ops || !vma-&gt;vm_ops-&gt;nopage)
2   return do_anonymous_page(mm, vma, page_table, pmd, write_access, address);
3
4do_anonymous_page() 分别处理写请求和读请求:
5if(write_access)
6{
7   // pte_unmap 宏释放一种临时内核映射,
8   // 该临时映射映射了在调用 handle_pte_fault() 前由 pte_offset_map 宏所建的页表项的高端内存物理地址
9   // 临时内核映射必须在调用 alloc_page() 之前释放,因为该函数可能阻塞当前进程
10  pte_unmap(page_table);
11
12  spin_unlock(&amp;mm-&gt;page_table_lock);
13 
14  page = alloc_page(GFP_HIGHUSER | __GFP_ZERO);
15 
16  spin_lock(&amp;mm-&gt;page_table_lock);
17  page_table = pte_offset_map(pmd, addr);
18
19  mm-&gt;rss++;  // 记录分配给进程的页框总数
20
21  // 相应的页表项设置为页框的物理地址,该页框被标记为既脏又可写的
22  entry = maybe_mkwrite(pte_mkdirty(mk_pte(page, vma-&gt;vm_page_prot)), vma);
23
24  lru_cache_add_active(page);  // 把新页框插入与交换相关的数据结构
25
26  SetPageReferenced(page);
27  set_pte(page_table, entry);
28  pte_unmap(page_table);
29  spin_unlock(&amp;mm-&gt;page_table_lock);
30 
31  return VM_FAULT_MINOR;
32}
33
34

处理读访问时,可以分配一个现有的称为零页的页,以推迟页框的分配。
零页在内核初始化期间被静态分配,并存放在 empty_zero_page 变量中。

用零页的物理地址设置页表项:


1
2
3
4
5
6
1entry = pte_wrprotect(mk_pte(virt_to_page(empty_zero_page), vma-&gt;vm_page_prot));
2set_pte(page_table, entry);
3spin_unlock(&amp;mm-&gt;page_table_lock);
4return VM_FAULT_MINOR;
5
6

该页标记为不可写,如果进程试图写该页,则激活写时复制机制。

写时复制

第一代 Unix 系统实现了傻瓜式的进程创建,fork() 比较耗时,因为需要:

  • 为子进程的页表分配页框
  • 为子进程的页分配页框
  • 初始化子进程的页表
  • 把父进程的页复制到子进程相应的页中

现在的 Unix 实现了写时复制。
父进程和子进程共享页框。
无论父进程还是子进程何时试图写一个共享的页框,就产生一个异常,这时内核就把该页复制到一个新的页框中并标记为可写。
原来的页框仍然是写保护的,当其他进程试图写入时,内核检查写进程是否是该页框的唯一属主,如果是,就把该页框标记为对该进程是可写的。

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

handle_pte_fault() 确定异常是由访问内存中现有的一个页而引起时,执行以下指令:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
1if(pte_present(entry))  // 如果页是存在的
2{
3   if(write_access)  // 访问权限是写允许的
4   {
5       if(!pte_write(entry))  // 页框是写保护的
6       {
7           return do_wp_page(mm, vma, address, pte, pmd, entry);
8       }
9       entry = pte_mkdirty(entry);
10  }
11  entry = pte_mkyong(entry);
12  set_pte(pte, entry);
13  flush_tlb_page(vma, address);
14  pte_unmap(pte);
15  spin_unlock(&amp;mm-&gt;page_table_lock);
16  return VM_FAULT_MINOR;
17}
18
19

do_wp_page():
首先获取与缺页异常相关的页框描述符。
然后读取页描述符的 _count 字段:如果等于 0(仅有一个进程拥有该页),写时复制就不必进行。
实际上,检查要复杂一些, 为当页插入到交换高速缓存,且当设置了页描述符的 PG_private 标志时,_count 字段也增加。
当写时复制不进行时,就将该页框标记为可写的,以面试图写时引起进一步的缺页异常:


1
2
3
4
5
6
7
1set_pte(page_table, maybe_mkwrite(pte_mkyoung(pte_mkdirty(pte)), vma));
2flush_tlb_page(vma, address);
3pte_unmap(page_table);
4spin_unlock(&amp;mm-&gt;page_table_lock);
5return VM_FAULT_MINOR;
6
7

如果多个进程通过写时复制共享页框,那么函数就把旧页框的内容复制到新分配的页框中。
为避免竞争条件,在开始复制前调用 get_page() 将 old_page 的使用计数加 1:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
1old-&gt;page = pte_page(pte);
2pte_unmap(page_table);
3get_page(old_page);  // old_page 的使用计数加 1
4spin_unlock(&amp;mm-&gt;page_table_lock);
5if(old_page == virt_to_page(empty_zero_page))  // 如果旧页框是零页
6{
7   new_page = alloc_page(GFP_HIGHUSR | __GFP_ZERO);  // 就在分配新页框时将它填充为 0
8}
9else
10{
11  new_page = allock_page(GFP_HIGHUSER);
12  vfrom = kmap_atomic(old_page, KM_USER0);  
13  vto = kmap_atomic(new_page, KM_USER1);
14  copy_page(vto, vfrom);   // 如果旧页框不是零页,复制页框的内容
15  kunmap_atomic(vfrom, KM_USER0);
16  kunmap_atomic(vto, KM_USER0);
17}
18
19

因为页框的分配可能阻塞进程,因此,检查自从函数开始执行以来是否已经修改了页表项。
如果是,新的页框被释放,old_page 的使用计数器被减少,函数结束。

如果所有进展顺利,新页框的物理地址最终被写进页表项,且使相应的 TLB 寄存器无效:


1
2
3
4
5
6
7
8
9
1spin_lock(&amp;mm-&gt;page_table_lock);
2entry = maybe_mkwrite(pte_mkdirty(mk_pte(new_page, vma-&gt;vm_page_prot)), vma);
3set_pte(page_table, entry);
4flush_tlb_page(vma, address);
5lru_cache_add_active(new_page);  // 将新页框插入到与交换相关的数据结构
6pte_unmap(page_table);
7spin_unlock(&amp;mm-&gt;page_table_lock); 
8
9

最后,do_wp_page() 把 old_page 的使用计数器减少两次。
第一次的减少是取消复制页框内容之前进行的安全性增加。
第二次的减少是反映当前进程不再拥有该页框。

处理非连续内存区访问

内核在更新非连续内存区对应的页表项时是非常懒惰的。
vmalloc() 和 vfree() 只把自己限制在更新主内核页表。

一旦内核初始化阶段结束,任何进程或内核线程便都不直接使用主内核页表。
内核态进程对非连续内存区第一次访问时,会产生缺页异常,缺页异常认识该情况,因为异常发生在内核态且产生缺页的线性地址大于 TASK_SIZE。
因此,do_page_fault() 检查相应的主内核页表项:


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
1vmalloc_fault:
2
3// 将 cr3 寄存器中的当前进程页全局目录的物理地址赋给局部变量 pgd_paddr
4asm(&quot;movl %%cr3, %0&quot;:&quot;=r&quot; (pgd_paddr));  
5
6// 将与 pgd_addr 相应的线性地址赋给局部变量 pgd
7pgd = pgd_index(address) + (pgd_t *)__va(pgd_paddr);
8
9// 将主内核页全局目录的线性地址赋给 pgd_k 局部变量
10pgd_k = init_mm.pgd + pgd_index(address);
11
12// 如果产生缺页的线性地址所对对应的主内核页全局目录项为空,跳到 no_context 代码处
13if(!pgd_present(*pgd_k))
14  goto no_context;
15
16// 检查与错误线性地址相对应的主内核页上级目录项
17pud = pud_offset(pgd, address);
18pud_k = pud_offset(pgd_k, address);
19if(!pud_present(*pud_k))
20  goto no_context;
21
22// 检查与错误线性地址相对应的主内核页中间目录项
23pmd = pmd_offset(pud, address);
24pmd_k = pmd_offset(pud_k, address);
25if(!pmd_present(*pmd_k))
26  goto no_context;
27
28// 将主目录项复制到进程页中间目录的相应项
29set_pmd(pmd, *pmd_k);
30pte_k = pte_offset_kernel(pmd_k, address);
31if(!pte_present(*pte_k))
32  goto no_context;
33return;
34
35

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

创建进程的地址空间

clone()、fork() 和 vfork() 创建一个新的进程时,内核调用 copy_mm()。
该函数通过建立新进程的所有页表和内存描述符来创建进程的地址空间。

如果通过 clone() 已经创建了新进程,且 flag 参数的 CLONE_VM 标志被设置,则 copy_mm() 将父进程 current 地址空间给子进程 tsk:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1
2if(clone_flags &amp; CLONE_VM)
3{
4   atomic_inc(&amp;current-&gt;mm-&gt;mm_users);
5
6   // 如果其他 CPU 持有进程页表自旋锁,就保证在释放锁之前,缺页处理程序不会结束
7   // 自旋锁除了保护页表外,还必须进程创建新的轻量进程,因为它们共享 current-&gt;mm 描述符
8   spin_unlock_wait(&amp;current-&gt;mm-&gt;page_table_lock);
9
10  tsk-&gt;mm = current-&gt;mm;
11  tsk-&gt;active_mm = current-&gt;mm;
12  return 0;
13}
14
15

如果没有设置 CLONE_VM 标志,copy_mm() 就必须创建一个新的地址空间。
分配一个新的内存描述符,把它的地址存放在新进程描述符 tsk 的 mm 字段,并把 current->mm 的内容复制到 tsk->mm 中,然后改变新进程描述符的一些字段:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1tsk-&gt;mm = kmem_cache_alloc(mm_cachep, SLAB_KERNEL);
2memcpy(tsk-&gt;mm, current-&gt;mm, sizeof(*tsk-&gt;mm));
3atomic_set(&amp;tsk-&gt;mm-&gt;mm_users, 1);
4atomic_set(&amp;tsk-&gt;mm-&gt;mm_count, 1);
5init_rwsem(&amp;tsk-&gt;mm-&gt;mmap_set);
6tsk-&gt;mm-&gt;core_waiters = 0;
7tsk-&gt;mm-&gt;page_table_lock = SPIN_LOCK_UNLOCKED;
8tsk-&gt;mm-&gt;ioctx_list_lock = RW_LOCK_UNLOCKED;
9tsk-&gt;mm-&gt;ioctx_list = NULL;
10tsk-&gt;mm-&gt;default_kioctx = INIT_KIOCTX(tsk-&gt;mm-&gt;default_kioctx, *tsk-&gt;mm);
11tsk-&gt;mm-&gt;free_area_cache = (TASK_SIZE/3 + 0xfff) &amp; 0xfffff000;
12tsk-&gt;mm-&gt;pgd = pgd_alloc(tsk-&gt;mm);
13tsk-&gt;mm-&gt;def_flags = 0;
14
15

然后调用依赖于体系结构的 init_new_context():对于 80×86,检查当前进程是否拥有定制的局部描述符,如果是,复制一份 current 的局部描述符表,并将其插入 tsk 地址空间。

最后,调用 dup_mmap() 复制父进程的线性区和页表:

  • 将新内存描述符 tsk->mm 插入到内存描述符的全局链表中。
  • 从 current->mm->mmap 指向的线性区开始扫描父进程的线性区链表。
  • 复制遇到的每个 vm_area_struct 线性区描述符
  • 将复制品插入到子进程的线性区链表和红黑树中
  • 插入一个新的线性区描述符后,如果需要,立即调用 copy_page_range() 创建必要的页表来映射该线性区所包含的一组页,并初始化新表的表项,尤其是,与私有的、可写的页所对应的任一页框都标记为对父子进程是只读的。

删除进程的地址空间

进程结束时,内核调用 exit_mm() 释放进程的地址空间:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1// 唤醒在 tsk-&gt;vfork_done 补充原语上睡眠的任一进程
2// 典型的,只有当现有进程通过 vfork() 被创建时,相应的等待队列才会为非空
3mm_release(tsk, tsk-&gt;mm);
4
5// 如果正在被终止的进程是内核线程
6if(!(mm = tsk-&gt;mm))  
7   return;
8
9// 如果正在被终止的进程不是内核线程,就必须释放内存描述符和所有相关的数据结构
10// 首先,检查 mm-&gt;core_waiters 标志是否被置位
11// 如果是,就把内存的所有内容卸载到一个转储文件中
12// 为避免转储文件的混乱,利用 mm-&gt;core_done 和 mm-&gt;core_startup_done 补充原语使共享同一内存描述符 mm 的轻量级进程执行串行化
13down_read(&amp;mm-&gt;mmap_sem);
14
15

接下来,递增内存描述符的主使用计数器,重新设置进程描述符的 mm 字段,并使处理器处于懒惰 TLB 模式:


1
2
3
4
5
6
7
8
9
1atomic_inc(&amp;mm-&gt;mm_count);
2spin_lock(tsk-&gt;alloc_lock);
3tsk-&gt;mm = NULL;
4up_read(&amp;mm-&gt;map_sem);
5enter_lazy_tlb(mm, current);
6spin_unlock(tsk-&gt;alloc_lock);
7mmput(mm);
8
9

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

堆的管理

每个 Unix 进程都拥有一个特殊的线性区—堆,满足进程的动态内存请求。
内存描述符的 start_brk 与 brk 字段分别限定了该区的开始地址和结束地址。

进程调用以下 API 请求和释放动态内存:

  • malloc(size) 请求 size 个字节的动态内存。分配成功则返回所分配内存单元的第一个字节的线性地址。
  • calloc(n, size) 请求含有 n 个大小为 size 的元素的一个数组。分配成功则将数组元素初始化为 0,并返回第一个元素的线性地址。
  • realloc(ptr, size) 改变由 malloc() 或 calloc() 分配的内存区字段的大小。
  • free(addr) 释放由 malloc() 或 calloc() 分配的起始地址为 addr 的线性区。
  • brk(addr) 直接修改堆的大小。addr 指定 current->mm->brk 的新值,返回线性区新的结束地址。
  • sbrk(incr) 类似于 brk(),但 incr 指定是增加还是减少以字节为单位的堆的大小。

brk() 是唯一以系统调用的方式实现的函数,而其他函数是使用 brk() 和 mmap() 系统调用实现的 C 语言库函数。

用户态进程调用 brk() 时,内核执行 sys_brk(addr):


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
1// 首先检验 addr 是否位于进程代码所在的线性区
2// 如果是,立即返回,因为堆不能与进程代码所在的线性区重叠
3mm = current-&gt;mm;
4down_write(&amp;mm-&gt;mmap_sem);
5if(addr &lt; mm-&gt;end_code)
6{
7out:
8   up_write(&amp;mm-&gt;mmap_sem);
9   return mm-&gt;brk;
10}
11
12// 由于 brk() 作用于某一个线性区,分配和释放完整的页
13// 因此,将 addr 的值调整为 PAGE_SIZE 的倍数,然后将调整结果于内存描述符的 brk 字段比较
14newbrk = (addr + 0xfff) &amp; 0xfffff000;
15oldbrk = (mm-&gt;brk + 0xfff) &amp; 0xfffff000;
16if(oldbrk == newbrk)
17{
18  mm-&gt;brk = addr;
19  goto out;
20}
21
22// 如果进程要求缩小堆,则调用 do_munmap() 完成并返回
23if(addr &lt;= mm-&gt;brk)
24{
25  if(!do_munmap(mm, newbrk, oldbrk-newbrk))
26      mm-&gt;brk = addr;
27  goto out;
28}
29
30// 如果进程请求扩大堆,首先检查是否允许
31// 如果进程企图分配限制范围之外的内存,只简单返回 mm-&gt;brk 的原有值
32rlim = current-&gt;signal-&gt;rlim[RLIMIT_DATA].rlim_cur;
33if(rlim &lt; RLIM_INFINITY &amp;&amp; addr - mm-&gt;start_data &gt; rlim)
34  goto out;
35
36// 然后,检查扩大后的堆是否可进程的其他线性区重叠,如果是,直接返回
37if(find_vma_intersection(mm, oldbrk, newbrk+PAGE_SIZE))
38  goto out;
39
40// 如果一切顺利,调用 do_brk()
41// 如果它返回 oldbrk,则分配成功,返回 addr;否则,返回旧的 mm-&gt;brk
42if(do_brk(oldbrk, newbrk-oldbrk) == oldbrk)
43  mm-&gt;brk = addr;
44goto out;
45
46

do_brk() 实际上是 do_mmap() 的进化版,仅处理匿名线性区,等价于:


1
2
3
1do_mmap(NULL, oldbrk, newbrk-oldbrk, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_FIXED|MAP_PRIVATE, 0)
2
3

do_brk() 假定线性区不映射磁盘上的文件,从而避免了检查线性区对象的几个字段,因此比 do_mmap() 稍快。

给TA打赏
共{{data.count}}人
人已打赏
安全运维

WordPress网站专用docker容器环境带Waf

2020-7-18 20:04:44

安全运维

运维安全-Gitlab管理员权限安全思考

2021-9-19 9:16:14

个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索