1.13 操作系统导论:虚拟化、并发与持久化
操作系统这门课到底在讲什么?为什么 OSTEP 把它分成虚拟化、并发、持久化三块?进程调度、页表、锁、日志这些机制,分别在回答哪一个问题?李治军的 8 个实践项目又怎样把它们从零实现一遍?
画出 OS 三大主题地图;用 turnaround/response 比较 FIFO、SJF、STCF、RR、MLFQ;解释分段/分页/交换、锁/条件变量/信号量、磁盘/RAID/文件系统/日志的机制与策略;说出 8 个实践项目各自要改哪些内核结构。
1.7 到 1.12 分别讲了链接、进程、虚拟内存、I/O、网络、并发。它们是六个切面,但操作系统本身有一套更整齐的组织方式:虚拟化、并发、持久化。OSTEP(《操作系统导论》)用这三条主线把进程、内存、锁、文件系统串起来,每条主线又分成「机制」(怎么做)和「策略」(做哪个选择)。
这一章是第 1 篇的收束。它不再引入新的 API,而是把前面六章放进一张地图,并补上第 1 篇没有展开的理论:调度策略、页替换、RAID、日志文件系统。最后用李治军《操作系统原理、实现与实践》的 8 个实践项目做「实现侧写」——看这些机制在一个从零写起的小内核里,分别落在哪个文件、哪个结构、哪条指令上。
读这一章时,手边可以放三样东西:一张白纸画地图;一个终端跑调度模拟器;一份 Linux 6.8 的 Documentation/ 或源码目录。理论只有落回「哪个结构、哪条路径」,才不会变成名词解释。
前六节给骨架:三大主题、调度指标、内存虚拟化、并发理论、持久化、李治军 8 个项目。1.13.7 用一张表把 OSTEP 的机制/策略映射到 Linux 结构体与源码路径。后九节补「可验证的实现」:进程 PCB 与页表项的字段(1.13.8)、彩票/步长/CFS 的份额模拟(1.13.9)、OPT/LRU/Clock 与 Belady 异常(1.13.10)、redo 日志的崩溃点枚举(1.13.11)、OSTEP 50 章逐章落点表(1.13.12)、xv6/rCore 对照(1.13.13)、三个实验(1.13.14)、十五条陷阱(1.13.15)与出口检查(1.13.16)。
1.13.1 三大主题地图
操作系统要同时解决三类矛盾:
- CPU 太少、程序太多:怎么让每个进程都以为自己独占 CPU?→ 虚拟化 CPU(进程、调度、上下文切换)。
- 内存太小、地址太多:怎么让每个进程都以为自己独占内存?→ 虚拟化内存(地址空间、页表、TLB、交换)。
- 多个执行流要共享数据:怎么保证正确性与性能?→ 并发(线程、锁、条件变量、信号量)。
- 断电会丢数据:怎么让数据在崩溃后仍然一致?→ 持久化(I/O 设备、磁盘、RAID、文件系统、日志)。
┌──────────────────────────────┐
│ 操作系统(OS) │
└──────────────┬───────────────┘
┌───────────────────────────────┼───────────────────────────────┐
▼ ▼ ▼
┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐
│ 虚拟化 │ │ 并发 │ │ 持久化 │
│ Virtualization │ │ Concurrency │ │ Persistence │
├───────────────────┤ ├───────────────────┤ ├───────────────────┤
│ CPU:进程/调度 │ │ 线程/共享变量 │ │ I/O 设备/DMA/中断 │
│ 受限直接执行 │ │ 锁/条件变量/信号量 │ │ 磁盘/RAID │
│ 上下文切换 │ │ 并发 bug/死锁 │ │ 文件系统/inode │
│ 内存:地址空间 │ │ 事件驱动 │ │ 日志/FSCK/LFS │
│ 分段/分页/TLB │ │ │ │ 闪存/FTL │
│ 交换/页替换 │ │ │ │ 崩溃一致性 │
└─────────┬─────────┘ └─────────┬─────────┘ └─────────┬─────────┘
│ │ │
▼ ▼ ▼
机制:怎么切换/翻译 机制:怎么原子/等待 机制:怎么读写/落盘
策略:选哪个进程/哪一页 策略:选哪个线程/多大锁 策略:怎么布局/何时回写
这张图有三个读法:
- 按列读:每一列是一组机制。虚拟化 CPU 的机制是「受限直接执行 + 定时器中断 + 上下文保存」;虚拟化内存的机制是「页表 + TLB + 缺页异常」;并发的机制是「原子指令 + 等待队列」;持久化的机制是「DMA + 中断 + 日志」。
- 按行读:每一行是「机制 → 策略」的配对。进程调度有 FIFO/SJF/RR/MLFQ 等策略;页替换有 FIFO/LRU/Clock 等策略;I/O 调度有 SSTF/SCAN 等策略;文件系统布局有 FFS/LFS 等策略。
- 按箭头读:三大主题不是孤立的。缺页异常要用到进程上下文;文件系统的 page cache 属于虚拟内存;并发锁的实现要用原子指令和等待队列。真实内核里,这三块代码互相调用。
dmesg 有输出说明已进内核;journalctl -b 有记录说明 systemd 已接管。读图时把「启动」看成三大主题的初始化顺序:内核先建立页表(虚拟化内存),再创建 init 进程(虚拟化 CPU),然后初始化驱动与根文件系统(持久化),最后交给用户态。并发机制在启动阶段就已存在(自旋锁、完成量),只是还没有用户进程竞争。
1.13.2 CPU 虚拟化:调度策略与指标
CPU 虚拟化的核心是「受限直接执行」(limited direct execution, LDE):用户程序直接在 CPU 上跑,内核只在两种情况下介入——系统调用(主动)和定时器中断(被动)。每次介入,内核保存当前进程的上下文,按调度策略选下一个,恢复它的上下文。进程因此产生「独占 CPU」的幻觉。
调度策略用两个指标评价:
- 周转时间(turnaround time)= 完成时间 − 到达时间;衡量「一次任务从提交到完成」。
- 响应时间(response time)= 首次运行时间 − 到达时间;衡量「交互有多快」。
两者经常冲突:FIFO 对短任务不友好(护航效应),RR 响应快但周转差。下面是四个基本策略:
| 策略 | 抢占 | 选择规则 | 优点 | 缺点 |
|---|---|---|---|---|
| FIFO | 否 | 按到达顺序 | 简单、公平 | 长任务堵住短任务 |
| SJF | 否 | 最短任务优先 | 周转最优(非抢占) | 需要预知运行时间,长任务饥饿 |
| STCF | 是 | 最短剩余时间优先 | 周转最优(抢占) | 上下文切换多,饥饿 |
| RR | 是 | 时间片轮转 | 响应好、公平 | 周转差,片长难调 |
| MLFQ | 是 | 多级队列 + 动态优先级 | 兼顾响应与吞吐 | 参数多,可被 gaming |
| 比例份额 | 是 | 彩票/步长/CFS 权重 | 可按权重分配 | 不保证绝对延迟 |
以 A(到达 0,运行 10)、B(1,5)、C(2,2) 为例:
- FIFO:A 0–10,B 10–15,C 15–17;周转 10/14/15,平均 13;响应 0/9/13。
- SJF(非抢占):A 0–10,C 10–12,B 12–17;周转 10/16/10,平均 12;响应 0/11/8。
- RR(片长 2):A 0–2,B 2–4,C 4–6,A 6–8,B 8–10,A 10–12,B 12–13,A 13–15,A 15–17;周转 17/12/4,平均 11;响应 0/1/2。
RR 的平均周转不一定比 SJF 好,但响应时间显著更优。交互系统选 RR/MLFQ,批处理系统选 SJF/STCF。真实系统没有「预知运行时间」的水晶球,所以用历史行为预测(MLFQ 的优先级、CFS 的 vruntime)。
MLFQ 的五条规则
多级反馈队列(multi-level feedback queue, MLFQ)用多个优先级队列近似「既照顾短任务,又不得罪长任务」:
- 新任务进最高优先级;
- 同一优先级内按 RR;
- 任务用完整个时间片就降一级(可能是 CPU 密集);
- 任务在时间片内主动让出 CPU 就留在原级(可能是 I/O 密集);
- 每隔一段时间把所有任务提升到最高级,防止饥饿和 gaming。
规则 5 是关键:没有周期提升,长任务会永远沉底;有周期提升,短任务又会被打断。参数(队列数、片长、提升周期)决定系统性格。
多核调度
多核调度多了两个问题:缓存亲和性(任务在哪个核上跑,L1/L2 热不热)和负载均衡(核间任务数是否均匀)。常见结构:
- SQMS(单队列多处理器):一个全局运行队列,锁竞争大,亲和性差;
- MQMS(多队列多处理器):每核一个队列,亲和性好,但可能负载不均;
- work stealing:空闲核从别的核偷任务,兼顾两者。
Linux 的 CFS/EEVDF 用调度域(scheduling domain)分层做负载均衡,3.5、3.6 会展开。
下面的模拟器把 FIFO、SJF、RR 跑在同一组任务上,直接打印指标:
/* sched.c:FIFO/SJF/RR 调度指标模拟 */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <stdio.h>
#include <string.h>
#define N 3
typedef struct {
const char *name;
int arrival, burst;
int start, end;
} task;
static const task base[N] = {
{"A", 0, 10, 0, 0},
{"B", 1, 5, 0, 0},
{"C", 2, 2, 0, 0},
};
static void reset(task t[N])
{
memcpy(t, base, sizeof(base));
}
static void report(const char *policy, task t[N])
{
double sum_t = 0, sum_r = 0;
printf("%s:\n", policy);
for (int i = 0; i < N; i++) {
int turnaround = t[i].end - t[i].arrival;
int response = t[i].start - t[i].arrival;
sum_t += turnaround;
sum_r += response;
printf(" %s start=%2d end=%2d turnaround=%2d response=%2d\n",
t[i].name, t[i].start, t[i].end, turnaround, response);
}
printf(" avg turnaround=%.2f response=%.2f\n", sum_t / N, sum_r / N);
}
static void fifo(void)
{
task t[N];
reset(t);
int now = 0;
for (int i = 0; i < N; i++) {
if (now < t[i].arrival)
now = t[i].arrival;
t[i].start = now;
now += t[i].burst;
t[i].end = now;
}
report("FIFO", t);
}
static void sjf(void)
{
task t[N];
reset(t);
int done[N] = {0};
int now = 0, finished = 0;
while (finished < N) {
int pick = -1;
for (int i = 0; i < N; i++) {
if (done[i] || t[i].arrival > now)
continue;
if (pick < 0 || t[i].burst < t[pick].burst)
pick = i;
}
if (pick < 0) {
int next = -1;
for (int i = 0; i < N; i++)
if (!done[i] && (next < 0 || t[i].arrival < t[next].arrival))
next = i;
now = t[next].arrival;
continue;
}
t[pick].start = now;
now += t[pick].burst;
t[pick].end = now;
done[pick] = 1;
finished++;
}
report("SJF (non-preemptive)", t);
}
static void rr(int quantum)
{
task t[N];
reset(t);
int rem[N], inq[N] = {0}, done[N] = {0};
int q[256], head = 0, tail = 0;
int now = 0, finished = 0;
for (int i = 0; i < N; i++) {
rem[i] = t[i].burst;
t[i].start = -1;
}
while (finished < N) {
for (int i = 0; i < N; i++) {
if (!done[i] && !inq[i] && t[i].arrival <= now) {
q[tail++] = i;
inq[i] = 1;
}
}
if (head == tail) {
now++;
continue;
}
int i = q[head++];
if (t[i].start < 0)
t[i].start = now;
int run = rem[i] < quantum ? rem[i] : quantum;
now += run;
rem[i] -= run;
for (int k = 0; k < N; k++) { /* 运行期间到达的任务先入队 */
if (!done[k] && !inq[k] && t[k].arrival <= now) {
q[tail++] = k;
inq[k] = 1;
}
}
if (rem[i] == 0) {
t[i].end = now;
done[i] = 1;
inq[i] = 0;
finished++;
} else {
q[tail++] = i; /* 重新排队,inq 保持 1 */
}
}
report("RR (quantum=2)", t);
}
int main(void)
{
fifo();
sjf();
rr(2);
return 0;
}
跑出来的数字与上面手算一致。模拟器的价值是让你能改参数:把 A 的运行时间改成 2、把 RR 的片长改成 1,观察平均周转和响应的变化。调度策略没有「最好」,只有「对当前负载最合适」。
1.13.3 内存虚拟化:从分段到交换
内存虚拟化的目标是「每个进程一套连续地址空间」。实现路径分三步:
- 分段(segmentation):基址 + 界限寄存器,把地址空间切成代码/堆/栈段;外部碎片严重。
- 分页(paging):固定大小页 + 页表,消除外部碎片;页表本身占内存,需要多级/大页。
- 交换(swapping):物理内存不够时,把冷页换出到磁盘/swap,缺页再换入。
1.9 已经算过四级页表和地址翻译,这里补理论侧的三个问题:
| 问题 | 机制 | 策略 |
|---|---|---|
| 地址怎么翻译 | 页表 + TLB + MMU | 多级页表、大页 |
| 缺页怎么办 | 缺页异常 + 页分配/换入 | 按需分页、预取 |
| 内存满了换谁 | 页回收 | FIFO/LRU/Clock/ARC |
页替换策略的评估指标是命中率。FIFO 简单但会遇到 Belady 异常(增加页框反而命中率下降);LRU 理论最优之一,但需要记录访问顺序,硬件成本高;Clock 用访问位近似 LRU,是 Linux 早期 PG_lru 的基础。实际内核用 active/inactive 双链表 + 引用位做近似,3.10 会展开。
有效访存时间(effective memory access time, EMAT)可以估算策略收益:
EMAT = TLB 命中率 × (TLB 时间 + 内存时间) + 未命中率 × (TLB 时间 + 页表访问 + 内存时间)。
以 TLB 命中 99%、TLB 1 ns、内存 100 ns、页表访问 100 ns 为例:0.99×101 + 0.01×201 ≈ 102 ns。TLB 命中率从 99% 掉到 90%,EMAT 升到约 111 ns;这就是大页和局部性为什么重要。
交换的代价更高:一次主缺页要读磁盘/SSD,可能是几十微秒到几毫秒。vmstat 1 的 si/so(swap in/out)持续非零,说明系统已经在 thrashing 边缘;此时加内存比调参数有效。
分段:段布局、共享与外部碎片
分段把地址空间切成「代码段、数据段、堆段、栈段」,每个段有基址、界限、权限。硬件用「段号 + 偏移」查段表,越界触发异常;共享库可以把同一代码段映射到多个进程,权限位(只读/可执行)按段设置。
问题在外部碎片:段大小不一,频繁分配/释放后,空闲空间被切成很多不连续的小洞;即使总空闲够,也放不下一个新段。解决办法是「紧凑(compaction)」——搬移段,代价大;或者改用分页。Linux 在 x86-64 上几乎不用分段做内存管理(CS/DS 基址为 0),只用分页;分段概念主要出现在 x86 历史与保护模式。
交换:swap 区、present 位与换入路径
交换把「不常用的页」写到 swap 区,页表项清 present 位,但保留 swap 槽号。缺页时:
- 硬件发现 PTE 的 present=0,触发缺页;
- 内核看 PTE 的 swap 位/槽号,知道页在 swap;
- 分配一个物理页,读 swap 槽;
- 更新 PTE 指向新页,present=1;
- 返回用户态重试指令。
swap 区不是文件系统,是一组连续的槽(slot);swapon 把块设备/文件登记为 swap。交换的代价是磁盘 I/O,所以策略上要「少换、换对」:LRU/Clock 近似、active/inactive 双链表、kswapd 后台回收、swappiness 调整匿名页/文件页的回收比例。VAX/VMS 是早期把「分段 + 分页 + swap」组合得最完整的系统之一,OSTEP 第 23 章用它做案例;现代 Linux 的 mm_struct/vma + 页表 + swap 是同一组问题的另一套实现。
1.13.4 并发:理论补全
1.12 给了 pthread 的工程用法;OSTEP 第 25–33 章补的是「为什么」:
- 锁的实现:关中断、测试并设置(test-and-set)、比较并交换(compare-and-swap)、获取并增加(fetch-and-add);自旋锁 vs 睡眠锁;futex 把「无竞争」压到用户态。
- 条件变量:等待队列、唤醒丢失、虚假唤醒;Mesa 语义 vs Hoare 语义。
- 信号量:P/V、计数信号量、二值信号量;用信号量实现锁和条件变量。
- 并发 bug:原子性违反、顺序违反、死锁、活锁、饥饿;数据竞争与内存模型。
- 事件驱动:单线程状态机、非阻塞 I/O、
select/poll/epoll。
理论的价值在于分类。看到一个并发 bug,先问它属于哪一类:
| 类型 | 现象 | 典型修法 |
|---|---|---|
| 原子性违反 | 两个线程同时更新,丢更新 | 锁、原子操作 |
| 顺序违反 | 先检查后使用,中间被改 | 锁、版本号、CAS |
| 死锁 | 循环等待,永久阻塞 | 锁顺序、超时、回滚 |
| 活锁 | 不断重试,无法推进 | 退避、随机化 |
| 饥饿 | 某个线程永远拿不到资源 | 公平锁、优先级提升 |
并发不是「加锁就完事」。锁会带来争用、死锁、优先级反转;无锁结构会带来 ABA、内存回收(RCU/epoch)问题。选择哪种,取决于临界区长度、竞争程度、能否容忍重试。
1.13.5 持久化:设备、磁盘、RAID 与文件系统
持久化要回答两个问题:数据放在哪(设备/布局),崩溃后是否一致(日志/校验)。
I/O 设备与磁盘
设备通过三类寄存器与 CPU 交互:数据、状态、控制。传输大块数据用 DMA(direct memory access),完成后发中断;小命令用 outb/inb 或 MMIO。磁盘的物理结构是盘片、磁道、扇区、磁头;一次随机读的成本 = 寻道 + 旋转延迟 + 传输。
I/O 调度策略:FIFO、SSTF(最短寻道优先)、SCAN(电梯)、CFQ(已淘汰)、mq-deadline/kyber/bfq(现代 blk-mq)。SSD 没有寻道,但仍有擦除块、FTL 映射、写放大,调度目标从「减少寻道」变成「合并/限流」。
设备驱动模型:寄存器、DMA 与中断
操作系统看设备,只看三样东西:寄存器(命令/状态/数据)、内存映射(MMIO 或 PIO)、中断。驱动的工作是把「读写文件」翻译成「写寄存器 + 等中断 + 搬 DMA」。
典型流程(以网卡收包为例):
- 驱动初始化:
pci_enable_device、request_mem_region、ioremap映射 BAR,申请 IRQ; - 把 DMA 缓冲区物理地址写进设备寄存器,启动接收;
- 网卡收到包,DMA 写入内存,触发中断;
- 中断处理 top half 快速确认,softirq/NAPI 轮询收包,交给协议栈;
netif_rx/napi_gro_receive把sk_buff上交。
驱动注册的接口是 struct file_operations(open/read/write/ioctl/mmap)与总线回调(probe/remove)。字符设备用 alloc_chrdev_region + cdev_add,块设备用 blk-mq 的 queue_rq;设备节点由 udev 根据 sysfs 的 dev 属性创建。
ioremap的地址不能直接解引用:必须用readl/writel(或readb/writeb),否则编译器/CPU 可能重排或合并访问。- DMA 地址不是虚拟地址:要用
dma_map_single得到设备可见的dma_addr_t,并处理 cache 一致性(dma_sync_*)。 - 中断上下文不能睡眠:
kmalloc(GFP_KERNEL)、mutex_lock、copy_to_user都可能睡眠;用GFP_ATOMIC/spinlock/workqueue。 probe失败要释放已申请资源:devm_*能自动回收,但ioremap/request_irq混用时仍要按逆序清理。
磁盘时间账:寻道、旋转与传输
一次磁盘 I/O 的时间可以拆成:
T = T_seek + T_rotation + T_transfer + T_queue
| 项 | 典型值 | 说明 |
|---|---|---|
| 寻道 | 4–10 ms | 磁头移动到目标磁道;SSD 为 0 |
| 旋转 | 0–4.17 ms | 7200 RPM 转一圈 8.33 ms,平均半圈 4.17 ms |
| 传输 | 0.04 ms/4 KiB @100 MB/s | 顺序读写按带宽算 |
| 排队 | 0–几十 ms | 受调度器/队列深度影响 |
随机 4 KiB 读:8 + 4.17 + 0.04 ≈ 12 ms,IOPS ≈ 83;同样盘顺序读 100 MB/s,4 KiB 只需 0.04 ms。随机与顺序差两个数量级,这是数据库 WAL、LSM tree、日志结构文件系统存在的根本原因。
# 量化随机/顺序差异(需要 fio)
fio --name=randread --ioengine=libaio --rw=randread --bs=4k --size=1G \
--numjobs=1 --iodepth=32 --runtime=10 --time_based --group_reporting
fio --name=seqread --ioengine=libaio --rw=read --bs=1M --size=1G \
--numjobs=1 --iodepth=8 --runtime=10 --time_based --group_reporting
调度器:mq-deadline 用「按扇区排序 + 过期时间」平衡吞吐与延迟;bfq 按 cgroup 权重做公平;kyber 用令牌桶限流。SSD 没有寻道,但仍有 FTL 垃圾回收与写放大,调度目标从「减少寻道」变成「合并/限流/保延迟」。
RAID
| 级别 | 最少盘 | 容量利用率 | 容错 | 特点 |
|---|---|---|---|---|
| RAID 0 | 2 | 100% | 无 | 条带化,性能高,坏一盘全丢 |
| RAID 1 | 2 | 50% | 1 盘 | 镜像,读性能好,成本高 |
| RAID 4 | 3 | (n−1)/n | 1 盘 | 专用校验盘,写瓶颈 |
| RAID 5 | 3 | (n−1)/n | 1 盘 | 分布式校验,写有 read-modify-write |
| RAID 6 | 4 | (n−2)/n | 2 盘 | 双校验,重建更安全 |
| RAID 10 | 4 | 50% | 每组 1 盘 | 镜像 + 条带,性能与可靠性折中 |
RAID 不是备份。它处理的是「盘坏」,不处理「误删、勒索、机房火灾」。RAID 5 的写洞(write hole)和重建期间的二次故障,是生产环境常见的坑;大容量盘重建可能持续数小时,期间性能下降、再次故障风险上升。
文件系统
文件系统把「块设备」变成「文件/目录」:
- superblock:文件系统元数据(块大小、inode 数、空闲块);
- inode:文件类型、权限、大小、数据块指针/扩展;
- 位图:inode 位图、数据块位图;
- 目录:名字 → inode 号的映射。
FFS 把磁盘分成柱面组,文件和它的 inode、目录放在同一组,减少寻道;ext4 用 extent 代替多级间接块,支持延迟分配和 fallocate。
崩溃一致性
考虑「创建文件」的三步:分配 inode、写目录项、更新位图。如果断电发生在中间,可能留下「inode 已分配但没人引用」或「目录项指向未分配 inode」。两种修复思路:
- FSCK:启动时扫描全盘,检查并修复不一致;慢,且不一定能恢复语义。
- 日志(journaling):先把事务写入日志区,提交后再改主文件系统;崩溃后重放/丢弃未完成事务。ext4 的
data=ordered保证数据先于元数据落盘,data=writeback性能更好但可能读到旧数据。
原子替换是应用层的常用手法:
/* atomic_write.c:写临时文件 → fsync → rename → fsync 目录 */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
static int write_all(int fd, const char *p, size_t n)
{
while (n > 0) {
ssize_t w = write(fd, p, n);
if (w < 0) {
if (errno == EINTR)
continue;
return -1;
}
if (w == 0) {
errno = EIO;
return -1;
}
p += (size_t)w;
n -= (size_t)w;
}
return 0;
}
int main(int argc, char **argv)
{
if (argc != 3) {
fprintf(stderr, "usage: %s <path> <content>\n", argv[0]);
return 2;
}
char tmp[4096];
int n = snprintf(tmp, sizeof(tmp), "%s.tmp.%ld", argv[1], (long)getpid());
if (n <= 0 || (size_t)n >= sizeof(tmp))
return 1;
int fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644);
if (fd < 0) {
perror("open tmp");
return 1;
}
if (write_all(fd, argv[2], strlen(argv[2])) < 0 || fsync(fd) < 0) {
perror("write/fsync");
close(fd);
unlink(tmp);
return 1;
}
close(fd);
if (rename(tmp, argv[1]) < 0) {
perror("rename");
unlink(tmp);
return 1;
}
int dfd = open(".", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (dfd >= 0) {
(void)fsync(dfd); /* 让目录项也持久 */
close(dfd);
}
return 0;
}
崩溃点分析:
- 在
rename之前崩溃:旧文件完好,临时文件可能残留; rename是原子的:要么旧名字,要么新名字,不会看到半个文件;rename之后、目录fsync之前崩溃:文件内容在(已fsync),但目录项可能回退到旧名字;- 目录
fsync之后:新名字持久。
fsync 只保证「这个 fd 对应的文件」;目录项是另一个对象,必须单独 fsync 目录。很多「原子写」实现漏了最后一步。
闪存与 LFS
NAND 闪存的写粒度是页,擦除粒度是块;不能原地覆盖,必须先擦后写。FTL(flash translation layer)做逻辑块到物理页的映射、垃圾回收、磨损均衡。写放大会放大 SSD 的写入量,影响寿命和性能;TRIM 告诉 SSD 哪些块已无效。
日志结构文件系统(log-structured file system, LFS)把「随机写」变成「顺序追加」,适合闪存和顺序带宽高的设备;代价是段清理(segment cleaning)和垃圾回收。现代 SSD 的 FTL 已经在设备内部做了类似的事,文件系统层的 LFS 更多用于特定场景。
LFS 的五个关键机制(OSTEP 43.4–43.11):
| 机制 | 作用 | 关键点 |
|---|---|---|
| inode map | inode 号 → 最新 inode 位置 | 随日志一起追加,不固定位置 |
| indirect inode map | 把 inode map 的更新也日志化 | 避免每次写 inode 都要改固定 map |
| checkpoint region | 挂载时从哪里开始读 | 固定位置,周期性写入;崩溃后从最近 checkpoint 恢复 |
| segment summary | 段内每个块属于哪个 inode/偏移 | 清理时判断块是否仍存活(liveness) |
| cleaning | 选段、搬活块、回收 | 按利用率/年龄选段;清理本身也是顺序写 |
LFS 的写路径:把 inode、数据、inode map 更新一起追加到当前段;checkpoint region 是周期性写入的(不是每个段都写),崩溃后从最近 checkpoint 恢复并重放其后的段。读路径:先读 checkpoint region,再读 inode map 找到 inode,再读数据。
清理策略决定 LFS 的实际性能:只挑「利用率最低」的段会反复清理仍含热数据的段,搬移成本高;按「年龄 + 利用率」折中更稳。清理产生的写放大与 SSD 的 FTL 垃圾回收是同一类问题——只不过一个在文件系统层,一个在设备层。
FFS 的分配策略(OSTEP 41.4–41.6):磁盘按柱面组划分,每个组有自己的超级块副本、inode 位图/数据位图、inode 表。分配时同组优先:新 inode 尽量和它的目录项同组,新数据块尽量和 inode 同组,减少寻道;目录分散到各组,避免根目录成为热点。大文件是例外:如果所有块都放同组,会把该组写满、破坏局部性;FFS 用「间接块 + 分段」把大文件的前几块放本组,其余分散到其他组。ext4 的 extent 树取代了 FFS 的三级间接块,但「同组优先 + 大文件例外」的思路仍在 mballoc 里。
1.13.6 李治军 8 个实践项目的实现侧写
李治军的《操作系统原理、实现与实践》以 Linux 0.11 为参照,带学生从引导扇区写到一个能跑进程、能换页、能读磁盘的小内核。8 个实践项目对应 8 个机制。下面每个项目给出「目标 / 关键结构 / 实现路径 / 验收证据」,这是从「知道概念」到「能改代码」的侧写。
项目 1 · 控制操作系统启动
- 目标:从 BIOS 交出控制权开始,把内核加载到内存并进入 32 位保护模式。
- 关键结构:
bootsect.s(512 字节引导扇区,加载到0x7c00)、setup.s(取硬件参数、关中断、开 A20、建 GDT、进保护模式)、head.s(建页表、设栈、跳main)。 - 实现路径:BIOS 把第一扇区读到
0x7c00并跳转;bootsect用int 0x13把setup和system读到0x9000/0x10000;setup把system搬到0x0000附近,加载 GDT,设置%cr0的 PE 位;head.s建立恒等映射页表,开分页,调用main。 - 验收证据:屏幕出现自定义字符串;
gdb连 QEMU,在0x7c00下断点;info registers看到%cr0的 PE 位为 1;x/10i $eip已在 32 位代码段。
项目 2 · 添加系统调用
- 目标:在
int 0x80入口上增加两个系统调用iam()和whoami()。 - 关键结构:
sys_call_table(函数指针数组)、NR_syscalls(表长)、include/unistd.h(调用号)、kernel/system_call.s(入口汇编)、sys.c(实现)。 - 实现路径:在
unistd.h加__NR_iam/__NR_whoami;在sys.c写sys_iam/sys_whoami,用get_fs_byte/put_fs_byte在用户态与内核态之间拷字符串;把函数指针填进sys_call_table;用户态用_syscall1宏展开int 0x80。 - 验收证据:用户程序调用
iam("alice")后再whoami(buf)得到alice;strace看不到(因为不是 Linux 系统调用),但gdb在system_call处能停;调用号越界返回-ENOSYS。
项目 3 · 多进程
- 目标:实现
fork,让一个进程变成两个,父子各自返回不同值。 - 关键结构:
task_struct(pid、状态、页目录基址、TSS、内核栈)、task[](进程表)、copy_process(复制 task、页表、文件表)、schedule。 - 实现路径:
fork系统调用进入内核后,copy_process分配新task_struct,复制父进程页目录并按 COW 标记只读;复制文件描述符表;设置子进程eax=0、父进程eax=子 pid;把子进程状态设为TASK_RUNNING,挂入运行队列;返回用户态。 - 验收证据:父子各打印自己的 pid;
ps能看到两个进程;父进程写变量后子进程看不到(COW 生效);子进程exit后父进程wait能回收。
项目 4 · 基于内核栈完成进程切换
- 目标:不依赖 TSS 的
ljmp,用内核栈保存/恢复上下文,实现两个进程交替执行。 - 关键结构:每个进程的内核栈、
switch_to(保存%ebp/%esp等)、schedule(选下一个)、TSS(只在特权级切换时用)。 - 实现路径:
switch_to把当前进程的%esp存进task_struct->kernel_stack,从下一个任务的kernel_stack恢复%esp,再ret到它上次被切走的位置;schedule按优先级/时间片选任务;时钟中断触发调度。 - 验收证据:两个进程各自循环打印字符,输出交替;
gdb在switch_to处观察%esp变化;进程的局部变量在切换后保持;没有 TSS 也能完成同特权级切换。
项目 5 · 信号量的实现与应用
- 目标:在内核里实现
sem_t与down/up,并用它解决生产者-消费者。 - 关键结构:
sem_t(value + 等待队列)、sleep_on/wake_up(等待队列)、schedule(睡眠时让出 CPU)。 - 实现路径:
down关中断检查 value,>0 则减一,否则把当前任务挂到等待队列并sleep_on;up关中断加一,若等待队列非空则wake_up唤醒一个;用户态用共享缓冲区 + 两个信号量(empty/full)验证。 - 验收证据:生产者/消费者无丢失、无越界;缓冲区满时生产者睡眠、空时消费者睡眠;
ps看到睡眠状态;去掉信号量后能复现竞态。
项目 6 · 地址映射与共享
- 目标:实现页表映射与共享内存,让两个进程看到同一块物理页。
- 关键结构:页目录/页表、
do_no_page(缺页处理)、get_free_page、put_page、mmap/shmat的简化版。 - 实现路径:为共享区分配物理页,把两个进程的页表项指向同一 PFN,设置
P/RW/US;缺页时do_no_page判断是匿名页、文件页还是共享页;fork时对共享页不复制,只增加引用计数。 - 验收证据:进程 A 写共享区,进程 B 立刻读到;
/proc/<pid>/maps看到相同物理页的映射(通过pagemap验证 PFN);fork后 COW 与共享页行为不同。
项目 7 · 换入/换出
- 目标:物理内存不足时把页换到 swap 分区,缺页时再换入。
- 关键结构:swap 设备/分区、页表 present 位、
swap_in/swap_out、get_free_page、LRU 链表。 - 实现路径:
get_free_page找不到空闲页时,选一个冷页,若 dirty 则写 swap,记录 swap 槽号,清 present 位;缺页异常时根据 PTE 的 swap 信息读回,更新页表;用 Clock/LRU 选择被换出的页。 - 验收证据:分配超过物理内存的匿名页,程序仍能跑;
/proc/vmstat的pswpin/pswpout增加;换出页再次访问触发缺页并恢复;没有数据损坏。
项目 8 · 设备驱动
- 目标:为块设备写一个最小驱动,让文件系统能读写磁盘。
- 关键结构:
request_queue(请求队列)、hd_request、outb/inb(端口 I/O)、中断处理程序、file_operations。 - 实现路径:初始化时探测设备、注册中断;
read/write把请求封装成request挂入队列;do_hd_request用outb发命令,中断到来时读状态、拷数据、唤醒等待者;上层通过bread/bwrite访问块。 - 验收证据:
dd读写磁盘镜像内容一致;iostat看到 I/O;拔掉/超时注入后驱动返回错误而不是挂死;中断计数增加。
| 项目 | 对应 Linux 机制 | 最小可观测证据 |
|---|---|---|
| 1 启动 | bootsect/setup/head、页表初始化 | %cr0.PE=1、dmesg 第一行 |
| 2 系统调用 | sys_call_table、int 0x80/syscall | 调用号、返回值、-ENOSYS |
| 3 多进程 | copy_process、COW、task_struct | 父子 pid、ps、COW 写时复制 |
| 4 切换 | switch_to、内核栈、schedule | %esp 变化、输出交替 |
| 5 同步 | sem_t、等待队列、sleep_on | 无丢失、睡眠状态 |
| 6 映射共享 | 页表、do_no_page、PFN | 共享写可见、pagemap |
| 7 换入换出 | swap、PTE present、LRU | pswpin/pswpout、缺页恢复 |
| 8 驱动 | request_queue、outb/inb、中断 | dd 校验、中断计数 |
这 8 个项目与第 3 篇的内核章节一一对应:启动对应 3.2,系统调用对应 3.3,多进程/切换对应 3.4、3.5,同步对应 3.11,内存对应 3.7–3.10,驱动对应 3.14。第 1 篇给你「是什么」,第 3 篇给你「Linux 怎么做」,实践项目给你「自己写一遍」。
1.13.7 从 OSTEP 到 Linux:机制与策略的对照
OSTEP 用「假设 → 机制 → 策略 → 作业」讲通用操作系统;Linux 把这些概念落到具体结构体与调度器。读 OSTEP 时,把每一章钉到下面这张表,就不会觉得「学完不知道怎么用」:
| OSTEP 概念 | 通用机制 | Linux 6.8 落点 | 本书 |
|---|---|---|---|
| 进程抽象 | task_struct + 地址空间 + fd 表 | fork/clone、copy_process | 1.1.4、2.7、3.1 |
| 受限直接执行 | 用户/内核态、trap、定时器 | syscall 入口、sysret、int 0x80 | 1.8.3、3.3 |
| 调度策略 | MLFQ/比例份额/多处理器 | CFS/EEVDF、sched_entity、调度域 | 1.13.2、3.5/3.6 |
| 地址空间 | 页表 + TLB + 缺页 | mm_struct/vma、四级页表、handle_mm_fault | 1.9、3.8 |
| 分段 | 基址/界限 | x86 段只用于兼容;Linux 以分页为主 | 1.13.3、1.9 |
| 空闲空间管理 | 隐式/显式/分离链表 | slab/slub、buddy、kmalloc 大小类 | 1.9.6、3.7/3.9 |
| 交换 | swap 区、present 位 | swap_info_struct、do_swap_page、zswap | 1.13.3、3.10 |
| 并发/锁 | 原子、自旋、睡眠锁 | spinlock/mutex/rwsem/RCU | 1.12、3.11 |
| 持久化/I/O | 设备、磁盘、RAID、FS | block_device、blk-mq、VFS、ext4/XFS | 1.13.5、3.12–3.14 |
| 崩溃一致性 | FSCK/日志 | jbd2、fsync、REQ_PREFLUSH/FUA | 1.13.11、3.12/3.13 |
| 分布式文件系统 | NFS/AFS | NFS 客户端、ESTALE、close-to-open | 3.12.8 |
这张表也是读 Linux 源码的入口:先问「这是 OSTEP 的哪个机制」,再找对应的 struct 与函数,比从 init/main.c 往下读更快。
1.13.8 进程、地址空间与分配器:机制补全
1.1 与 1.9 讲了进程与页表的「是什么」,OSTEP 第 4–6、14–23 章补的是「内核怎么维护」。这一节把三样东西的字段摊开:进程控制块、页表项、空闲空间管理器。
进程状态机与 PCB
OSTEP 4.4 把进程状态简化为三个:运行(Running,占着 CPU)、就绪(Ready,可运行但在队列里等)、阻塞(Blocked,等 I/O 或事件)。Linux 在此基础上多了「新建/僵尸/停止」,ps 的 STAT 列就是这套状态机的外显:
| OSTEP 状态 | Linux task_struct->state | ps | 迁移条件 |
|---|---|---|---|
| 就绪 | TASK_RUNNING(在 runqueue 上) | R | 被调度器选中 → 运行 |
| 运行 | TASK_RUNNING(在 CPU 上) | R | 时间片耗尽 → 就绪;等事件 → 阻塞 |
| 阻塞 | TASK_INTERRUPTIBLE/TASK_UNINTERRUPTIBLE | S/D | 事件完成或信号 → 就绪 |
| — | __TASK_STOPPED | T | SIGCONT → 就绪 |
| — | EXIT_ZOMBIE | Z | 父进程 wait 回收后消失 |
「就绪」与「运行」在 state 字段里是同一个值,区别只在「是否在 CPU 上」;这是读内核代码时最容易迷糊的一处。真正的区分靠 runqueue 与 on_cpu 标志。
PCB(Linux 的 task_struct)不是「一个进程一份简单记录」,而是几KB的结构,字段按生命周期分组:
| 字段 | 内容 | 谁维护 | 何时变 |
|---|---|---|---|
state、flags | 状态、PF_* 标志 | 调度器/信号 | 每次状态迁移 |
pid、tgid | 线程 ID、线程组 ID(=进程 ID) | copy_process | 创建时固定 |
mm、active_mm | 用户地址空间;内核线程借用上一个 mm | exec/fork/切换 | exec 换新 mm |
files | fd 表(files_struct),带引用计数 | open/close/dup | 每次 fd 操作 |
fs | 根目录、当前目录、umask | chdir/chroot/umask | 路径解析相关 |
cred | uid/gid、capabilities | setuid 等 | 凭证变更 |
sighand、signal | 信号处理表、pending/blocked 集合 | 信号子系统 | sigaction/投递 |
thread | 切换现场:sp、ip、FPU 状态、fsbase | switch_to | 每次上下文切换 |
sched_entity | vruntime、权重、调度类 | CFS/EEVDF | 每次运行/入队 |
nsproxy | 命名空间(mnt/pid/net/uts/ipc/user/cgroup) | clone/unshare | 创建容器时 |
fork 复制的是这张表加页表;clone 按标志选择共享哪些部分(CLONE_VM 共享地址空间、CLONE_FILES 共享 fd 表、CLONE_THREAD 同线程组),线程就是「共享了 mm 与 files 的 task」。这解释了为什么「线程」在 Linux 里不是独立概念:它只是 clone 参数不同的进程。
受限直接执行的两个问题
OSTEP 第 6 章把「虚拟化 CPU」归结为两个问题,每个问题对应一套硬件机制:
| 问题 | 用户态想做什么 | 硬件机制 | 内核入口 |
|---|---|---|---|
| 受限操作 | 读写文件、发网络包 | 特权级(ring 3 → ring 0)、syscall 指令 | entry_SYSCALL_64 → do_syscall_64 |
| 进程切换 | 一直占着 CPU | 定时器中断、int 0x80/syscall 主动让出 | 中断处理 → schedule() |
协作式(cooperative)切换靠进程主动 yield,一个死循环就能锁死系统;抢占式(preemptive)靠定时器中断强制收回 CPU。现代通用 OS 全是抢占式,代价是「任何指令边界都可能被切走」,所以并发程序必须假设「两条指令之间可能发生任何事」。
页表项:10 个位决定一次翻译
x86-64 四级页表把 48 位虚拟地址切成 9+9+9+9+12,每级 512 项、每项 8 字节,一个页表页正好 4 KiB。PTE 的低 12 位是标志:
| 位 | 名称 | 含义 | 谁改 |
|---|---|---|---|
| 0 | P | 存在;为 0 触发缺页 | 内核 |
| 1 | RW | 可写;为 0 时写触发保护缺页(COW 靠它) | 内核 |
| 2 | US | 用户态可访问;为 0 只有内核能碰 | 内核 |
| 3 | PWT | 写穿透(页表级缓存策略) | 内核 |
| 4 | PCD | 禁止缓存 | 内核 |
| 5 | A | 已访问;Clock/LRU 近似用它 | CPU 置位,内核清除 |
| 6 | D | 脏页;决定换出时是否要写回 | CPU 置位,内核清除 |
| 7 | PS | 大页(2 MiB/1 GiB) | 内核 |
| 63 | NX | 不可执行(W^X 的基础) | 内核/链接器 |
这张表解释了三件事:COW 是「清 RW 位 + 写时缺页」;页替换的 Clock 算法靠 A 位;mprotect 改的就是这些位。第五级页表(LA57)把虚拟地址扩到 57 位,多一级 9 位索引,Linux 4.14 起在支持的 CPU 上可用。
空闲空间管理:从 first-fit 到 slab
malloc 与内核的页分配器面对同一个问题:一堆空洞,怎么切、怎么并。OSTEP 第 17 章的四条基本策略至今仍是教科书答案:
| 策略 | 选块规则 | 优点 | 缺点 |
|---|---|---|---|
| first-fit | 第一个够大的 | 快 | 低地址碎片多 |
| best-fit | 最小的够大的 | 省空间 | 遍历全表;留下大量小碎片 |
| worst-fit | 最大的 | 剩余块大、可复用 | 大块很快被切碎 |
| next-fit | 从上次位置继续 | 分布均匀 | 大块难找 |
分配器还要处理分裂(split,切大块)与合并(coalesce,释放时与相邻空闲块合并),合并需要边界标记(boundary tag)记录前一块大小。内核里更常用 buddy(按 2 的幂分裂/合并,O(log n),内部碎片最多 50%)与 slab/slub(按对象大小缓存,避免反复构造/析构)。用户态 glibc 的 malloc 用 arena + bin + tcache,小对象走 tcache 无锁路径,大对象 mmap 直接向内核要页。
- 「
fork复制了父进程的内存」——只复制页表并清RW位,物理页共享;写时缺页才复制一页。所以fork后立刻exec的程序,几乎不产生内存复制。 - 「虚拟地址就是物理地址减一个偏移」——四级页表逐级翻译,每级都可能缺页;连续虚拟地址不保证连续物理地址,大页(2 MiB)只是减少层级。
- 「
free会把内存还给系统」——glibc 的free多数时候只把块放回 bin;只有mmap的大块或malloc_trim才归还。进程 RSS 不降不等于泄漏。 - 「进程状态
R就是在跑」——R包含「在 runqueue 上等 CPU」;判断是否真在跑要看/proc/<pid>/stat的第 39 字段(CPU 号)两次采样是否变化。
1.13.9 调度模拟:彩票、步长与 CFS 的公平性
1.13.2 给了 FIFO/SJF/RR/MLFQ 的指标计算。这一节补「按比例分配」这一族:彩票调度(lottery)、步长调度(stride)与 Linux 的 CFS/EEVDF,并用一个程序把「份额误差」量化。
彩票调度:每个任务持有彩票,每次调度随机抽一张,持有者运行一个时间片。期望份额 = 自己的彩票数 / 总数。优点是实现极简、天然支持动态权重;缺点是份额是概率的,短时间窗口内偏差明显,误差量级约 1/sqrt(n)(n 为抽签次数)。
步长调度:给每个任务一个步长 stride = K / tickets,每次选 pass 值最小的运行,运行后 pass += stride。它是确定性的,长期份额精确等于彩票比例,代价是要维护 pass 的全局最小值(可用堆)。
CFS:Linux 2.6.23–6.5 的默认调度器,用 vruntime += delta × (NICE_0_LOAD / weight) 记录「已获得的加权 CPU 时间」,总选 vruntime 最小者。nice 每降 1,权重约乘 1.25:
| nice | 权重 | 相对 nice 0 的 CPU 比例 |
|---|---|---|
| −20 | 88761 | 86.7 倍 |
| −10 | 9548 | 9.3 倍 |
| 0 | 1024 | 1 倍 |
| 10 | 110 | 0.107 倍 |
| 19 | 15 | 0.015 倍 |
Linux 6.6 起默认调度器换成 EEVDF(eligible virtual deadline first):仍按权重分配,但给每个任务一个「虚拟截止时间」,在 eligible 集合里选截止时间最早者。它改善了 CFS 在延迟敏感负载上的表现,/proc/<pid>/sched 里的字段名也随之变化(se.vruntime 仍在,新加了 se.deadline 等)。
下面的程序在同一组彩票数上跑两种算法,直接对比「实际份额」与「期望份额」:
/* lottery.c:彩票调度 vs 步长调度的份额对照
* cc -std=c17 -D_GNU_SOURCE -O2 -Wall -Wextra -Werror lottery.c -o lottery
*/
#include <stdint.h>
#include <stdio.h>
#define NTASK 3
#define QUANTA 10000
#define STRIDE_K 100000L
struct task {
const char *name;
int tickets;
long wins; /* 彩票调度被选中的次数 */
long runs; /* 步长调度被选中的次数 */
long pass; /* 步长调度的虚拟时间 */
long stride;
};
static uint32_t rng = 2463534242u;
static uint32_t xs_next(void) /* xorshift32:可复现,不依赖 libc rand */
{
rng ^= rng << 13;
rng ^= rng >> 17;
rng ^= rng << 5;
return rng;
}
int main(void)
{
struct task t[NTASK] = {
{ "A", 100, 0, 0, 0, 0 },
{ "B", 50, 0, 0, 0, 0 },
{ "C", 50, 0, 0, 0, 0 },
};
int total = 0;
for (int i = 0; i < NTASK; i++) {
total += t[i].tickets;
t[i].stride = STRIDE_K / t[i].tickets;
}
for (int q = 0; q < QUANTA; q++) { /* 彩票:按彩票数抽签 */
int pick = (int)(xs_next() % (uint32_t)total);
int acc = 0, who = NTASK - 1;
for (int i = 0; i < NTASK; i++) {
acc += t[i].tickets;
if (pick < acc) { who = i; break; }
}
t[who].wins++;
}
for (int q = 0; q < QUANTA; q++) { /* 步长:选 pass 最小者 */
int who = 0;
for (int i = 1; i < NTASK; i++)
if (t[i].pass < t[who].pass)
who = i;
t[who].runs++;
t[who].pass += t[who].stride;
}
printf("tickets A=100 B=50 C=50, quanta=%d\n", QUANTA);
printf("%-2s %8s %10s %10s %10s\n", "id", "expect", "lottery", "stride", "pass");
for (int i = 0; i < NTASK; i++) {
double expect = 100.0 * (double)t[i].tickets / (double)total;
printf("%-2s %7.1f%% %9.1f%% %9.1f%% %10ld\n",
t[i].name, expect,
100.0 * (double)t[i].wins / QUANTA,
100.0 * (double)t[i].runs / QUANTA,
t[i].pass);
}
return 0;
}
预期:stride 列精确等于 50/25/25,三个 pass 最终都收敛到 5,000,000 附近——步长调度的本质就是让所有任务的 pass 保持相等,谁的步长大谁跑得少;lottery 列在 50/25/25 附近波动,1 万次抽签的偏差通常在 ±1.5 个百分点内。把 QUANTA 改成 100,波动明显变大——这就是「概率公平」与「确定性公平」的差别,也是 CFS 选 vruntime 而不是随机抽签的原因。
1.13.10 页替换模拟:OPT、LRU、Clock 与 Belady 异常
1.13.3 给了页替换的三种策略名。这一节用同一条引用串把它们的缺页数算出来,并复现 FIFO 的 Belady 异常:增加页框反而缺页更多。
先看四种算法的判定依据:
| 算法 | 替换谁 | 是否栈算法 | 实现成本 | Belady 异常 |
|---|---|---|---|---|
| OPT(Belady) | 未来最久不用的页 | 是 | 需要未来信息,不可实现 | 无 |
| LRU | 最久未使用的页 | 是 | 每次访问要更新时间戳/链表 | 无 |
| FIFO | 最早进入的页 | 否 | 一个环形队列 | 有 |
| Clock(二次机会) | 环形扫描,A=1 则清位放过 | 近似 LRU | 一个环形队列 + 访问位 | 极少见 |
「栈算法」指页框数为 n 时命中的页集合,一定是 n+1 时命中集合的子集;只有具备这个性质,增加页框才不会增加缺页。LRU 与 OPT 是栈算法,FIFO 不是,因此会出现 Belady 异常。
经典引用串 1 2 3 4 1 2 5 1 2 3 4 5:3 个页框时 FIFO 缺页 9 次,4 个页框时缺页 10 次。下面的程序把四种算法、两种页框数一次算完:
/* page_repl.c:FIFO/OPT/LRU/Clock 缺页数对照(含 Belady 异常)
* cc -std=c17 -D_GNU_SOURCE -O2 -Wall -Wextra -Werror page_repl.c -o page_repl
*/
#include <stdio.h>
#include <string.h>
#define NREF 12
#define MAXF 8
static const int ref[NREF] = { 1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5 };
static int frames[MAXF];
static void reset(void)
{
for (int i = 0; i < MAXF; i++)
frames[i] = -1;
}
static int present(int nf, int page)
{
for (int i = 0; i < nf; i++)
if (frames[i] == page)
return i;
return -1;
}
static int fifo(int nf)
{
int faults = 0, next = 0;
reset();
for (int i = 0; i < NREF; i++) {
if (present(nf, ref[i]) >= 0)
continue;
faults++;
frames[next] = ref[i];
next = (next + 1) % nf;
}
return faults;
}
static int lru(int nf)
{
int faults = 0, stamp[MAXF] = {0};
reset();
for (int i = 0; i < NREF; i++) {
int hit = present(nf, ref[i]);
if (hit >= 0) {
stamp[hit] = i + 1;
continue;
}
faults++;
int victim = 0;
for (int j = 1; j < nf; j++)
if (stamp[j] < stamp[victim])
victim = j;
frames[victim] = ref[i];
stamp[victim] = i + 1;
}
return faults;
}
static int opt(int nf)
{
int faults = 0;
reset();
for (int i = 0; i < NREF; i++) {
if (present(nf, ref[i]) >= 0)
continue;
faults++;
int victim = 0, farthest = -1;
for (int j = 0; j < nf; j++) {
int k = i + 1;
while (k < NREF && ref[k] != frames[j])
k++;
if (k > farthest) {
farthest = k;
victim = j;
}
}
frames[victim] = ref[i];
}
return faults;
}
static int clock_repl(int nf)
{
int faults = 0, hand = 0, use[MAXF] = {0};
reset();
for (int i = 0; i < NREF; i++) {
int hit = present(nf, ref[i]);
if (hit >= 0) {
use[hit] = 1;
continue;
}
faults++;
while (use[hand]) { /* 二次机会:清位后放过 */
use[hand] = 0;
hand = (hand + 1) % nf;
}
frames[hand] = ref[i];
use[hand] = 1;
hand = (hand + 1) % nf;
}
return faults;
}
int main(void)
{
printf("ref:");
for (int i = 0; i < NREF; i++)
printf(" %d", ref[i]);
printf("\n%6s %6s %6s %6s\n", "FIFO", "OPT", "LRU", "Clock");
for (int nf = 3; nf <= 4; nf++)
printf("%6d %6d %6d %6d (frames=%d)\n",
fifo(nf), opt(nf), lru(nf), clock_repl(nf), nf);
return 0;
}
预期输出:FIFO 一列是 9 与 10——页框从 3 增到 4,缺页反而多一次,这就是 Belady 异常;OPT(7→6)与 LRU(10→8)都下降;本例的 Clock 与 FIFO 同为 9→10,说明「近似 LRU」并不保证栈算法性质,换一条引用串它也可能下降。
这个模拟器的价值在于可改参数:换一条更长的随机引用串,把页框数从 2 扫到 8,就能看到 LRU 的命中率曲线如何逼近 OPT、FIFO 如何在某些区间掉头向下。真实内核不用纯 LRU(硬件不支持高效精确排序),而是用 active/inactive 双链表 + 访问位做近似,3.10 会展开。
1.13.11 崩溃一致性:日志协议与崩溃点枚举
1.13.5 讲了「先写日志再改主文件系统」的思路。这一节把「创建文件」拆成三次元数据更新,枚举每个崩溃点,用程序证明:没有日志时某些崩溃点必然留下不一致;有 redo 日志时所有崩溃点都能恢复。
考虑在目录里新建一个文件,需要三步:
- 目录项指向 inode 2(
dir -> 2); - inode 位图标记 2 已分配;
- inode 内容初始化(类型、权限、大小、数据块指针)。
如果按 1→2→3 的顺序直接写盘,崩在 1 之后就是「目录项指向一个未分配的 inode」——fsck 只能把它删掉,用户看到文件消失。崩在 2 之后、3 之前,则是「inode 已分配但内容全零」,同样不可用。日志要保证的是:这三步要么全做,要么全不做。
redo 日志的协议是:
1. 把三步的「新值」写进日志区(txn begin + 记录)
2. 写提交记录(commit)—— 这一步落盘是原子分界点
3. checkpoint:把日志里的新值应用到主文件系统
4. 事务完成,日志空间可回收
恢复规则只有两条:有提交记录 → 重放整个事务(重放必须幂等);没有提交记录 → 丢弃。崩溃点因此分成两类:提交前(丢弃,旧状态完好)与提交后(重放,新状态完整)。
/* journal.c:三次元数据更新的崩溃点枚举(redo 日志)
* cc -std=c17 -D_GNU_SOURCE -O2 -Wall -Wextra -Werror journal.c -o journal
*/
#include <stdio.h>
#define NINODE 4
struct disk {
int dir; /* 目录项指向的 inode,-1 表示没有 */
int bitmap[NINODE]; /* 1 = 已分配 */
int inode[NINODE]; /* 1 = 已初始化 */
};
static const struct disk base = { -1, {0, 0, 0, 0}, {0, 0, 0, 0} };
static void step(struct disk *d, int k)
{
if (k == 0)
d->dir = 2; /* 1. 写目录项 */
else if (k == 1)
d->bitmap[2] = 1; /* 2. 标记位图 */
else if (k == 2)
d->inode[2] = 1; /* 3. 初始化 inode */
}
/* 目录项指向的 inode 必须既已分配又已初始化 */
static int dangling(const struct disk *d)
{
return d->dir >= 0 && (!d->bitmap[d->dir] || !d->inode[d->dir]);
}
/* 已分配且已初始化,却没有任何目录项引用:泄漏,不是损坏 */
static int leaked(const struct disk *d)
{
return d->bitmap[2] && d->inode[2] && d->dir != 2;
}
int main(void)
{
printf("%-8s %-28s %-28s\n", "崩溃点", "无日志(就地更新)", "redo 日志(提交后重放)");
for (int k = 0; k <= 3; k++) {
struct disk d = base;
for (int i = 0; i < k; i++)
step(&d, i);
struct disk j = base; /* 崩溃发生在提交之后:重放全部三步 */
if (k == 3)
for (int i = 0; i < 3; i++)
step(&j, i);
printf("写 %d 步 %s%s %s%s\n", k,
dangling(&d) ? "不一致" : "一致",
leaked(&d) ? "(泄漏)" : " ",
dangling(&j) ? "不一致" : "一致",
leaked(&j) ? "(泄漏)" : " ");
}
return 0;
}
预期:无日志列在 写 1 步、写 2 步 显示「不一致」,写 3 步 才一致;redo 列四行全「一致」。真实文件系统还会用校验和(ext4 的 metadata_csum)发现日志本身的撕裂,用 barrier/FLUSH 保证提交记录真的落盘——否则「提交」可能还在磁盘缓存里。
三种日志模式的区别值得单列:
| 模式(ext4) | 数据何时写 | 保证 | 性能 | 风险 |
|---|---|---|---|---|
data=journal | 数据也进日志 | 数据与元数据都原子 | 最慢 | 无(代价大) |
data=ordered(默认) | 数据先于元数据提交 | 不会出现「元数据指向旧数据」 | 中 | 崩溃后可能看到旧数据 |
data=writeback | 不保证顺序 | 只有元数据一致 | 最快 | 可能读到未初始化/旧数据 |
fsync 的语义要配日志一起看:fsync(fd) 保证该文件的数据与元数据落盘;rename 之后还要 fsync 父目录,否则目录项可能回退。日志保证的是「文件系统结构一致」,不保证「你的应用语义正确」——应用层仍需「临时文件 + fsync + rename + fsync 目录」这套组合。
日志不是唯一方案。OSTEP 42.4 还列了三种:
| 方案 | 保证 | 代价 | 代表 |
|---|---|---|---|
| soft updates | 按依赖顺序写,避免不一致 | 实现复杂、要跟踪依赖 | BSD FFS |
| copy-on-write | 新块写好后原子切换根指针 | 写放大、碎片 | ZFS/Btrfs |
| backpointer | 每个块记录「谁指向我」 | 空间/更新开销 | 一些研究型 FS |
它们的共同目标都是「崩溃后不需要 fsck 全盘扫」;区别在「用日志记录意图」「用写顺序避免坏状态」还是「用新树替换旧树」。ext4 选日志,是因为它在兼容性与性能之间最平衡。
1.13.12 OSTEP 50 章逐章落点表
这张表把 OSTEP 全书 50 章逐章映射到本书。页码是王海鹏译本(Z-Library 版)的印刷页码;「状态」用四档:已覆盖=本书有独立小节展开;合并=并入相邻主题;背景=只给概念与指路;淘汰=超出本书范围或已被替代。虚拟化部分(1–24)密集落在 1.1、1.9、1.13;并发(25–34)落在 1.12;持久化(35–45)落在 1.13.5、1.13.11;分布式(46–50)超出本书主线,只留指路。
| 章 | 页 | 主题 | 本书落点 | 状态 |
|---|---|---|---|---|
| 1 | 2 | 关于本书的对话 | 1.13 导语 | 背景 |
| 2 | 4 | 操作系统介绍(三大主题) | 1.13.1 | 已覆盖 |
| 3 | 18 | 关于虚拟化的对话 | 1.13.1 | 背景 |
| 4 | 20 | 抽象:进程 | 1.13.8、1.1.4 | 已覆盖 |
| 5 | 28 | 插叙:进程 API | 1.1.4、2.7 | 已覆盖 |
| 6 | 38 | 机制:受限直接执行 | 1.13.8 | 已覆盖 |
| 7 | 48 | 进程调度:介绍 | 1.13.2 | 已覆盖 |
| 8 | 58 | 调度:多级反馈队列 | 1.13.2 | 已覆盖 |
| 9 | 66 | 调度:比例份额 | 1.13.9 | 已覆盖 |
| 10 | 74 | 多处理器调度(高级) | 1.13.2 | 已覆盖 |
| 11 | 82 | CPU 虚拟化总结对话 | 1.13.1 | 背景 |
| 12 | 84 | 内存虚拟化对话 | 1.13.1 | 背景 |
| 13 | 86 | 抽象:地址空间 | 1.9、1.13.3 | 已覆盖 |
| 14 | 92 | 插叙:内存操作 API | 1.13.8、1.9.9 | 已覆盖 |
| 15 | 100 | 机制:地址转换 | 1.9.6、1.13.8 | 已覆盖 |
| 16 | 112 | 分段 | 1.13.3 | 已覆盖 |
| 17 | 120 | 空闲空间管理 | 1.13.8 | 已覆盖 |
| 18 | 132 | 分页:介绍 | 1.9、1.13.3 | 已覆盖 |
| 19 | 142 | 分页:TLB | 1.9.6、1.13.3 | 已覆盖 |
| 20 | 154 | 分页:较小的表 | 1.13.3 | 已覆盖 |
| 21 | 166 | 超越物理内存:机制 | 1.13.3 | 已覆盖 |
| 22 | 172 | 超越物理内存:策略 | 1.13.10 | 已覆盖 |
| 23 | 186 | VAX/VMS 虚拟内存系统 | 1.13.3 | 背景 |
| 24 | 194 | 内存虚拟化总结对话 | 1.13.3 | 背景 |
| 25 | 196 | 关于并发的对话 | 1.12 导语、1.13.4 | 背景 |
| 26 | 198 | 并发:介绍 | 1.12.2 | 已覆盖 |
| 27 | 210 | 插叙:线程 API | 1.12.8 | 已覆盖 |
| 28 | 220 | 锁 | 1.12.10 | 已覆盖 |
| 29 | 238 | 基于锁的并发数据结构 | 1.12.13.2 | 已覆盖 |
| 30 | 250 | 条件变量 | 1.12.3 | 已覆盖 |
| 31 | 264 | 信号量 | 1.12.4、1.12.11 | 已覆盖 |
| 32 | 280 | 常见并发问题 | 1.12.12、1.12.16 | 已覆盖 |
| 33 | 292 | 基于事件的并发 | 1.12.7 | 已覆盖 |
| 34 | 300 | 并发总结对话 | 1.12.17 | 背景 |
| 35 | 302 | 关于持久性的对话 | 1.13.5 | 背景 |
| 36 | 304 | I/O 设备 | 1.13.5、3.13.1–3.13.5 | 合并(深落在第 3 篇) |
| 37 | 314 | 磁盘驱动器 | 1.13.5、3.13.3 | 合并(调度在 3.13.3) |
| 38 | 326 | RAID | 1.13.5、0.10.7、3.13.5 | 合并(选型在 1.13.5/0.10;3.13.5 只讲 flush 前提) |
| 39 | 342 | 插叙:文件和目录 | 1.10、1.13.5 | 已覆盖 |
| 40 | 358 | 文件系统实现 | 1.13.5、3.12.1–3.12.5 | 合并(深落在 3.12) |
| 41 | 374 | 局部性和快速文件系统 | 1.13.5、3.12.5 | 合并(ext4 块组在 3.12.5) |
| 42 | 380 | 崩溃一致性:FSCK 和日志 | 1.13.11 | 已覆盖 |
| 43 | 396 | 日志结构文件系统 | 1.13.5、3.12.5 | 合并(LFS 五机制在 1.13.5) |
| 44 | 408 | 数据完整性和保护 | 3.12.5.1、3.13.5 | 合并(写错/丢失写/scrub 在 3.12.5.1) |
| 45 | 417 | 持久化总结对话 | 1.13.5 | 背景 |
| 46 | 418 | 关于分布式的对话 | — | 淘汰(超出范围) |
| 47 | 420 | 分布式系统 | — | 淘汰(超出范围) |
| 48 | 430 | Sun 网络文件系统(NFS) | 3.12.8.1 | 已覆盖 |
| 49 | 442 | Andrew 文件系统(AFS) | 3.12.8.2 | 已覆盖 |
| 50 | 453 | 分布式总结对话 | 3.12.8.3 | 背景 |
读表的三个要点:
- 虚拟化的重心在 1.9 与 1.13。OSTEP 用 24 章讲虚拟化,本书把「机制」(页表、TLB、上下文切换)放在 1.9、1.13.8,「策略」(调度、替换)放在 1.13.2、1.13.9、1.13.10。读 OSTEP 时若觉得「每章都在重复」,是因为它按「一个假设 + 一个机制 + 一个作业」展开,本书按「机制/策略」合并。
- 并发只对应 1.12 一章,但覆盖了 OSTEP 25–34 的核心:锁的实现(1.12.10)、条件变量与信号量(1.12.3–1.12.4)、并发 bug(1.12.12)、事件(1.12.7)。OSTEP 29 章(并发数据结构)已在 1.12.13.2 补了懒初始化链表/hand-over-hand/并发哈希;RCU/无锁对照在 1.12.11。
- 分布式五章(46–50)分开处理:46/47 是对话与总览,判淘汰;48 NFS、49 AFS 已在 3.12.8.1/3.12.8.2 完整展开;50 是总结对话,判背景。
1.13.13 xv6 与 rCore:两个可读完的小内核
1.13.6 用李治军的 8 个项目做了「实现侧写」。如果想把「机制」真正读成代码,还有两条更成熟的路线:MIT 的 xv6(C + RISC-V,约 9 千行)与清华的 rCore(Rust + RISC-V,按章递增)。两者都把 Linux 的复杂度砍到「能在一周内读完一个子系统」的量级,正好对应 OSTEP 的三大主题。
先看同一批子系统在两套代码里的落点:
| 子系统 | xv6(C,RISC-V) | rCore(Rust,RISC-V) | Linux 6.8 | 本书 |
|---|---|---|---|---|
| 启动 | entry.S 进 S 态、start.c 设 mstatus/stvec | entry.asm + rust_main,linker.ld 布局 | head_64.S、startup_64 | 1.13.6 项目 1、3.2 |
| 系统调用 | syscall.c 的 syscalls[] 表 + usys.S | syscall/mod.rs 的 syscall! 宏 + 分发 | syscall_64.tbl、do_syscall_64 | 3.3、1.13.6 项目 2 |
| 进程/任务 | proc.c:struct proc、allocproc、fork、scheduler | task/mod.rs:TaskControlBlock、TaskManager | kernel/fork.c、sched/core.c | 3.4–3.5 |
| 地址空间 | vm.c:walk、mappages、kvmmake;Sv39 三级页表 | mm/memory_set.rs:MemorySet、MapArea、页表 | mm/mmap.c、mm/page_alloc.c | 3.7–3.10 |
| 陷入/中断 | trap.c:usertrap、kerneltrap、devintr;trampoline.S | trap/mod.rs:trap_handler、跳板页 | arch/x86/entry/ | 3.3 |
| 锁与同步 | spinlock.c、sleeplock.c | sync/mod.rs:UPSafeCell、mutex、semaphore | kernel/locking/ | 3.11、1.12 |
| 文件系统 | fs.c(inode、dirlookup)、log.c(redo 日志)、bio.c(buffer cache) | easy-fs crate:块位图、inode 位图、disk inode | fs/ext4、fs/vfs | 1.13.11、3.12 |
| 物理内存 | kalloc.c:空闲链表 | buddy_system_allocator crate | buddy + slab/slub | 1.13.8 |
| 用户态 | user/(sh、ls、cat)、ulib.c | user crate 的 syscall 封装 | glibc | 第 2 篇 |
几个值得对照的细节:
- 页表层级。xv6 用 Sv39(39 位虚拟地址、三级页表、每级 512 项),与 x86-64 的四级页表结构同构,只是层数与位数不同;读
vm.c的walk()能一次看懂「逐级索引 + 缺页」。rCore 在MemorySet里把「逻辑段(MapArea)」与「页表」分离,映射时按段批量建表,这更接近 Linux 的 VMA(vm_area_struct)。 - 日志。xv6 的
log.c是 OSTEP 42 章的可运行版本:begin_op/log_write把块写入内存日志,commit写日志头并bwrite到日志区,install_trans重放到主区。对照 1.13.11 的协议,能一一对上「提交记录」「重放」「幂等」。 - 并发模型。xv6 只有自旋锁与睡眠锁,且大量依赖「关中断 + 单核假设」;rCore 用
UPSafeCell把「运行时借用检查」封在unsafe里,编译期阻止同一对象的可变别名。两者都不是「生产级并发」——没有 RCU、没有 per-CPU 数据、没有无锁队列,这正是 Linux 与教学内核的差距。 - 规模与取舍。xv6 约 9 千行、rCore 每章增加几百到上千行;Linux 6.8 约 3 千万行。教学内核省略的是「性能与硬件多样性」,保留的是「机制的最小正确实现」。读它们的目的不是「学会写内核」,而是「验证你已经理解机制」。
学习路径建议:先读 xv6 的 proc.c + vm.c + log.c(三份文件覆盖进程、内存、持久化),再读 rCore 对应章节,最后回到 Linux 的 kernel/fork.c、mm/mmap.c、fs/ext4/ 看工业实现多出来的东西(锁粒度、per-CPU 缓存、NUMA、错误注入)。
1.13.14 三个实验:调度、缺页与 ext4 的证据
理论要落到可观测的数字。三个实验分别对应 OSTEP 的三大主题,命令都在 Linux 6.8 上验证过语义;输出数值因机器而异,看数量级与比例,不看绝对值。
实验一:nice 值与 CPU 份额
目标:验证 CFS/EEVDF 的权重模型——nice 每差 10,CPU 份额约差 9 倍。
taskset -c 0 nice -n 0 sh -c 'while :; do :; done' & P0=$!
taskset -c 0 nice -n 10 sh -c 'while :; do :; done' & P10=$!
sleep 10
ps -o pid,ni,pcpu,time,comm -p $P0,$P10
grep -E 'se.sum_exec_runtime|nr_switches|nr_migrations' /proc/$P0/sched
kill $P0 $P10
预期:两个进程都绑在 CPU 0 上,ps 的 %CPU 约为 90% 与 10%。不绑核时它们会各占一个核、都接近 100%,比例不能用来验证权重。/proc/<pid>/sched 的 se.sum_exec_runtime 是累计运行纳秒数,nr_switches 是自愿+非自愿切换次数。把 nice 改成 0 与 19,比例会拉到约 68:1——这就是权重表(1024 对 15)的直接后果。
再看一眼「谁在等 CPU」:
perf sched record -- sleep 5
perf sched latency | head -20
perf sched latency 给出每个任务的等待延迟(wait-time)与调度延迟(sched-delay)的最大值/平均值。数字大不代表有问题:CPU 密集任务本来就该等;要看的是交互任务的等待是否被批处理任务拖长。
实验二:缺页与 swap
目标:把「按需分页」变成 pgfault 计数,把「内存压力」变成 pswpin/pswpout。
cat > touch.c <<'EOF'
#define _GNU_SOURCE
#include <stdio.h>
#include <sys/mman.h>
#include <unistd.h>
#define BYTES (256UL << 20) /* 256 MiB */
int main(void)
{
char *p = mmap(NULL, BYTES, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (p == MAP_FAILED) { perror("mmap"); return 1; }
for (unsigned long off = 0; off < BYTES; off += 4096)
p[off] = 1; /* 每页写一个字节:触发一次缺页 */
printf("touched %lu pages\n", BYTES / 4096);
return 0;
}
EOF
cc -std=c17 -D_GNU_SOURCE -O2 -Wall -Wextra -Werror touch.c -o touch
grep -E 'pgfault|pgmajfault' /proc/vmstat
/usr/bin/time -v ./touch 2>&1 | grep -E 'Maximum resident|page faults'
grep -E 'pgfault|pgmajfault' /proc/vmstat
预期:touch 报告约 65536 次 minor page faults(256 MiB / 4 KiB),Maximum resident set size 约 262144 KB;/proc/vmstat 的 pgfault 增量与页数同量级,pgmajfault 通常为 0(没有 swap)。这证明「mmap 不分配物理页,第一次写才分配」。
制造压力再看 swap(谨慎,会拖慢机器):
free -m; swapon --show
stress-ng --vm 2 --vm-bytes 2G --timeout 20s & # 没有 stress-ng 可用 dd/自写程序替代
vmstat 1 5
vmstat 的 si/so 是每秒换入/换出 KiB,持续非零说明系统在 thrashing 边缘;pswpin/pswpout 是累计页数(不是字节)。这两个单位经常被混用。
实验三:ext4 的布局与日志
目标:看到 inode、extent、日志特征,理解「日志是文件系统的一个隐藏 inode」。
findmnt -no SOURCE,FSTYPE,OPTIONS /
sudo dumpe2fs -h /dev/nvme0n1p2 | grep -E 'Block count|Inode count|Journal|Filesystem features'
sudo debugfs -R 'stat /etc/hostname' /dev/nvme0n1p2
sudo filefrag -v /var/log/syslog | head -20
预期:Filesystem features 含 has_journal、extent、64bit、metadata_csum;debugfs stat 显示 inode 号、链接数、块数、extent 树((ETB0):...);filefrag 显示逻辑块到物理块的映射与是否连续。日志本身是 inode 8,dumpe2fs 的 Journal inode: 8 能看到;日志大小(Journal size)决定一次事务能合并多少元数据。
三个实验的共同方法:先给预期,再跑命令,最后解释差异。没有预期的观测只是数据,不是证据。
1.13.15 十五条操作系统陷阱
| # | 现象 | 原因 | 修复/判断 |
|---|---|---|---|
| 1 | 用周转时间比较 CFS 与 RR | CFS 没有固定时间片,指标不适用 | 交互看响应/延迟分位,批处理看吞吐 |
| 2 | 长任务永远拿不到 CPU | MLFQ 没有周期提升,长任务沉底 | 规则 5 的周期提升不是可选 |
| 3 | RR 片长调到 100 µs 后吞吐下降 | 切换开销占比升高 | 片长 ≥ 1 ms,按负载调 |
| 4 | 以为 LRU 可直接实现 | 硬件不提供精确访问时间戳 | 用 Clock/active-inactive 近似 |
| 5 | 加页框后缺页反而变多 | FIFO 不是栈算法(Belady 异常) | 用 LRU/Clock,或至少知道边界 |
| 6 | swap 开得越大系统越卡 | 工作集远超物理内存,thrashing | 加内存或限 cgroup,而非加 swap |
| 7 | 以为 RAID 5 能防误删 | RAID 只处理盘故障 | 备份 + 快照;RAID 不是备份 |
| 8 | 日志模式下仍丢应用数据 | data=writeback 不保证数据先落盘 | data=ordered + 应用层 fsync |
| 9 | fsync 后文件名仍回退 | 只同步文件,没同步目录项 | rename 后 fsync 父目录 |
| 10 | 把 inode 号当文件名 | 目录项才是「名字 → inode」的映射 | 硬链接共享 inode,名字在目录里 |
| 11 | 在 xv6 里用自旋锁保护长临界区 | 关中断 + 自旋,长临界区会丢中断/烧 CPU | 长临界区用睡眠锁 |
| 12 | 以为 rCore 的 UPSafeCell 是线程安全 | 它只做运行时借用检查,单核假设 | 多核要真锁 + 原子操作 |
| 13 | 通读 OSTEP 50 章 | 46–50 章是分布式,超出主线 | 按 1.13.12 的表取舍 |
| 14 | vmstat 的 si/so 当成累计字节 | 单位是 KB/s,累计页数看 pswpin/pswpout | 先看单位再下结论 |
| 15 | 认为「小内核 = 简单内核」 | xv6/rCore 省略了性能与硬件多样性 | 读机制,不读性能 |
- 「作业题可以跳过」——OSTEP 的作业(模拟器
scheduler.py、mem.py)是理解调度与替换的最短路径;跳过作业只读正文,很容易停在「名词级」理解。 - 「机制与策略是一回事」——机制回答「怎么切换/翻译」,策略回答「选谁」;混在一起会导致「给策略打补丁却改坏了机制」。
- 「读了 OSTEP 就不用读 Linux 源码」——OSTEP 给的是模型,Linux 给的是工程约束下的实现(per-CPU、RCU、cgroup、NUMA)。两者是地图与地形的关系。
1.13.16 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 画出三大主题地图 | 每块能列出机制与策略 | 1.13.1 |
| 算调度指标 | 能对一组任务算周转/响应 | 1.13.2 |
| 解释 MLFQ 规则 | 能说出周期提升的作用 | 1.13.2 |
| 解释页替换 | 能比较 FIFO/OPT/LRU/Clock,说出 Belady 异常 | 1.13.10 |
| 解释日志 | 能画出 redo 日志的提交点与重放 | 1.13.11 |
| 说出 8 个项目 | 每个能指出关键结构与验收证据 | 1.13.6 |
| 描述进程与页表字段 | 能说出 task_struct 四组字段与 PTE 的 P/RW/US/A/D | 1.13.8 |
| 算调度份额 | 能用彩票数推期望份额,解释 stride 与 CFS 的区别 | 1.13.9 |
| 定位 50 章 | 任给 OSTEP 一章能说出本书落点与状态 | 1.13.12 |
| 对照 xv6/rCore | 能指出同一子系统在三套代码里的文件 | 1.13.13 |
| 做三个实验 | 能给预期、跑命令、解释偏差 | 1.13.14 |
Q1 三个任务 A(0,6)、B(1,3)、C(2,1),分别用 FIFO 和 RR(片长 2) 调度,算平均周转时间与平均响应时间。
Q2 RAID 5 用 5 块 4 TB 盘,可用容量是多少?能容忍几块盘同时故障?重建期间为什么危险?
Q3 一个应用先 write 文件、再 rename,但没有 fsync。断电后可能看到什么?补上 fsync 的顺序应该是什么?
Q4 为什么 fork 之后通常要 exec?「受限直接执行」与这两个系统调用有什么关系?
答案
A1 FIFO:A 0–6,B 6–9,C 9–10;周转 A6、B8、C8,平均 7.33;响应 0、5、7,平均 4。RR(2):A 0–2,B 2–4,C 4–5,A 5–7,B 7–8,A 8–10;周转 A10、B7、C3,平均 6.67;响应 0、1、2,平均 1。
A2 可用容量 = (5−1)×4 TB = 16 TB;容忍 1 块盘故障。重建期间所有盘都满负荷读,剩余盘再坏一块就丢数据;大容量盘重建可能持续数小时,期间性能下降、二次故障概率上升。
A3 可能看到旧文件、新文件、或临时文件残留;rename 本身原子,但目录项和数据可能没落盘,崩溃后可能回退到旧名字或出现空文件。正确顺序:写临时文件 → fsync(临时文件) → close → rename → fsync(父目录)。
A4 fork 复制地址空间后,子进程通常要跑另一个程序;exec 用新程序替换当前映像,避免复制后立刻丢弃。受限直接执行让用户程序直接跑在 CPU 上,fork/exec 是用户主动进入内核的「陷阱」路径;内核在陷阱里完成创建/替换,再返回用户态。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| O(OSTEP) | 第 1–3 章 对话、操作系统介绍 | 1.13.1、1.13.8 | 完整:三大主题、设计目标、LDE 的两个问题 |
| O(OSTEP) | 第 4–6 章 进程、进程 API、受限直接执行 | 1.13.8、1.1.4 | 完整:状态机、PCB 字段、fork/exec、陷阱与切换 |
| O(OSTEP) | 第 7–10 章 调度:介绍、MLFQ、比例份额、多核 | 1.13.2、1.13.9 | 完整:周转/响应、MLFQ 五规则、彩票/步长、SQMS/MQMS |
| O(OSTEP) | 第 13–24 章 虚拟化内存 | 1.9、1.13.3、1.13.8、1.13.10 | 完整:地址空间、分段、分页、TLB、多级页表、交换、替换 |
| O(OSTEP) | 第 25–34 章 并发 | 1.12、1.13.4 | 已覆盖:详见 1.12.8–1.12.16,本章只留理论骨架 |
| O(OSTEP) | 第 35–45 章 持久化 | 1.13.5、1.13.11 | 完整:I/O 设备、磁盘、RAID、FS 实现、FSCK/日志、LFS、闪存 |
| O(OSTEP) | 第 46–50 章 分布式 | —(4.x 网络篇) | 淘汰:超出本书主线,只留指路 |
| L(李治军) | 第 1–8 章与实践项目 1–8 | 1.13.6 | 完整:8 个实现侧写,对应 Linux 机制与验收证据 |
| X(xv6) | proc.c、vm.c、log.c 等 | 1.13.13 | 精选:进程/页表/日志三份文件的对照 |
| Rc(rCore) | 第 1–8 章 | 1.13.13 | 精选:Rust 实现与 UPSafeCell、easy-fs |
| R(奔跑吧) | 第 1–6 章 | 1.13.2、1.13.5 | 背景:调度/内存/同步的 Linux 实现,详见第 3 篇 |
OSTEP 第 1–3 章小节清单
| OSTEP 小节 | 页码 | 状态 | 本书落点 |
|---|---|---|---|
| 1 关于本书的对话 | 2 | 背景 | 1.13 导语(三条主线从哪来) |
| 2.1 虚拟化 CPU | 5 | 已覆盖 | 1.13.1、1.13.2(进程与调度) |
| 2.2 虚拟化内存 | 6 | 已覆盖 | 1.13.3、1.9(地址空间与页表) |
| 2.3 并发 | 7 | 已覆盖 | 1.12、1.13.4(线程与共享) |
| 2.4 持久性 | 9 | 已覆盖 | 1.13.5、1.13.11(设备、FS、日志) |
| 2.5 设计目标 | 11 | 合并 | 1.13.1(抽象、隔离、性能、可靠性) |
| 2.6 简单历史 | 13 | 背景 | 1.13 导语(批处理 → 分时 → 个人机) |
| 2.7 小结 | 15 | 背景 | 1.13.1(三大主题地图) |
| 3 关于虚拟化的对话 | 18 | 背景 | 1.13.1(「虚拟桃子」= 虚拟化直觉) |
50 章完整落点见 1.13.12 的表。
下一站:第 1 篇到此结束。你已经有了「程序如何被链接、进程如何被调度、内存如何被翻译、I/O 如何穿过内核、连接如何建立、并发如何同步」的完整地图。接下来进入 第 2 篇 · Unix/Linux 系统编程,把这些机制变成每天能用的系统调用与工程习惯。