从零写OS(三十四):阻塞式管道与调度器的两个 Bug

上一章实现了阻塞式 TTY,这一章在同样的思路上让 pipe 的 read 也变成阻塞式,同时修复了调度器里隐藏的两个 Bug,让 fork + exec + wait4 的完整流程跑通。 问题:非阻塞 pipe read 的后果 ch33 之前,vfs_read 对 pipe 的实现是: if (pipe_buf_empty(p)) return 0; // 管道空 → 直接返回 0 对于 shell 管道命令,busybox sh 会: fork 出子进程执行左侧命令,写 pipe 父进程(或另一子进程)读 pipe,处理右侧命令 如果读端在写端还没写入时就读到 0,shell 认为 EOF,管道提前关闭,命令输出丢失。 解决方案:sti/hlt 等待 与 ch33 阻塞式 TTY read 相同的思路:在内核态用 sti; hlt 轮询等待。 // pipe read:等待数据或写端关闭 while (pipe_buf_empty(p) && pipe_has_writer(p)) { __asm__ volatile("sti" ::: "memory"); __asm__ volatile("hlt" ::: "memory"); __asm__ volatile("cli" ::: "memory"); } if (pipe_buf_empty(p)) return 0; // 写端已关闭且无数据 → EOF 关键点: ...

May 22, 2026 · 2 min · 大飞

从零写OS(三十三):阻塞式 TTY —— read 不再忙等

上一章 busybox sh 成功显示了 / # 提示符,但 shell 拿不到任何输入——因为 tty_read 是忙轮询的,没有字符就直接返回 0,shell 以为收到 EOF,立刻退出。这一章实现真正的阻塞式 TTY,让 shell 能等待用户输入。 之前的问题 之前的 tty_read 大概是这样: int tty_read(char *buf, int len) { // 轮询串口状态寄存器 if (!(inb(0x3F8 + 5) & 1)) return 0; // 没数据就返回 0 *buf = inb(0x3F8); return 1; } 这有两个问题: 没有输入时返回 0,上层程序(shell)以为是 EOF 如果上层在循环里调这个,CPU 100% 占用 正确做法:没有输入时挂起进程,等串口中断来了再唤醒。 串口中断(IRQ4) COM1 对应 IRQ4,接在 PIC 主片的 IR4 引脚。要用串口中断,需要两步: 1. 在 PIC 上 unmask IRQ4: // PIC 主片 IMR 寄存器:0 表示开放,1 表示屏蔽 uint8_t mask = inb(0x21); mask &= ~(1 << 4); // 清除 bit4,开放 IRQ4 outb(0x21, mask); 2. 开启串口的接收中断: ...

May 22, 2026 · 2 min · 大飞

从零写OS(三十二):启动 busybox sh —— 用户指针与内核页表的陷阱

ch31 已经能跑 musl libc 的 hello world 了,这一章目标更高:启动 busybox sh,看到 / # 提示符。busybox 是个真正的程序,碰到的问题也更真实。 准备工作 获取静态编译的 busybox wget https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox chmod +x busybox file busybox # busybox: ELF 64-bit LSB executable, x86-64, statically linked 更新 ext2 镜像 busybox 需要 /bin/sh、/etc/passwd、/etc/group: sudo mkdir -p /tmp/ext2mnt/bin sudo mkdir -p /tmp/ext2mnt/etc sudo cp $(BUSYBOX) /tmp/ext2mnt/bin/busybox sudo cp $(BUSYBOX) /tmp/ext2mnt/bin/sh printf 'root:x:0:0:root:/root:/bin/sh\n' | sudo tee /tmp/ext2mnt/etc/passwd > /dev/null printf 'root:x:0:\n' | sudo tee /tmp/ext2mnt/etc/group > /dev/null Bug 1:GDT TSS 描述符溢出 x86_64 下 TSS 描述符是 16 字节(两个 qword),GDT 必须为它预留两个连续槽位。之前只预留了一个,导致 TSS 描述符的高 8 字节覆盖了相邻的全局变量(恰好是 mmap_next),进程一分配匿名内存就跳到奇怪的地址。 ...

May 22, 2026 · 2 min · 大飞

从零写OS(三十一):运行 musl libc 程序 —— 两个隐藏的寄存器 Bug

上一章对齐了 Linux syscall ABI,现在试着跑一个真正用 musl libc 静态编译的 C 程序。目标很简单: #include <stdio.h> int main(int argc, char **argv) { printf("hello from musl libc!\n"); printf("argc=%d\n", argc); return 0; } 编译:musl-gcc -static -O2 -o hello.elf hello.c 结果踩了两个隐藏很深的寄存器 bug,花了不少时间才找出来。 新增的 syscall musl libc 启动时会调用一批 syscall,其中有三个是绕不过的: syscall 编号 说明 arch_prctl(ARCH_SET_FS, addr) 158 设置 TLS base(FS 寄存器) set_tid_address(tidptr) 218 设置线程 ID 地址,返回 pid writev(fd, iov, iovcnt) 20 向量写,musl 的 stdio 用它输出 arch_prctl 需要写 MSR 0xC0000100(FS.base),这是 TLS 的基地址,musl 用来存放线程局部变量: case SYS_ARCH_PRCTL: if (a1 == ARCH_SET_FS) { wrmsr(0xC0000100, a2); // FS.base MSR return 0; } return (uint64_t)(-EINVAL); writev 需要遍历 iov 数组,把多段数据拼起来写到 tty: ...

May 22, 2026 · 3 min · 大飞

从零写OS(三十):对齐 Linux syscall ABI,让 musl libc 能跑起来

musl libc 编译出来的程序会直接用 syscall 指令,传的是 Linux x86-64 标准调用号。我们之前的调用号是自己编的,如果不对齐,musl 程序一个 syscall 都跑不通。这一章把内核的 syscall 接口全面对齐 Linux ABI。 Linux x86-64 syscall 约定 Linux 的 x86-64 syscall 约定: 调用:rax=syscall号,rdi=a1,rsi=a2,rdx=a3,r10=a4,r8=a5,r9=a6 返回:rax(负数表示错误,如 -ENOENT = -2) CPU 自动保存:rcx=用户 rip,r11=用户 rflags 注意第 4 个参数是 r10,不是 rcx。原因是 syscall 指令会把用户 rip 存进 rcx,所以 r10 顶替了 rcx 的位置。这一点非常容易踩坑。 关键改动 1. syscall 号全部换成 Linux 标准 之前的调用号是自己定义的,现在全部换成 Linux 标准值: 功能 之前 现在 read 6 0 write 1 1 ✓ open 5 2 close 7 3 fork 3 57 exit 2 60 kill 12 62 write 碰巧没变,其他大部分都不一样。 ...

May 22, 2026 · 3 min · 大飞

从零写OS(二十九):block cache —— 给磁盘加一层缓存

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) 标记为脏。 ...

May 15, 2026 · 4 min · 大飞

从零写OS(二十八):timer —— 让进程睡一会儿

Linux 里的 sleep(2) 背后是什么? 进程调用 sleep,内核把它标记为"阻塞",让出 CPU 给其他进程,等时间到了再唤醒它。实现这个功能需要两个组件配合:定时器(知道时间到了)+ 调度器(切换进程)。 好消息是这两个我们都有了:PIT 100Hz 时钟 + round-robin 调度器。这一章只需要把它们接起来。 核心思路 SYS_SLEEP(ms) → sleep_until = pit_get_ticks() + ms/10 → state = PROC_BLOCKED IRQ0 handler(每 10ms) → pit_tick() // ticks++ → timer_tick() // 扫描所有进程,到期则唤醒 → sched_tick() // 调度下一个 READY 进程 进程睡觉时设好"闹钟"(sleep_until),然后自己进入 PROC_BLOCKED。 每次时钟中断,timer_tick 扫一遍所有进程,到期的改回 PROC_READY,下次调度就能运行了。 实现 1. process_t 加一个字段 typedef struct { // ... 原有字段 uint64_t sleep_until; // 睡到哪个 tick,0 表示没在睡 } process_t; 2. timer.c void timer_tick(void) { uint64_t now = pit_get_ticks(); for (int i = 0; i < MAX_PROCS; i++) { if (procs[i].state == PROC_BLOCKED && procs[i].sleep_until > 0) { if (now >= procs[i].sleep_until) { procs[i].sleep_until = 0; procs[i].state = PROC_READY; } } } } 逻辑极简:遍历,到期,唤醒。 ...

May 15, 2026 · 2 min · 大飞

从零写OS(二十七):字符设备 —— /dev/null 和 /dev/zero

Linux 里有个特殊目录 /dev/,里面住着各种设备文件: /dev/null ← 写什么扔什么,读永远 EOF /dev/zero ← 读出来全是 \0 /dev/random ← 读出来是随机字节 /dev/tty ← 当前终端 对用户程序来说,它们和普通文件没区别:open、read、write、close,完全一样的接口。 这一章实现字符设备(char device)框架,让 VFS 能路由 /dev/ 路径,并内置 null 和 zero 两个最基础的设备。 什么是字符设备 字符设备(character device)的特点: 按字节读写,没有块/扇区的概念 没有随机寻址(不像磁盘文件可以 seek) 读写是即时的:写进去就消失(null)或立刻可读(zero/tty) 与之对应的是块设备(block device),如硬盘,以固定大小的块为单位操作。 设备注册表 核心数据结构 cdev_t: typedef struct { char name[16]; // 设备名,如 "null"、"zero" int (*open) (void); int (*read) (void *buf, uint32_t len); int (*write)(const void *buf, uint32_t len); void (*close)(int fd); int used; } cdev_t; static cdev_t devs[CDEV_MAX]; // 全局设备表 通过函数指针实现多态——不同设备注册不同的 read/write 实现。 chardev_register 向 devs[] 添加一个设备,chardev_open 按名字查找并调用 open()。 ...

May 14, 2026 · 2 min · 大飞

从零写OS(二十六):poll —— 同时等待多个 fd

上一章实现了 TTY 输入,但用户程序只能这样等输入: .read_loop: syscall SYS_READ(fd=0, buf, 64) cmp rax, 0 jle .read_loop 问题很明显: CPU 全速空转:没有输入时 SYS_READ 每次返回 0,进程白白占用 CPU 无法同时等多个 fd:如果既要等 stdin,又要等 pipe,必须串行轮询,任一 fd 都可能饿死 poll 解决这两个问题。 poll 是什么 poll(fds[], nfds, timeout) 是一个系统调用: 传入一组 pollfd_t 结构,每个描述"我关心 fd X 的什么事件" 内核检查每个 fd 是否满足条件 返回就绪的 fd 数量,并在 revents 里标记哪些事件发生了 struct pollfd { int fd; // 监听哪个 fd short events; // 关心:POLLIN(可读)/ POLLOUT(可写) short revents; // 内核填写:实际发生了什么 }; int poll(struct pollfd *fds, int nfds, int timeout_ms); 有了 poll,程序可以: poll([{fd=0, POLLIN}, {fd=pipe_r, POLLIN}], 2, -1) → 等到任意一个有数据,才返回 实现架构 整体分四层: ...

May 14, 2026 · 3 min · 大飞

从零写OS(二十五):TTY 终端输入

到目前为止,进程只能输出,没有办法接收用户输入。SYS_READ 形同虚设,键盘按了也没反应。 这一章实现 TTY——Unix 对终端设备的最初抽象,让进程能以行为单位从串口读取输入。 TTY 是什么 TTY 原本是 teletypewriter(电传打字机)的缩写。在 Unix 里,它泛指"终端"——一个字符设备,既能接收键盘输入,又能输出文字。 现代 Linux 里的 /dev/tty、/dev/pts/0 都是 TTY 的后代。我们这里用串口 COM1 模拟一个最简单的 TTY。 整体架构 用户键盘输入 ↓ 串口硬件触发 IRQ4 isr_handler → tty_recv(c) ← 回显 + 写入 ring buffer ↓ 遇到 '\n' line_ready = 1 ↓ 进程 syscall SYS_READ(fd=0) tty_read(buf, len) ← 把一行搬到用户 buf ↓ 用户程序处理输入 三个关键组件: Ring Buffer:固定大小的环形缓冲区,存放还未被读走的字符 行规程(line discipline):积累字符,遇 \n 才通知"行就绪" IRQ4 中断处理:从串口读一个字节,交给 TTY Ring Buffer 环形缓冲区是一个固定数组,加上读指针和写指针: [ _ _ _ _ h e l l o \n _ _ ] ↑ ↑ rx_read rx_write rx_len = 6 写字符:rx_buf[rx_write] = c; rx_write = (rx_write + 1) % TTY_BUF_SIZE; rx_len++ ...

May 14, 2026 · 2 min · 大飞
京ICP备14031575号-3