图 1:第 9 章章首插图
这一篇在干嘛?
前一章讲了内核自己怎么分配内存,这一章讲内核怎么给用户态进程分配内存。核心思路是”能拖就拖”:进程要内存时先只给它一段线性地址区间(内存区域),真正的物理页框等到访问那一刻发生缺页异常了才分配。理解这套机制,你才能看懂 malloc、mmap、fork 到底做了什么,以及”段错误”是怎么被内核判定的。
引言:进程地址空间 | 内存描述符 | 内存区域 | 数据结构与访问权限 | 查找与分配线性地址区间 | 释放线性地址区间 | 缺页异常处理程序 | 请求调页与写时复制 | 地址空间的创建销毁与堆管理
引言:进程地址空间
先对比一下内核和用户态分配内存的处境有多不同。
内核函数要动态内存很直接:__get_free_pages()、kmalloc()、vmalloc() 要多少给多少。这行得通有两个原因:内核是系统中优先级最高的组件,它请求内存一定有充分理由,没必要推迟;内核信任自己,假定所有内核函数都没有编程错误,不需要防着谁。
用户态进程就完全两码事了:
- 进程申请内存不紧急。程序被加载时不代表马上要用到所有代码页;调用
malloc()也不代表马上会访问拿到的全部内存。所以内核的总原则是:尽量推迟给用户进程分配动态内存。 - 用户程序不可信任。内核必须能捕获用户态进程造成的所有寻址错误。
内核靠什么实现”推迟”?答案是引入一种新资源:当用户态进程申请动态内存时,它得到的不是页框,而是使用一段新线性地址区间的权利——这段区间成为进程地址空间的一部分,被称为”内存区域”(memory region)。等进程真正访问这段地址、触发缺页异常时,内核才把物理页框补上(也就是第 2 章提过的请求调页思想)。
进程的地址空间(address space)就是该进程允许使用的全部线性地址的集合。每个进程看到的线性地址互不相干,内核还可以随时往地址空间里增删地址区间。典型情况下,进程在以下时机获得新的内存区域:
- 在控制台敲一条命令,shell 创建新进程执行它,新进程得到一整套全新的地址空间;
- 运行中的进程调用
execve装载完全不同的程序:进程 ID 不变,但旧内存区域全部释放,换上新的一套; - 进程对文件做”内存映射”(
mmap),内核给它一个新内存区域来映射该文件; - 进程不断往用户态栈压数据,把栈对应的内存区域用满了,内核可能扩大这个区域;
- 进程创建 IPC 共享内存区与其他进程共享数据;
- 进程通过
malloc()扩充堆(heap),内核可能扩大堆对应的内存区域。
与之相关的系统调用汇总如下:
| 系统调用 | 说明 |
|---|---|
brk() | 改变进程堆的大小 |
execve() | 装载新的可执行文件,从而改变进程地址空间 |
_exit() | 终止当前进程并销毁其地址空间 |
fork() | 创建新进程,因而创建新地址空间 |
mmap(), mmap2() | 为文件创建内存映射,扩大进程地址空间 |
mremap() | 扩大或缩小一个内存区域 |
remap_file_pages() | 为文件创建非线性映射 |
munmap() | 销毁文件的内存映射,收缩进程地址空间 |
shmat() | 挂接共享内存区 |
shmdt() | 分离共享内存区 |
为什么内核必须精确掌握进程拥有哪些内存区域?因为缺页异常处理程序要靠它区分两类引发异常的地址:
- 编程错误造成的非法地址——比如野指针,要给进程发 SIGSEGV;
- 缺页造成的地址——地址确实属于进程地址空间,只是对应页框还没分配。这类”缺页”在进程看来完全合法,内核补上页框让进程继续跑即可,这正是请求调页(demand paging)的实现基础。
内存区域有两个硬性约定:起始地址和长度都必须是 4096 的倍数,这样区域标识的数据恰好填满分配给它的页框。
内存描述符
进程地址空间的全部信息都装在一个 mm_struct 类型的对象里,叫内存描述符,由进程描述符的 mm 字段引用。主要字段如下:
| 类型 | 字段 | 说明 |
|---|---|---|
vm_area_struct * | mmap | 内存区域对象链表的头指针 |
struct rb_root | mm_rb | 内存区域对象红黑树的根 |
vm_area_struct * | mmap_cache | 最后一次引用的内存区域对象 |
unsigned long (*)( ) | get_unmapped_area | 在地址空间中查找空闲线性地址区间的方法 |
void (*)( ) | unmap_area | 释放线性地址区间时调用的方法 |
unsigned long | mmap_base | 第一个分配的匿名内存区域或文件映射的线性地址 |
unsigned long | free_area_cache | 内核查找空闲地址区间的起始搜索地址 |
pgd_t * | pgd | 指向页全局目录 |
atomic_t | mm_users | 次使用计数器 |
atomic_t | mm_count | 主使用计数器 |
int | map_count | 内存区域的数目 |
rw_semaphore | mmap_sem | 内存区域的读/写信号量 |
spinlock_t | page_table_lock | 保护内存区域和页表的自旋锁 |
struct list_head | mmlist | 内存描述符链表中相邻元素的指针 |
unsigned long | start_code / end_code | 可执行代码的起始/结束地址 |
unsigned long | start_data / end_data | 初始化数据的起始/结束地址 |
unsigned long | start_brk / brk | 堆的起始/当前结束地址 |
unsigned long | start_stack | 用户态栈的起始地址 |
unsigned long | arg_start / arg_end | 命令行参数的起始/结束地址 |
unsigned long | env_start / env_end | 环境变量的起始/结束地址 |
unsigned long | total_vm / locked_vm / rss | 区域总页数/锁定页数/驻留内存页数 |
所有内存描述符挂在一个双向链表上(mmlist 字段),链表首元素是进程 0(init_mm)的 mmlist 字段,多处理器系统上由 mmlist_lock 自旋锁保护。
两个计数器的区别是本节最容易混淆的地方:
mm_users:统计共享该内存描述符的轻量级进程数;mm_count:内存描述符的主使用计数,所有mm_users里的”用户”合计只算 1 个单位。mm_count减到 0 时描述符才被释放。
举例:两个轻量级进程共享一个内存描述符,mm_users 为 2,mm_count 为 1。如果这个描述符暂时借给内核线程用,内核递增的是 mm_count——这样即使两个轻量级进程都死了(mm_users 归零),描述符也不会被释放,因为 mm_count 还大于 0。反过来,如果内核想在一段较长操作中确保描述符不被释放,就递增 mm_users(比如第 17 章的 try_to_unuse()),效果一样但语义不同。
获取新内存描述符用 mm_alloc():因为描述符放在 slab 分配器高速缓存里,它调用 kmem_cache_alloc(),初始化后把 mm_count 和 mm_users 都置 1。释放路径则分两级:mmput() 递减 mm_users,减到 0 时释放局部描述符表、内存区域描述符和页表,再调 mmdrop();mmdrop() 递减 mm_count,减到 0 才真正释放 mm_struct。
内核线程怎么办?
内核线程只在内核态运行,从不访问低于 TASK_SIZE(即 PAGE_OFFSET,通常 0xc0000000)的线性地址,所以它不需要内存区域,内存描述符的大多数字段对它毫无意义。
但页表中 TASK_SIZE 以上(内核空间)的映射对所有进程都应该一致,内核线程用哪套页表其实无所谓。为了避免无谓的 TLB 和高速缓存刷新,Linux 让内核线程直接沿用上一个普通进程的页表。为此每个进程描述符有两个指针:
mm:指向进程拥有的内存描述符;active_mm:进程运行时实际使用的内存描述符。
普通进程两者相同;内核线程的 mm 永远是 NULL,被调度执行时其 active_mm 被初始化为上一个运行进程的 active_mm。
还有一个小麻烦:内核态修改了高地址(TASK_SIZE 以上)的页表项(典型如 vmalloc()),理论上应同步到全系统所有进程的页表里。逐个改太贵,Linux 采取延迟策略:只更新以 swapper_pg_dir 为主页全局目录的”规范”页表集(存在 init_mm 变量里),等某个进程真正访问对应地址缺页时,再由缺页处理程序把规范页表项抄过去(见缺页异常处理程序一节的 vmalloc_fault 分支,背景见第 8 章)。
内存区域
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 | 被映射文件中的偏移 |
struct file * | vm_file | 被映射文件的文件对象,匿名区为 NULL |
void * | vm_private_data | 区域的私有数据 |
unsigned long | vm_truncate_count | 非线性文件映射中释放地址区间时使用 |
vm_end – vm_start 就是区域长度。同一进程的内存区域永不重叠;新区域紧挨着旧区域且访问权限相同时,内核会尽量合并它们。
图 2:向进程地址空间添加或删除一个线性地址区间(原书图 9-1)
如图 2 所示,往地址空间加入一段新地址时,内核先看能否扩大既有区域(情形 a),不行才新建区域(情形 b);删除一段地址时,内核调整受影响区域的大小(情形 c),必要时一个区域会被分裂成两个小区域(情形 d)。
vm_ops 字段指向 vm_operations_struct,存放内存区域的方法。对 UMA 系统只有四个方法:
| 方法 | 说明 |
|---|---|
open | 内存区域被加入进程的区域集合时调用 |
close | 内存区域被移出进程的区域集合时调用 |
nopage | 进程访问的页不在 RAM 但地址属于该区域时,由缺页异常处理程序调用 |
populate | 为区域的线性地址预填页表项(prefaulting),主要用于非线性文件映射 |
数据结构与访问权限
链表 + 红黑树:一对搭档
一个进程的所有内存区域按地址升序串成单链表(vm_next 串联),头是内存描述符的 mmap 字段。相邻区域之间可以有未使用的地址空洞。map_count 记录区域数,默认上限 65536 个,管理员可通过 /proc/sys/vm/max_map_count 调整。
图 3:与进程地址空间相关的各描述符之间的关系(原书图 9-2)
只用链表有个问题:查找、插入、删除的耗时都与链表长度成正比。绝大多数进程的区域不多(几十个以内),无所谓;但面向对象数据库、malloc 调试器这类应用可能持有成百上千个区域,链表性能会崩掉。
所以 Linux 2.6 同时把区域放进红黑树(首树根是 mm_rb 字段,每个区域的红黑树数据在 vm_rb 字段)。红黑树里每个节点最多两个孩子,节点按键值排序:左子树的所有元素都小于 N,右子树都大于 N(图 4a)。此外还必须满足四条规则:
- 每个节点要么红色要么黑色;
- 树根必须是黑色;
- 红节点的孩子必须是黑色;
- 从任一节点到其后代叶子的每条路径,包含的黑节点数必须相同(空指针也算黑节点)。
图 4:红黑树示例(原书图 9-3)
这四条规则保证 n 个内部节点的红黑树高度最多约 2×log(n)+1,查找、插入、删除的耗时都与树大小的对数成正比——区域数翻倍只多一次迭代。插入新节点时先作为红色叶子插入,若破坏规则就调整颜色或”旋转”子树(如图 4 中插入 4 的过程:先改 3、4、7 的颜色,再对以 19 为根的子树旋转)。
于是两个结构各司其职:红黑树用于”定位包含某地址的区域”,链表用于”遍历全部区域”。插入/删除时内核通过红黑树找到前驱后继,再顺手更新链表,无需扫描。
三类页标志与访问权限
一个内存区域由若干页号连续的页组成。到目前为止我们见过两类页相关的标志:
- 页表项里的硬件标志(Read/Write、Present、User/Supervisor 等),由 80×86 分页单元硬件直接检查(见第 2 章);
- 页描述符 flags 字段里的标志,供内核内部使用(见第 8 章)。
现在引入第三类:内存区域的标志,存放在 vm_flags 字段里:
| 标志 | 说明 |
|---|---|
VM_READ | 页可读 |
VM_WRITE | 页可写 |
VM_EXEC | 页可执行 |
VM_SHARED | 页可被多个进程共享 |
VM_MAYREAD / VM_MAYWRITE / VM_MAYEXEC / VM_MAYSHARE | 允许设置对应 VM_ 标志 |
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 | fork 新进程时不复制该区域 |
VM_DONTEXPAND | 禁止通过 mremap() 扩展区域 |
VM_RESERVED | 特殊区域,其页不得换出 |
VM_ACCOUNT | 创建 IPC 共享内存时检查空闲内存是否充足 |
VM_HUGETLB | 通过扩展分页机制处理区域中的页 |
VM_NONLINEAR | 区域实现文件的非线性映射 |
区域的读写执行权限可以任意组合(比如”可读不可执行”)。要让硬件直接执行权限检查,这些权限必须复制到所有对应页表项里。初始权限值存在 vm_page_prot 字段,新增页时内核按它设置页表项。
难点在于把区域权限翻译成页保护位并不直接,原因有三:
- 有些情况下,即使
vm_flags允许某访问,也要故意让它触发缺页。典型例子:写时复制(COW)中,两个进程各有一份内容相同、可写但私有的页(VM_SHARED清除),内核把它们放在同一个页框里,任何一方写它时都要先触发异常; - 80×86 的页表只有两个保护位(R/W 和 U/S),而 U/S 位对用户态区域必须恒置 1;
- 支持 PAE 的 Pentium 4 的 64 位页表项多一个 NX(不可执行)位。
于是 Linux 定了如下降级规则(无 PAE 时):
- 读权限 ⇔ 执行权限(互为充要);
- 写权限蕴含读权限。
有 NX 时改为:
- 执行权限蕴含读权限;
- 写权限蕴含读权限。
另外,为配合 COW,非共享的页框一律写保护。16 种读写执行共享的组合按下面规则缩水后存进 protection_map 数组(16 个元素):
- 页有 Write 和 Share 权限 → 置 R/W 位;
- 页有 Read 或 Exec 但既无 Write 也无 Share → 清 R/W 位;
- 支持 NX 且页无 Exec 权限 → 置 NX 位;
- 页无任何权限 → 清 Present 位使每次访问都缺页;但为了和真正的”页不存在”区分,Linux 同时把页尺寸位置 1。
查找与分配线性地址区间
本节是一组底层辅助函数,为 do_mmap() 和 do_munmap() 服务。
find_vma():找最近的区域
find_vma(mm, addr) 找第一个 vm_end 大于 addr 的区域并返回其描述符;找不到返回 NULL。注意:返回的区域不一定包含 addr——addr 可能落在空洞里。
为了提速,每个内存描述符有 mmap_cache 字段缓存”上次引用的区域”。程序的地址访问具有局部性:上次访问落在某区域,下次大概率还在该区域。所以函数先检查缓存:
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 宏负责从红黑树节点指针反推出内存区域描述符的地址。逻辑是:候选节点 vm_end > addr 就先记为 vma;若其 vm_start <= addr 则地址确实落在区域内,成功返回;否则往左子树继续找更小的”结束地址”;vm_end <= addr 则往右子树找。
配套的还有:find_vma_prev() 额外通过 pprev 参数返回所选区域的前一个区域;find_vma_prepare() 则用于插入前定位——返回新叶子应放的位置及前驱区域和父节点。
find_vma_intersection():找与区间重叠的区域
vma = find_vma(mm, start_addr);
if (vma && end_addr <= vma->vm_start)
vma = NULL;
return vma;逻辑很巧:先用 find_vma() 找到第一个结束地址大于 start_addr 的区域,如果它的起始地址又超过了区间终点(end_addr <= vma->vm_start),说明中间隔着空洞、没有重叠,返回 NULL。
get_unmapped_area():找空闲区间
get_unmapped_area() 在进程地址空间里找一段可用的线性地址区间,len 指定长度,addr 非空时指定搜索起点,成功返回区间首地址,失败返回 -ENOMEM。
文件映射场景会走文件操作的 get_unmapped_area 方法(见第 16 章);匿名映射走内存描述符的方法,具体实现依进程布局而定,可能是 arch_get_unmapped_area()(从低往高分)或 arch_get_unmapped_area_topdown()(从高往低分)。前者等价于:
if (len > TASK_SIZE)
return -ENOMEM;
addr = (addr + 0xffff) & 0xffffff000;
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+0xffff)&0xffffff000)
return -ENOMEM;
start_addr = addr = (TASK_SIZE/3+0xffff)&0xffffff000;
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(通常 3GB),addr 向上取整到页边界; - 搜索起点通常取
mm->free_area_cache(上次分配区域的末尾之后),初始为用户态地址空间的三分之一(通常 1GB 处)——因为低地址的三分之一预留给有固定起始地址的区域(可执行文件的正文段、数据段、bss 段,见第 20 章); - 从起点开始逐个区域扫描:还没扫到顶就放不下 → 回到 1/3 处重扫一遍,仍失败返回
-ENOMEM;某区域后面的空洞够大 → 更新free_area_cache并返回该地址;空洞不够 → 跳到该区域末尾继续。
insert_vm_struct() 与删除辅助
insert_vm_struct(mm, vma) 把一个初始化好 vm_start/vm_end 的新区域插进链表和红黑树:先用 find_vma_prepare() 定位,再由 vma_link() 完成——①插进 mm->mmap 链表;②插进 mm->mm_rb 红黑树;③匿名区域还要挂进对应 anon_vma 的链表(见第 17 章的反向映射);④递增 mm->map_count。
__vma_unlink(mm, vma, prev) 负责把 vma 从链表和红黑树中摘除,若 mmap_cache 正指向被删区域则一并清掉。
释放线性地址区间
do_mmap():分配线性地址区间
do_mmap() 为当前进程创建并初始化一个新内存区域,参数包括:file/offset(映射文件时的文件对象和偏移)、addr(搜索起点)、len(区间长度)、prot(访问权限:PROT_READ/PROT_WRITE/PROT_EXEC/PROT_NONE)、flag(其余标志)。
常用 flag:
MAP_SHARED/MAP_PRIVATE:区域页可否在多进程间共享,对应VM_SHARED;MAP_FIXED:区间首地址必须严格等于 addr;MAP_ANONYMOUS:区域不关联任何文件(匿名映射);MAP_GROWSDOWN、MAP_LOCKED、MAP_DENYWRITE、MAP_EXECUTABLE:与表 9-5 的 VM_ 标志同名同义;MAP_NORESERVE:不做空闲页框数量的预检查;MAP_POPULATE:预分配映射所需的全部页框;MAP_NONBLOCK:配合 MAP_POPULATE,预分配时不得阻塞。
do_mmap() 对 offset 做些检查后转手给 do_mmap_pgoff()。以匿名区域为例,后者流程如下:
- 参数合法性检查:区间长度为零或越界(超过
TASK_SIZE);区域数超过map_count上限;要求锁定页但进程无权或超过RLIMIT_MEMLOCK限制——任一成立即返回负错误码。 - 调
get_unmapped_area()找空闲区间。 - 组合 prot/flags 计算新区域的 vm_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;- 调
find_vma_prepare()定位插入点;若发现已有区域与新区间重叠(返回的区域起始地址早于新区间终点),就调do_munmap()删掉重叠区间再重来:
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))
return -ENOMEM;
}- 检查加入新区域后进程地址空间总大小
(mm->total_vm << PAGE_SHIFT) + len是否超过RLIMIT_AS限制,超了返回-ENOMEM。(这个检查放在第 4 步之后做,因为第 4 步可能删掉了一些区域。) - 若未设置
MAP_NORESERVE且新区间含私有可写页,用security_vm_enough_memory()检查系统空闲页框是否足够,不足返回-ENOMEM。 - 私有匿名区间先尝试
vma_merge():若前一区域标志完全相同,直接扩展它覆盖新区间(若新区间恰好填满两个同标志区域之间的空洞,还能三合一);成功则跳到第 12 步。 - 用
kmem_cache_alloc()从 slab 分配一个新的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()初始化共享匿名区域(主要用于进程间通信,见第 19 章)。 - 调
vma_link()把新区域插入链表和红黑树。 - 递增内存描述符的
total_vm。 - 若设置
VM_LOCKED,调make_pages_present()依次分配区域内所有页并锁定在 RAM 中——底层是get_user_pages()遍历每个页的起始地址,先follow_page()查页表,缺页就调handle_mm_fault()补页框。 - 返回新区域的线性地址。
do_munmap():释放线性地址区间
要删除的区间通常并不正好是一个内存区域——可能只占某区域的一部分,也可能横跨多个区域。do_munmap(mm, start, len) 分两个阶段:第一阶段(16 步)扫描并”脱链”,第二阶段(712 步)更新页表并真正删除。
- 参数检查:区间越界、start 不是 4096 的倍数、长度为零 → 返回
-EINVAL。 mpnt = find_vma_prev(mm, start, &prev)找第一个结束地址大于 start 的区域。- 没找到、或该区域与区间无重叠(
mpnt->vm_start >= end)→ 无事可做,返回 0。 - 若区间起点落在 mpnt 内部(
start > mpnt->vm_start),调split_vma()把 mpnt 一分为二:区域外保留、区域内待删。同时把 prev 更新为 mpnt(即待删区段之前的那个区域)。 - 若区间终点落在某区域内部,再调一次
split_vma()把最后一个重叠区域拆成”待删 + 区外保留”。 - 把 mpnt 指向区间内的第一个区域:
mpnt = prev ? prev->vm_next : mm->mmap; 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;- 获取
mm->page_table_lock自旋锁。 - 调
unmap_region()清空覆盖该区间的页表项并释放页框。 - 释放自旋锁。
- 逐个释放第 7 步收集的区域描述符(
unmap_vma():更新total_vm/locked_vm、执行unmap_area方法更新free_area_cache、调用区域的 close 方法、匿名区域摘出 anon_vma 链表、kmem_cache_free()释放描述符)。 - 返回 0。
split_vma() 与 unmap_region()
split_vma(mm, vma, addr, new_below) 把与区间相交的区域拆成两个:
kmem_cache_alloc()再拿一个vm_area_struct,失败返回-ENOMEM;- 新描述符各字段照抄 vma;
new_below为 0(区间起点在区域内):把new->vm_start和vma->vm_end都设为 addr,新区域排在 vma 后面;new_below为 1(区间终点在区域内):把new->vm_end和vma->vm_start都设为 addr,新区域排在 vma 前面;- 有 open 方法就执行它;
- 把新区域挂进链表和红黑树,并调整红黑树以适应 vma 的新尺寸;
- 返回 0。
unmap_region(mm, vma, prev, start, end) 负责释放区间内页框:
- 调
lru_add_drain(); - 调
tlb_gather_mmu()初始化每 CPU 变量mmu_gathers(80×86 上只是把 mm 指针存进本地 CPU 的 mmu_gathers); - 调
unmap_vmas()扫描区间内所有页表项:单 CPU 时反复调free_swap_and_cache()释放页;多 CPU 时把页描述符指针收集进 mmu_gathers; - 调
free_pgtables()回收上一步清空了的页表; - 调
tlb_finish_mmu()收尾:先flush_tlb_mm()刷新 TLB,多处理器系统再调free_pages_and_swap_cache()释放收集到的页框。
缺页异常处理程序
缺页异常处理程序的核心任务:区分”编程错误”和”合法但尚未分配的页”。80×86 上的中断服务例程是 do_page_fault(),它拿引发异常的线性地址与当前进程的内存区域对比,按下面的总体方案处理:
图 5:缺页处理程序总体方案(原书图 9-4)
实际代码远比这张草图复杂——处理程序要识别好几种 awkward 的子情况。完整流程图如下(vmalloc_fault、good_area、bad_area、no_context 是 do_page_fault() 里的标号):
图 6:缺页处理程序流程图(原书图 9-5)
入口参数
regs:指向 pt_regs 结构,保存异常发生时的处理器寄存器;error_code:异常发生时控制单元压栈的 3 位错误码:- 位 0 为 0:访问的页不存在(页表项 Present 标志为 0);为 1:访问权限非法;
- 位 1 为 0:读/执行访问;为 1:写访问;
- 位 2 为 0:内核态发生;为 1:用户态发生。
第一步从 cr2 控制寄存器读出引发异常的线性地址:
asm("movl %%cr2,%0":"=r" (address));
if (regs->eflags & 0x00020200)
local_irq_enable();
tsk = current;分支一:地址在第四个 GB(内核空间)
info.si_code = SEGV_MAPERR;
if (address >= TASK_SIZE) {
if (!(error_code & 0x101))
goto vmalloc_fault;
goto bad_area_nosemaphore;
}如果是内核态访问了不存在的页框(错误码位 0 和位 2 都为 0),跳到 vmalloc_fault——多半是访问了非连续内存区(vmalloc 区),见本节末尾;否则直接判为非法地址,走 bad_area_nosemaphore。
分支二:中断、临界区、内核线程
if (in_atomic() || !tsk->mm)
goto bad_area_nosemaphore;in_atomic() 为 1 的两种情况:正在执行中断处理程序或可延迟函数;正在执行禁用了内核抢占的临界区。内核线程的 mm 恒为 NULL。这些场景下都不允许把缺页地址与 current 的内存区域做对比(内核线程根本不用低地址;中断上下文访问低地址可能阻塞当前进程)——统统当非法处理。
分支三:正常进程,先拿信号量
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);
}为什么用 down_read_trylock() 而不是 down_read()?防止死锁。如果信号量已被(本进程或别的路径)拿去写且一直不释放,这里就不能傻等。若获取失败且异常发生在内核态,处理程序用 search_exception_table() 判断:异常指令是否在访问系统调用参数里的用户态地址?是的话说明信号量肯定被别的进程持有(系统调用服务例程访问用户地址空间前都会小心地不持写锁),放心阻塞等待即可;否则就是内核 bug 或硬件故障,走 bad_area_nosemaphore。
分支四:查找内存区域 + 栈自动扩展
vma = find_vma(tsk->mm, address);
if (!vma)
goto bad_area;
if (vma->vm_start <= address)
goto good_area;vma 为 NULL:没有任何区域结束在 address 之后,地址必然非法。返回的区域包含 address:跳 good_area。都不满足:地址落在空洞里,但还有一次机会——它可能来自对用户态栈的 push 指令!
栈区域的特别之处:它带 VM_GROWSDOWN 标志,向低地址扩展,vm_end 固定而 vm_start 只能减小、永不增大。区域边界只是”粗略”包住栈,原因有二:区域大小必须是 4KB 的倍数而栈大小任意;分配给区域的页框在区域删除前从不回收,即使进程连续执行 pop 指令,区域大小也不变。所以当进程把栈顶页框用满后再 push,访问的地址就落在区域之外——但这不是编程错误,必须单独处理:
if (!(vma->vm_flags & VM_GROWSDOWN))
goto bad_area;
if (error_code & 4 /* User Mode */
&& address + 32 < regs->esp)
goto bad_area;
if (expand_stack(vma, address))
goto bad_area;
goto good_area;区域带 VM_GROWSDOWN 且异常发生在用户态时,检查 address 是否只比栈指针 esp 小一点点——注意 pusha 这类指令是先访问内存后递减 esp,所以给 32 字节的容差。检查通过就调 expand_stack() 确认进程有权扩展栈和地址空间,OK 则把 vma->vm_start 下调到 address。若异常发生在内核态则跳过容差检查直接扩展(内核在访问用户态栈)。
bad_area:地址彻底非法
bad_area:
up_read(&tsk->mm->mmap_sem);
bad_area_nosemaphore:
if (error_code & 4) { /* User Mode */
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;
}用户态出错:给 current 发 SIGSEGV(就是我们熟悉的”段错误”,见第 11 章)。force_sig_info() 保证进程无法忽略或阻塞该信号,info 里带上 SEGV_MAPERR(页不存在)或 SEGV_ACCERR(权限非法)的细分码。
内核态出错还有两种可能要区分:
no_context:
if ((fixup = search_exception_table(regs->eip)) != 0) {
regs->eip = fixup;
return;
}- 异常指令本意是访问系统调用传进来的用户态地址:跳到”修正代码”(fixup code),通常给系统调用返回一个错误码(详见第 10 章的修正机制);
- 真正的内核 bug:在控制台和内核消息缓冲区打印 CPU 寄存器和内核栈的完整转储,然后
do_exit()杀掉当前进程——这就是所谓的 Kernel oops。转储信息供内核黑客复盘触发条件、定位修复 bug。
good_area:合法区域内的访问
good_area:
info.si_code = SEGV_ACCERR;
write = 0;
if (error_code & 2) { /* write access */
if (!(vma->vm_flags & VM_WRITE))
goto bad_area;
write++;
} else /* read access */
if ((error_code & 1) || !(vma->vm_flags & (VM_READ | VM_EXEC)))
goto bad_area;写访问:区域必须可写,否则判非法;可写则置 write=1。 读/执行访问:若页已在内存(错误码位 0 为 1),说明是用户态访问了特权页框(U/S 位为 0),判非法;页不在内存则区域必须可读或可执行。
权限对上了,进入 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;
}返回值含义:
VM_FAULT_MINOR:次缺页——处理过程中没让进程睡眠;VM_FAULT_MAJOR:主缺页——处理过程让进程睡了(多半是从磁盘读数据填充页框);VM_FAULT_OOM:内存耗尽;VM_FAULT_SIGBUS:其他错误。
OOM 时通常杀掉当前进程;但若 current 是 init 进程(pid==1)就网开一面:把它放回运行队列末尾重新调度,回来后重试:
if (ret == VM_FAULT_OOM) {
out_of_memory:
up_read(&tsk->mm->mmap_sem);
if (tsk->pid != 1) {
if (error_code & 4) /* User Mode */
do_exit(SIGKILL);
goto no_context;
}
yield();
down_read(&tsk->mm->mmap_sem);
goto survive;
}SIGBUS 时给进程发 SIGBUS 信号(内核态则转 no_context)。
handle_mm_fault(mm, vma, address, write_access) 先确认映射 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;
}各级目录按需分配后,最终交给 handle_pte_fault() 检查页表项,只有两条路:
- 页不存在(没在任何页框里)→ 分配新页框并正确初始化——请求调页;
- 页存在但只读 → 分配新页框并复制旧页框内容——写时复制。
vmalloc_fault:借规范页表一用
第 8 章说过,vmalloc()/vfree() 懒得更新所有进程的页表,只更新主内核页表(init_mm.pgd)。而内核初始化结束后主内核页表并不被任何进程直接使用。于是内核态第一次访问某非连续内存区时,CPU 的 MMU 翻译线性地址会撞上一个空的页表项、触发缺页。处理程序认得这个特殊情况——内核态发生、地址大于 TASK_SIZE——于是去查主内核页表:
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, address);
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_k 指向主内核页全局目录的对应项。逐级检查主内核页表的 PGD/PUD/PMD 项,任一为空都说明这不是 vmalloc 的锅,转 no_context(当内核 bug 处理);否则把主内核页中间目录项抄进当前进程的对应项,再检查主内核页表项存在与否,一切正常就直接返回——进程无需察觉,继续执行刚才那条访问指令即可。
请求调页与写时复制
请求调页(Demand Paging)
请求调页=把页框分配推迟到最后一刻:直到进程访问一个不在 RAM 中的页、触发缺页,才分配。
为什么值得?进程并不会一开始就访问地址空间里的所有地址,有些地址可能永远用不到;程序局部性原理(见第 2 章)保证程序运行的每个阶段只真正引用一小部分页。请求调页提高了系统平均空闲页框数,让同样大的 RAM 产出更高的整体吞吐。代价是每次缺页都要内核处理、耗 CPU 周期——好在局部性原理也保证进程一旦用起一组页就会持续用一阵,缺页可视为稀有事件。
一个不在内存的页有三种身份,handle_pte_fault() 靠检查页表项来分辨:
entry = *pte;
if (!pte_present(entry)) {
if (pte_none(entry))
return do_no_page(mm, vma, address, write_access, pte, pmd);
if (pte_file(entry))
return do_file_page(mm, vma, address, write_access, pte, pmd);
return do_swap_page(mm, vma, address, pte, pmd, entry, write_access);
}- 页表项全零(
pte_none为 1):页从未被访问过且不映射磁盘文件,或页线性映射某个磁盘文件 →do_no_page(); - Present 清零、Dirty 置位(
pte_file为 1):非线性磁盘文件映射 →do_file_page()(见第 16 章); - 页表项不全零但 Present 和 Dirty 都清零:页曾被访问过、内容暂时保存在磁盘上 →
do_swap_page()(见第 17 章)。
看情况 1 的 do_no_page():它检查区域的 nopage 方法判断是否映射了文件。若 vma->vm_ops 或 vma->vm_ops->nopage 为 NULL,说明是匿名映射,转 do_anonymous_page() 分配新页框:
if (!vma->vm_ops || !vma->vm_ops->nopage)
return do_anonymous_page(mm, vma, page_table, pmd, write_access, address);写访问和读访问分开处理。写访问——老老实实分配一个清零的新页框:
if (write_access) {
pte_unmap(page_table);
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() 要在 alloc_page() 之前调——分配页框可能阻塞当前进程,而临时内核映射不能跨阻塞持有。页框拿到后置为可写+脏,rss 递增记账,lru_cache_add_active() 把页框挂进交换相关数据结构(见第 17 章)。
读访问更进一步:进程第一次读这页,内容无所谓——与其立刻分配清零页框,不如先给它一个现成的零页(zero page):内核初始化时在 empty_zero_page 变量里静态分配的 4096 字节全零页。
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;零页被标记为不可写:一旦进程试图写它,立即触发写时复制,那时才真正获得属于自己的可写页。这就是把”拖延”贯彻到底。
写时复制(Copy On Write)
第一代 Unix 创建进程很笨:fork() 时把父进程的整个地址空间逐字复制一份给子进程——分配子进程页表、分配子进程页、初始化页表、逐页拷贝数据。又慢又费内存,还把高速缓存搅得一团糟。更荒唐的是往往白干:许多子进程一上来就 exec 装载新程序,继承的地址空间整个扔掉(见第 20 章)。
现代 Unix(包括 Linux)改用写时复制(COW):不复制页框,父子进程共享页框;共享期间页框不可写。谁写谁触发异常,内核把页复制到新页框并标记为可写,原页框保持写保护;等另一个进程也写它时,内核检查自己是否已是页框唯一拥有者,是则直接放开写权限。页描述符的 _count 字段记录共享该页框的进程数:释放页框或执行一次 COW 时递减,减到 -1 页框才真正释放(见第 8 章)。
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_mkyoung(entry);
set_pte(pte, entry);
flush_tlb_page(vma, address);
pte_unmap(pte);
spin_unlock(&mm->page_table_lock);
return VM_FAULT_MINOR;
}该函数是体系结构无关的;在 80×86 上,“页在内存+写访问”必然意味着页框写保护(权限检查硬件已做过),所以总是调 do_wp_page()。
do_wp_page() 先判断是否真的需要复制:读出页表项对应的页描述符,检查 _count 字段——等于 0(唯一拥有者)就不需要 COW,直接把页框标记为可写,一劳永逸(_count 的检查稍复杂:页被挂进交换高速缓存、或 PG_private 置位时它也会被加 1):
set_pte(page_table, maybe_mkwrite(pte_mkyoung(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_HIGHUSER | __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),省掉一次拷贝、少访问一遍内存,保护硬件缓存。
因为 alloc_page() 可能阻塞,回来后要检查页表项是否已被人改过(pte 与 *page_table 不再相同):改过就放弃——释放新页框、回退引用计数、直接返回。
一切正常,把新页框写入页表项并刷新 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);最后 do_wp_page() 把旧页框的引用计数减两次:一次撤销复制前的安全加 1,一次表示当前进程不再拥有这个页框。
地址空间的创建销毁与堆管理
创建进程地址空间
创建新进程时(见第 3 章),内核调 copy_mm() 搭建子进程的地址空间。普通进程通过 COW 继承父进程地址空间:页只要还被读就保持共享,一旦有人写就复制——过一段时间后,fork 出的进程通常就有了与父进程不同的自己的地址空间。轻量级进程则相反:clone() 带 CLONE_VM 标志,直接共用父进程的地址空间,根本不复制,所以创建飞快,代价是父子进程必须小心协调访问。
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;
}spin_unlock_wait() 确保:如果别的 CPU 正持有页表自旋锁,要等它释放后才返回——这个锁除了保护页表,还负责禁止”再创建共享 current->mm 描述符的新轻量级进程”这类并发。
不带 CLONE_VM 时,copy_mm() 分配新内存描述符、整体拷贝 current->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->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() 在 80×86 上负责:当前进程若有自定义局部描述符表(LDT)就复制一份挂进新地址空间。
接着 dup_mmap() 复制内存区域和页表:先把新内存描述符挂进全局链表,然后从 current->mm->mmap 开始扫描父进程区域链表,逐个复制 vm_area_struct 并插入子进程的链表和红黑树;每插完一个区域就调 copy_page_range() 建立所需页表并初始化页表项。关键一步:凡是私有可写页(VM_SHARED 关、VM_MAYWRITE 开),父子进程的对应页表项都标成只读——这就是埋下 COW 的伏笔。
删除进程地址空间
进程终止时内核调 exit_mm():
mm_release(tsk, tsk->mm);
if (!(mm = tsk->mm)) /* kernel thread ? */
return;
down_read(&mm->mmap_sem);mm_release() 唤醒所有睡在 tsk->vfork_done 完成量上的进程(典型场景:本进程由 vfork() 创建,见第 3 章、第 5 章)。内核线程没有 mm,直接返回。
非内核线程则:先看 mm->core_waiters——若置位说明进程正在往 core 文件转储内存,要用 core_done/core_startup_done 完成量把共享同一 mm 的轻量级进程串行化,防止 core 文件写坏。然后递增 mm_count、清空进程描述符的 mm 字段、让 CPU 进入懒惰 TLB 模式(见第 2 章):
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() 负责释放 LDT、内存区域描述符和页表;内存描述符本身因为 mm_count 刚被加过 1 不会在这里释放,而是等进程被真正赶出本地 CPU 时由 finish_task_switch() 释放(见第 7 章)。
管理堆
每个 Unix 进程都有一块叫堆(heap)的内存区域,专门满足动态内存请求。内存描述符的 start_brk 和 brk 字段界定堆的起止地址。用户态可用的接口:
malloc(size):请求 size 字节动态内存,成功返回首地址;calloc(n, size):请求 n 个 size 大小元素的数组,成功则清零并返回首元素地址;realloc(ptr, size):改变 malloc/calloc 分配的内存区大小;free(addr):释放以 addr 开头的分配区;brk(addr):直接修改堆大小,addr 指定current->mm->brk的新值,返回值为区域新结束地址(进程需检查是否与请求值一致);sbrk(incr):与 brk 类似,但 incr 以字节为增量单位。
注意:只有 brk() 是真正的系统调用,其余全在 C 库里用 brk() 和 mmap() 拼出来。
用户态调用 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 对齐到页边界再与现有 brk 比较——同页的话只需改 brk 字段:
newbrk = (addr + 0xffff) & 0xffffff000;
oldbrk = (mm->brk + 0xffff) & 0xffffff000;
if (oldbrk == newbrk) {
mm->brk = addr;
goto out;
}缩小堆:调 do_munmap() 收回多余区间:
if (addr <= mm->brk) {
if (!do_munmap(mm, newbrk, oldbrk-newbrk))
mm->brk = addr;
goto out;
}扩大堆:先查进程的 RLIMIT_DATA 限额,超了不分配;再用 find_vma_intersection() 确认扩大的堆不会撞上其他内存区域:
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, newbrk-oldbrk) 真正干活的函数是 do_mmap() 的精简版,只处理匿名区域,等价于:
do_mmap(NULL, oldbrk, newbrk - oldbrk,
PROT_READ|PROT_WRITE|PROT_EXEC,
MAP_FIXED|MAP_PRIVATE, 0)它比 do_mmap() 略快,因为假定区域不映射磁盘文件,省掉了对内存区域对象字段的一系列检查。
常见坑:把 malloc 当成"真分配"
malloc()返回成功不代表拿到一个物理页框——C 库只是帮你调了brk()/mmap()划出一段线性地址。第一次写这块内存时你会触发一次缺页(甚至先读到零页再 COW 一次)。分析性能时别把”分配很快”误会成”没有开销”,开销只是被推迟了。
常见坑:混淆 mm_users 和 mm_count
记住口径:
mm_users数的是”有几个使用者”(轻量级进程数),mm_count数的是”这个结构还被引用着吗”(mm_users 整体算 1,内核线程借用再 +1)。释放条件是mm_users归零触发资源清理、mm_count归零才释放结构本身。写内核模块引用他人 mm 时,分不清该加哪个就会用出 use-after-free。
通关标准
合上书能独立回答:① 进程申请内存时为什么拿到的是”地址区间”而不是页框?② 缺页处理程序如何用
error_code三位 +find_vma()区分非法访问、栈扩展、请求调页、COW 四种情况?③ fork 后父子进程写同一个页时发生了什么?——三问都能讲清楚,本章过关。
为什么说内存区域的起始地址和长度必须是 4096 的倍数?
因为内存区域最终要落到页框上,页大小是 4096 字节。起始地址和长度都对齐到页边界,区域标识的数据才能不多不少正好填满它占用的每个页框;同时线性地址到物理地址的翻译以页为单位,非对齐的区域根本无法用页表表达。
内核线程为什么把 mm 设为 NULL,用别人的页表不会有问题吗?
内核线程只在内核态运行,从不访问 TASK_SIZE 以下的用户空间地址,因此根本不需要自己的内存区域和用户页表;而 TASK_SIZE 以上的内核映射对所有进程一致,所以直接沿用上一个普通进程的页表(active_mm 机制)完全安全,还省掉了切换时的 TLB/缓存刷新。唯一要处理的是高地址页表的同步,Linux 用”只更新 init_mm 主内核页表 + 访问时缺页补抄(vmalloc_fault)“的延迟策略解决。
读一个从未访问过的匿名页,内核真的分配了新页框吗?
通常没有。do_anonymous_page() 对读访问只把页表项指向静态的零页(empty_zero_page,内核初始化时分配的 4096 字节全 0),并标记为不可写。零页被所有进程共享,几乎零成本。直到进程第一次写这个地址,才触发写时复制,拿到真正属于自己的清零页框。
为什么 do_wp_page() 里旧页框的引用计数要先加 1、最后又减 2?
加 1(get_page)是为了在复制页框内容期间防止旧页框被人释放(alloc_page 可能阻塞,阻塞期间可能有人并发操作);最后的两次减 1:第一次撤销复制前的安全加 1,第二次表示当前进程从此不再拥有该页框。两次之后 _count 才正确反映剩余共享者的数量。
栈区域为什么允许 vm_start 缩小,却不允许 vm_end 变化,也不随 pop 缩小?
栈向低地址生长(VM_GROWSDOWN),高边界固定。区域大小必须是页的整数倍而栈大小任意,边界天然是”粗略”的;又因为分配出去的页框在区域删除前不回收(连交换到磁盘后页框信息也还保留着),pop 只是移动栈指针、不释放页,所以 vm_start 只会因 push 缺页而下调,永不回升。