2. xv6 内核架构概览:进程调度、中断处理与并发基础

在深入调试 spinlock 竞争条件之前,必须建立对 xv6 内核整体架构的清晰认知。本章从进程调度、中断处理与并发模型三个维度,拆解 xv6 的核心运行机制,为后续分析锁竞争提供必要的上下文。

2.1 xv6 内核整体结构

xv6 是一个基于 宏内核 设计的类 Unix 教学操作系统,所有内核服务(进程管理、内存管理、文件系统、设备驱动)运行在同一个内核地址空间,共享同一个页表。这种设计简化了模块间通信,但也意味着任何模块的并发错误都可能影响整个系统。

关键设计决策: xv6 内核线程共享内核页表,所有 CPU 核心通过同一个内核虚拟地址空间访问数据结构。这意味着 全局变量、链表、缓存 等资源天然暴露在多核竞争之下。
xv6 宏内核架构分层 用户进程 (User Processes) 系统调用接口 (syscall.c / syscall.h) 内核核心模块 (Kernel Core) 进程调度 (proc.c / scheduler) 中断处理 (trap.c / plic.c) 内存管理 (kalloc.c / vm.c) 文件系统 (fs.c / bio.c / log.c) 设备驱动 (uart.c / virtio_disk.c) 同步原语 (spinlock.c / sleeplock.c) RISC-V 硬件 (CPU / PLIC / UART / VirtIO)

2.2 进程调度:上下文切换与调度器

xv6 的进程调度采用 协作式 + 时钟中断抢占 的混合模型。每个 CPU 核心运行独立的调度器线程,通过 scheduler() 函数循环选择 RUNNABLE 状态的进程执行。

2.2.1 进程状态机

状态 枚举值 含义 典型触发操作
UNUSED 0 进程槽位空闲 进程退出后回收
USED 1 进程已分配但未就绪 allocproc() 初始化
SLEEPING 2 进程因等待事件阻塞 sleep() 调用
RUNNABLE 3 进程可被调度执行 wakeup() 或 yield()
RUNNING 4 进程正在 CPU 上执行 scheduler() 选中
ZOMBIE 5 进程已退出,等待父进程回收 exit() 后

2.2.2 上下文切换流程

xv6 的上下文切换通过 swtch.S 汇编函数实现,保存当前 CPU 的寄存器状态到 context 结构体,然后恢复目标进程的上下文。关键路径如下:

  1. 时钟中断触发:每个 CPU 的定时器中断调用 trap() → usertrap() 或 kerneltrap()。
  2. yield() 主动让出:中断处理中调用 yield(),将当前进程状态设为 RUNNABLE,然后调用 sched()。
  3. sched() 执行切换:sched() 调用 swtch(),保存当前进程的 context,恢复当前 CPU 调度器线程的 context。
  4. scheduler() 选择新进程:调度器遍历进程表,找到下一个 RUNNABLE 进程,调用 swtch() 切换到该进程。
竞争条件风险点: 在 sched() 和 scheduler() 之间,进程状态 p->state 的修改必须受 p->lock 保护。如果两个 CPU 同时修改同一个进程的状态,会导致进程被重复调度或丢失调度。
xv6 上下文切换流程 (单 CPU 视角) 用户进程执行 时钟中断触发 yield() 让出 CPU swtch() 保存/恢复 scheduler() 选择进程 swtch() 切换到新进程 新用户进程执行 每个 CPU 独立运行调度器 进程表全局共享

2.3 中断处理:PLIC 与 trap 路径

xv6 运行在 RISC-V 架构上,中断处理分为 U 模式(用户态) 和 S 模式(内核态) 两条路径。所有中断和异常首先进入 stvec 指向的 trampoline.S,然后分发到 usertrap() 或 kerneltrap()。

2.3.1 中断类型与来源

中断类型 scause 编码 来源 处理函数
软件中断 1 (S-mode) 核间中断 (IPI) kerneltrap()
定时器中断 5 (S-mode) CLINT 定时器 kerneltrap() → yield()
外部中断 9 (S-mode) PLIC (UART / VirtIO 磁盘) kerneltrap() → devintr()
环境调用异常 8 (U-mode) ecall 指令(系统调用) usertrap() → syscall()
缺页异常 12 / 13 / 15 地址转换失败 usertrap() → 终止进程

2.3.2 PLIC 中断路由

平台级中断控制器 (PLIC) 负责将外部设备中断路由到指定的 CPU 核心。xv6 在启动时配置 PLIC,使每个 CPU 都能接收所有设备中断,但通过 plic_claim() 和 plic_complete() 实现一次只有一个 CPU 处理一个中断源。

调试提示: 多核环境下,PLIC 的中断分配是 非确定性的。两个 CPU 可能同时收到同一个设备的中断,但只有 claim 成功的 CPU 会处理。这种非确定性是复现竞争条件调试的难点之一。
xv6 中断处理路径 (外部中断示例) UART / 磁盘 PLIC 路由 CPU 0 内核态 kerneltrap() devintr() CPU 1 内核态 kerneltrap() plic_claim() PLIC 将中断同时送达所有 CPU 但只有 claim 成功的 CPU 处理中断

2.4 并发基础:多核环境下的竞争条件

xv6 支持多核 (SMP) 后,并发问题成为内核稳定性的核心挑战。以下三个并发场景是 spinlock 调试的焦点:

2.4.1 三种并发场景

场景 描述 典型例子 保护机制
CPU vs CPU 两个核心同时访问共享数据结构 两个 CPU 同时调用 kalloc() kmem.lock (spinlock)
CPU vs 中断 CPU 在临界区中被中断处理打断 CPU 修改链表时 UART 中断到达 push_off() / pop_off()
中断 vs 中断 嵌套中断处理共享数据 时钟中断中访问进程表 中断优先级 + 锁

2.4.2 竞争条件示例:kalloc 内存分配器

xv6 的物理内存分配器维护一个空闲页链表 kmem.freelist。在没有锁保护的情况下,两个 CPU 同时调用 kalloc() 会导致:

// 未加锁的 kalloc() 伪代码
void *kalloc(void) {
    struct run *r = kmem.freelist;  // CPU 0 读取 freelist
    // ---- CPU 1 此时也读取 freelist,得到相同地址 ----
    if (r)
        kmem.freelist = r->next;    // CPU 0 更新 freelist
    // ---- CPU 1 也更新 freelist,但 r->next 已被 CPU 0 修改 ----
    return (void*)r;                // 两个 CPU 返回同一块内存!
}
后果: 两个进程获得同一物理页,导致内核数据结构被互相覆盖,最终表现为 随机崩溃、数据损坏、文件系统错误。这正是 spinlock 需要解决的核心问题。

2.5 本章小结:架构与并发的关系

  • 宏内核架构 使得所有模块共享地址空间,锁保护成为必需品。
  • 进程调度 中的状态切换和上下文保存/恢复必须原子化,否则会导致进程丢失或重复调度。
  • 中断处理 的非确定性路由增加了竞争条件复现的难度,调试时需要固定中断亲和性或使用确定性触发。
  • 三种并发场景 对应不同的锁使用模式,spinlock 是解决 CPU vs CPU 和 CPU vs 中断场景的基础工具。