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),进程一分配匿名内存就跳到奇怪的地址。
修复很简单:GDT 数组多留一个空槽给 TSS 高半部分。
Bug 2:syscall handler 中的用户指针失效
这才是这一章最重要的发现,也是最难追踪的 bug。
问题现象
busybox 启动后打印:
/etc/passwd: bad record
明明镜像里有这个文件,为什么读失败?
根本原因
内核在 syscall_handler 开头调用 vmm_switch(kernel_pml4) 切换到内核页表。切换之后,所有作为 syscall 参数传入的用户空间虚拟地址在内核页表下不再有效。
直接把用户 VA 当 C 指针使用,读到的是零或垃圾数据:
// 错误的写法
const char *path = (const char *)a1; // a1 是用户 VA
vfs_path_lookup(path); // 内核页表下读到空字符串!
完整的错误链
SYS_OPEN收到a1 = 0x...(用户 VA,指向 “/etc/passwd”)- 内核页表下解引用该地址,读到空字符串
"" vfs_path_lookup("")返回 0(找不到)vfs_open的 fallback 逻辑调用vfs_fops->create(root, "etc/passwd")在根目录创建了一个名为"etc/passwd"的空文件- busybox 读这个空文件时解析失败,输出
bad record
调试技巧
定位"路径查找是否被调用"的方法:在 vfs_path_lookup 开头加一行串口打印,观察是否出现对应路径的日志。如果完全没有日志,说明问题在 syscall 参数层面(用户指针失效),而非 VFS 层面。
修复:copy_str_from_user
解决方案是通过物理地址走 current->pml4 页表,安全地从用户空间读取数据:
static int copy_str_from_user(char *kdst, uint64_t uva, uint32_t maxlen) {
for (uint32_t i = 0; i < maxlen - 1; i++) {
uint64_t va = uva + i;
uint64_t page = vmm_virt_to_phys(current->pml4, va & ~0xFFFULL);
if (!page) { kdst[i] = 0; return -1; }
char c = *(char *)(page + (va & 0xFFF));
kdst[i] = c;
if (!c) return 0;
}
kdst[maxlen - 1] = 0;
return 0;
}
关键在于 vmm_virt_to_phys(current->pml4, ...) ——走的是进程自己的页表,与当前 CR3 无关。不管内核切没切换页表,都能正确找到用户内存对应的物理地址。
同理,读写二进制数据也需要对应的辅助函数:
| 操作 | 函数 |
|---|---|
| 从用户读字符串 | copy_str_from_user(kdst, uva, maxlen) |
| 从用户读二进制数据 | copy_from_user(kdst, uva, len) |
| 向用户写数据 | copy_to_user(uva, ksrc, len) |
全部内部使用 vmm_virt_to_phys(current->pml4, ...) 走物理地址。
受影响的 syscall
需要处理用户指针的 syscall 都要改:SYS_OPEN、SYS_OPENAT、SYS_EXECVE、SYS_CHDIR、SYS_WAIT4、SYS_UNAME、SYS_GETCWD、SYS_GETTIMEOFDAY、SYS_CLOCK_GETTIME、SYS_NANOSLEEP、SYS_POLL、SYS_PIPE、SYS_READLINKAT、SYS_NEWFSTATAT。
Bug 3:ext2 子目录不存在
即使修复了用户指针问题,路径查找还需要 /etc 目录真实存在于 ext2.img 中。用 debugfs 验证:
debugfs -R 'ls /etc' ext2.img
如果 /etc 不在镜像里,vfs_path_lookup("/etc/passwd") 在第一层 lookup("etc") 时就返回 0。Makefile 里创建镜像时要确保目录结构正确建立。
新增 SYS_LSEEK
busybox 在解析 /etc/passwd 时使用 lseek 进行文件内定位:
int64_t vfs_lseek(int fd, int64_t offset, int whence) {
file_t *f = get_file(fd);
if (!f) return -EBADF;
int64_t new_off;
if (whence == SEEK_SET) new_off = offset;
else if (whence == SEEK_CUR) new_off = f->offset + offset;
else if (whence == SEEK_END) new_off = f->size + offset;
else return -EINVAL;
if (new_off < 0) return -EINVAL;
f->offset = new_off;
return new_off;
}
最终结果
syscall initialized
[tss] init ok, rsp0=0x0000000000101000
[init] pid=0x00000000004fe8dc
/bin/sh: can't access tty; job control turned off
/ #
busybox sh 成功启动,显示 / # 提示符,无任何错误。
不过此时 shell 在收到 EOF 后立即退出——因为 tty_read 是非阻塞的,没有输入就直接返回 0。实现真正交互式 shell 还需要阻塞式 TTY read,这是下一章的事。
小结
这一章有两个值得记住的教训:
用户指针不能直接用:syscall handler 切换到内核页表后,用户传进来的 VA 就失效了。所有访问用户内存的地方必须通过 copy_str_from_user / copy_from_user / copy_to_user,内部走进程自己的 pml4 查物理地址。这是个系统性问题,涉及到几乎所有接收指针参数的 syscall。
GDT 布局细节:x86_64 的 TSS 描述符占两个槽,不是一个。这种低级错误导致的内存破坏极难追踪,因为它会破坏相邻的全局变量,表现形式千奇百怪。