从零写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(四十九):消灭 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(四十一):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 · 大飞

从零写OS(三十八):SMP 启动 —— 唤醒第二颗 CPU

到目前为止,内核一直跑在单核上。这一章迈出多核的第一步:让所有 CPU 核心都进入内核。 验证结果: [acpi] MADT found, 2 CPU(s) [smp] booting AP lapic=1 [cpu0] online [cpu1] online [smp] all APs online, total CPUs=2 / # x86 多核启动的规则 x86 多核启动有一套固定规则。系统上电后只有一颗 CPU 跑起来,叫做 BSP(Bootstrap Processor)。其余的核心叫 AP(Application Processor),处于等待状态,需要 BSP 主动唤醒。 唤醒的方式是通过 LAPIC(Local APIC) 发送 IPI(Inter-Processor Interrupt)。每颗核心内置一个 LAPIC,是核间通信的硬件基础。 第一步:找到所有 CPU CPU 的信息藏在 ACPI MADT 表里。MADT(Multiple APIC Description Table)是固件写好放在内存里的一张表,描述了系统上有多少颗 CPU 以及每颗 CPU 的 LAPIC ID。 内核启动时扫描 MADT,把所有有效 CPU 的 LAPIC ID 记下来: void acpi_parse_madt(void) { madt_t *madt = acpi_find_table("APIC"); // 遍历所有条目,找 type=0 的 Processor Local APIC 条目 // 记录 lapic_id → cpu_lapic_ids[],ncpus++ } 第二步:发送 INIT + STARTUP IPI 唤醒 AP 的序列是固定的(来自 Intel SDM): ...

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