上一章实现了阻塞式 TTY,这一章在同样的思路上让 pipe 的 read 也变成阻塞式,同时修复了调度器里隐藏的两个 Bug,让 fork + exec + wait4 的完整流程跑通。

问题:非阻塞 pipe read 的后果

ch33 之前,vfs_read 对 pipe 的实现是:

if (pipe_buf_empty(p)) return 0;   // 管道空 → 直接返回 0

对于 shell 管道命令,busybox sh 会:

  1. fork 出子进程执行左侧命令,写 pipe
  2. 父进程(或另一子进程)读 pipe,处理右侧命令

如果读端在写端还没写入时就读到 0,shell 认为 EOF,管道提前关闭,命令输出丢失。

解决方案:sti/hlt 等待

与 ch33 阻塞式 TTY read 相同的思路:在内核态用 sti; hlt 轮询等待。

// pipe read:等待数据或写端关闭
while (pipe_buf_empty(p) && pipe_has_writer(p)) {
    __asm__ volatile("sti" ::: "memory");
    __asm__ volatile("hlt" ::: "memory");
    __asm__ volatile("cli" ::: "memory");
}
if (pipe_buf_empty(p)) return 0;   // 写端已关闭且无数据 → EOF

关键点:

  • sti 打开中断,让 PIT(调度器)能抢占当前进程
  • hlt 让 CPU 停下来等下一个中断,省电
  • 中断返回后 cli 关中断,检查条件,满足则退出循环

调度器如何配合

sti; hlt 期间,PIT IRQ0 触发 sched_tick。调度器看到当前进程在内核态(cs & 3 == 0),会:

  1. 保存当前内核栈指针(ksp)到 procs[current].ksp
  2. 切换到另一个 READY 进程运行(比如管道写端的进程)
  3. 写端写完数据、退出后,读端进程重新变为 READY
  4. 调度器用 kernel_resume(ksp) 恢复读端,从 hlt 下一条指令继续

这就是协作式内核抢占:通过 sti 主动让出 CPU,无需显式的 yield 系统调用。

关键数据结构

pipe 需要记录是否还有写端打开,否则读端永远等不到 EOF:

typedef struct {
    uint8_t  buf[PIPE_BUF_SIZE];
    uint32_t head, tail;
    int      writer_count;   // 写端 fd 的引用计数
    int      reader_count;   // 读端 fd 的引用计数
} pipe_t;

当所有写端 fd 都 close 后,writer_count == 0,读端循环检测到这个条件退出,返回 0(EOF)。

fork + exec + wait4 的完整流程

ch34 验证的完整流程:

shell (pid=1)
  │
  ├─ fork → ls (pid=2)   // execve "/bin/ls"
  │         写 stdout(pipe 写端或 tty)
  │         exit(0) → state=ZOMBIE
  │
  └─ wait4(-1, &status)
        ┌ proc_wait 找到 pid=2 ZOMBIE → 回收,返回 exit_code=0
        └ 返回用户态,打印下一个提示符

wait4 的阻塞实现与 pipe read 相同:

while (1) {
    code = proc_wait(&child_pid);
    if (code != -2) break;          // -2 表示"有子进程但还没退出"
    __asm__ volatile("sti" ::: "memory");
    __asm__ volatile("hlt" ::: "memory");
    __asm__ volatile("cli" ::: "memory");
}

调度器 Bug 1:fork 子进程继承父进程 ksp

现象

子进程启动后崩溃,调度器日志显示跳回了父进程的内核栈地址。

原因

proc_fork 内部做 *child = *parent,把父进程整个 PCB 复制给子进程,包括 parent->ksp

如果父进程在内核态被抢占过(ksp ≠ 0),子进程继承了这个 ksp,调度器会尝试 kernel_resume(child->ksp) 跳回父进程的内核栈,导致栈错乱。

修复

*child = *parent;
child->pid        = slot;
child->ksp        = 0;      // ← 清零,子进程从用户态入口开始执行
child->parent_pid = parent->pid;

调度器 Bug 2:dispatch 条件用了过期的 ctx.cs

现象

刚 fork 出来还没有 exec 的子进程,调度器把它调度时直接崩溃,跳到了地址 0。

原因

调度器判断"进入用户态还是 kernel_resume“时,用的是:

if ((procs[n].ctx.cs & 3) == 3) {
    enter_usermode_ctx(&p->ctx);
} else {
    kernel_resume(ksp);
}

ctx.cs 只在进程从用户态被抢占时才更新。如果一个进程从未进入过用户态(比如刚 fork 出来还没 exec),ctx.cs 是初始值 0x08(内核段),调度器错误走 kernel_resume,而此时 ksp=0,结果跳到地址 0,崩溃。

修复

改用 ksp 判断:

if (!procs[n].ksp) {
    enter_usermode_ctx(&p->ctx);   // ksp=0 → 从用户态 ctx 进入
} else {
    kernel_resume(ksp);            // ksp≠0 → 恢复内核态断点
}

ksp=0 表示进程目前处于用户态(或等待第一次进入用户态),ksp≠0 表示进程在内核态被暂停。

最终结果

/ # ls
bin         etc         lost+found
/ #

busybox sh 能执行 ls,fork + exec + wait4 完整工作,shell 在 ls 退出后正常回到提示符。

小结

阻塞式 pipe 的实现复用了 ch33 的 sti; hlt 模式,核心在于 writer_count 引用计数让读端知道何时是真正的 EOF。

调度器的两个 Bug 根源相同:对进程状态的假设不够严谨。kspctx.cs 更可靠,因为它直接反映进程当前是否在内核态挂起;fork 后清零 ksp 则是避免父子进程共享内核栈指针的必要操作。