从零写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