从零写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 · 大飞

从零写OS(二十四):signal 信号机制

到目前为止,进程只能顺序跑完,没有"被打断"的能力。按 Ctrl+C 没有反应,子进程崩溃父进程不知道,kill 命令更无从谈起。 这一章实现 signal——内核向进程发送异步通知的机制。 信号是什么 信号是一个整数编号,内核用它告诉进程"发生了某件事": 信号 编号 默认行为 SIGUSR1 10 终止 SIGKILL 9 终止(不可捕获) SIGTERM 15 终止 SIGCHLD 17 忽略 进程可以用 signal(sig, handler) 注册自定义处理函数,也可以接受默认行为。 关键:信号不立刻打断进程 信号发送时只是设置一个 pending 位,不立刻跳转。等进程下次从内核态返回用户态时,才检查并派发: SYS_KILL → pending_signals |= (1 << sig) syscall 处理完 → 准备 sysret ↓ 检查 pending_signals ↓ 有信号 → 操纵 user_rip/user_rsp → sysret 跳到 handler handler 执行完 ret → 回到原来被中断的位置 这和硬件中断不同——硬件中断可以在任意时刻打断 CPU,信号只在内核→用户的切换点才生效。 派发机制(trampoline) signal_dispatch 收到两个指针:内核栈上保存的 user_rip(原来准备 sysret 到的地址)和 user_rsp。它做的事: 1. user_rsp -= 8 2. *(user_rsp) = user_rip // 原返回地址压到用户栈 3. user_rip = handler_addr // sysret 改跳到 handler sysret 跳到 handler,handler 执行完 ret,弹出用户栈上的原 rip,回到 kill syscall 之后继续。 ...

May 14, 2026 · 2 min · 大飞

从零写OS(二十三):procfs 进程文件系统

上一章有了 pipe 和 fd,现在进程可以互相传数据了。但还有一个问题:用户程序没有办法查询"系统里现在有哪些进程"——这类信息只存在内核里,外面拿不到。 加专用 syscall 当然可以,但 Linux 选择了一个更优雅的方案:procfs。 一切皆文件 procfs 的思路是:把内核状态伪装成文件,挂在 /proc/ 目录下。 /proc/0/status → 进程 0 的信息 /proc/1/status → 进程 1 的信息 用户程序用完全一样的 open/read/close 就能读到,不需要学新 API: int fd = open("/proc/1/status"); read(fd, buf, 128); // buf 里是 "pid: 1\nstate: running\nparent: 0\n" 这就是 Linux “一切皆文件” 哲学的体现——统一接口,背后实现可以完全不同。 虚拟文件和真实文件的区别 普通文件的 read:从磁盘读数据。 procfs 文件的 read:内核现场生成内容,没有磁盘,没有 inode,数据就是 procs[] 数组里的字段格式化出来的字符串。 open("/proc/1/status") → vfs_open 识别 "/proc/" 前缀 → procfs_open 解析 pid,格式化状态字符串存入内核 buf → 返回 VFile(type=VFILE_PROC) read(fd, buf, len) → vfs_read 识别 VFILE_PROC → 从内核 buf 按 offset 拷贝给用户 实现 procfs 内核结构 typedef struct { int used; uint32_t pid; char buf[256]; // open 时格式化好的字符串 uint32_t buf_len; uint32_t offset; // 支持分段 read } proc_fd_t; open 时一次性生成完整字符串,后续 read 只是按 offset 切片拷贝——和普通文件行为完全一致,可以多次 read。 ...

May 14, 2026 · 2 min · 大飞

从零写OS(二十二):管道 pipe 与 dup2

上一章实现了文件描述符,进程可以用 open/read/close 访问 ext2 文件。但两个进程之间还没有办法通信。shell pipeline cmd1 | cmd2 的本质是:cmd1 的输出直接流进 cmd2 的输入,中间不落磁盘。 这一章实现 pipe 和 dup2,打通进程间通信的最小路径。 pipe 是什么 pipe 是内核里的一块环形字节缓冲区。系统调用 pipe(fds) 返回两个文件描述符: fds[0] = 读端 fds[1] = 写端 写进程 → fds[1] → [内核 ring buffer 256字节] → fds[0] → 读进程 和普通文件一样,管道也走 fd → VFS → 底层数据 这三层,只不过底层数据不是磁盘,而是内核里的 pipe_t。 dup2 是什么 dup2(oldfd, newfd) 让 newfd 指向和 oldfd 同一个内核文件描述符,如果 newfd 已经打开则先关掉它。 shell pipeline 的核心操作就是 dup2 + exec: // 子进程(cmd1),stdout → pipe 写端 dup2(fds[1], 1); close(fds[0]); close(fds[1]); exec("cmd1"); // cmd1 以为自己在写 stdout,实际写进了管道 // 父进程(cmd2),stdin → pipe 读端 dup2(fds[0], 0); close(fds[0]); close(fds[1]); // 关掉写端,否则 cmd2 永远等不到 EOF exec("cmd2"); pipe 的 EOF 语义 read 管道什么时候返回 0(EOF)? ...

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