从零写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(四十一):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(三十二):启动 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(十八):fork 与 Copy-on-Write

上一章每个进程有了自己的地址空间。这一章实现 fork()——创建一个子进程,继承父进程的全部内存。 最朴素的 fork 实现 fork 的语义是"完整复制当前进程"。最直接的做法:遍历父进程页表,找到每一个物理页,分配新页,复制 4096 字节内容,给子进程建新映射。 能用,但很浪费。大多数 fork() 之后会紧接着 exec()——旧内存根本用不上,全拷了白拷。进程堆如果有几十 MB,每次 fork 都要等好几毫秒。 Copy-on-Write:先共享,写了再分 CoW 的思路是:fork 时不复制,让父子共享同一批物理页;等到谁要写,再给他一份新的。 实现上分两步: 第一步:fork 时打标记 遍历父进程用户页表,对每一页做两件事: 清掉 WRITABLE 位,变成只读 打上 PAGE_COW 标志(借用 x86 页表的 bit 9,这一位 CPU 不使用,留给软件自定义) 子进程页表复制同一个物理页地址,同样只读 + CoW。 fork 后: 父进程 → PT → 物理页 0xA000 (只读, CoW) ↑ 共享 子进程 → PT → 物理页 0xA000 (只读, CoW) 注意一个关键细节:PDPT/PD/PT 这三级结构页必须为子进程单独分配。如果父子共用同一棵结构树,后续 vmm_map_page 修改子进程时会把父进程的 PT 一起改掉,隔离失效。数据页可以共享,结构页不能。 另一个细节:改完父进程页表后必须刷新 TLB。否则 CPU 缓存里还是旧的可写映射,父进程写该页不会触发 fault,CoW 形同虚设。 ...

May 8, 2026 · 2 min · 大飞

从零写OS(十七):每个进程有自己的地址空间

前几章的进程共享同一张页表——所有进程看到的是同一片内存。这意味着进程 A 知道进程 B 的地址,就能直接读写它的数据。一个 bug 就能破坏整个系统。 这一章解决这个问题:给每个进程一张自己的页表,互相看不见彼此的内存。 切换页表就是切换世界 x86-64 的虚拟地址翻译规则写在页表里,页表的根地址放在 CR3 寄存器里。 这意味着:写 CR3 就是切换地址空间。进程 A 跑的时候 CR3 指向 A 的页表,进程 B 跑的时候 CR3 指向 B 的页表。同一个虚拟地址 0x400000,在 A 里翻译到物理页 X,在 B 里翻译到物理页 Y——两边完全隔离,互不干扰。 切换进程时,只需要一行: void vmm_switch(uint64_t *pml4) { __asm__ volatile ("mov %0, %%cr3" :: "r"((uint64_t)pml4) : "memory"); } 硬件帮我们做了全部翻译工作。 创建新页表 每个进程需要自己的 PML4。但不能从空白开始——内核代码的映射必须保留,否则切过去之后 CPU 取不到内核指令,立刻 Page Fault。 做法:把内核的 kernel_pml4 整张复制一份作为起点,然后再往里加用户空间的映射。 uint64_t *vmm_create_page_table() { uint64_t *new_pml4 = (uint64_t *)pmm_alloc(); for (int i = 0; i < 512; i++) new_pml4[i] = kernel_pml4[i]; // 继承内核映射 return new_pml4; } 往指定页表里建映射 之前的 map_page 只能操作当前 CR3 指向的页表。现在需要给进程建映射,但不想先切换过去,所以新接口接受一个显式的 pml4 参数: ...

May 7, 2026 · 2 min · 大飞

从零写OS(七):虚拟内存,给每个程序一个假的地址空间

上一章内核能分配物理页了,但用的全是物理地址。 物理地址有个问题:全局唯一,谁都能访问。进程 A 如果知道进程 B 的物理地址,直接就能读写它的数据。这不行。 解决方案是虚拟内存:每个程序看到的地址都是"假的",CPU 访问时由硬件自动翻译成真实的物理地址。程序互相隔离,谁也看不见谁。 地址翻译的硬件机制:四级页表 x86-64 的地址翻译靠 MMU(Memory Management Unit,内存管理单元,CPU 内部硬件,负责把虚拟地址翻译成物理地址)完成,翻译规则写在页表里,页表的根地址放在 CR3 寄存器里。 一个 64 位虚拟地址被这样拆开: 63 48 47 39 38 30 29 21 20 12 11 0 [ 符号扩展 | PML4_IDX | PDPT_IDX | PD_IDX | PT_IDX | 页内偏移 ] 9 bit 9 bit 9 bit 9 bit 12 bit 翻译过程是四级查表: CR3 → PML4[PML4_IDX] → PDPT[PDPT_IDX] → PD[PD_IDX] → PT[PT_IDX] → 物理页帧 每级页表是一个 512 项的数组,每项 8 字节,刚好占满一个 4KB 页。每项的低 12 位是 flags,高位是下一级页表(或最终物理页)的地址。 ...

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