Linux 内核里有个叫 page cache(以前叫 buffer cache)的东西,所有磁盘 IO 都要经过它。为什么?因为磁盘太慢了——ATA PIO 读一个扇区要等几毫秒,而内存访问只要几纳秒。把最近访问过的扇区留在内存里,下次再访问直接从内存读,速度提升几千倍。

这一章给我们的 ext2 文件系统加上这层缓存,同时顺手修了一个隐藏很深的调度器 bug。

设计:LRU write-back 缓存

最简单够用的设计:固定 64 个 slot,每个 slot 缓存一个 512 字节扇区,LRU 淘汰,write-back 写回

typedef struct {
    uint32_t lba;
    uint8_t  data[512];
    uint8_t  valid;
    uint8_t  dirty;
    uint32_t lru_time;
} bcache_slot_t;

static bcache_slot_t slots[64];
static uint32_t      clock = 0;

lru_time 用一个全局 clock 计数器实现——每次访问 clock++,命中的 slot 拿到最新值,淘汰时找 lru_time 最小的那个。

bcache_get(lba)

uint8_t *bcache_get(uint32_t lba) {
    clock++;

    // 命中?
    for (int i = 0; i < 64; i++) {
        if (slots[i].valid && slots[i].lba == lba) {
            slots[i].lru_time = clock;
            return slots[i].data;
        }
    }

    // 找 LRU victim
    int victim = 0;
    for (int i = 1; i < 64; i++) {
        if (!slots[i].valid) { victim = i; break; }
        if (slots[i].lru_time < slots[victim].lru_time) victim = i;
    }

    // victim 是 dirty 的?先写回磁盘
    if (slots[victim].valid && slots[victim].dirty)
        ata_write_sector(slots[victim].lba, slots[victim].data);

    // 读新扇区
    ata_read_sector(lba, slots[victim].data);
    slots[victim] = (bcache_slot_t){ lba, ..., valid=1, dirty=0, lru_time=clock };
    return slots[victim].data;
}

返回的是指向缓存数据的指针,调用方可以直接读写这块内存。写完后调 bcache_dirty(lba) 标记为脏。

write-back 的意义

write-back:修改只写到缓存,不立即落盘。好处是写操作几乎没有延迟;代价是断电会丢数据,所以需要 sync 系统调用手动刷盘,或者在 evict 时自动写回。

write-through 相反,每次写都同步落盘——安全但慢。实际 OS 用 write-back + 周期性 flush(Linux 的 pdflush / writeback 线程)。

ext2 写路径

以前 ext2 只有读,现在加上写。核心是 write_bytes

static void write_bytes(uint32_t byte_offset, const void *buf, uint32_t len) {
    while (len > 0) {
        uint32_t lba   = byte_offset / 512;
        uint32_t off   = byte_offset % 512;
        uint32_t chunk = 512 - off;
        if (chunk > len) chunk = len;

        uint8_t *sector = bcache_get(lba);   // read into cache
        for (uint32_t i = 0; i < chunk; i++) sector[off + i] = src[i];
        bcache_dirty(lba);                    // mark dirty

        src += chunk; byte_offset += chunk; len -= chunk;
    }
}

注意 bcache_get 先把整个扇区读进来,再修改局部字节,再标脏。这就是 read-modify-write——不能直接写,否则扇区里其他字节就丢了。

ext2_create

创建文件分三步:

  1. alloc_inode:读 inode bitmap,找第一个空闲位,置位,返回 i+1(inode 号从 1 开始)
  2. 初始化 inode:mode=普通文件,links_count=1,其他清零,write_inode 写入 inode 表
  3. 插入目录项:读父目录的数据块,找空闲位置(空洞或分裂末尾 entry)写入新的 dir_entry

ATA PIO 写

读用命令 0x20,写用命令 0x30,协议完全对称:

static void ata_write_sector(uint32_t lba, const void *buf) {
    ata_wait_ready();
    outb(0x1F6, 0xF0 | ((lba >> 24) & 0x0F));  // drive 1 (slave = ext2.img)
    outb(0x1F2, 1);          // sector count
    outb(0x1F3, lba & 0xFF);
    outb(0x1F4, (lba >> 8) & 0xFF);
    outb(0x1F5, (lba >> 16) & 0xFF);
    outb(0x1F7, 0x30);       // WRITE SECTORS command
    ata_wait_drq();
    for (int i = 0; i < 256; i++) outw(0x1F0, p[i]);
    ata_wait_ready();
}

0x1F60xF0 = LBA 模式(bit 6)+ drive 1 slave(bit 4)+ 上位 LBA 位清零。

SYS_SYNC

case SYS_SYNC:
    bcache_sync();   // 把所有 dirty slot 写回磁盘
    return 0;

用户调 SYS_SYNC 确保数据落盘。这是 POSIX sync() 的最简实现。

隐藏 Bug:定时器打断用户代码,寄存器消失

这才是这一章最有意思的部分——实现写文件之后,测试发现 SYS_FWRITE 收到的 fd 是 0x18548(一个内核地址),而不是正确的 0

追踪过程

调试串口输出:

[SYS_OPEN] path=/test.txt ofd=0x00000000    ← fd=0,正确
[SYS_FWRITE] pfd=0x18548 vfd=0xffffff9d    ← 应该是 0,但变成了内核地址

0x18548 正好是 procs[1].fd_table[1] 的地址——内核 BSS 段里的某个字段。

用户代码是这样写的:

mov rax, SYS_OPEN
lea rdi, [rel fname]
syscall               ; 返回 fd=0 in rax
mov rbx, rax          ; rbx = 0(callee-saved,应该安全)

mov rax, SYS_FWRITE
mov rdi, rbx          ; 应该是 rdi=0
syscall               ; 但 pfd=0x18548 ??

rbx 是 callee-saved 寄存器,按 x86-64 ABI,函数调用不会破坏它。但是……

根本原因:enter_usermode 没有恢复 rbx

定时器 100Hz,每 10ms 触发一次。它可能在两条用户指令之间触发mov rbx, rax 执行完了,下一条 mov rax, SYS_FWRITE 还没执行,定时器来了。

用户执行 mov rbx, rax   → rbx = 0
[定时器 IRQ]
  isr_common: push 所有寄存器(保存了 rbx=0)
  sched_tick: p->ctx.rbx = regs->rbx = 0  ← 保存正确
  (假设当前没有其他进程可运行,直接恢复同一个进程)
  enter_usermode(rip, cs, rflags, rsp, ss)
    → push ss, rsp, rflags, cs, rip
    → iretq
  iretq 只恢复了 rip/cs/rflags/rsp/ss
  rbx ← 内核函数遗留的随机值!
用户继续执行 mov rax, SYS_FWRITE
用户继续执行 mov rdi, rbx   → rdi = 垃圾值 = 0x18548
用户继续执行 syscall

enter_usermode 缺少了恢复 rbx/r12-r15/rbp 的步骤。

修复一:enter_usermode_ctx

新增一个汇编函数,从 context_t 结构体完整恢复所有寄存器:

; enter_usermode_ctx(context_t *ctx)
; context_t: {r15, r14, r13, r12, rbx, rbp, rip, rsp, cs, ss, rflags}
enter_usermode_ctx:
    mov  rax, rdi
    push qword [rax + 72]   ; ss
    push qword [rax + 56]   ; rsp
    push qword [rax + 80]   ; rflags
    push qword [rax + 64]   ; cs
    push qword [rax + 48]   ; rip
    mov  r15, [rax + 0]
    mov  r14, [rax + 8]
    mov  r13, [rax + 16]
    mov  r12, [rax + 24]
    mov  rbx, [rax + 32]
    mov  rbp, [rax + 40]
    iretq

修复二:定时器不能抢占内核态

还有另一个问题:定时器也可能在 syscall 执行过程中触发(用户进程发了 syscall,内核正在处理,这时 regs->cs = 0x08,ring 0)。

如果这时 sched_tick 执行,会:

  1. 把内核的 rbx 存入 p->ctx.rbx(污染用户上下文)
  2. 在没有其他进程时清空 current = NULL(syscall 代码还在用 current!)

修复:检查中断发生时的 CS,只有在用户态(ring 3)才允许抢占:

void sched_tick(int_regs_t *regs) {
    if (current_proc >= 0 && procs[current_proc].state == PROC_RUNNING) {
        if ((regs->cs & 3) != 3) return;   // 内核态,不抢占
        // 保存用户寄存器...
    }
    // 调度...
}

cs & 3 是 CPL(Current Privilege Level):3 = 用户态,0 = 内核态。

测试结果

bcache test start
write ok
sync ok
read back: hello from bcache!

写入 → sync → 重新打开读回,内容正确。

小结

这一章实现了两件事:

bcache:LRU write-back 块缓存,64 个 slot,命中直接返回内存指针,未命中淘汰 LRU slot(先写回 dirty 的),再读入新扇区。ext2 的读写全部走缓存,只有 evict 和 sync 时才真正访问磁盘。

调度器修复:定时器中断随时可能发生,包括在用户两条指令之间、也包括在 syscall 执行中途。调度器必须:

  1. 只在用户态(cs & 3 == 3)时保存/切换上下文
  2. 恢复进程时完整恢复所有寄存器(包括 rbx/r12-r15/rbp),而不只是 iretq 的 5 个

这个 bug 在没有写文件的章节里完全不会出现——因为之前的用户程序不依赖 callee-saved 寄存器跨越多条指令边界。一旦 mov rbx, rax; ...; mov rdi, rbx 这样的模式出现,就会被随机触发的定时器打坏。