系统概述与启动流程
(关注宏观状态流转:从 Machine Mode 到 Supervisor Mode,再到第一个用户进程的诞生)
核心概念
启动流程的本质是特权级的逐级让渡:从硬件最高特权(Machine Mode)出发,经过层层初始化和降权,最终将控制权交给最不信任的用户程序。整个过程是一条从"全权控制"到"受限执行"的信任链——每一步都在缩小 CPU 的权限范围,同时建立更多的软件抽象层。
通用执行流程
一次完整的启动遵循以下宏观阶段:
-
裸机入口(汇编阶段):
- CPU 加电后,硬件将每个核心(hart)跳转到固定地址(
0x80000000)。 - 此时没有任何 C 运行时环境——无栈、无全局变量。汇编代码的唯一职责是为每个核心计算独立的栈地址,然后跳转到 C 函数。多核系统中,每个核心按自己的 ID 获得不重叠的栈空间,避免并发踩踏。
- CPU 加电后,硬件将每个核心(hart)跳转到固定地址(
-
Machine Mode 初始化与降级:
- Machine Mode 是硬件最高特权级,负责配置只有它才能访问的寄存器:
- 设置返回特权级:将
mstatus.MPP设为 Supervisor Mode,使得"异常返回"指令(mret)执行后降级而非保持最高权限。 - 设置返回地址:将异常程序计数器(RISC-V:
mepc)设为内核main()函数地址。 - 委托异常:将所有中断/异常委托给 Supervisor Mode 处理,让内核能自己管理中断而不需要固件介入。
- 配置物理内存保护:允许 Supervisor 访问全部物理内存。
- 启动定时器:配置时钟中断,为后续的抢占式调度打下基础。
- 设置返回特权级:将
- 执行
mret:CPU 跳转到main(),特权级降为 Supervisor Mode。
- Machine Mode 是硬件最高特权级,负责配置只有它才能访问的寄存器:
-
内核 C 环境建立:
main()中的初始化顺序体现了依赖链——先有内存分配器(其他子系统都需要分配内存),再有页表(启用虚拟地址),然后是进程表、中断处理、设备驱动,最后是文件系统:- 物理页分配器(
kinit):管理空闲物理内存 - 内核页表(
kvminit):建立内核的虚拟地址映射 - 启用分页(
kvminithart):让虚拟地址翻译生效 - 进程表(
procinit):初始化 PCB 数组 - 中断向量(
trapinit/trapinithart):设置异常处理入口 - 设备中断(
plicinit/plicinithart):配置中断控制器 - 块缓存/文件系统/文件表:持久化子系统
- 第一个用户进程(
userinit)
- 物理页分配器(
-
多核同步:
- 只有 CPU 0(BSP, Bootstrap Processor)执行全部初始化。其他核心(AP)通过内存屏障等待 BSP 完成后,只启用分页和中断,直接进入调度器。内存屏障确保 BSP 的写操作对其他核心可见,防止指令重排导致 AP 看到不一致的初始化状态。
-
第一个用户进程诞生:
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 个宏观阶段:
-
触发与陷落 (Trap):
- 用户态执行特殊指令(如 RISC-V 的
ecall,x86 的syscall/int 0x80),或遇到异常(如缺页),或硬件发出中断信号。 - 硬件自动接管:提升 CPU 特权级,自动保存最关键的返回地址(异常程序计数器,RISC-V:
sepc)和状态寄存器,并跳转到预设的内核入口(RISC-V:stvec寄存器指向的地址)。
- 用户态执行特殊指令(如 RISC-V 的
-
上下文保存 (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 放在用户页表的固定虚拟地址? 因为切换页表的那一刻,内核页表还不可用,必须有一个在用户页表中就能访问到的、存放内核信息的地方。这是一个"鸡生蛋"问题的精巧解法。
- 软件接管(通常是一段精心设计的汇编代码,如 xv6 的
-
环境切换 (Switch Environment):
- 切换栈:从用户栈切换到该进程专属的内核栈(防止用户态篡改内核栈)。
- 切换页表:从用户页表切换到内核页表。
- 关键难点:切换页表的瞬间,CPU 仍在执行指令。因此,这段切换代码所在的内存页(Trampoline)必须在用户页表和内核页表中等值映射(映射到相同的虚拟地址),否则切换瞬间会导致页错误崩溃。这是整个 Trap 机制中最精巧的设计之一。
-
分发与执行 (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()访问,不能直接解引用,防止恶意用户传入内核地址。
- 进入内核 C 函数(如
-
恢复与返回 (Restore & Return):
- 将系统调用的返回值写入用户态的返回值寄存器(RISC-V:
a0)。 - 设置返回特权级(清除"先前特权级"标志位,使
sret后回到 User Mode)。 - 逆向执行上述步骤:从 Trapframe 恢复所有通用寄存器 → 切换回用户页表和用户栈 → 执行特权返回指令(RISC-V:
sret),CPU 回到用户态继续执行下一条指令。
- 将系统调用的返回值写入用户态的返回值寄存器(RISC-V:
内核态中断处理
当内核自身遇到中断(如定时器中断),不需要切换页表和栈——因为已经在内核中了。直接保存 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. 重入问题:如果信号处理函数中又收到同一个信号怎么办?sigaction 的 SA_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 的期间设置 chan 和 state,wakeup 在持有 p->lock 的期间检查它们。这保证了"设置睡眠状态"和"检查睡眠状态"之间不会被插入任何东西——唤醒不会丢失。
类比 POSIX 条件变量:这个模式等价于
pthread_cond_wait中"持有 mutex → 检查条件 → 调用 wait(原子地释放 mutex 并睡眠)“的范式。如果在检查条件之前释放 mutex,就可能丢失信号——这是所有条件变量的通病。
锁的使用规则
- 锁序规则:所有代码必须以相同的顺序获取多把锁,否则会死锁(如 A→B 和 B→A 的循环等待)。
- 持锁睡眠规则:持有自旋锁时不能睡眠(因为睡眠需要调用调度器,而调度器可能需要同一把锁)。
- 中断规则:持有自旋锁时中断必须关闭(防止中断处理函数重入同一把锁)。
从全局锁到细粒度锁: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 链表。优化方案:
- 哈希分桶:将缓存块按
(dev, blockno)哈希到 N 个桶中,每个桶一把锁。查找缓存时只需锁对应的桶,不同桶的操作完全并行。 - per-CPU 缓存:每个 CPU 维护自己的 LRU 列表,只有自己的列表为空时才从其他 CPU “偷” 一个块。消除了单点竞争。
- 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 子进程处理请求)要注意内存复制代价。现代做法是用
vfork或posix_spawn替代。 - 僵尸进程:父进程不 wait,子进程变僵尸占用 PCB。这就是 daemon 要 double fork 的原因——让 init 成为祖先进程负责回收。
- 上下文切换的开销:每次上下文切换都有 TLB 刷新、缓存污染的隐性代价。高并发服务倾向于用协程/用户态线程减少内核态切换。
源码锚点
kernel/proc.c:kfork()(fork 的全量复制语义)、kernel/exec.c:kexec()(exec 的地址空间重建)、kernel/proc.c:scheduler()/sched()(调度器与上下文切换)
二、内存管理
虚拟内存解决了什么矛盾?
程序需要连续的地址空间来简化编程,但物理内存是碎片化的、多进程共享的。虚拟内存通过页表这个"翻译层",为每个进程提供独立的、连续的地址空间假象。它同时带来三大价值:
- 隔离:一个进程的野指针不会破坏另一个进程的数据。
- 抽象:程序不需要关心物理内存布局。
- 按需分配:可以分配比物理内存更大的地址空间(配合延迟分配和 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() 都无法提供:
- 文件映射:将文件内容直接映射到内存,通过指针访问代替
read()/write(),减少数据拷贝。 - 匿名内存:分配不与任何文件关联的大块内存(如
malloc对大块的分配)。 - 灵活布局:在地址空间的任意位置映射,不受堆增长方向的限制。
核心机制
// 用户态接口
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),避免额外的元数据块。
三、块缓存
块缓存是磁盘块在内存中的缓存副本,它同时承担两个角色:
- 性能缓存:减少慢速磁盘 I/O(内存访问比磁盘快 10 万倍)。
- 同步点:确保对同一磁盘块的并发访问被序列化(通过 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)
核心思想:先把所有修改写到磁盘的日志区,确认日志完整后再写到真实位置。
事务流程:
begin_op():标记事务开始,检查日志空间是否足够(不够则睡眠等待)。- 文件系统操作中调用
log_write():标记块为"脏",暂不写磁盘。同一块多次写入只记录一次(日志吸收)。 end_op():标记事务结束,如果是最后一个并发事务则触发提交。- 提交过程(
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(文件系统镜像生成工具)
阅读建议:本笔记是全局复习的框架,重点在"为什么这样设计"和"提供了什么抽象"。建议结合源码阅读时,先理解每个模块的核心矛盾和设计选择,再深入具体实现。实现细节会随架构和需求变化,但设计原理是通用的。