上一章实现了 TCP/IP 栈,用户程序能发 HTTP 请求了。但验证时发现 /bin/ls 完全没反应,wget_test 也跑不起来。这一章记录排查过程——一共修了 6 个 bug,涉及 ext2 fast symlink、VFS 路径解析、内核栈溢出、缺失 syscall,以及两个调度器 cpu_pin 问题。
最终效果:
/ # ls
bin etc lost+found
/ # cd bin
/bin # ls
busybox cp kill mv pwd sh wget_test
cat echo ls ps rm umount
/bin # wget_test
wget_test start
connecting...
connected!
request sent
HTTP/1.0 200 OK
...
DONE
/bin #
背景:busybox 的目录结构
Makefile 用这种方式制作 ext2 镜像:
sudo cp busybox-x86_64 /tmp/ext2mnt/bin/busybox
sudo cp busybox-x86_64 /tmp/ext2mnt/bin/sh # sh 是真实复制
for cmd in ls cat echo pwd ...; do
sudo ln -sf /bin/busybox /tmp/ext2mnt/bin/$cmd # 其他命令是符号链接
done
/bin/sh 是真实文件,/bin/ls 等是指向 /bin/busybox 的符号链接。
Bug 1:ext2 fast symlink 导致 ATA 无限轮询
问题
运行 ls 时内核卡死,串口没有任何输出,无法输入。
原因
ext2 有一个优化:当符号链接目标路径 ≤ 60 字节时,路径字符串直接存在 inode 的 i_block[0..14](60 字节)里,不占用数据块,此时 i_blocks == 0。
内核的 ext2_read 直接把 inode.i_block[0] 当作磁盘块号去读。/bin/busybox 的 ASCII 字节值被解释成一个无效扇区号,ATA PIO 轮询 DRQ bit 永不返回。
修复
// ext2.c: ext2_read()
int ext2_read(uint32_t ino, uint32_t offset, void *buf, uint32_t len) {
Ext2Inode inode;
read_inode(ino, &inode);
// fast symlink: 目标存在 i_block[] 里,不是磁盘块号
if ((inode.i_mode & 0xF000) == 0xA000 && inode.i_blocks == 0) {
if (offset >= inode.i_size) return 0;
uint32_t avail = inode.i_size - offset;
if (len > avail) len = avail;
uint8_t *src = (uint8_t *)inode.i_block + offset;
for (uint32_t i = 0; i < len; i++) ((uint8_t *)buf)[i] = src[i];
return (int)len;
}
// ... 正常块读取
}
判断条件:mode == 0xA000(symlink)且 i_blocks == 0(fast symlink)。
Bug 2:VFS 路径解析不 follow 符号链接
问题
即使 ext2_read 能正确读出符号链接内容,execve("/bin/ls") 仍然失败——内核打开的是符号链接 inode 本身,而不是它指向的 /bin/busybox。
修复
在 vfs_path_lookup_from 的路径解析循环里,每解析一个路径分量后,检查是否是符号链接,是则递归解析:
// vfs.c
static uint32_t vfs_resolve_symlink(uint32_t ino, int depth);
static uint32_t vfs_path_lookup_from(const char *path, uint32_t start_ino) {
// ...
while (*p) {
// 解析路径分量 component
uint32_t child = vfs_fops->lookup(dir_ino, component);
if (!child) return 0;
uint16_t cmode = ext2_get_mode(child);
if ((cmode & 0xF000) == 0xA000) { // 是符号链接
child = vfs_resolve_symlink(child, 0);
if (!child) return 0;
}
dir_ino = child;
}
return dir_ino;
}
static uint32_t vfs_resolve_symlink(uint32_t ino, int depth) {
if (depth > 8) return 0; // 防止循环链接
char target[256];
int n = vfs_fops->read(ino, 0, target, sizeof(target) - 1);
if (n <= 0) return 0;
target[n] = 0;
uint32_t resolved = vfs_path_lookup_from(target, vfs_root_inum);
if (!resolved) return 0;
uint16_t rmode = ext2_get_mode(resolved);
if ((rmode & 0xF000) == 0xA000)
return vfs_resolve_symlink(resolved, depth + 1);
return resolved;
}
Bug 3:vfs_getdents 内核栈溢出
问题
在内核栈(4096 字节)上声明了同样大小的局部数组:
int vfs_getdents(int fd, void *buf, uint32_t count) {
uint8_t raw[4096]; // 占满整个内核栈!
// ...
}
这会导致栈溢出,覆盖调用帧。
修复
static uint8_t raw[4096]; // 改为 static
Bug 4:缺少 faccessat syscall
问题
加了前面三个修复后,ls 仍然无反应。通过对 SYS_NEWFSTATAT 和 SYS_EXECVE 加调试打印分析,发现 [stat] /bin/ls 能打印,但之后 sh 没有 fork 子进程去跑 ls。
原因:busybox sh 在 fork+exec 之前,会先用 faccessat 检查命令是否存在,返回 -ENOSYS 则认为命令不存在,直接放弃。
修复
// syscall.h
#define SYS_ACCESS 21
#define SYS_FACCESSAT 269
#define SYS_FACCESSAT2 439
// syscall.c
case SYS_ACCESS:
case SYS_FACCESSAT:
case SYS_FACCESSAT2: {
char *path = current->kpath;
uint64_t path_va = (nr == SYS_ACCESS) ? a1 : a2;
if (copy_str_from_user(path, path_va, 256) < 0) return -EFAULT;
int vn = vfs_open_at(path, current_cwd_ino());
if (vn < 0) return -ENOENT;
vfs_close(vn);
return 0;
}
Bug 5:proc_fork 继承父进程 cpu_pin
问题
加了 faccessat 后,[fork] 日志出现了,fork 成功,但之后子进程(ls)永远不被调度,[execve] 从不出现。
原因
proc_fork 里 *child = *parent 复制了父进程的全部字段,包括 cpu_pin。父进程在被调度时,调度器设置了 cpu_pin = this_cpu_id() = 0。子进程继承了 cpu_pin = 0。
而调度器的选进程条件是:
if (procs[n].state == PROC_READY && !procs[n].ksp
&& (procs[n].ctx.cs & 3) == 3
&& procs[n].cpu_pin < 0) // ← cpu_pin 必须 < 0
cpu_pin = 0 不满足 < 0,子进程永远不被选中。
修复
// process.c: proc_fork()
*child = *parent;
child->pid = slot;
child->ksp = 0;
child->parent_pid = parent->pid;
child->exit_code = 0;
child->cpu_pin = -1; // 新增:不继承父进程的 cpu 绑定
Bug 6:sched_tick 保存进程时不重置 cpu_pin
问题
ls 能运行了,能看到文件列表,但 ls 退出后 sh 卡死,不响应输入,wait4 永远不返回。
原因
sched_tick 在 timer 中断时保存当前进程上下文(state → PROC_READY),但没有重置 cpu_pin:
// 原代码
procs[cp].state = PROC_READY;
procs[cp].ksp = (uint64_t)regs - 8;
// 没有重置 cpu_pin!
所以 sh 在进入 wait4 的 sti; hlt 循环后,被 timer 中断保存,cpu_pin 仍然是 0,调度器找下一个进程时同样因为 cpu_pin >= 0 而跳过 sh。
ls 退出后,调度器找不到任何可运行的进程(sh 的 cpu_pin 还是 0),陷入空转。
修复
// process.c: sched_tick()
if (!is_user) {
if (cp != 0) {
procs[cp].state = PROC_READY;
procs[cp].ksp = (uint64_t)regs - 8;
procs[cp].cpu_pin = -1; // 新增
}
} else {
procs[cp].state = PROC_READY;
procs[cp].cpu_pin = -1; // 新增
// ... 保存寄存器
}
调试方法回顾
这次排查涉及多个层次,以下是有效的调试手段:
串口打印定位层次:在 [stat]、[fork]、[execve]、[exit]、[wait4 ok]、[kresume] 这些关键点加打印,按出现顺序推断卡在哪一步。
只在具体 case 里加打印:如果在 syscall_handler 入口(switch 之前)加串口打印,每个 syscall 都打印,频率极高会导致 triple fault → reboot loop。
cpu_pin 的调度语义:cpu_pin >= 0 表示"已绑定到某个 CPU 正在运行",cpu_pin = -1 表示"空闲,可被任意 CPU 调度"。进程被保存(从 RUNNING 变 READY)时,必须同时重置 cpu_pin = -1,否则调度器认为它还在某个 CPU 上跑着,不会再调度它。
*child = *parent 的陷阱:proc_fork 里整体复制父进程结构体很方便,但会连同 cpu_pin、ksp 等运行时状态一起复制,需要逐一重置为初始值。
修改文件汇总
| 文件 | 改动 |
|---|---|
kernel/fs/ext2/ext2.c |
ext2_read 加 fast symlink 检测 |
kernel/fs/vfs.c |
vfs_path_lookup_from 加符号链接 follow;vfs_getdents raw[] 改 static |
kernel/syscall.h |
新增 SYS_ACCESS / SYS_FACCESSAT / SYS_FACCESSAT2 |
kernel/syscall.c |
实现 faccessat handler |
kernel/process.c |
proc_fork 重置 cpu_pin;sched_tick 保存时重置 cpu_pin |