从零写OS(五十四):完整信号系统——sigaction、mask 与 sigreturn

跑 busybox sh 时发现一个诡异的问题:Ctrl+C 能杀掉进程,但 handler 执行完之后程序直接崩了——寄存器全乱了。调查下去,发现 ch53 的信号系统有三个根本缺陷。 问题一:寄存器没有保存 ch53 的 signal_dispatch 只保存了 user_rip 和 user_rsp: uint64_t new_rsp = *user_rsp - 8; *(uint64_t *)(phys + ...) = *user_rip; // 仅压返回地址 *user_rsp = new_rsp; *user_rip = handler; handler 执行期间会用到 r15, r14, …, rbx, rbp——这些全都没有保存。handler 返回时弹出的 rip 确实是原来的位置,但所有调用者保存的寄存器都被 handler 破坏了,接下来的代码跑的是垃圾数据。 问题二:sigaction 是空 stub case SYS_SIGACTION: case SYS_SIGPROCMASK: case SYS_SIGRETURN: return 0; // 什么都没做 musl 的 signal() 底层调用 sigaction,返回 0 让它以为设置成功了,实际上 handler 根本没有注册进去。 问题三:没有 signal mask handler 执行期间,如果同一个信号再次到来,会再次触发 dispatch,handler 递归执行,栈很快就溢出了。 解法:signal_frame_t + 真正的 sigaction 核心数据结构 把所有寄存器打包成一个结构体,压到用户栈: ...

June 8, 2026 · 3 min · 大飞

从零写OS(五十三):进程退出时的资源回收

跑了一段时间之后,内核会越来越慢,最终卡死——不断创建进程、运行、退出,但内存一直在增长。原因是 proc_exit 什么都没回收。 proc_exit 原来做了什么 void proc_exit(int code) { current->exit_code = code; current->state = PROC_ZOMBIE; schedule(); // 完事了 } 进程用到的所有资源:fd、内核栈、用户页表、用户物理页——全部泄漏。 补全资源回收 void proc_exit(int code) { // 1. 关闭所有 fd for (int i = 0; i < PROC_MAX_FD; i++) { if (current->fd_table[i] >= 0) { vfs_close(current->fd_table[i]); current->fd_table[i] = -1; } } // 2. 释放内核栈 kfree(current->stack); current->stack = NULL; // 3. 释放用户页表和所有用户物理页 if (current->pml4 != kernel_pml4) { vmm_switch(kernel_pml4); // 先切回内核页表 vmm_free_user_pages(current->pml4); // 再释放 current->pml4 = NULL; } current->exit_code = code; current->state = PROC_ZOMBIE; schedule(); } vmm_free_user_pages:遍历四级页表释放 void vmm_free_user_pages(uint64_t *pml4) { for (int i4 = 0; i4 < 256; i4++) { // 只看低 256 项(用户空间) if (!(pml4[i4] & PAGE_PRESENT)) continue; uint64_t *pdpt = ENTRY_ADDR(pml4[i4]); for (int i3 = 0; i3 < 512; i3++) { // 跳过大页(内核 1GB 映射) uint64_t *pd = ENTRY_ADDR(pdpt[i3]); for (int i2 = 0; i2 < 512; i2++) { uint64_t *pt = ENTRY_ADDR(pd[i2]); for (int i1 = 0; i1 < 512; i1++) { if (pt[i1] & PAGE_USER) pmm_free(ENTRY_ADDR(pt[i1])); // 释放用户物理页 } pmm_free(pt); // 释放 PT 页 } pmm_free(pd); } pmm_free(pdpt); pml4[i4] = 0; } pmm_free(pml4); } 只遍历低 256 项对应的用户地址空间,高 256 项是内核映射(共享 kernel_pml4),不能释放。 ...

June 4, 2026 · 2 min · 大飞

从零写OS(五十二):waitpid 阻塞、信号投递修复、ext2 间接块

这章修了三个基础性的问题,每一个都影响着内核能否正确支持 busybox 的日常使用。 waitpid:从轮询到真正阻塞 sh 执行命令时需要等子进程退出: int pid = fork(); if (pid == 0) { exec(...); } waitpid(pid, &status, 0); // 等子进程 原来的实现是 sti + hlt 轮询: while (1) { code = proc_wait(&child_pid); if (code != -2) break; sti(); hlt(); cli(); // 等定时器唤醒,再试 } 这有两个问题:1. 浪费 CPU,定时器每 10ms 唤醒一次,立刻又继续轮询;2. 多核下 AP 的调度时钟可能不触发 BSP 的 hlt,等待时间不稳定。 改为真正的阻塞:父进程把自己设为 PROC_BLOCKED,调度器不再调度它,直到子进程退出时主动唤醒: // SYS_WAIT4 int32_t code = proc_wait(&child_pid); if (code == -2) { current->wait_wstatus_va = a2; // 记住要写 wstatus 的用户地址 current->state = PROC_BLOCKED; // 挂起 return -EINTR; // 返回调度器 } 子进程退出时(proc_exit)唤醒父进程: if (ppid < MAX_PROCS && procs[ppid].state == PROC_BLOCKED) { procs[ppid].state = PROC_READY; // 唤醒 } 信号投递:不能直接写用户虚拟地址 信号处理时,内核需要把返回地址压到用户栈(user_rsp - 8)。原来直接写: ...

June 4, 2026 · 2 min · 大飞

从零写OS(五十一):三个崩溃,三个修复

性能优化之后,跑 ls | grep foo 时发现三类崩溃,分别来自三个不同的地方。 崩溃一:内核栈溢出 ls | grep foo 在 busybox sh 里需要同时运行三个进程:sh、ls、grep,加上 fork 的中间状态,进程数量一下上来了。 运行时随机崩溃,串口输出乱码。通过 QEMU 调试发现是内核栈溢出——某个进程的内核栈被覆盖了。 原来内核栈只有 4KB: #define STACK_SIZE 4096 p->stack = (uint8_t *)pmm_alloc(); // 只有 1 个物理页 busybox 的调用链比较深(sh → fork → exec → elf_load → ext2_read → bcache_get → …),4KB 撑不住。 改成 8KB,用 kmalloc 分配(pmm_alloc 只能分配 4KB): #define STACK_SIZE 8192 p->stack = (uint8_t *)kmalloc(STACK_SIZE); 崩溃二:cd .. 跳到根目录 / # mkdir -p /usr/bin / # cd /usr/bin /usr/bin # cd .. / # ← 应该到 /usr,结果跳到 / 路径解析遇到 .. 时,原来直接跳回根目录: ...

June 4, 2026 · 1 min · 大飞

从零写OS(五十):bcache 哈希表 —— O(n) 变 O(1)

修完 static 缓冲区的 bug 之后,发现加载 busybox 明显变慢了——因为 kmalloc/kfree 在每次 ext2 读取时都要分配释放缓冲区,调用次数多了开销就出来了。 更根本的问题是块缓存(bcache)效率太低:64 个槽位,每次查找都要线性扫描。 这章做两个性能优化。 bcache:哈希表替代线性扫描 原来的实现 uint8_t *bcache_get(uint32_t lba) { for (int i = 0; i < BCACHE_SLOTS; i++) { // O(n) 扫描 if (slots[i].valid && slots[i].lba == lba) { slots[i].lru_time = clock; return slots[i].data; } } // cache miss... } 64 个槽位,命中率低,每次 miss 还要再扫一遍找 LRU victim,加载 busybox 要读几百个 sector,扫描次数非常多。 改造:256 桶哈希表 #define BCACHE_SLOTS 512 // 槽位 64 → 512 #define BCACHE_HASH_SIZE 256 #define BCACHE_HASH(lba) ((lba) & (BCACHE_HASH_SIZE - 1)) typedef struct bcache_slot { uint32_t lba; uint8_t *data; // 改为指针,pmm_alloc 独立分配 int valid, dirty; uint64_t lru_time; struct bcache_slot *hash_next; // 哈希链表 } bcache_slot_t; static bcache_slot_t *hash_table[BCACHE_HASH_SIZE]; 查找变成 O(1) 均摊: ...

June 4, 2026 · 2 min · 大飞

从零写OS(四十九):消灭 static 缓冲区

内核模块跑起来之后,发现 SMP 下偶发崩溃。追查下去,发现是一类隐藏很深的 bug:static 局部缓冲区。 static 局部变量在 SMP 下是定时炸弹 // ext2.c 里随处可见这样的代码 int ext2_read(uint32_t inum, ...) { static uint8_t blk[4096]; // 危险! // ... } static 局部变量存在 .bss 段里,整个内核只有一份。单核下没问题,但 SMP 下: CPU0 和 CPU1 同时读不同的文件 都调用 ext2_read,都在用同一个 blk[] 互相覆盖对方的数据 → 文件读出来是乱的 ext2.c 里有十几处这样的 static 缓冲区,vfs.c 里也有。全部改成 kmalloc: // 修改后 uint8_t *blk = (uint8_t *)kmalloc(block_size); if (!blk) return -1; // ... 使用 ... kfree(blk); 同时还有一个更隐蔽的问题:间接块的 LBA 缓存: // 修改前:有状态缓存,SMP 下竞争 static uint32_t ind1[1024]; static uint32_t ind1_bno = 0; // 上次读的 LBA,用来判断是否要重读 if (ind1_bno != inode.i_block[12]) { ind1_bno = inode.i_block[12]; read_block(ind1_bno, ind1); } 两个核心同时修改 ind1_bno,都以为自己读的数据有效——去掉缓存,每次都读: ...

June 4, 2026 · 1 min · 大飞

从零写OS(四十八):内核模块 —— insmod/rmmod

动态链接实现之后,内核能加载 PIE 可执行文件了。这章把同样的 ELF 重定位思路用到内核自身:实现内核模块,让内核在运行时动态加载/卸载功能。 验证: / # insmod hello.ko hello from module! / # rmmod hello bye from module! .ko 文件是什么 .ko 是一种特殊的 ELF 文件,类型是 ET_REL(可重定位目标文件)——也就是还没有链接的 .o 文件。 和普通可执行文件的区别: 普通 ELF 内核模块(.ko) 已链接,地址固定 未链接,需要重定位 用户态运行 内核态运行 main() 入口 module_init / module_exit 依赖 libc 依赖内核导出符号 内核符号表(ksyms) 模块代码需要调用内核函数(比如 kprintf)。内核把对外开放的函数注册到一张符号表里: static ksym_t ksyms[] = { { "kprintf", (uint64_t)kprintf }, { "kmalloc", (uint64_t)kmalloc }, { "kfree", (uint64_t)kfree }, { NULL, 0 } }; uint64_t ksym_lookup(const char *name) { for (int i = 0; ksyms[i].name; i++) if (strcmp(ksyms[i].name, name) == 0) return ksyms[i].addr; return 0; } 加载流程 insmod 的工作分四步: ...

June 4, 2026 · 2 min · 大飞

从零写OS(四十一):TLB Shootdown —— 多核下的页表一致性

ch40 里 AP 还没开中断,原因是页表修改没有保护。这章解决这个问题:实现 TLB Shootdown,让多核下的页表修改安全可见。 问题:每颗 CPU 有自己的 TLB TLB(Translation Lookaside Buffer)是每颗核心内置的页表缓存,把虚拟地址→物理地址的映射缓存起来,避免每次都走四级页表。 问题在于,当一颗核心修改了页表(比如 fork 的 CoW、exec 建立新地址空间),其他核心的 TLB 里可能还缓存着旧的映射: CPU0 修改页表:VA 0x1000 → 新物理页 0xABCD000 CPU1 的 TLB: VA 0x1000 → 旧物理页 0x1234000 ← 未失效! CPU1 访问 VA 0x1000 → 访问了错误的物理页 → 数据错误 解决方法:修改页表后,给所有其他核心发一个 IPI,让它们执行 invlpg 使对应 TLB 条目失效。这个过程叫 TLB Shootdown。 实现 全局协调变量 volatile uint64_t tlb_shootdown_addr = 0; volatile int tlb_ack_count = 0; 发起方(修改页表的核心) void tlb_shootdown(uint64_t vaddr) { int online = ap_online_count; if (online <= 0) { // 单核路径,直接本地 invlpg __asm__ volatile("invlpg (%0)" :: "r"(vaddr) : "memory"); return; } tlb_shootdown_addr = vaddr; __sync_synchronize(); // 写屏障:确保其他核能看到 addr tlb_ack_count = 0; __sync_synchronize(); lapic_send_ipi_others(TLB_SHOOTDOWN_VECTOR); // 广播 IPI while (tlb_ack_count < online) // 等所有 AP 应答 __asm__ volatile("pause"); __asm__ volatile("invlpg (%0)" :: "r"(vaddr) : "memory"); // 本核也刷新 } ap_online_count 只有在 AP 真正开中断后才增加,确保发 IPI 时 AP 能响应。 ...

June 4, 2026 · 2 min · 大飞

从零写OS(四十):per-CPU 调度器 —— 每核一个时钟

ch39 加了锁,数据安全了。但调度器还有个问题:PIT(8253 定时器)是系统里唯一的一个,只有 CPU0 能收到 IRQ0。AP 没有定时中断,没法触发调度。 这章做两件事: 用 LAPIC 定时器替代 PIT,每颗核心独立触发调度 把 current_proc 改成 per-CPU 变量,每颗核各自记录自己在跑哪个进程 全局 current_proc 的灾难 int current_proc = 0; // 全局变量 // CPU0 把它改成 3(选中进程3) // CPU1 同时把它改成 5 // CPU0 接着继续,以为自己在跑进程3,实际内存里已经是 5 // → 崩溃 解决方法是每颗核心维护自己的 current_proc,互不干扰: typedef struct { int cpu_id; int current_proc; } cpu_t; cpu_t cpus[MAX_CPUS]; static inline cpu_t *this_cpu(void) { // 读 LAPIC ID → 查表 → 返回本核的 cpu_t int lid = lapic_id(); for (int i = 0; i < ncpus; i++) if (cpu_lapic_ids[i] == lid) return &cpus[i]; return &cpus[0]; } #define CUR_PROC (this_cpu()->current_proc) 所有原来用 current_proc 的地方改成 CUR_PROC。CPU0 修改自己的,CPU1 修改自己的,互不干扰。 ...

June 4, 2026 · 2 min · 大飞

从零写OS(三十九):自旋锁 —— 多核下的第一道防线

ch38 把两颗 CPU 都启动了,但这带来了一个新问题:两颗核心同时访问同一份数据会怎样? 考虑这个场景:CPU0 和 CPU1 同时调用 pmm_alloc() 申请物理页,都扫描到了同一个空闲位,都标记为"已用",于是把同一个物理页分配给了两个不同的进程。这两个进程会互相覆盖对方的内存,然后崩溃。 这章实现自旋锁(spinlock),让同一时间只有一颗核心能进入临界区。 为什么普通变量不能做锁 直觉上,可以用一个整数变量做锁: int lock = 0; if (lock == 0) { // CPU0 读到 0 lock = 1; // CPU1 也读到 0,同时进入! // 临界区 } 问题在于"读-判断-写"不是原子操作。两颗核心在读和写之间有一个竞争窗口,都能同时通过检查。 xchg:原子交换 x86 提供了 xchg 指令,它原子地交换寄存器和内存的值——读和写在硬件层面是不可分割的: static inline void spin_lock(spinlock_t *lock) { uint32_t val = 1; __asm__ volatile ( "1: xchgl %0, %1\n" // 原子:把 1 写入 lock,把旧值读到 val " testl %0, %0\n" // 旧值为 0? " jz 2f\n" // 是 → 获锁成功 " pause\n" // 否 → 稍等,再试 " jmp 1b\n" "2:\n" : "+r"(val), "+m"(lock->locked) :: "memory" ); } xchg 隐含 lock 前缀,直接是总线级原子操作。如果拿到的旧值是 0,说明锁之前是空闲的,现在已经被我们锁上了。如果拿到的旧值是 1,说明别人持有锁,自旋等待。 ...

June 4, 2026 · 2 min · 大飞
京ICP备14031575号-3