上一章实现了 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符号链接


问题

运行 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_NEWFSTATATSYS_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 在进入 wait4sti; 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_pinksp 等运行时状态一起复制,需要逐一重置为初始值。


修改文件汇总

文件 改动
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