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
创建文件分三步:
- alloc_inode:读 inode bitmap,找第一个空闲位,置位,返回
i+1(inode 号从 1 开始) - 初始化 inode:mode=普通文件,links_count=1,其他清零,write_inode 写入 inode 表
- 插入目录项:读父目录的数据块,找空闲位置(空洞或分裂末尾 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();
}
0x1F6 的 0xF0 = 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 执行,会:
- 把内核的 rbx 存入
p->ctx.rbx(污染用户上下文) - 在没有其他进程时清空
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 执行中途。调度器必须:
- 只在用户态(
cs & 3 == 3)时保存/切换上下文 - 恢复进程时完整恢复所有寄存器(包括 rbx/r12-r15/rbp),而不只是 iretq 的 5 个
这个 bug 在没有写文件的章节里完全不会出现——因为之前的用户程序不依赖 callee-saved 寄存器跨越多条指令边界。一旦 mov rbx, rax; ...; mov rdi, rbx 这样的模式出现,就会被随机触发的定时器打坏。