《从零手写操作系统 (49):虚拟内存进阶——Demand Paging、Swap与缺页异常处理》

前言:打破物理内存的枷锁

在前面的章节中,我们实现了进程调度、文件系统和网络栈,但此时的内存管理仍是"全量预分配"的原始模型:fork()必须复制整个地址空间,程序启动时必须加载全部代码段,物理内存耗尽时只能OOM杀进程。一个没有按需分页的操作系统,就像一座没有电梯的摩天大楼------无论地基多稳固,你也无法高效利用上层空间。

虚拟内存不是"内存扩展技术",而是"计算资源的时空解耦抽象"。它将"逻辑上需要"与"物理上驻留"彻底分离:进程可以拥有远超物理RAM的地址空间,页面仅在首次访问时才从磁盘调入(Demand Paging),修改过的页面在内存紧张时被换出到Swap分区,未写入的只读页面甚至可以直接丢弃并从可执行文件重新加载。本章将实现完整的缺页异常处理链、Swap后端和写时复制(COW),让你的OS首次支持内存超卖、高效fork和mmap文件映射,迈入现代虚拟内存管理的深水区。

本章里程碑:

  • ✅ 缺页异常处理链:VMA查找→权限检查→页面分配/调入→页表更新
  • ✅ Demand Paging:匿名页零填充、文件页按需读取、可执行页懒加载
  • ✅ Swap子系统:swap分区识别、slot位图管理、pageout/pagein路径
  • ✅ 写时复制(COW):fork时标记共享只读、写入时触发私有拷贝
  • ✅ 页面置换算法:LRU近似(Active/Inactive双链表)、脏页回写
  • ✅ 验证:fork后父子独立写入不干扰、大程序仅加载使用部分、内存压力下swap正常工作

核心概念:虚拟内存不是"页表操作集合",而是"访问语义的动态解析器"
为什么缺页异常是虚拟内存的"心脏"而非"错误"?

因为在按需分页系统中,缺页是常态而非异常。每次访问未驻留页面都会触发#PF,内核将其视为"延迟分配的请求"而非"非法访问"。这与早期分段系统中缺页=崩溃的语义截然不同。缺页处理器的职责是:根据访问地址找到对应的VMA,判断访问是否合法,然后决定是从零初始化、从文件读取、从swap调入还是触发COW拷贝。

⚠️ 关键洞察 :缺页异常是虚拟内存的"惰性求值引擎" 。若你的实现把页面分配放在mmap/fork时而非#PF时,你就退化为全量预分配,丧失了虚拟内存的核心价值。教学实现中必须在do_page_fault()入口区分"合法缺页"与"真正违规":前者走分配/调入路径,后者才发SIGSEGV。这个区分依赖于VMA的存在性和权限匹配。

VMA:地址空间的"语义地图"

VMA(Virtual Memory Area)描述了进程中一段连续地址范围的属性:起始/结束地址、权限(rwx)、类型(匿名/文件映射)、关联的文件及偏移、标志(COW/GROWSDOWN等)。它是缺页处理器决策的唯一依据。当#PF发生时,内核通过find_vma(addr)定位对应VMA,再根据VMA类型选择处理策略。

关键约束:①VMA必须按地址排序以支持二分查找;②VMA合并/分裂必须原子化(持mmap_lock);③VMA不包含任何物理页信息------那是页表的职责。这种"语义与物理分离"的设计使同一VMA下的页面可以独立驻留或换出。教学实现中先用红黑树+链表维护VMA,后续可优化为vm_area_struct缓存。

COW的本质:延迟的私有化承诺

fork()时若复制所有页面,1GB进程的fork耗时可达数百毫秒。COW将复制推迟到"真正写入时":fork仅复制页表项并标记为只读+COW,父子共享同一物理页;当任一进程写入时触发#PF,内核检测到COW标志后分配新页、复制内容、更新页表为可写。若父进程从未写入,子进程也无需承担复制开销。

关键约束:①COW页必须是只读的,否则硬件不会触发#PF;②引用计数跟踪共享者数量,最后一个持有者可避免拷贝直接提权;③COW标志存储在页表项的软件可用位中(x86 PTE bit 9-11)。教学实现中务必在copy_page_range()时正确设置COW位,并在wp_page_copy()中验证refcount。


实战代码
缺页异常处理主路径
复制代码
// arch/x86/mm/fault.c
#include <mm/vma.h>
#include <mm/swap.h>

// ★ x86 #PF入口
void do_page_fault(struct pt_regs *regs, unsigned long error_code) {
    unsigned long addr = read_cr2(); // 故障地址
    struct mm_struct *mm = current->mm;
    
    // ★ 快速路径:内核态fault直接panic
    if (addr >= TASK_SIZE && !user_mode(regs)) {
        die("kernel fault", regs, error_code);
        return;
    }
    
    mmap_read_lock(mm); // ★ 持读锁查找VMA
    struct vm_area_struct *vma = find_vma(mm, addr);
    
    // 无VMA或地址超出范围 → SIGSEGV
    if (!vma || addr < vma->vm_start) {
        mmap_read_unlock(mm);
        force_sig(SIGSEGV);
        return;
    }
    
    // ★ 权限检查
    bool write = error_code & PFERR_WRITE_MASK;
    if (write && !(vma->vm_flags & VM_WRITE)) goto bad_access;
    if (!(error_code & PFERR_USER_MASK) && !(vma->vm_flags & VM_EXEC)) goto bad_access;
    
    // ★ 分发到具体处理函数
    int ret;
    if (vma->vm_flags & VM_ANON) {
        ret = handle_anon_fault(vma, addr, write);
    } else {
        ret = handle_file_fault(vma, addr, write);
    }
    
    mmap_read_unlock(mm);
    if (ret == VM_FAULT_OOM) pagefault_out_of_memory();
    return;
    
bad_access:
    mmap_read_unlock(mm);
    force_sig(SIGSEGV);
}
COW写时复制处理
复制代码
// kernel/mm/memory.c

// ★ 写入COW页时的处理
static int wp_page_copy(struct vm_area_struct *vma, 
                        unsigned long addr, pte_t *pte) {
    struct page *old_page = pte_page(*pte);
    
    // ★ 引用计数==1时可避免拷贝,直接提权
    if (page_ref_count(old_page) == 1) {
        pte_t newpte = pte_mkwrite(pte_mkdirty(*pte));
        set_pte_at(vma->vm_mm, addr, pte, newpte);
        update_mmu_cache(vma, addr, newpte);
        return VM_FAULT_WRITE;
    }
    
    // ★ 分配新页并复制
    struct page *new_page = alloc_page(GFP_HIGHUSER_MOVABLE);
    if (!new_page) return VM_FAULT_OOM;
    
    copy_user_highpage(new_page, old_page, addr, vma);
    
    // ★ 更新页表:旧页减引用,新页设为可写
    pte_t newpte = mk_pte(new_page, vma->vm_page_prot);
    newpte = pte_mkwrite(pte_mkdirty(newpte));
    
    ptl = pte_lockptr(vma->vm_mm, pte);
    spin_lock(ptl);
    page_remove_rmap(old_page, vma, addr);
    put_page(old_page);           // ★ 减少旧页引用
    page_add_new_anon_rmap(new_page, vma, addr);
    set_pte_at(vma->vm_mm, addr, pte, newpte);
    spin_unlock(ptl);
    
    update_mmu_cache(vma, addr, newpte);
    return VM_FAULT_WRITE;
}
Swap换入换出路径
复制代码
// kernel/mm/swap_state.c

// ★ 从swap读入页面
int swapin_read_sync(swp_entry_t entry, struct page *page) {
    struct swap_info_struct *si = swp_type(entry);
    unsigned long offset = swp_offset(entry);
    
    // ★ 提交块设备读请求
    struct bio *bio = bio_alloc(si->bdev, 1, REQ_OP_READ, GFP_KERNEL);
    bio->bi_iter.bi_sector = si->start + offset * PAGE_SECTORS;
    bio_add_page(bio, page, PAGE_SIZE, 0);
    submit_bio_wait(bio);         // ★ 同步等待完成
    
    // ★ 清除swap slot占用标记
    swap_slot_free(si, offset);
    bio_put(bio);
    return 0;
}

// ★ 将脏页写出到swap
int swap_write_page(struct page *page) {
    swp_entry_t entry = get_swap_page(); // ★ 分配swap slot
    if (!entry.val) return -ENOMEM;
    
    struct swap_info_struct *si = swp_type(entry);
    struct bio *bio = bio_alloc(si->bdev, 1, REQ_OP_WRITE, GFP_NOIO);
    bio->bi_iter.bi_sector = si->start + swp_offset(entry) * PAGE_SECTORS;
    bio_add_page(bio, page, PAGE_SIZE, 0);
    submit_bio_wait(bio);
    bio_put(bio);
    
    // ★ 更新页表项为swap entry
    // (调用方负责加锁和TLB flush)
    return 0;
}

关键细节解析

1. 为什么缺页处理必须区分read_lock与write_lock?

因为大多数缺页仅需读取VMA信息(查找、权限检查),只有修改VMA(mprotect/munmap)才需写锁 。若所有#PF都持write_lock,多线程并发缺页时会严重串行化。Linux使用mmap_read_lock()作为默认,仅在VMA变更路径升级为write_lock。教学实现中务必在do_page_fault入口用read_lock,并在find_vma返回后验证VMA未被并发修改(可通过sequence counter或重试机制)。这是高并发下内存性能的关键。

2. 为什么COW的refcount检查必须在页表锁内?

因为refcount可能被其他CPU并发修改 。若先检查refcount==1再持锁,可能在持锁前另一CPU已释放了对该页的引用(如另一个子进程退出),导致本应拷贝的场景被错误地提权。反之,若持锁后检查,则保证看到的是最新状态。教学实现中应将page_ref_count()检查放在pte_lock之后,且在发现refcount变化时优雅降级为拷贝路径。这个微小的顺序差异决定了COW的正确性。

3. 为什么swap slot分配必须使用位图而非链表?

因为swap空间是稀疏且频繁随机访问的 。链表分配O(n)且缓存不友好,而位图支持O(1)查找空闲slot、天然紧凑、cache line利用率极高。更重要的是,swap slot的分配/释放常发生在内存回收的热路径上,任何额外开销都会被放大千倍。教学实现中应使用unsigned long\[\]位图+find_next_zero_bit(),并用spinlock保护并发分配。切勿用kmalloc维护空闲列表------那会在内存紧张时雪上加霜。


调试Checklist:虚拟内存排查
症状 可能原因 排查方法
fork后父子写入互相污染 COW位未设置/wp_page_copy未触发/refcount错 dump PTE软件位验证COW标志;kprintf #PF handler中write fault分支;测试父子各自写入后memcmp
大程序启动慢/OOM demand paging未实现/VMA未正确创建/file fault未调readpage strace验证mmap后立即access是否触发#PF;dump VMA列表确认覆盖代码段;测试小buffer读取大文件
swap in/out数据损坏 swap offset计算错/bio sector对齐错/page未flush cache hexdump swap分区验证写入内容;验证PAGE_SECTORS宏定义;测试swap后md5sum比对
缺页死锁 mmap_lock嵌套获取/页分配递归触发#PF/swap IO持锁 lockdep检测锁序;验证GFP_NOWAIT在#PF路径使用;dump等待队列确认阻塞点
COW UAF refcount提前归零/put_page在锁外/并发fork竞争 KASAN启用;验证page_remove_rmap+put_page原子性;stress test多进程fork+write
文件映射内容错乱 readpage未正确处理partial page/page cache未一致/offset溢出 dump page cache内容;验证file_ra_state初始化;测试mmap后pread比对

🔧 黄金法则 :虚拟内存调试的终极武器是缺页事件追踪器+页表快照器 。实现debug_trace_pagefault(addr, type, result)记录每次#PF的地址、原因和处理结果,同时实现debug_dump_pagetable(mm, addr_range)以人类可读格式显示PTE状态(present/COW/dirty/swapped)。VM bug几乎总是"异步延迟与状态的不一致":页表说present但物理页已释放、COW标志在但refcount已为1、swap slot标记占用但实际为空。只有将抽象的缺页事件流与具体的页表物理状态叠加验证,才能发现这些隐藏在惰性求值系统中的时序裂缝。不要在#PF handler里加kprintf------你看到的只是单次故障的投影,真正的正确性存在于VMA语义、页表状态和物理页生命周期的三角一致性中。


本章小结与下一步

今天我们让内存管理挣脱了物理枷锁:

  • ✅ 缺页异常处理链完整实现合法缺页与违规访问区分
  • ✅ Demand Paging支持匿名页、文件页、可执行页懒加载
  • ✅ Swap子系统提供可靠的页面换入换出能力
  • ✅ COW使fork()时间复杂度从O(n)降至O(页表项数)
  • ✅ LRU近似算法支撑内存压力下的智能置换
  • ✅ 验证:fork独立性、大程序懒加载、swap吞吐全部达标

从此,你的操作系统拥有了真正的虚拟内存能力。当你第一次看到1GB进程fork瞬间完成、100MB程序仅占用10MB RSS启动、内存满载时swap自动接管而不崩溃时,你见证的是OS从"物理内存管理器"到"无限地址空间提供者"的认知革命。

下一章预告:《用户态运行时:C Runtime、动态链接与ELF加载器》

当前的系统只能运行静态链接的二进制。下一章将实现CRT0启动代码、glibc最小子集、ld.so动态链接器、ELF解释器,让你的OS支持gcc编译的标准C程序、共享库加载和符号解析,打通从源码到执行的最后壁垒。


参考资料
  • Linux Kernel: mm/memory.c, mm/swap_state.c, arch/x86/mm/fault.c, Documentation/vm/
  • Robert Love, Linux Kernel Development Ch.15 (The Virtual Memory Subsystem)
  • Mel Gorman, Understanding the Linux Virtual Memory Manager
  • Intel SDM Vol.3 Ch.4 (Paging) & Ch.6 (Exception Handling)
  • 本系列完整代码:你的GitHub仓库链接(Commit: v1m2s3w)

📝 作者注 :这是《从零手写操作系统》系列的第49篇。虚拟内存是整个教程中概念密度最高、硬件耦合最深、并发陷阱最密集 的章节。它要求你同时理解体系结构、数据结构、并发控制和存储I/O四重维度。强烈建议先在QEMU单步调试简单缺页,再引入swap;先实现纯匿名COW,再添加文件映射;先用固定置换策略,再实现LRU。把"#PF框架"、"匿名demand paging"、"COW"、"file mapping"、"swap"分成五个独立里程碑,并用缺页追踪+页表快照双重验证每一步的状态一致性,是避免在虚拟内存深渊中迷失的关键纪律。 下一章,我们让系统从"能跑汇编"走向"能跑标准C程序"!

相关推荐
迷途之人不知返1 小时前
【基础IO】-4-再谈重定向
linux
Android系统攻城狮2 小时前
Linux Gstreamer深度解析之gst_audio_ring_buffer_parse_caps调用流程与实战(八十)
linux·运维·服务器·gstreamer音视频·音视频进阶
l1t2 小时前
DeepSeek总结的 DuckDB 2.0 文档新内容
服务器·网络·数据库
erzong2312 小时前
Linux网络:UDP编程踩坑日记
linux·网络·udp
盐焗鹌鹑蛋2 小时前
【Linux】线程库与封装
linux
lwx9148522 小时前
Deepin操作系统
linux·服务器·网络
先知后行。2 小时前
进程间通信(IPC)
linux·运维·服务器
misty youth2 小时前
Navicat与PHPstudy小皮
mysql·adb·php·navicat
weixin_571667413 小时前
Windows 通过 SSH 反向隧道向 Ubuntu 转发代理
linux·运维·windows·代理