Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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           │
│ 交换/页替换        │         │                   │         │ 崩溃一致性         │
└─────────┬─────────┘         └─────────┬─────────┘         └─────────┬─────────┘
          │                             │                             │
          ▼                             ▼                             ▼
   机制:怎么切换/翻译          机制:怎么原子/等待        机制:怎么读写/落盘
   策略:选哪个进程/哪一页      策略:选哪个线程/多大锁    策略:怎么布局/何时回写

这张图有三个读法:

  1. 按列读:每一列是一组机制。虚拟化 CPU 的机制是「受限直接执行 + 定时器中断 + 上下文保存」;虚拟化内存的机制是「页表 + TLB + 缺页异常」;并发的机制是「原子指令 + 等待队列」;持久化的机制是「DMA + 中断 + 日志」。
  2. 按行读:每一行是「机制 → 策略」的配对。进程调度有 FIFO/SJF/RR/MLFQ 等策略;页替换有 FIFO/LRU/Clock 等策略;I/O 调度有 SSTF/SCAN 等策略;文件系统布局有 FFS/LFS 等策略。
  3. 按箭头读:三大主题不是孤立的。缺页异常要用到进程上下文;文件系统的 page cache 属于虚拟内存;并发锁的实现要用原子指令和等待队列。真实内核里,这三块代码互相调用。
Linux 启动流程:固件、引导、内核、initramfs、systemd 七个阶段与两个关键分界:内核交棒给用户态、systemd 达到 target。 从按下电源到登录提示符UEFI/BIOSPOST → 找 ESPbootloaderGRUB / systemd-bootkernel解压 / 初始化 / initcallinitramfs加载驱动 / 挂根initsystemd PID 1targetmulti-user / graphicallogingetty / sshd内核阶段的关键节点start_kernel → rest_init → kernel_init → run_init_process用户态阶段的关键节点systemd → 并行启动 unit → 达到 default.target观测:dmesg -T(内核)、journalctl -b(systemd)、systemd-analyze blame / critical-chain故障:initramfs 缺驱动 → 卡在「Waiting for root device」;unit 依赖环 → 启动超时
读图:从左到右读。前两段是固件与引导器,第三段内核初始化,第四段 initramfs 负责「挂上真正的根」,第五段起是用户态。排启动问题先确定卡在哪一段: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)用多个优先级队列近似「既照顾短任务,又不得罪长任务」:

  1. 新任务进最高优先级;
  2. 同一优先级内按 RR;
  3. 任务用完整个时间片就降一级(可能是 CPU 密集);
  4. 任务在时间片内主动让出 CPU 就留在原级(可能是 I/O 密集);
  5. 每隔一段时间把所有任务提升到最高级,防止饥饿和 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 内存虚拟化:从分段到交换

内存虚拟化的目标是「每个进程一套连续地址空间」。实现路径分三步:

  1. 分段(segmentation):基址 + 界限寄存器,把地址空间切成代码/堆/栈段;外部碎片严重。
  2. 分页(paging):固定大小页 + 页表,消除外部碎片;页表本身占内存,需要多级/大页。
  3. 交换(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 槽号。缺页时:

  1. 硬件发现 PTE 的 present=0,触发缺页;
  2. 内核看 PTE 的 swap 位/槽号,知道页在 swap;
  3. 分配一个物理页,读 swap 槽;
  4. 更新 PTE 指向新页,present=1;
  5. 返回用户态重试指令。

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」。

典型流程(以网卡收包为例):

  1. 驱动初始化:pci_enable_device、request_mem_region、ioremap 映射 BAR,申请 IRQ;
  2. 把 DMA 缓冲区物理地址写进设备寄存器,启动接收;
  3. 网卡收到包,DMA 写入内存,触发中断;
  4. 中断处理 top half 快速确认,softirq/NAPI 轮询收包,交给协议栈;
  5. 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 属性创建。

设备驱动的四个坑
  1. ioremap 的地址不能直接解引用:必须用 readl/writel(或 readb/writeb),否则编译器/CPU 可能重排或合并访问。
  2. DMA 地址不是虚拟地址:要用 dma_map_single 得到设备可见的 dma_addr_t,并处理 cache 一致性(dma_sync_*)。
  3. 中断上下文不能睡眠:kmalloc(GFP_KERNEL)、mutex_lock、copy_to_user 都可能睡眠;用 GFP_ATOMIC/spinlock/workqueue。
  4. probe 失败要释放已申请资源:devm_* 能自动回收,但 ioremap/request_irq 混用时仍要按逆序清理。

磁盘时间账:寻道、旋转与传输

一次磁盘 I/O 的时间可以拆成:

T = T_seek + T_rotation + T_transfer + T_queue

项典型值说明
寻道4–10 ms磁头移动到目标磁道;SSD 为 0
旋转0–4.17 ms7200 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 02100%无条带化,性能高,坏一盘全丢
RAID 1250%1 盘镜像,读性能好,成本高
RAID 43(n−1)/n1 盘专用校验盘,写瓶颈
RAID 53(n−1)/n1 盘分布式校验,写有 read-modify-write
RAID 64(n−2)/n2 盘双校验,重建更安全
RAID 10450%每组 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 mapinode 号 → 最新 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、LRUpswpin/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_process1.1.4、2.7、3.1
受限直接执行用户/内核态、trap、定时器syscall 入口、sysret、int 0x801.8.3、3.3
调度策略MLFQ/比例份额/多处理器CFS/EEVDF、sched_entity、调度域1.13.2、3.5/3.6
地址空间页表 + TLB + 缺页mm_struct/vma、四级页表、handle_mm_fault1.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、zswap1.13.3、3.10
并发/锁原子、自旋、睡眠锁spinlock/mutex/rwsem/RCU1.12、3.11
持久化/I/O设备、磁盘、RAID、FSblock_device、blk-mq、VFS、ext4/XFS1.13.5、3.12–3.14
崩溃一致性FSCK/日志jbd2、fsync、REQ_PREFLUSH/FUA1.13.11、3.12/3.13
分布式文件系统NFS/AFSNFS 客户端、ESTALE、close-to-open3.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->stateps迁移条件
就绪TASK_RUNNING(在 runqueue 上)R被调度器选中 → 运行
运行TASK_RUNNING(在 CPU 上)R时间片耗尽 → 就绪;等事件 → 阻塞
阻塞TASK_INTERRUPTIBLE/TASK_UNINTERRUPTIBLES/D事件完成或信号 → 就绪
—__TASK_STOPPEDTSIGCONT → 就绪
—EXIT_ZOMBIEZ父进程 wait 回收后消失

「就绪」与「运行」在 state 字段里是同一个值,区别只在「是否在 CPU 上」;这是读内核代码时最容易迷糊的一处。真正的区分靠 runqueue 与 on_cpu 标志。

PCB(Linux 的 task_struct)不是「一个进程一份简单记录」,而是几KB的结构,字段按生命周期分组:

字段内容谁维护何时变
state、flags状态、PF_* 标志调度器/信号每次状态迁移
pid、tgid线程 ID、线程组 ID(=进程 ID)copy_process创建时固定
mm、active_mm用户地址空间;内核线程借用上一个 mmexec/fork/切换exec 换新 mm
filesfd 表(files_struct),带引用计数open/close/dup每次 fd 操作
fs根目录、当前目录、umaskchdir/chroot/umask路径解析相关
creduid/gid、capabilitiessetuid 等凭证变更
sighand、signal信号处理表、pending/blocked 集合信号子系统sigaction/投递
thread切换现场:sp、ip、FPU 状态、fsbaseswitch_to每次上下文切换
sched_entityvruntime、权重、调度类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 位是标志:

位名称含义谁改
0P存在;为 0 触发缺页内核
1RW可写;为 0 时写触发保护缺页(COW 靠它)内核
2US用户态可访问;为 0 只有内核能碰内核
3PWT写穿透(页表级缓存策略)内核
4PCD禁止缓存内核
5A已访问;Clock/LRU 近似用它CPU 置位,内核清除
6D脏页;决定换出时是否要写回CPU 置位,内核清除
7PS大页(2 MiB/1 GiB)内核
63NX不可执行(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 直接向内核要页。

关于「进程与内存」的四个误解
  1. 「fork 复制了父进程的内存」——只复制页表并清 RW 位,物理页共享;写时缺页才复制一页。所以 fork 后立刻 exec 的程序,几乎不产生内存复制。
  2. 「虚拟地址就是物理地址减一个偏移」——四级页表逐级翻译,每级都可能缺页;连续虚拟地址不保证连续物理地址,大页(2 MiB)只是减少层级。
  3. 「free 会把内存还给系统」——glibc 的 free 多数时候只把块放回 bin;只有 mmap 的大块或 malloc_trim 才归还。进程 RSS 不降不等于泄漏。
  4. 「进程状态 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 比例
−208876186.7 倍
−1095489.3 倍
010241 倍
101100.107 倍
19150.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 日志时所有崩溃点都能恢复。

考虑在目录里新建一个文件,需要三步:

  1. 目录项指向 inode 2(dir -> 2);
  2. inode 位图标记 2 已分配;
  3. 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)超出本书主线,只留指路。

章页主题本书落点状态
12关于本书的对话1.13 导语背景
24操作系统介绍(三大主题)1.13.1已覆盖
318关于虚拟化的对话1.13.1背景
420抽象:进程1.13.8、1.1.4已覆盖
528插叙:进程 API1.1.4、2.7已覆盖
638机制:受限直接执行1.13.8已覆盖
748进程调度:介绍1.13.2已覆盖
858调度:多级反馈队列1.13.2已覆盖
966调度:比例份额1.13.9已覆盖
1074多处理器调度(高级)1.13.2已覆盖
1182CPU 虚拟化总结对话1.13.1背景
1284内存虚拟化对话1.13.1背景
1386抽象:地址空间1.9、1.13.3已覆盖
1492插叙:内存操作 API1.13.8、1.9.9已覆盖
15100机制:地址转换1.9.6、1.13.8已覆盖
16112分段1.13.3已覆盖
17120空闲空间管理1.13.8已覆盖
18132分页:介绍1.9、1.13.3已覆盖
19142分页:TLB1.9.6、1.13.3已覆盖
20154分页:较小的表1.13.3已覆盖
21166超越物理内存:机制1.13.3已覆盖
22172超越物理内存:策略1.13.10已覆盖
23186VAX/VMS 虚拟内存系统1.13.3背景
24194内存虚拟化总结对话1.13.3背景
25196关于并发的对话1.12 导语、1.13.4背景
26198并发:介绍1.12.2已覆盖
27210插叙:线程 API1.12.8已覆盖
28220锁1.12.10已覆盖
29238基于锁的并发数据结构1.12.13.2已覆盖
30250条件变量1.12.3已覆盖
31264信号量1.12.4、1.12.11已覆盖
32280常见并发问题1.12.12、1.12.16已覆盖
33292基于事件的并发1.12.7已覆盖
34300并发总结对话1.12.17背景
35302关于持久性的对话1.13.5背景
36304I/O 设备1.13.5、3.13.1–3.13.5合并(深落在第 3 篇)
37314磁盘驱动器1.13.5、3.13.3合并(调度在 3.13.3)
38326RAID1.13.5、0.10.7、3.13.5合并(选型在 1.13.5/0.10;3.13.5 只讲 flush 前提)
39342插叙:文件和目录1.10、1.13.5已覆盖
40358文件系统实现1.13.5、3.12.1–3.12.5合并(深落在 3.12)
41374局部性和快速文件系统1.13.5、3.12.5合并(ext4 块组在 3.12.5)
42380崩溃一致性:FSCK 和日志1.13.11已覆盖
43396日志结构文件系统1.13.5、3.12.5合并(LFS 五机制在 1.13.5)
44408数据完整性和保护3.12.5.1、3.13.5合并(写错/丢失写/scrub 在 3.12.5.1)
45417持久化总结对话1.13.5背景
46418关于分布式的对话—淘汰(超出范围)
47420分布式系统—淘汰(超出范围)
48430Sun 网络文件系统(NFS)3.12.8.1已覆盖
49442Andrew 文件系统(AFS)3.12.8.2已覆盖
50453分布式总结对话3.12.8.3背景

读表的三个要点:

  1. 虚拟化的重心在 1.9 与 1.13。OSTEP 用 24 章讲虚拟化,本书把「机制」(页表、TLB、上下文切换)放在 1.9、1.13.8,「策略」(调度、替换)放在 1.13.2、1.13.9、1.13.10。读 OSTEP 时若觉得「每章都在重复」,是因为它按「一个假设 + 一个机制 + 一个作业」展开,本书按「机制/策略」合并。
  2. 并发只对应 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。
  3. 分布式五章(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/stvecentry.asm + rust_main,linker.ld 布局head_64.S、startup_641.13.6 项目 1、3.2
系统调用syscall.c 的 syscalls[] 表 + usys.Ssyscall/mod.rs 的 syscall! 宏 + 分发syscall_64.tbl、do_syscall_643.3、1.13.6 项目 2
进程/任务proc.c:struct proc、allocproc、fork、schedulertask/mod.rs:TaskControlBlock、TaskManagerkernel/fork.c、sched/core.c3.4–3.5
地址空间vm.c:walk、mappages、kvmmake;Sv39 三级页表mm/memory_set.rs:MemorySet、MapArea、页表mm/mmap.c、mm/page_alloc.c3.7–3.10
陷入/中断trap.c:usertrap、kerneltrap、devintr;trampoline.Strap/mod.rs:trap_handler、跳板页arch/x86/entry/3.3
锁与同步spinlock.c、sleeplock.csync/mod.rs:UPSafeCell、mutex、semaphorekernel/locking/3.11、1.12
文件系统fs.c(inode、dirlookup)、log.c(redo 日志)、bio.c(buffer cache)easy-fs crate:块位图、inode 位图、disk inodefs/ext4、fs/vfs1.13.11、3.12
物理内存kalloc.c:空闲链表buddy_system_allocator cratebuddy + slab/slub1.13.8
用户态user/(sh、ls、cat)、ulib.cuser 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 与 RRCFS 没有固定时间片,指标不适用交互看响应/延迟分位,批处理看吞吐
2长任务永远拿不到 CPUMLFQ 没有周期提升,长任务沉底规则 5 的周期提升不是可选
3RR 片长调到 100 µs 后吞吐下降切换开销占比升高片长 ≥ 1 ms,按负载调
4以为 LRU 可直接实现硬件不提供精确访问时间戳用 Clock/active-inactive 近似
5加页框后缺页反而变多FIFO 不是栈算法(Belady 异常)用 LRU/Clock,或至少知道边界
6swap 开得越大系统越卡工作集远超物理内存,thrashing加内存或限 cgroup,而非加 swap
7以为 RAID 5 能防误删RAID 只处理盘故障备份 + 快照;RAID 不是备份
8日志模式下仍丢应用数据data=writeback 不保证数据先落盘data=ordered + 应用层 fsync
9fsync 后文件名仍回退只同步文件,没同步目录项rename 后 fsync 父目录
10把 inode 号当文件名目录项才是「名字 → inode」的映射硬链接共享 inode,名字在目录里
11在 xv6 里用自旋锁保护长临界区关中断 + 自旋,长临界区会丢中断/烧 CPU长临界区用睡眠锁
12以为 rCore 的 UPSafeCell 是线程安全它只做运行时借用检查,单核假设多核要真锁 + 原子操作
13通读 OSTEP 50 章46–50 章是分布式,超出主线按 1.13.12 的表取舍
14vmstat 的 si/so 当成累计字节单位是 KB/s,累计页数看 pswpin/pswpout先看单位再下结论
15认为「小内核 = 简单内核」xv6/rCore 省略了性能与硬件多样性读机制,不读性能
关于 OSTEP 读法的三个提醒
  1. 「作业题可以跳过」——OSTEP 的作业(模拟器 scheduler.py、mem.py)是理解调度与替换的最短路径;跳过作业只读正文,很容易停在「名词级」理解。
  2. 「机制与策略是一回事」——机制回答「怎么切换/翻译」,策略回答「选谁」;混在一起会导致「给策略打补丁却改坏了机制」。
  3. 「读了 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/D1.13.8
算调度份额能用彩票数推期望份额,解释 stride 与 CFS 的区别1.13.9
定位 50 章任给 OSTEP 一章能说出本书落点与状态1.13.12
对照 xv6/rCore能指出同一子系统在三套代码里的文件1.13.13
做三个实验能给预期、跑命令、解释偏差1.13.14
即时自测 · 1.13

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–81.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 虚拟化 CPU5已覆盖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 系统编程,把这些机制变成每天能用的系统调用与工程习惯。