上一章实现了阻塞式 TTY,这一章在同样的思路上让 pipe 的 read 也变成阻塞式,同时修复了调度器里隐藏的两个 Bug,让 fork + exec + wait4 的完整流程跑通。
问题:非阻塞 pipe read 的后果
ch33 之前,vfs_read 对 pipe 的实现是:
if (pipe_buf_empty(p)) return 0; // 管道空 → 直接返回 0
对于 shell 管道命令,busybox sh 会:
- fork 出子进程执行左侧命令,写 pipe
- 父进程(或另一子进程)读 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),会:
- 保存当前内核栈指针(ksp)到
procs[current].ksp - 切换到另一个 READY 进程运行(比如管道写端的进程)
- 写端写完数据、退出后,读端进程重新变为 READY
- 调度器用
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 根源相同:对进程状态的假设不够严谨。ksp 比 ctx.cs 更可靠,因为它直接反映进程当前是否在内核态挂起;fork 后清零 ksp 则是避免父子进程共享内核栈指针的必要操作。