系统概述与启动流程

(关注宏观状态流转:从 Machine Mode 到 Supervisor Mode,再到第一个用户进程的诞生)

核心概念

启动流程的本质是特权级的逐级让渡:从硬件最高特权(Machine Mode)出发,经过层层初始化和降权,最终将控制权交给最不信任的用户程序。整个过程是一条从"全权控制"到"受限执行"的信任链——每一步都在缩小 CPU 的权限范围,同时建立更多的软件抽象层。

通用执行流程

一次完整的启动遵循以下宏观阶段:

  1. 裸机入口(汇编阶段)

    • CPU 加电后,硬件将每个核心(hart)跳转到固定地址(0x80000000)。
    • 此时没有任何 C 运行时环境——无栈、无全局变量。汇编代码的唯一职责是为每个核心计算独立的栈地址,然后跳转到 C 函数。多核系统中,每个核心按自己的 ID 获得不重叠的栈空间,避免并发踩踏。
  2. Machine Mode 初始化与降级

    • Machine Mode 是硬件最高特权级,负责配置只有它才能访问的寄存器:
      • 设置返回特权级:将 mstatus.MPP 设为 Supervisor Mode,使得"异常返回"指令(mret)执行后降级而非保持最高权限。
      • 设置返回地址:将异常程序计数器(RISC-V: mepc)设为内核 main() 函数地址。
      • 委托异常:将所有中断/异常委托给 Supervisor Mode 处理,让内核能自己管理中断而不需要固件介入。
      • 配置物理内存保护:允许 Supervisor 访问全部物理内存。
      • 启动定时器:配置时钟中断,为后续的抢占式调度打下基础。
    • 执行 mret:CPU 跳转到 main(),特权级降为 Supervisor Mode。
  3. 内核 C 环境建立

    • main() 中的初始化顺序体现了依赖链——先有内存分配器(其他子系统都需要分配内存),再有页表(启用虚拟地址),然后是进程表、中断处理、设备驱动,最后是文件系统:
      1. 物理页分配器(kinit):管理空闲物理内存
      2. 内核页表(kvminit):建立内核的虚拟地址映射
      3. 启用分页(kvminithart):让虚拟地址翻译生效
      4. 进程表(procinit):初始化 PCB 数组
      5. 中断向量(trapinit/trapinithart):设置异常处理入口
      6. 设备中断(plicinit/plicinithart):配置中断控制器
      7. 块缓存/文件系统/文件表:持久化子系统
      8. 第一个用户进程(userinit
  4. 多核同步

    • 只有 CPU 0(BSP, Bootstrap Processor)执行全部初始化。其他核心(AP)通过内存屏障等待 BSP 完成后,只启用分页和中断,直接进入调度器。内存屏障确保 BSP 的写操作对其他核心可见,防止指令重排导致 AP 看到不一致的初始化状态。
  5. 第一个用户进程诞生

    • userinit() 创建 PID=1 的进程,但此时它只有空的地址空间。真正的用户程序是通过 exec() 系统调用加载 /init 程序完成的——这发生在该进程首次被调度器选中时的 forkret() 中。选择在进程上下文(而非 main())中加载文件系统和执行 exec,是因为文件系统操作需要 sleep() 等进程原语,而 main() 还没有进程上下文。

设计权衡与工业界对比

维度 xv6 的选择 工业界 (Linux) 的做法
CPU 拓扑 硬编码单核假设,BSP 串行初始化 解析 Device Tree 动态发现硬件,多核并行启动 (SMP)
硬件描述 地址硬编码在头文件中 Device Tree Blob (DTB) 运行时描述硬件拓扑
初始化策略 一次性全部初始化 按需初始化,部分模块延迟到首次使用
启动协议 QEMU 直接跳转到内核 UEFI/BIOS → bootloader → 内核的标准启动链

用户态启发

  • 程序加载的通用逻辑:任何程序执行都遵循"建立运行环境 → 加载代码 → 设置参数 → 跳转入口"的模式。理解 exec 的流程有助于理解动态链接器(ld.so)的工作原理。
  • 依赖初始化顺序:大型 C++ 服务的启动同样面临子系统依赖问题。理解"先内存管理,再虚拟内存,再进程,最后文件系统"的顺序,有助于设计自己的服务初始化链。

源码锚点

kernel/start.c(Machine Mode 配置与降级)、kernel/main.c(子系统初始化顺序)、kernel/proc.c:userinit()(第一个进程的创建)


核心机制:中断/Trap 与 锁/并发

(关注特权级切换的通用抽象,以及并发控制的边界与规则)

核心概念

  • Trap 的本质:特权级切换的基础设施。无论是系统调用(主动陷入)、缺页异常(被动触发)还是硬件中断(外部信号),都通过同一套机制进入内核。它是用户态与内核态之间唯一的"合法通道"。
  • 锁的核心目的:在多核并发环境下,用串行化换取共享状态的一致性。

一、Trap 机制:特权级切换的完整路径

为什么需要这么复杂?

特权级切换时,CPU 必须完成四件事:保存"从哪来"(用户态的全部寄存器状态)、确认"到哪去"(内核的处理入口和栈)、切换"信任域"(页表从用户空间切换到内核空间)、处理完毕后精确恢复——仿佛什么都没发生。任何一个环节出错,都会导致系统崩溃或安全漏洞。

通用执行流程

无论 x86、ARM 还是 RISC-V,一次完整的 Trap 都遵循以下 5 个宏观阶段:

  1. 触发与陷落 (Trap)

    • 用户态执行特殊指令(如 RISC-V 的 ecall,x86 的 syscall/int 0x80),或遇到异常(如缺页),或硬件发出中断信号。
    • 硬件自动接管:提升 CPU 特权级,自动保存最关键的返回地址(异常程序计数器,RISC-V: sepc)和状态寄存器,并跳转到预设的内核入口(RISC-V: stvec 寄存器指向的地址)。
  2. 上下文保存 (Save Context)

    • 软件接管(通常是一段精心设计的汇编代码,如 xv6 的 trampoline)。
    • 将所有通用寄存器保存到内存中的固定结构——陷入帧 (Trapframe),防止后续内核代码覆盖用户态数据。
    • 每个进程都有自己独立的 trapframe,它既保存用户寄存器快照,也存放内核元数据(内核页表地址、内核栈指针、处理函数入口)。

    Trapframe 的设计体现了"一个结构,两个角色"的思想——前 5 个字段是内核元数据(告诉 trampoline 代码"回到内核需要什么"),后面全部是用户寄存器快照:

    // kernel/proc.h — 每个进程独享一份,映射到用户页表的固定虚拟地址
    struct trapframe {
      /* 内核元数据:trampoline 代码切换环境时使用 */
      uint64 kernel_satp;   // 内核页表地址
      uint64 kernel_sp;     // 该进程的内核栈顶
      uint64 kernel_trap;   // usertrap() 函数地址
      uint64 epc;           // 用户态程序计数器(被中断的那条指令)
      uint64 kernel_hartid; // CPU 核心 ID
    
      /* 用户寄存器快照:完整保存用户态执行现场 */
      uint64 ra, sp, gp, tp;
      uint64 t0-t6;         // 临时寄存器
      uint64 s0-s11;        // 保存寄存器
      uint64 a0-a7;         // 参数/返回值寄存器
    };
    

    为什么 trapframe 放在用户页表的固定虚拟地址? 因为切换页表的那一刻,内核页表还不可用,必须有一个在用户页表中就能访问到的、存放内核信息的地方。这是一个"鸡生蛋"问题的精巧解法。

  3. 环境切换 (Switch Environment)

    • 切换栈:从用户栈切换到该进程专属的内核栈(防止用户态篡改内核栈)。
    • 切换页表:从用户页表切换到内核页表。
    • 关键难点:切换页表的瞬间,CPU 仍在执行指令。因此,这段切换代码所在的内存页(Trampoline)必须在用户页表和内核页表中等值映射(映射到相同的虚拟地址),否则切换瞬间会导致页错误崩溃。这是整个 Trap 机制中最精巧的设计之一。
  4. 分发与执行 (Dispatch & Execute)

    • 进入内核 C 函数(如 usertrap)。
    • 查表路由:根据"陷入原因"(RISC-V: scause 寄存器)判断是系统调用、设备中断还是异常,分别走不同处理路径。
    • 系统调用的进一步分发:根据寄存器中的"系统调用号"(RISC-V: a7),在系统调用派发表中找到对应的内核函数(如 sys_write):
    // kernel/syscall.c — 函数指针数组,系统调用号作为索引
    static uint64 (*syscalls[])(void) = {
      [SYS_fork]   sys_fork,   [SYS_exit]   sys_exit,
      [SYS_wait]   sys_wait,   [SYS_pipe]   sys_pipe,
      [SYS_read]   sys_read,   [SYS_write]  sys_write,
      [SYS_exec]   sys_exec,   [SYS_open]   sys_open,
      // ... 共 21 个系统调用
    };
    
    void syscall(void) {
      int num = p->trapframe->a7;  // 从 trapframe 读取系统调用号
      if (num > 0 && num < NELEM(syscalls) && syscalls[num])
        p->trapframe->a0 = syscalls[num]();  // 调用并把返回值写入 a0
    }
    
    • 参数获取:从之前保存的 Trapframe 中提取用户传入的参数(RISC-V: a0-a5),并进行严格的合法性校验——所有用户指针必须通过 copyin()/copyout() 访问,不能直接解引用,防止恶意用户传入内核地址。
  5. 恢复与返回 (Restore & Return)

    • 将系统调用的返回值写入用户态的返回值寄存器(RISC-V: a0)。
    • 设置返回特权级(清除"先前特权级"标志位,使 sret 后回到 User Mode)。
    • 逆向执行上述步骤:从 Trapframe 恢复所有通用寄存器 → 切换回用户页表和用户栈 → 执行特权返回指令(RISC-V: sret),CPU 回到用户态继续执行下一条指令。

内核态中断处理

当内核自身遇到中断(如定时器中断),不需要切换页表和栈——因为已经在内核中了。直接保存 caller-saved 寄存器到当前内核栈,调用内核的 trap 处理函数。关键约束:进入内核 trap 处理时中断必须关闭,防止嵌套中断导致栈溢出。

信号 (Signal) — 用户态异步事件注入

xv6 没有实现信号机制,但信号是 Unix/Linux 中最重要的进程间通信方式之一。它的核心问题是:内核如何在不破坏用户态执行现场的前提下,强制用户程序执行一段特定代码?

信号解决的矛盾

进程通常只能通过系统调用主动与内核交互。但有些事件需要内核异步通知进程:

  • 用户按下 Ctrl+C(SIGINT)
  • 子进程退出(SIGCHLD)
  • 非法内存访问(SIGSEGV)
  • 定时器到期(SIGALRM)

信号的本质是内核向用户态注入的一次"软件中断"——它打断进程的正常执行流,强制跳转到用户注册的处理函数。

信号投递的通用流程
1. 信号产生:
   内核事件(如 Ctrl+C)或 kill() 系统调用 → 设置目标进程的 pending 信号位

2. 信号投递(在 trap 返回用户态之前检查):
   usertrap() / prepare_return() 检查 pending & ~blocked
   → 如果有待处理信号:
     a. 在用户栈上构造一个"信号帧"(保存当前的 trapframe)
     b. 修改 trapframe 中的 PC → 指向用户注册的 handler
     c. 修改 trapframe 中的 SP → 指向信号帧
     d. 将信号编号作为参数传给 handler

3. 信号处理函数执行:
   用户态 handler(signum) 正常执行

4. 信号返回:
   handler 结尾调用 sigreturn() 系统调用
   → 内核从信号帧恢复原始 trapframe
   → 进程从被中断的地方继续执行,仿佛什么都没发生
信号处理的三种模式
模式 含义 典型信号
默认行为 内核预设的处理(终止、忽略、core dump、停止) SIGINT → 终止
忽略 用户显式设置为 SIG_IGN SIGPIPE
自定义处理 用户注册 handler 函数 SIGINT → 自定义清理逻辑
关键设计难点

1. 重入问题:如果信号处理函数中又收到同一个信号怎么办?sigactionSA_RESTART 标志控制被中断的系统调用是否自动重启。

2. 信号帧的构造位置:信号帧必须放在用户栈上(而非内核栈),因为 sigreturn 需要从用户态调用。如果栈空间不足(如栈溢出场景),SIGSEGV 的处理本身也会失败——这就是为什么有些系统会保留一个备用信号栈 (sigaltstack)。

3. 多信号的竞争:多个信号同时 pending 时的投递顺序未定义(POSIX 不保证)。内核通常按信号编号从小到大投递。

4. 与 trap 的集成:信号检查必须在 trap 返回路径的最后一步——因为此时 trapframe 已经准备好返回用户态,注入信号帧不会破坏内核状态。

xv6 的 killed 机制是信号的极简原型:xv6 的 p->killed 标志就是一种"只有 SIGKILL"的信号系统——进程在 trap 返回时检查 killed 标志,如果为 1 则退出。信号机制将其泛化为:多种信号类型 + 用户可注册处理函数 + 可阻塞/忽略。

设计权衡与工业界对比

维度 xv6 的选择 工业界 (Linux) 的做法
Trap 入口 全部走 trampoline(统一入口) syscall 有专用快速入口 (vDSO),减少路径长度
上下文保存 全部寄存器保存到 trapframe 仅保存 caller-saved,使用 pt_regs 结构
系统调用表 静态数组,编译时固定 动态系统调用表,支持版本兼容和过滤
页表切换 每次 trap 都切换 内核页表全局共享,单次写入;PCID 减少 TLB 刷新
信号处理 不支持 在 trap 返回路径中注入信号处理帧
参数校验 copyin/copyout 拷贝数据 copy_from_user + 地址范围检查,更高效

用户态启发

  • 系统调用开销:每次系统调用经历完整 trap 路径(数千周期)。这解释了为什么高性能服务器使用 epoll/io_uring 等批量 I/O 机制——减少 trap 次数就是减少开销。一次 writev 比多次 write 高效得多。
  • vDSO 的价值gettimeofday() 等频繁调用通过映射内核代码到用户空间,避免 trap 开销。这是"用空间换时间"在特权级切换场景的应用。

源码锚点

kernel/trampoline.S(trampoline page,跨特权级切换的"两栖"代码)、kernel/trap.c:usertrap()(Trap 分发入口)、kernel/syscall.c(系统调用派发表与参数解析)


二、锁与并发控制

为什么需要锁?

多核系统中,多个 CPU 可能同时访问同一数据。如果没有同步机制,就会出现竞态条件——结果取决于指令执行的相对时序,不可复现、不可预测。锁的核心目的是保护共享状态的一致性,代价是牺牲并发度。

两种锁的语义

自旋锁 (Spinlock) —— 短期、不可睡眠的锁:

  • 获取不到就忙等待(spin),不放弃 CPU。
  • 持有期间必须关中断。原因:如果持有锁时被中断打断,而中断处理函数又尝试获取同一把锁 → 死锁。这是"自旋锁必须配关中断"的根本原因。
  • 适用场景:临界区极短(几条指令),如修改一个计数器、操作链表指针。

获取锁的核心是一个原子交换操作——确保多核竞争时只有一个能成功:

// kernel/spinlock.c
void acquire(struct spinlock *lk)
{
  push_off();                              // 关中断(嵌套计数)
  if (holding(lk)) panic("acquire");       // 死锁检测:自己不能重入

  // 原子交换:将 lk->locked 设为 1,返回旧值
  // __ATOMIC_ACQUIRE 确保临界区的内存操作不会被重排到获取锁之前
  while (__atomic_exchange_n(&lk->locked, 1, __ATOMIC_ACQUIRE) != 0)
    ;                                      // 忙等待直到旧值为 0

  lk->cpu = mycpu();                       // 记录持有者(用于调试)
}

void release(struct spinlock *lk)
{
  if (!holding(lk)) panic("release");
  lk->cpu = 0;

  // __ATOMIC_RELEASE 确保临界区的修改在释放锁之前对其他 CPU 可见
  __atomic_store_n(&lk->locked, 0, __ATOMIC_RELEASE);

  pop_off();                               // 恢复中断状态
}

RISC-V 细节__atomic_exchange_n 编译为 amoswap.w.aq(原子交换 + acquire 语义),__atomic_store_n 编译为 fence rw,w + sw(release 语义)。acquire/release 配对确保了临界区的内存可见性。

睡眠锁 (Sleeplock) —— 长期、可睡眠的锁:

  • 获取不到就让出 CPU(sleep),等待被唤醒。
  • 持有期间可以睡眠(如等待磁盘 I/O 完成)。
  • 底层基于自旋锁 + sleep/wakeup 条件等待机制实现。
  • 适用场景:临界区可能很长,如 inode 操作(涉及磁盘读写)。

sleep/wakeup — 条件等待机制

这是 xv6 中最精巧的并发原语之一。它基于**通道(channel)**的条件变量,用一个指针地址作为匹配标识。

为什么"顺序"是生死攸关的?

sleep 的核心问题是防止丢失唤醒 (lost wakeup)。看以下两个版本:

  CPU 0 (sleep)                      CPU 1 (wakeup)
  ─────────────                      ──────────────
  错误版本:先释放条件锁,再获取进程锁

  release(lk)     ← 释放条件锁
       │          ← 时间窗口:wakeup 可能在此刻发生
       │                             acquire(&p->lock)
       │                             p->state != SLEEPING  ← 还没设!
       │                             release(&p->lock)
       │                             wakeup 白跑了
  acquire(&p->lock)
  p->state = SLEEPING  ← 太晚了,wakeup 已经过去
  sched()               ← 永远睡死

  ─────────────────────────────────────────────────────

  正确版本:先获取进程锁,再释放条件锁

  acquire(&p->lock) ← 锁住自己
  release(lk)       ← 释放条件锁
       │                此时 wakeup 尝试 acquire(&p->lock)
       │                但拿不到——必须等我们设好状态
  p->state = SLEEPING
  sched()             ← 安全睡眠
                      ▼ wakeup 终于拿到 p->lock
                      p->state == SLEEPING ✓
                      p->state = RUNNABLE   ← 唤醒成功

错误版本 — 先释放条件锁,再获取进程锁:

sleep(chan, lk):
    release(lk)          // ← 释放条件锁
    acquire(&p->lock)    // ← 获取进程锁
    p->chan = chan
    p->state = SLEEPING
    sched()

问题:在 release(lk)acquire(&p->lock) 之间,另一个 CPU 可能调用 wakeup(chan)——此时当前进程还没设为 SLEEPING,wakeup 找不到它。等当前进程继续执行设为 SLEEPING 后,wakeup 已经过去了,进程永远睡死。

xv6 的正确版本 — 先获取进程锁,再释放条件锁:

// kernel/proc.c
void sleep(void *chan, struct spinlock *lk)
{
  struct proc *p = myproc();

  acquire(&p->lock);  // ① 先锁住自己——确保 wakeup 不会"错过"我们
  release(lk);         // ② 再释放条件锁——此时即使 wakeup 发生,
                       //    它也拿不到 p->lock,必须等我们设好状态

  p->chan = chan;       // ③ 设置睡眠通道
  p->state = SLEEPING; // ④ 标记为睡眠
  sched();             // ⑤ 让出 CPU

  // 被 wakeup 唤醒后继续执行:
  p->chan = 0;         // 清除通道
  release(&p->lock);   // 释放进程锁
  acquire(lk);         // 重新获取条件锁
}
// kernel/proc.c
void wakeup(void *chan)
{
  struct proc *p;
  for (p = proc; p < &proc[NPROC]; p++) {
    if (p != myproc()) {
      acquire(&p->lock);           // 必须获取 p->lock
      if (p->state == SLEEPING && p->chan == chan)
        p->state = RUNNABLE;       // 标记为可运行(唤醒)
      release(&p->lock);
    }
  }
}

关键洞察p->lock 在 sleep 和 wakeup 之间形成了一个临界区。sleep 在持有 p->lock 的期间设置 chanstate,wakeup 在持有 p->lock 的期间检查它们。这保证了"设置睡眠状态"和"检查睡眠状态"之间不会被插入任何东西——唤醒不会丢失。

类比 POSIX 条件变量:这个模式等价于 pthread_cond_wait 中"持有 mutex → 检查条件 → 调用 wait(原子地释放 mutex 并睡眠)“的范式。如果在检查条件之前释放 mutex,就可能丢失信号——这是所有条件变量的通病。

锁的使用规则

  1. 锁序规则:所有代码必须以相同的顺序获取多把锁,否则会死锁(如 A→B 和 B→A 的循环等待)。
  2. 持锁睡眠规则:持有自旋锁时不能睡眠(因为睡眠需要调用调度器,而调度器可能需要同一把锁)。
  3. 中断规则:持有自旋锁时中断必须关闭(防止中断处理函数重入同一把锁)。

从全局锁到细粒度锁:xv6 的并发瓶颈与优化思路

xv6 中大量使用全局锁(如 bcache.lock 保护整个块缓存、kmem.lock 保护全部物理页),这意味着同一时刻只有一个 CPU 能访问这些共享资源。在多核系统上,这会导致严重的锁竞争 (lock contention)——CPU 花在等锁上的时间比真正干活的时间还多。

细粒度锁的核心思想

将一把大锁拆成多把小锁,每把小锁只保护数据的一个子集,让不访问同一子集的操作可以并行:

xv6 的做法(粗粒度):
  bcache.lock ──保护──→ 所有 NBUF 个缓存块

优化后(细粒度):
  bcache.bucket[0].lock ──保护──→ hash 值为 0 的缓存块
  bcache.bucket[1].lock ──保护──→ hash 值为 1 的缓存块
  ...
  bcache.bucket[n].lock ──保护──→ hash 值为 n 的缓存块
典型优化案例:块缓存 (bcache)

xv6 的 bget() 用一把全局锁保护整个 LRU 链表。优化方案:

  1. 哈希分桶:将缓存块按 (dev, blockno) 哈希到 N 个桶中,每个桶一把锁。查找缓存时只需锁对应的桶,不同桶的操作完全并行。
  2. per-CPU 缓存:每个 CPU 维护自己的 LRU 列表,只有自己的列表为空时才从其他 CPU “偷” 一个块。消除了单点竞争。
  3. LRU 的近似替代:精确 LRU 需要全局排序,是锁竞争的根源。改用 CLOCK 算法(近似 LRU)或采样策略,用"足够好"换取"足够快”。
其他细粒度锁优化场景
场景 xv6 的锁 优化方向
物理页分配 全局 kmem.lock per-CPU free list + 批量借用
进程表遍历 遍历时逐个 acquire(&p->lock) RCU 读端无锁 + 延迟回收
inode 缓存 itable.lock 全局锁 per-bucket 哈希表
文件描述符表 ftable.lock 全局锁 per-fd 或 per-CPU

细粒度锁的代价:更多锁 = 更多内存、更复杂的锁序管理、更高的死锁风险。Linux 用 lockdep 在运行时检测锁依赖图,自动报告潜在死锁。没有 lockdep 的情况下,必须手动维护锁序文档。

无锁的终极方案:RCU (Read-Copy-Update) 让读操作完全无锁——读端只需禁止抢占(rcu_read_lock),写端先复制一份数据修改,再原子替换指针,最后等待所有读端离开临界区后释放旧数据。代价是写操作更复杂,且需要垃圾回收机制。

设计权衡与工业界对比

维度 xv6 的选择 工业界 (Linux) 的做法
自旋锁实现 简单忙等待 ticket lock → qspinlock(减少缓存行 bouncing)
读写并发 不支持 rwlock:读并发、写独占
无锁机制 RCU (Read-Copy-Update):读操作零开销
死锁检测 lockdep 运行时锁依赖图检测
锁粒度 全局锁(如 bcache.lock per-CPU / per-namespace 细粒度锁
条件等待 channel 指针 + sleep/wakeup POSIX 条件变量 + futex

用户态启发

  • 用户态锁的实现pthread_mutex 最终通过 futex 实现——无竞争时在用户态自旋(零系统调用),有竞争时才陷入内核睡眠。这与 xv6 的 spinlock/sleeplock 思路一脉相承。
  • 条件变量模式:sleep/wakeup 的 channel 模式是 POSIX 条件变量的底层原型。理解它有助于正确使用 pthread_cond_wait——必须在持有 mutex 的情况下检查条件,否则会丢失信号。
  • 锁粒度的选择:xv6 的全局 bcache.lock 是性能瓶颈的典型例子。高并发服务中,应该尽量使用细粒度锁或无锁数据结构(如 concurrent hash map)。

源码锚点

kernel/spinlock.c:acquire()(自旋锁与中断控制)、kernel/sleeplock.c:acquiresleep()(睡眠锁实现)、kernel/proc.c:sleep()/wakeup()(条件等待机制)


资源管理模块(进程与内存)

(关注状态机、资源交接抽象,以及虚拟内存的映射机制)

核心概念

  • 进程控制块 (PCB):进程在内核中的"身份证"。它封装了程序运行所需的全部资源:地址空间、打开的文件、执行上下文、父子关系。进程的状态机驱动着整个 OS 的运行。
  • 虚拟内存的本质:为每个进程提供独立的、连续的地址空间假象,实际物理内存可能是碎片化的。它是隔离、抽象和按需分配三大价值的基石。

一、进程管理

进程状态机

进程的生命周期是一个状态机,每个状态对应一种资源占有方式:

                    fork()     scheduler()
  UNUSED ──→ USED ────→ RUNNABLE ⇄ RUNNING
                           ↑  ↓          ↓
                          sleep()      exit()
                            ↓            ↓
                         SLEEPING ──→ ZOMBIE ──→ UNUSED
                                      (wait 回收)
  • RUNNABLE:万事俱备,只等调度器选中。
  • SLEEPING:主动让出 CPU,等待某个事件(I/O 完成、锁释放、子进程退出)。
  • ZOMBIE:已死但未葬——进程已退出,但 PCB 还在等父进程 wait() 收集退出状态。

PCB 是进程在内核中的"身份证",它封装了进程运行所需的全部资源:

// kernel/proc.h — 进程控制块
struct proc {
  struct spinlock lock;

  /* 受 p->lock 保护:进程核心状态 */
  enum procstate state;  // UNUSED / USED / SLEEPING / RUNNABLE / RUNNING / ZOMBIE
  void *chan;             // sleep 通道(指针地址作为匹配标识)
  int killed;            // 终止标记
  int xstate;            // 退出状态码(返回给父进程)
  int pid;

  /* 受 wait_lock 保护:进程关系 */
  struct proc *parent;   // 父进程指针

  /* 私有数据:不需要锁保护 */
  uint64 kstack;               // 内核栈虚拟地址
  uint64 sz;                   // 用户地址空间大小
  pagetable_t pagetable;       // 用户页表
  struct trapframe *trapframe; // 陷入帧(保存用户态寄存器)
  struct context context;      // 内核上下文(swtch 使用,仅保存 callee-saved 寄存器)
  struct file *ofile[NOFILE];  // 打开的文件表(最多 16 个)
  struct inode *cwd;           // 当前工作目录
  char name[16];               // 进程名(调试用)
};

注意锁的分层p->lock 保护进程状态,wait_lock 保护父子关系。这种分层避免了在频繁的状态变更中持有全局锁,是细粒度锁设计的体现。

fork / exec / wait / exit — 资源的复制、重建、回收

fork 解决的矛盾:如何创建一个与当前执行环境几乎相同的独立实体?

xv6 的做法是全量复制——复制页表、复制物理页、复制文件描述符引用。这保证了父子进程完全独立,但代价高昂(O(内存大小))。子进程的返回值被设为 0(通过修改 trapframe 中的 a0),父进程返回子进程 PID,这是区分父子的唯一手段。

exec 解决的矛盾:如何在不改变进程身份(PID)的前提下,彻底替换它正在执行的程序?

它销毁旧的地址空间,按 ELF 格式构建新的——逐段加载代码和数据、分配用户栈(含 guard page 防止栈溢出越界)、将参数字符串和 argv 指针压入栈。最后设置新的程序计数器和栈指针。exec 是"换脑手术"——PID 不变,但地址空间、代码、数据全部换新。

exit/wait 解决的矛盾:进程退出后,谁来回收它的资源?

xv6 设计了 ZOMBIE 状态作为"缓冲"——exit 不直接释放 PCB,而是等父进程通过 wait() 收集退出状态后再释放。exit 同时将孤儿进程重新挂到 init 进程下,确保总有人负责回收。如果父进程先死,孤儿进程不会成为"幽灵"。

上下文切换

上下文切换的本质是保存当前进程的内核寄存器,恢复下一个进程的寄存器,跳转执行。只需保存 callee-saved 寄存器(返回地址、栈指针、s0-s11),因为 caller-saved 寄存器已经在调用约定中由调用者保存。

// kernel/proc.h — 上下文结构,仅 14 个寄存器(vs trapframe 的 31 个)
struct context {
  uint64 ra;   // 返回地址
  uint64 sp;   // 栈指针
  uint64 s0-s11; // callee-saved 寄存器
};

调度器选中进程后调用 swtch 从调度器上下文切换到进程上下文;进程需要让出 CPU 时调用 swtch 从进程上下文切回调度器上下文。注意:内核态的上下文切换不需要切换页表——所有进程共享内核页表。

为什么只需保存 14 个寄存器? RISC-V 调用约定中,ra, sp, s0-s11 是 callee-saved(被调用函数必须保存恢复),t0-t6, a0-a7 是 caller-saved(调用者自己负责)。swtch 是一个普通函数调用,所以编译器已经帮我们保存了 caller-saved 寄存器。这就是为什么理解调用约定对理解上下文切换至关重要。

调度器

xv6 使用简单的 Round-Robin 调度:每个 CPU 独立运行一个无限循环,遍历进程表找到 RUNNABLE 进程切换执行。没有优先级,没有时间片量化(仅靠定时器中断强制抢占)。没有进程时执行 wfi(Wait For Interrupt)降低功耗。

管道 — 进程间通信

管道是 xv6 中唯一的进程间通信机制,它的设计体现了"文件即接口"的思想——管道和普通文件共享同一套 read/write 系统调用:

// kernel/pipe.c — 管道就是一个环形缓冲区 + 两个文件描述符
struct pipe {
  struct spinlock lock;
  char data[PIPESIZE];  // 512 字节的环形缓冲区
  uint nread;           // 已读字节数
  uint nwrite;          // 已写字节数
  int readopen;         // 读端是否打开
  int writeopen;        // 写端是否打开
};

同步规则:写满时写端睡眠、唤醒读端;读空时读端睡眠、唤醒写端。关闭写端后读端返回 0(EOF),关闭读端后写端返回 -1。

设计权衡与工业界对比

维度 xv6 的选择 工业界 (Linux) 的做法
fork 实现 全量复制物理页 COW (Copy-On-Write):共享物理页,写入时才复制
线程模型 无(进程即执行单位) clone() 支持共享地址空间的线程
PID 管理 简单递增 PID namespace 隔离,PID 回收与复用
调度算法 简单 Round-Robin CFS (Completely Fair Scheduler) 基于虚拟运行时间
进程上限 编译时常量 (64) 动态分配,受 pid_max 和资源限制

COW 为何重要? fork 后紧跟 exec 是 shell 执行命令的标准模式。如果 fork 全量复制,exec 又立即丢弃,复制就白做了。COW 让 fork 的代价从 O(内存大小) 降到 O(页表大小)。

COW fork 的核心原理

COW 的核心思想是:fork 时不复制物理页,而是让父子进程共享同一份物理内存,仅在某一方尝试写入时才真正复制。

实现的关键是对页表权限位的利用:

fork 时:
  1. 创建子进程页表,但不分配新物理页
  2. 将父子双方的 PTE 都标记为"只读"(清除 PTE_W)
  3. 记录每个物理页的引用计数(共享计数)

写入时(触发 Store Page Fault):
  1. 陷入内核,检测到是 COW 页(PTE_V 有效,PTE_W 无效,但标记为 COW)
  2. 如果该物理页引用计数 == 1:直接将 PTE 改为可写(无需复制,已经是独占)
  3. 如果引用计数 > 1:分配新物理页 → 复制内容 → 更新 PTE 指向新页并设为可写
     → 原物理页引用计数减 1

关键设计:COW 页在页表中是"只读"的,但并非真的只读——缺页处理函数会区分"真正的只读页越权写入"(应 SIGSEGV)和"COW 页的正常写入"(应复制)。这需要一个额外的位(如 RISC-V 的 RSW 位或软件维护的 bitmap)来标记哪些页是 COW 页。

COW 的连锁效应:当物理页被复制后,如果该页还被其他进程共享,需要通过 TLB shootdown 通知其他 CPU 刷新 TLB 中的旧翻译——否则其他 CPU 可能继续使用旧的只读映射。这是多核 COW 的隐藏复杂性。

用户态启发

  • fork 的性能意识:fork 后不 exec 的场景(如网络服务 fork 子进程处理请求)要注意内存复制代价。现代做法是用 vforkposix_spawn 替代。
  • 僵尸进程:父进程不 wait,子进程变僵尸占用 PCB。这就是 daemon 要 double fork 的原因——让 init 成为祖先进程负责回收。
  • 上下文切换的开销:每次上下文切换都有 TLB 刷新、缓存污染的隐性代价。高并发服务倾向于用协程/用户态线程减少内核态切换。

源码锚点

kernel/proc.c:kfork()(fork 的全量复制语义)、kernel/exec.c:kexec()(exec 的地址空间重建)、kernel/proc.c:scheduler()/sched()(调度器与上下文切换)


二、内存管理

虚拟内存解决了什么矛盾?

程序需要连续的地址空间来简化编程,但物理内存是碎片化的、多进程共享的。虚拟内存通过页表这个"翻译层",为每个进程提供独立的、连续的地址空间假象。它同时带来三大价值:

  1. 隔离:一个进程的野指针不会破坏另一个进程的数据。
  2. 抽象:程序不需要关心物理内存布局。
  3. 按需分配:可以分配比物理内存更大的地址空间(配合延迟分配和 swap)。

虚拟地址空间布局

代码段(.text,可读可执行)→ 数据段(.data,可读可写)→ 堆(向上增长,通过 sbrk 扩展)→ 栈(固定大小,向下增长)→ guard page(不可访问,检测栈溢出)→ trapframe(陷入帧,内核写入)→ trampoline(陷入入口代码,与内核共享)。

内核地址空间使用直接映射(虚拟地址 = 物理地址 + 偏移),简化了内核的地址转换。

页表遍历与映射

页表的本质是一个函数虚拟地址 → 物理地址 + 权限

xv6 使用 RISC-V 的 Sv39 方案,39 位虚拟地址被分为三级索引,每级 9 位(512 个条目),最后一级索引到具体的页表项(PTE)。每个 PTE 包含物理页号和权限位(读/写/执行/用户可访问)。

// 虚拟地址的位划分 (Sv39):
// [38..30] 9位 → L2 索引(页目录)
// [29..21] 9位 → L1 索引(页目录)
// [20..12] 9位 → L0 索引(页表)
// [11..0]  12位 → 页内偏移(4KB 页)

walk() 是页表遍历的核心——从根页表开始,逐级索引,找到最终的 PTE:

// kernel/vm.c
pte_t *walk(pagetable_t pagetable, uint64 va, int alloc)
{
  if (va >= MAXVA) panic("walk");

  for (int level = 2; level > 0; level--) {    // 从 L2 到 L1 逐级下降
    pte_t *pte = &pagetable[PX(level, va)];    // 用虚拟地址的对应 9 位做索引
    if (*pte & PTE_V) {                         // 有效 → 下一级页表已存在
      pagetable = (pagetable_t)PTE2PA(*pte);   // 提取物理地址,继续
    } else {
      if (!alloc || (pagetable = kalloc()) == 0)
        return 0;                               // 不分配或内存不足
      memset(pagetable, 0, PGSIZE);
      *pte = PA2PTE(pagetable) | PTE_V;        // 建立映射,继续
    }
  }
  return &pagetable[PX(0, va)];                // 返回 L0 的 PTE 指针
}

关键设计walk 返回的是 PTE 的指针而非值,调用者可以直接修改它来建立或改变映射。alloc 参数控制是否按需创建中间级页表——这就是"按需构建页表"的机制,与延迟分配配合,避免为未使用的地址空间浪费页表内存。

物理页分配器

物理页分配器管理空闲的物理内存页。xv6 使用最简单的数据结构——空闲链表:每个空闲页的起始位置存放指向下一个空闲页的指针。

// kernel/kalloc.c — 空闲页复用自身的内存作为链表节点
struct run {
  struct run *next;  // 指向下一个空闲页(就存在该页自己的内存中)
};

struct {
  struct spinlock lock;   // 保护 freelist 的全局锁
  struct run *freelist;   // 空闲页链表头
} kmem;

kalloc() 从链表头取出一个页,kfree() 将页放回链表头。全局锁保护链表操作。

精巧之处:空闲页没有被使用,所以可以借用它的内存存放链表指针——零额外空间开销。这是侵入式链表 (intrusive list) 的经典应用。

调试技巧:kfree 用特定值填充(memset(pa, 1, PGSIZE)),kalloc 用另一个值填充,方便检测悬挂引用和未初始化读取——访问已释放的页面会看到 0x0101...,访问未初始化的页面会看到 0x0505...

延迟分配 (Lazy Allocation)

sbrk() 可以只增加进程的虚拟地址空间大小,不立即分配物理页。当进程实际访问该地址时,触发缺页异常,内核才分配物理页并建立映射——如果分配了但从未使用,物理页永远不会被浪费。这是一种"按需付费"的策略。

mmap — 内存映射文件与灵活的地址空间管理

sbrk() 只能管理堆区域,而 mmap 允许用户程序将文件匿名内存映射到虚拟地址空间的任意位置,是现代 OS 中最重要的内存管理接口。

mmap 解决的矛盾

用户程序需要三种能力,sbrk() 都无法提供:

  1. 文件映射:将文件内容直接映射到内存,通过指针访问代替 read()/write(),减少数据拷贝。
  2. 匿名内存:分配不与任何文件关联的大块内存(如 malloc 对大块的分配)。
  3. 灵活布局:在地址空间的任意位置映射,不受堆增长方向的限制。
核心机制
// 用户态接口
void *mmap(void *addr, size_t len, int prot, int flags, int fd, off_t offset);

内核需要维护一个 VMA (Virtual Memory Area) 链表,记录进程地址空间中每一段映射的属性:

// Linux 中的 vm_area_struct(xv6 未实现,但原理一致)
struct vm_area {
  uint64 start, end;    // 映射的虚拟地址范围
  uint64 flags;         // 权限:PROT_READ / PROT_WRITE / PROT_EXEC
  uint64 file_offset;   // 文件偏移(匿名映射时为 -1)
  struct file *file;    // 关联的文件(匿名映射时为 NULL)
  struct vm_area *next; // 链表指针
};
mmap 的两种模式
文件映射 匿名映射
flags MAP_FILE MAP_ANONYMOUS
数据来源 磁盘文件 零页
典型用途 共享库加载、数据库 mmap malloc 大块分配、线程栈
共享 vs 私有 MAP_SHARED:写回磁盘;MAP_PRIVATE:COW 通常 MAP_PRIVATE
缺页处理的扩展

mmap 区域的缺页处理比 sbrk 更复杂——内核需要查找 VMA 链表确定该地址属于哪个映射,然后:

  • 文件映射:从磁盘读取对应偏移的数据到新分配的物理页
  • 匿名映射:分配零填充的物理页
  • COW:复制并解除共享

mmap 的性能陷阱MAP_SHARED 文件映射的写入会直接修改 page cache,由内核的 pdflush 线程异步刷盘。如果不调 msync(),崩溃后可能丢失数据——这与 write() + fsync() 的语义不同。

与 sbrk 的关系:现代 malloc 实现(如 glibc)对小块用 sbrk 扩展堆,对大块(通常 > 128KB)用 mmap(MAP_ANONYMOUS) 分配独立区域。这样大块释放时可以直接 munmap 归还给内核,而 sbrk 只能收缩堆顶。

设计权衡与工业界对比

维度 xv6 的选择 工业界 (Linux) 的做法
页表级数 3 级 (Sv39) 4~5 级 (Sv48/Sv57),支持更大地址空间
物理分配器 简单空闲链表 + 全局锁 Buddy System(减少碎片)+ Slab(对象缓存)+ per-CPU 缓存
延迟分配 基础支持 (lazy sbrk) 完整支持 (mmap lazy, demand paging)
COW 不支持 fork 时默认 COW
大页 不支持 2MB/1GB Huge Pages,减少 TLB miss
内存回收 无 swap kswapd + LRU 链表 + swap 分区

用户态启发

  • 栈溢出检测:guard page 的设计启发——在栈底放一个不可访问的页,溢出时触发段错误而非静默破坏数据。调试 segfault 时,如果地址靠近栈区域,大概率是栈溢出。
  • malloc 的底层:用户态 malloc 最终通过 sbrk()mmap() 向内核申请虚拟地址空间。理解虚拟地址布局有助于诊断内存问题——代码段地址说明函数指针错误,栈地址说明栈溢出,堆地址说明堆损坏。
  • 虚拟内存与性能:页表遍历是开销(每级一次内存访问),这就是 TLB(地址翻译缓存)存在的原因。大页 (Huge Pages) 通过减少页表级数和 TLB miss 来提升性能,对数据库等大内存应用尤为重要。

源码锚点

kernel/vm.c:walk()(三级页表遍历)、kernel/kalloc.c:kalloc()/kfree()(物理页分配器)、kernel/proc.c:growproc()(延迟分配的入口)


持久化模块(文件系统与日志)

(关注 inode 抽象、路径解析,以及崩溃恢复的原子性保证)

核心概念

  • VFS 抽象:将磁盘这种线性的、固定大小的块设备,组织成树形的、可按名存取的文件层次结构。文件、目录、设备都被统一为 inode,通过文件描述符表对外暴露统一的读写接口。
  • 日志 (Logging) 的原子性保证:文件系统操作通常需要修改多个磁盘块。如果中途崩溃(断电),文件系统会处于不一致状态。Write-Ahead Logging 通过"先写日志,再写真实位置"确保操作的原子性——崩溃恢复时检查日志,有完整事务则重放,否则丢弃。

一、文件系统的五层架构

xv6 的文件系统分为五层,每层为上层提供抽象,隐藏下层的复杂性:

路径层     namei() —— 将 "/a/b/c" 解析为 inode
目录层     dirlookup() / dirlink() —— 目录是包含 (name, inum) 对的特殊文件
文件层     ialloc() / iget() / readi() / writei() —— inode 的分配、缓存与读写
日志层     begin_op() / log_write() / end_op() —— 崩溃恢复的原子性保证
块缓存层   bread() / bwrite() / brelse() —— 磁盘块的内存缓存与并发同步
磁盘       virtio_disk_rw()

路径层不关心数据在磁盘的哪个扇区,块缓存层不关心它缓存的是文件数据还是 inode 元数据。这种分层是"关注点分离"原则的典型体现。

文件系统对外暴露的统一接口是文件描述符,它通过引用计数实现共享:

// kernel/file.h — 文件描述符的内核表示
struct file {
  enum { FD_NONE, FD_PIPE, FD_INODE, FD_DEVICE } type; // 三种文件类型
  int ref;          // 引用计数(dup 时增加,close 时减少,归零时释放)
  char readable;    // 是否可读
  char writable;    // 是否可写
  struct pipe *pipe;     // FD_PIPE: 管道
  struct inode *ip;      // FD_INODE / FD_DEVICE: 指向 inode
  uint off;              // FD_INODE: 当前读写偏移
  short major;           // FD_DEVICE: 设备号
};

为什么 file 需要引用计数? 因为 dup 系统调用可以让多个文件描述符指向同一个 file 结构。只有当所有引用都关闭后,才能释放底层资源。这是引用计数模式的典型应用——类似 C++ 的 shared_ptr

二、路径解析与 inode

路径解析 (namei)

路径解析是从根目录(或当前目录)开始,逐级查找路径组件的过程。每一步都是"锁定目录 inode → 在目录内容中查找名字 → 获取下一级 inode → 解锁"的循环。这是一个递归下降的过程,类似于编译器中语法分析器对 AST 的遍历。

inode — 文件的内核表示

inode 是文件系统中最核心的抽象。每个文件有一个 inode,记录类型(普通文件/目录/设备)、大小、链接数、数据块位置。

inode 有两层表示——磁盘上的持久化版本和内存中的缓存版本:

// kernel/fs.h — 磁盘上的 inode(永久存储)
struct dinode {
  short type;              // 文件类型:0=空闲, 1=目录, 2=文件, 3=设备
  short major;             // 主设备号(仅设备类型)
  short minor;             // 次设备号(仅设备类型)
  short nlink;             // 硬链接计数(降为 0 时文件可被回收)
  uint size;               // 文件大小(字节)
  uint addrs[NDIRECT + 1]; // 数据块地址:12 个直接 + 1 个间接
};
// kernel/file.h — 内存中的 inode(缓存版本,额外包含管理字段)
struct inode {
  uint dev;              // 设备号
  uint inum;             // inode 编号(磁盘上的位置)
  int ref;               // 引用计数(有多少指针指向此 inode)
  struct sleeplock lock; // 保护以下字段(操作可能涉及磁盘 I/O,用睡眠锁)
  int valid;             // 是否已从磁盘读取(0 = 需要 ilock 时重新读入)

  /* 以下是从磁盘 dinode 复制来的字段 */
  short type, major, minor, nlink;
  uint size;
  uint addrs[NDIRECT + 1];
};

两层分离的价值iget() 通过引用计数管理内存 inode 的生命周期(ref=0 时可回收),ilock() 通过 valid 标志实现延迟加载(首次锁定时才从磁盘读入)。这类似于数据库的 buffer pool 管理——频繁访问的元数据放在内存中,减少磁盘 I/O。

数据块映射 (bmap)

inode 通过 addrs[] 数组指向数据块。前 12 个是直接块(小文件高效,无需间接寻址),第 13 个是一级间接块(指向一个包含 256 个块号的块,支持约 270KB 的文件)。

这是"用少量元数据寻址大量数据"的基本思路。直接块覆盖小文件(大多数文件都很小),间接块覆盖大文件。工业级文件系统(如 ext4)用多级间接块和 Extent 树支持 TB 级文件。

从一级间接到多级索引:大文件支持

xv6 的 addrs[12] 只有一级间接块,单个文件最大约 270KB((12 + 256) × 1024)。要支持大文件,核心思路是增加间接层级

xv6 现状:
  addrs[0..11]  → 12 个直接块(12KB)
  addrs[12]     → 一级间接块(256 × 1KB = 256KB)
  最大文件 ≈ 268KB

扩展方案(双间接 + 三间接):
  addrs[12]     → 一级间接块:256 个数据块指针
  addrs[13]     → 双间接块:256 × 256 = 65536 个数据块指针
  addrs[14]     → 三间接块:256 × 256 × 256 = 16M 个数据块指针
  最大文件 ≈ 16GB(使用 4KB 块时可达 TB 级)

bmap 的扩展是递归的:

// 扩展后的 bmap 伪代码
static uint bmap(struct inode *ip, uint bn)
{
  if (bn < NDIRECT)                         // 直接块
    return ip->addrs[bn];
  bn -= NDIRECT;

  if (bn < NINDIRECT)                       // 一级间接
    return indirect(ip->addrs[NDIRECT], bn);
  bn -= NINDIRECT;

  if (bn < NINDIRECT * NINDIRECT)           // 双间接
    return double_indirect(ip->addrs[NDIRECT+1], bn);
  bn -= NINDIRECT * NINDIRECT;

  return triple_indirect(ip->addrs[NDIRECT+2], bn); // 三间接
}

多级索引的代价:每增加一级间接,就多一次磁盘 I/O 才能获取数据块地址。对于随机读写,三间接块可能需要 4 次 I/O(三间接块本身 → 双间接块 → 单间接块 → 数据块)。这就是为什么 ext4 引入了 Extent 树——用连续块范围(起始块号 + 长度)替代逐块索引,一个 extent 可以描述数 MB 的连续数据,大幅减少元数据 I/O。

Extent 树的核心思想
传统索引(xv6 风格):
  每个数据块都需要一个指针 → 大文件需要大量间接块

Extent 树(ext4 风格):
  每个 extent 描述一段连续的块范围:
  struct extent {
    uint start_block;  // 起始物理块号
    uint length;       // 连续块数
  };
  一个 extent 可以覆盖 1MB+ 的数据,只需一个条目

为什么大多数文件很小? 文件大小的分布是高度偏斜的——绝大多数文件只有几 KB,少数文件达到 GB 级。xv6 的 12 直接块设计正是针对这个分布的优化:小文件(< 12KB)零间接 I/O,只有大文件才需要间接块。这也是为什么 ext4 会将小 extent 直接存在 inode 中(inline extent),避免额外的元数据块。

三、块缓存

块缓存是磁盘块在内存中的缓存副本,它同时承担两个角色:

  1. 性能缓存:减少慢速磁盘 I/O(内存访问比磁盘快 10 万倍)。
  2. 同步点:确保对同一磁盘块的并发访问被序列化(通过 sleep lock)。
// kernel/buf.h — 每个缓存块的结构
struct buf {
  int valid;            // 是否已从磁盘读取
  int disk;             // 磁盘是否正在使用此 buf(DMA 传输中)
  uint dev;             // 设备号
  uint blockno;         // 磁盘块号
  struct sleeplock lock;// 保护此块的并发访问(用睡眠锁,因为 I/O 可能耗时)
  uint refcnt;          // 引用计数(>0 时不回收)
  struct buf *prev, *next; // LRU 双向链表指针
  uchar data[BSIZE];    // 块数据(1024 字节)
};

缓存使用 LRU(最近最少使用)策略管理:最近使用的放在链表头部,需要回收时从尾部获取。bread() 查找缓存,未命中则从磁盘读取;brelse() 释放缓存并移到 LRU 头部。

四、日志 — 崩溃恢复的原子性保证

核心矛盾

创建文件需要同时修改 inode 表、数据块位图、目录块。如果写完 inode 表后断电,位图和目录还没更新,文件系统就不一致了——可能有两个文件指向同一块磁盘空间,或者分配了一块"幽灵"块。

解决方案:Write-Ahead Logging (WAL)

核心思想:先把所有修改写到磁盘的日志区,确认日志完整后再写到真实位置

事务流程:

  1. begin_op():标记事务开始,检查日志空间是否足够(不够则睡眠等待)。
  2. 文件系统操作中调用 log_write():标记块为"脏",暂不写磁盘。同一块多次写入只记录一次(日志吸收)。
  3. end_op():标记事务结束,如果是最后一个并发事务则触发提交。
  4. 提交过程(commit()):
// kernel/log.c
static void commit()
{
  if (log.lh.n > 0) {
    write_log();      // ① 将脏块从块缓存复制到磁盘的日志区
    write_head();     // ② 写入日志头 —— 这是"提交点",事务从此不可逆
    install_trans(0); // ③ 将日志块复制到磁盘的真实位置
    log.lh.n = 0;     // ④ 清空日志
    write_head();     // ⑤ 写入空日志头 —— 标记事务完全结束
  }
}

为什么 write_head() 是提交点? 因为日志头记录了"本次事务修改了哪些块"。如果崩溃发生在 ② 之前,日志头为空,恢复时忽略——事务被丢弃。如果崩溃发生在 ② 之后 ③ 之前,日志头非空,恢复时重放日志——事务被完成。这把一个多块写入的复杂操作简化为一个"单块写入是否成功"的原子判断。

崩溃恢复:启动时检查日志头——如果非空(说明上次提交后没来得及写到真实位置),则重放日志;如果为空(说明上次事务正常完成或未开始),则忽略。

关键洞察write_head() 是原子性保证的边界——只要日志头没写完整,事务就不算提交,崩溃后丢弃。这把一个多块写入的复杂操作简化为一个"单块写入是否成功"的判断。

日志吸收:如果同一个块在一次事务中被多次写入,只记录最后一次。这减少了日志空间的使用。

设计权衡与工业界对比

维度 xv6 的选择 工业界 (Linux/ext4) 的做法
Buffer Cache 固定大小 LRU 链表 + 全局锁 Page Cache + Radix Tree + per-CPU LRU
日志类型 物理 redo log 支持物理/逻辑日志,可配置 journal 模式
文件大小上限 ~270KB(12 直接 + 1 间接) TB 级(Extent 树 + 多级间接)
目录索引 线性扫描 HTree / B-tree 索引
并发 粗粒度锁 per-inode 锁 + RCU 无锁路径查找
文件系统类型 单一简单 FS VFS 抽象层支持 ext4/xfs/btrfs/…

用户态启发

  • 数据库为何需要自己的 WAL? 文件系统的日志保证的是文件系统元数据的一致性,不保证用户数据的原子性。数据库需要更细粒度的事务控制(跨多行、跨多表的原子操作),所以必须自己实现 WAL。
  • 随机 I/O 的代价:理解 bmap 有助于理解为什么顺序读写比随机读写快——顺序访问块号连续,可以预读;随机访问每次都要查间接块,且磁盘寻道时间差异巨大。
  • fsync 的意义write() 只写到内核缓存,fsync() 才刷到磁盘。不调 fsync,崩溃后可能丢失最近写入。对数据库和日志系统来说,fsync 的时机就是持久性的边界。
  • 为什么 copy-on-write 文件系统(如 btrfs)更安全? 它们永不覆写数据块,而是写到新位置再原子更新指针。这与日志层的思路异曲同工,但粒度更细,甚至不需要日志区。

源码锚点

kernel/fs.c:namei()(路径解析)、kernel/log.c:commit()(日志提交的五步原子操作)、kernel/bio.c:bread()(块缓存读取)


内核的编译与构建

从源码到可运行内核:构建流程概述

xv6 的构建过程分为四个阶段,每个阶段的产物是下一阶段的输入。理解这个流程有助于在修改内核代码时知道"改了什么,影响了什么"。

阶段一:内核编译

kernel/*.S (汇编) + kernel/*.c (C源码)
  → 分别编译为 kernel/*.o (目标文件)
    → 由链接脚本 kernel.ld 链接为 kernel/kernel (ELF可执行文件)
  • 汇编文件entry.S, swtch.S, trampoline.S, kernelvec.S):处理硬件级别的操作——启动入口、上下文切换、陷入处理、中断向量。
  • C 文件:内核的全部逻辑——进程管理、内存管理、文件系统、设备驱动、系统调用。
  • 链接脚本 kernel.ld:定义内核在内存中的布局——代码段在前(0x80000000),数据段紧随其后,末尾的 end 符号标记可用物理内存的起点。

阶段二:用户库编译

user/ulib.c, user/printf.c, user/umalloc.c  →  编译为 .o 文件
user/usys.pl (Perl脚本)  →  自动生成 usys.S (系统调用桩代码)  →  编译为 usys.o

usys.pl 是一个关键的代码生成器——它为每个系统调用生成一小段汇编桩代码,负责将参数放入约定寄存器并执行 ecall 指令。这样用户程序调用 write() 时,最终会执行这段桩代码陷入内核。

阶段三:用户程序编译与链接

user/*.c (如 cat.c, sh.c, ls.c) + 用户库 (ULIB)
  → 分别链接为 user/_cat, user/_sh, user/_ls 等可执行文件

每个用户程序独立链接,使用 user.ld 链接脚本定义用户地址空间布局。

阶段四:磁盘镜像生成

mkfs/mkfs.c  →  编译为 mkfs/mkfs (主机上运行的工具)
mkfs/mkfs fs.img README $(UPROGS)  →  fs.img (文件系统镜像)

mkfs 是一个在主机(开发机)上运行的程序,它将用户程序和 README 文件打包为一个初始的文件系统镜像。这个镜像包含完整的文件系统结构——超级块、inode 区域、数据块区域。

最终产物与运行

# 一键构建并运行
make qemu

# 等价于:
# 1. 编译内核 → kernel/kernel
# 2. 编译用户程序 → user/_*
# 3. 生成文件系统镜像 → fs.img
# 4. 启动 QEMU 模拟器加载内核和磁盘镜像

QEMU 启动时的配置:-machine virt(RISC-V 虚拟机)、-m 128M(128MB 内存)、-smp 3(3 个 CPU 核心)、-bios none(无 BIOS,直接加载内核)、-drive file=fs.img(磁盘镜像)。

关键编译选项说明

选项 含义 为什么需要
-mcmodel=medany 代码可在任意地址运行 内核加载在 0x80000000 非零地址
-ffreestanding 不依赖主机运行时环境 内核没有标准库
-fno-builtin-* 禁用编译器内建函数替换 确保调用 xv6 自己的实现
-nostdlib 不链接标准库 内核是独立的运行环境
-fno-omit-frame-pointer 保留帧指针 便于栈回溯调试
-Wall -Werror 严格编译警告 代码质量保证

源码锚点

Makefile(完整构建规则与编译选项)、kernel/kernel.ld(内核链接脚本)、mkfs/mkfs.c(文件系统镜像生成工具)


阅读建议:本笔记是全局复习的框架,重点在"为什么这样设计"和"提供了什么抽象"。建议结合源码阅读时,先理解每个模块的核心矛盾和设计选择,再深入具体实现。实现细节会随架构和需求变化,但设计原理是通用的。