
ChCore 的 Lab5 是我刷内核实验以来觉得最有意思的一关尤其是这个被称为 BowerAccess 的按需访问模块不同版本的实验里可能叫法不完全一样有人把它叫 Lab5 6.4也有人直接叫零页映射实验。这一关看起来只是补几个页表操作函数但真正跑起来以后你会发现它把虚拟内存管理的三大件全串起来了页表项标志位、缺页异常流程、物理页面的按需分配。我前后花了差不多两个通宵才彻底跑通期间踩了不少坑这篇就把我的完整思路、实现要点和调试经验整理出来给后面做 Lab5 的同学一个参考。不管你是刚把用户态跑起来的新手还是已经在写内核代码的老手这篇文章里的细节应该都能帮你在 BowerAccess 这一关少走一大截弯路。1. 实验背景与整体思路1.1 BowerAccess 关卡在考什么先把这个实验的定位说清楚。ChCore 是一套教学用的微内核操作系统Lab5 属于虚拟内存管理这一大块。而 BowerAccess 这个关卡的核心目标是让用户程序在做内存映射时不要一口气把物理页面全分配好而是先给一个“空架子”等程序实际访问这段虚拟地址的那一刻再由内核通过缺页异常把真正的物理页准备好。这听起来有点像“懒加载”确实内存映射的懒加载就是它。其关键收益有两点一是减少启动时的内存浪费二是能让某些只读区域被多个进程安全共享典型例子就是零页。我最初以为这个关卡的难点在页表操作后来才发现难点其实在“缺页之后往哪里走、走到什么代码里、怎么从内核态回到用户态继续执行”这一整条链路。1.2 为什么“按需访问”比预分配更难做如果只是把所有物理页在 mmap 的时候就分配好事情会简单很多直接把虚拟地址和物理地址写进页表设置好权限完事。但按需访问的难点在于你无法预知用户程序会按什么顺序、访问哪一页你只能在缺页发生的瞬间做出判断。这个判断包括几个层次这个虚拟地址是不是合法映射是写操作还是读操作页表项当前是什么状态是第一次访问还需要分配零页还是访问了一个已经释放的物理页一连串问题都要在非常有限的异常处理上下文里解决而且解决完之后还要保证用户程序能从发生缺页的那条指令继续执行跟什么都没发生一样。这就把一个简单的“映射”问题上升到了异常处理与执行流恢复的问题。1.3 这一关卡的整体链路我把整个流程画一条主线虽然这里是文字描述但你自己写代码时要始终记着这条线用户程序调用 mmap内核记录 vma 信息并把虚拟地址区间对应的页表项设置为“未映射”或“只读零页”。用户程序执行到访问该地址的指令MMU 查页表发现条目无效或权限不足触发缺页异常。CPU 跳转进入内核的异常向量表保存用户现场。缺页异常处理函数读取 ESR 和 FAR判断访问原因。处理函数查 vma确认地址范围合法。如果是写访问且当前页标着只读则分配真实物理页、复制内容对应 CoW再重新映射为可写。如果是读访问且页表项为空则映射到一个共享的零页。最后异常返回恢复用户现场重新执行那条触发异常的指令。在这整个链路里最容易漏掉的是第 1 步和第 8 步。很多同学改了半天页表却忘了异常返回后指令根本没有重新执行然后陷入反复缺页的死循环。下文我会专门展开讲这个坑。2. 原理拆解页表、零页与缺页异常2.1 页表翻译与 PTE 标志位的含义做 Lab5 之前先把页表项PTE的布局彻底搞懂否则后面全是在盲改。在 AArch64 架构下一个页表项通常是 64 位其中标志位集中在低 12 位。几个关键的标志位如下标志位含义实验中的作用bit0有效位Valid0 表示未映射访问必缺页bit1表项类型Table/Block块描述符映射大页或段bit6访问权限位AP控制内核态/用户态访问bit7只读位AP[2]1 表示只读写权限按需时用于触发缺页bit8不可执行UXN用户态不可执行bit10访问标志位AF硬件访问标记部分场景需要置 1在 BowerAccess 里经常要做的事情就是把一个页表项的有效位清掉或者把 AP 只读位置上然后等缺页后再通过 OR 操作恢复。这里有个容易被忽略的点修改页表项后不是立即生效需要做 TLB 失效操作。ChCore 里对应的接口是flush_tlb_by_va或者flush_tlb_all忘了刷 TLB 的话可能你明明改了页表硬件还是按旧条目走导致缺页行为奇怪。2.2 零页映射的“欺骗”机制零页映射是 BowerAccess 里最巧妙的一个设计。它的思路是内核提前准备一个全零的物理页所有对“尚未写入”的页进行读访问时都不要分配新的物理页而是直接把这个零页映射给用户并且权限设置为只读。为什么能这么做因为读这个动作不会改变页的内容多个进程读同一个零页内容完全一致不会产生任何冲突。只有当用户要写这块地址时硬件才会因为只读权限触发写权限缺页此时内核才去分配一个独立的物理页把零页的内容复制过去再把这个新页映射为可写。这样有一个非常明显的好处如果你 mmap 了一大块内存但只是从头到尾读一遍那么整块区域从头到尾都可能只占一个物理页。假如你映射 4MB 内存实际上只用了 4KB 的零页效果一下就出来了。零页本身不复杂复杂的是你必须保证“零页永远是全零”。一旦某个写缺页处理复制内容时搞错了源地址把零页内容覆盖了那么后面所有进程都会读到脏数据而且这种 bug 极难察觉因为表现是随机性的。所以我在实现时专门加了一个检查零页上永远不允许直接写哪怕内核态也不行凡是能走到写路径的都要换掉映射。2.3 缺页异常处理流程的细节缺页异常在整个异常体系里属于同步异常也就是说它是当前指令主动触发的和处理中断不一样。AArch64 下触发缺页后CPU 会跳到异常向量表的某个入口此时要完成以下几个动作保存通用寄存器到当前任务的上下文结构。从far_el1寄存器读出触发异常的虚拟地址FAR。从esr_el1寄存器读出异常原因EC 字段用来区分是翻译错误还是权限错误。在内存管理模块里查找这个虚拟地址是否属于某个vm_region。根据访问类型和当前页表项状态决定映射方式。修改页表并刷新 TLB。恢复寄存器执行异常返回指令eret。这里面第 3 步很多人会看走眼。ESR 的 EC 字段如果显示是0x25说明是数据异常如果是0x26是指令异常。实验里用户读数据页时遇到的是数据异常。如果你把所有的缺页都当数据异常处理当用户访问的是指令地址时可能就会错误地分配一个可写页非常危险。另外ESR 还有一个 DFSC数据错误状态码字段。如果这个值是0x5或0x7是翻译错误如果是0x3或0x6则可能和访问权限有关。在实现 CoW 时你要根据这个字段判断当前是“页不存在”还是“权限不够”因为两者的处理逻辑完全不同。3. 实操过程从映射到触发3.1 环境准备与代码阅读顺序工欲善其事必先利其器。做这个实验之前先把代码按顺序读一遍不要一上来就写。我的阅读顺序是先看kernel/mm下的页表操作相关代码找到map_page_in_pgd、unmap_page_in_pgd、query_page_in_pgd这些函数搞懂参数里va、pa、flags的关系。再看kernel/mm/fault.c或者类似位置的缺页处理入口确认缺页函数是在哪里被调用的。然后看user下的 libc 封装找到 mmap 的系统调用编号和触发方式。最后看user里实验自带的测试程序了解它是怎么检查结果的。我在读代码时习惯在关键函数入口加一行打印比如打印当前vaddr和ESR值。这个习惯在后面调试时帮了我大忙。一开始不要嫌麻烦缺页调试的最大的障碍就是“不知道什么地方发生了缺页、为什么会发生”。3.2 修改页表映射函数BowerAccess 关卡的核心工作之一是完善页表映射函数。这里我以常见的map_page_in_pgd为例说一说容易出错的地方。这个函数要做的事情是从pgd开始逐级往下走如果中间的页表项不存在就分配一个新的页表页填好下一级页表的地址和标志然后继续往下走直到最末级写入物理地址。我见过很多第一次写这个函数的同学写了三四层循环每一层都在处理“表项不存在”的逻辑。这里的关键点在于中间级页表项的 flag 不能照抄末级页表的 flag。比如中间级项通常需要设置为表描述符Table descriptor而不是块描述符Block descriptor。用错类型之后轻则访问异常重则直接把内核弄崩。另外映射函数的最后一定要记得设置用户态访问权限位。Lab5 里大部分映射都是给用户进程用的所以AP标志必须允许 EL0 访问。我当时就犯过这个错页表项有效、物理地址也正确但用户程序一访问就报权限错误原因是 AP 位没打开。这个错误特别容易被视为“缺页处理有问题”实际上问题出在最开始的映射上。3.3 注册缺页异常处理入口页表改完后缺页处理函数的入口也需要检查。在 ChCore 中异常向量表通常是用汇编写的分成同步异常、IRQ、FIQ 几个大入口。Lab5 要求你把同步异常的入口接到内存管理模块的handle_page_fault上。如果你发现自己在实现完映射后一运行就卡死或者反复重启优先检查异常向量表跳转是否正确。一个非常经典的错误是汇编入口保存寄存器时只保存了一部分或者eret之前没有正确恢复sp导致返回到用户态时栈指针已经乱了。这个问题的表现就是用户程序第一次触发缺页后就再也回不到正常执行流。3.4 按需调页的核心实现按需分配的实现其实不长但要写得严谨。下面是典型流程的伪代码以 ChCore 风格为例int handle_page_fault(uint64_t fault_addr, uint64_t esr) { // 1. 判断 fault_addr 是否在某个已注册的 vm_region 内 struct vm_region *region find_vm_region(current_vma, fault_addr); if (region NULL) { return -EFAULT; // 非法访问 } // 2. 检查权限是否允许 uint64_t access_permission get_esr_permission(esr); if (!(region-perm access_permission)) { return -EACCES; } // 3. 根据当前页表项状态决定处理方式 pte_t *pte get_pte(current_pgd, fault_addr); if (!pte || !pte_is_valid(pte)) { // 首次访问映射零页只读或分配真实页 if (region-perm VM_WRITE) { // 有写权限的映射也不能直接分配先给零页 map_readonly_zero_page(current_pgd, fault_addr); } else { map_readonly_zero_page(current_pgd, fault_addr); } } else if (pte_is_readonly(pte) is_write_access(esr)) { // 写一个只读页触发 CoW handle_cow(fault_addr, pte); } // 4. 刷新 TLB flush_tlb_by_va(fault_addr); return 0; }这里面最关键的就是第 3 步的判断顺序。必须先检查地址范围合法性再检查权限最后再检查页表项状态。如果把权限检查放在页表检查后面很可能当页表项还是空的时直接就把一个非法写当成合法处理风险很大。还需要注意零页映射后一定要保持只读权限。否则用户一写就会直接修改共享零页造成进程间数据污染。这个逻辑和 CoW 是天然配套的零页是只读的所以第一次写会触发 CoWCoW 处理里先分配新页、复制零页内容、再修改权限为可写。3.5 CoW 实现时容易忽略的引用计数CoWCopy-on-Write本身就是 Lab5 的可选加分项但在 BowerAccess 里如果你把按需访问和零页映射做透了CoW 几乎是顺水推舟的事情。CoW 的实现需要维护物理页的引用计数防止多个进程共享同一个物理页时一方修改就释放了正在被别人用的页面。我实现的思路是在一个全局数组中记录每个物理页的引用次数。映射零页时零页引用计数加 1。其他物理页被多个虚拟地址映射时引用计数也加 1。发生写缺页且页表项为只读时先检查引用计数如果引用计数为 1可以直接把当前页表项改成可写不要复制页面省一次拷贝。如果引用计数大于 1分配一个新物理页把旧页内容复制过去然后新页的引用计数置为 1旧页引用计数减 1。页面释放时引用计数减到 0 才真正回收。这个优化很关键很多进程在 fork 之后只有少数页面会被修改如果每次写都复制一页性能会非常难看。引用计数为 1 的优化看似不起眼但可以把绝大部分写操作变成单纯改 PTE效率倍增。4. 踩坑记录与调试技巧4.1 反复缺页导致死循环我遇到的第一个大坑是用户在写完后依然反复触发同一个缺页。排查了很久发现问题出在异常处理返回后 PC 没有回到正确的指令上。AArch64 的同步异常返回机制里elr_el1保存的是触发异常的指令地址。大多数情况下异常返回时应直接写回elr_el1并执行eret让 CPU 重新执行那条触发异常的指令。但如果你在异常处理函数里无意中修改了elr_el1或者恢复现场时用了错误的寄存器CPU 可能跳到别的指令上去了。最典型的表现第一次访问触发缺页内核把页表修好了但返回后重新执行的还是发生缺页的那条指令于是再次触发缺页再次返回再次触发形成死循环。从外部看就是用户程序卡死CPU 使用率 100%。解决方案很简单写一个每次缺页都打印当前fault_addr和 PC 的调试版本如果发现 fault_addr 一直不变说明修复页表的逻辑没生效优先查 TLB 刷新如果 fault_addr 变了但总是在同一个区域内循环则要查异常返回地址是否正确。4.2 修改页表后 TLB 没失效另外一个非常隐蔽的坑是 TLB。修改页表之后如果没有调用刷新 TLBCPU 可能仍然使用缓存中的旧页表项。这个问题的坑在于它有时候表现正常有时候不正常因为 TLB 的替换策略不是我们能精确控制的。最可靠的做法是凡是修改了某个虚拟地址的映射状态就立刻调用flush_tlb_by_va把这个虚拟地址对应的 TLB 项清掉。如果你改了整张页表就直接调flush_tlb_all。不要在实验里为了省几个时钟周期而不刷 TLB出错后的排查时间远超你省下的那点时间。4.3 用户态访问权限位搞混我犯过的第二个大坑是 AP 位。AArch64 的 AP 位有两级分别控制 EL1 和 EL0 的访问权限。在做用户态实验时必须保证页表项允许 EL0 读/写AP[1] 设置为 0AP[2] 设置相应权限。如果是内核页表则只允许 EL1 访问AP[1] 设置为 1。很多同学映射用户页时只照抄了内核页表的标志导致用户态程序一访问就进入缺页而且 ESR 显示的是 permission fault 而不是 translation fault。如果遇到这种情况不要怀疑缺页逻辑回头看看最初映射时写入的 flags。可以参考这个对照表使用场景AP 位组合简化UXN用户态只读AP0b010用户态可读写AP0b000内核态只读AP0b111内核态可读写AP0b1014.4 调试工具的使用心得在这个实验里我最常用的调试手段按频率排序是串口打印。在异常处理入口打印fault_addr、ESR、PC。频率最高效果最直接。GDB 连接 QEMU。在树莓派模拟环境里GDB 可以断点停在handle_page_fault里单步查看页表内容。地址转储。在改页表前后把页表项的原始值打印出来确认改动是否到位。用 GDB 时有个小技巧直接监视far_el1和esr_el1的值。gdb里可以加一个硬件观察点当far_el1的值变化时自动停下这样就能知道用户程序到底想访问哪块地址。这个方法定位“用户态非法访问”特别有效比在代码里盲猜快太多。4.5 系统调用参数传递错误别笑这个错我真的犯过。Lab5 的 mmap 系统调用可能有多个参数比如地址、长度、权限、fd、偏移。如果你在用户态封装层写错了参数顺序比如把perm和fd搞反了内核收到后可能会把一个很大的 fd 值当成权限导致后面的 vm_region 权限检查永远失败。碰到用户态程序一执行 mmap 就返回错误别急着去看内核实现先在封装层打印参数。我之前花了整整一个晚上结果发现是用户态传参时漏了一个参数导致栈上的某个垃圾值被当成了 fd。这种低级错误有时比任何复杂的逻辑都更浪费时间。5. 版本与命名差异及其他注意点5.1 关于 Lab5-6.4 和 BowerAccess 的命名如果你在别的仓库或文档里看到 Lab5 后面跟了一个 6.4不要慌。这个编号在不同学校的镜像仓库里不一样有的代表“实验 5 的第四个练习的第 6 小节”有的纯粹是课程官网上的章节号。至于 BowerAccess你可以把它理解成“按需访问”这一机制在这个实验版本中的代号不同的实验版本可能叫 demand paging、lazy allocation 或者 zero-page mapping。关键还是要看实验说明里要求你实现哪些接口。我建议你做实验时先把实验仓库里docs或readme中的标题抓出来确认目标接口的准确名字不要只依赖网上的技术博客。因为同一份代码在不同版本间接口名称变化不小去年叫vm_map的接口今年可能改成map_page_in_pgd了照抄旧代码很容易编译失败或运行崩溃。5.2 物理内存管理器的联动按需调页不是孤立的功能它和物理内存分配器耦合得很紧。缺页发生时你要调用get_page分配新页CoW 时你要调用free_page释放旧引用。如果你的物理内存管理器本身有 bug那么缺页处理时就会间接触发更多异常形成连锁崩溃。我建议在做 Lab5 之前先把 Lab2 的物理内存管理实验好好验证一遍。特别是分配页的边界条件当 free list 为空时应该怎么办分配返回值是否为 0页面号到虚拟地址的换算是否正确这些看似基础的问题在缺页处理的紧张路径上会被无限放大。5.3 并发与多核的坑ChCore 支持多核调度这也意味着缺页处理可能发生在任意一个核上。如果你的实现里用了全局变量记录状态一定记得加锁或使用原子操作。比如上面的引用计数如果两个核同时访问同一个物理页并触发了 CoW不加锁的话引用计数就会错乱轻则多复制一页重则把共享页释放掉。为了快速验证可以先把实验跑在单核模式等逻辑完全正确后再开多核。我在实验时先强制让 QEMU 只用一个核把所有并行问题都规避掉通过后再开多核压力测试。这个策略能帮你把“逻辑 bug”和“并发 bug”分开定位否则两者混在一起几乎没法查。6. 一些个人体会和可以继续扩展的方向做到最后我最大的体会是BowerAccess 这个关卡真正训练的不是你怎么写页表函数而是怎么在异常处理的上下文里保持头脑清醒。缺页处理程序运行在内核态但它服务的对象是用户态程序中间涉及的现场保存、权限判断、映射修复和现场恢复每一步都必须精确走错一步结果就是灾难。如果你做完这个实验还有余力我建议你继续做下面几个扩展实验给缺页处理增加统计信息记录每种缺页发生的次数这能帮你理解工作负载的内存访问模式。实现mremap或者mprotect系统调用把页表操作做得更灵活。尝试实现基于交换设备的按需换入换出把缺页从“分配页面”拓展到“从磁盘加载”这会让你对操作系统的内存管理有更完整的理解。还有一个小技巧实验做完后把你写的代码放到-O2编译一次再跑一遍。很多时候你在调试模式下能通过的代码开了优化后就因为未初始化变量或者未定义行为出了问题提前发现这些问题能锻炼你写出更稳健的内核代码。最后分享一个我个人的土办法每次改完页表相关代码先在关键路径上打印五次页表项内容确认每一次修改都符合预期再关掉调试输出。这样看起来慢实际上比出错后在 QEMU 里反复重启要快得多。希望这份记录能帮你在 ChCore 的 Lab5 里少熬一个夜。