从零编写操作系统 24 - 管道:让两个进程通过字节流协作
第 23 篇的 Shell 已经能启动 cat 和 wc,却只能让它们各自连接终端。管道要解决的问题,是把一个进程的 fd 1 接到另一个进程的 fd 0,同时让双方在速度不一致、提前退出和创建失败时都能结束。
本篇加入一个 64 字节有界管道和 pipe 系统调用。Shell 现在可以执行一条两段流水线:
1 | |
README.TXT 有 1479 字节,远大于缓冲区。这个样本会反复经历写满、阻塞、读出和唤醒,不能靠一次复制碰巧通过。专项实验还覆盖读端早退、第二个子进程创建失败、坏用户指针、对象耗尽和重复回收。
管道不只是一段共享内存
环形缓冲只能保存字节。一个可用的管道还要回答四个问题:
- 缓冲为空时,读者应该等待还是收到 EOF?
- 缓冲已满时,写者怎样让出 CPU?
- 哪一次
close才表示一侧真的消失? - Shell 在哪一步关闭自己的端点,失败时按什么顺序回收?
本篇的完整路径如下:
1 | |
fd 表、open-file 和管道对象分成三层。fd 是进程内的整数;open-file 保存引用计数和读写能力;管道对象保存字节与等待状态。把三层混在一起,最常见的结果是某个继承 fd 一关闭,整个管道就被误判为没有 writer。
两个端点共享一个有界对象
内核只提供一个固定管道槽,足够完成本篇的一条前台流水线:
1 | |
创建时同时占用两个 open-file 槽。读端只设置 readable,写端只设置 writable,二者都指向同一个 struct pipe。当前实现每一侧只有一个 open-file 对象,所以 readers 和 writers 是 0/1 状态;父子进程共享端点时增加的是 open-file 的 refs。
1 | |
Shell 关闭 fd 4 后,写端引用从 2 变成 1,writers 仍为 1。只有 cat 的 fd 1 也关闭,open-file 的最终引用才消失,管道才把 writers 改成 0。
这个层次延续了第 22 篇的句柄所有权:复制句柄增加中间对象的引用,底层资源只响应最后一次释放。
空缓冲不一定是 EOF
读写规则可以压缩成一张状态表:
| 操作 | 条件 | 结果 |
|---|---|---|
| read | used > 0 |
取出 min(length, used),唤醒一个 writer |
| read | used == 0 && writers > 0 |
进入读等待队列 |
| read | used == 0 && writers == 0 |
返回 0,也就是 EOF |
| write | 有空间且 readers > 0 |
写入可用空间,唤醒一个 reader |
| write | 缓冲满且 readers > 0 |
进入写等待队列 |
| write | readers == 0 |
返回本项目的 EPIPE 错误 |
读者不能只检查 used == 0。producer 还活着时,空只表示“字节尚未到达”;最后一个写端已经关闭时,空才表示后续永远不会再有数据。
等待过程沿用第 15 篇的单 CPU 协议。内核关闭中断后检查条件,把当前任务登记进对象的等待队列,再切换任务。唤醒后用 while 重查:
1 | |
条件检查和入队处在同一个不可抢占区间,写者无法在两步之间写入并发送一次无人接收的唤醒。醒来仍重查条件,则不依赖“每次唤醒只对应一个确定事件”。
短传输把背压送回用户态
一次系统调用最多接收 256 字节,而管道只有 64 字节。写者发现还有空间时,只提交当前能容纳的部分并返回短计数。cat 的 write_all 必须继续发送剩余字节:
1 | |
若缓冲已经满,writer 进入 write_waiters,不在循环里消耗 CPU。reader 取走数据后唤醒一个 writer。反方向也一样:wc 在空缓冲上阻塞,producer 写入后再恢复。
这里没有承诺 POSIX 的 PIPE_BUF 原子性。多个 writer、原子小写入和信号语义都不属于当前 ABI;短写是用户程序必须处理的正常结果。
close 负责传播永久条件
普通读写只需要唤醒一个对端,因为缓冲中的一次变化未必足够满足所有等待者。端点关闭不同:最后一个 reader 消失后,“无读者”不会再恢复;所有 writer 都应醒来观察错误。最后一个 writer 消失后,所有 reader 也应醒来观察 EOF。
因此本篇在原有 wake_one_locked 旁增加 wake_all_locked:
1 | |
file_put 把 open-file 引用减到零时,才关闭对应 pipe side 并唤醒对面。进程退出仍沿用第 19、22 篇的顺序:先关闭全部 fd,再发布 ZOMBIE。若把关闭推迟到 wait,已经退出却未被回收的 writer 会让 reader 永远等不到 EOF。
pipe() 必须一次交付两个 fd
SYS_PIPE=11 接收一个可写的二元素数组。实现先验证完整的 8 字节用户范围,再准备资源:
1 | |
用户地址在分配前验证,因此坏指针不会留下任何对象。两个 fd 在一次关中断临界区内安装;如果槽位不足,两个端点逆序释放。用户态不会看到“只成功了一半”的管道。
专项命令 pipetest 先传入未映射地址 0x1000,再执行四轮创建、第二次创建、关闭。一个管道存活时再次创建返回 ENFILE;关闭两端后下一轮又能成功。最终输出:
1 | |
Shell 的顺序决定流水线能否结束
Shell 的主路径固定为:
1 | |
主动让出一次 CPU 是本实验的确定性探针。producer 在 consumer 发布前运行,1479 字节输出会填满 64 字节缓冲并阻塞;Shell 随后恢复并创建 consumer。它没有等待 producer 完成,因此不会形成“producer 等空间、Shell 等 producer、consumer 尚未启动”的环形等待。
两个子进程都发布后,Shell 必须关闭自己的两个 fd。若保留父写端,即使 producer 已退出,writers 仍不为零,consumer 读空后只能继续睡眠。
第二个 spawn 失败时,顺序更敏感:
1 | |
若先 wait 再关闭读端,producer 没有机会从永久满缓冲中退出。
解析器只增加一个明确语法
Day 23 把所有元字符都拒绝。现在扫描器把一个 | 识别为分隔符,左右两侧分别构造 argv;echo abc|wc 与带空格写法等价。缺少任一侧或出现第二个管道符都会整行拒绝:
1 | |
引号、反斜杠、tab、;、&、< 和 > 仍未实现。多段管道也没有被半支持。解析成功后,两侧命令名分别经过原有 8.3 转换和 FAT 查找。
运行结果把成功与残留区分开
正常路径的结果与宿主 wc 一致:
1 | |
读端早退使用 head 只取一个字节。producer 已经在满缓冲上等待;consumer 退出关闭最终读端后,producer 被唤醒并收到 broken-pipe:
1 | |
第二个程序不存在时也能恢复:
1 | |
最终串口统计为:
1 | |
creates == releases 说明八次成功创建全部回收。wblock=6 证明缓冲容量确实施加了回压;eof=2 对应两条完整消费的流水线;broken=2 对应读端早退和 consumer 创建失败。读出的 1484 字节等于完整文件 1479 字节、echo 的 4 字节和早退探针的 1 字节。失败流水线留下的已提交字节使 written 大于 read,这是预期清理路径,不是伪造的完整消费。
GDB 在 Shell 退出后的内核循环中再次读取对象状态:
1 | |
完整串口记录、GDB 资源现场与键盘注入序列保留了本次真实运行数据。专项完成后,Day 23 至 Day 16 的十项相关回归也全部退出 0,见累计回归记录。
当前边界
本篇只有一个固定管道槽和一个 64 字节缓冲区,只支持 cmd1 | cmd2。没有多级流水线、重定向、后台任务、命名管道、非阻塞 I/O、select/poll、SIGPIPE 或多 writer 原子写保证。
等待队列是四任务槽的位图,调度和临界区针对单 CPU。关闭路径实现了 wake-all,但没有承诺等待顺序或公平性。测试环境是 QEMU 8.2.2、SeaBIOS 1.16.3、pc-i440fx-7.2、TCG、qemu32、1 CPU 和 64 MiB;没有据此声称真实硬件或 SMP 已验证。
机制速查
| 遇到的问题 | 可迁移做法 | 本篇落点 |
|---|---|---|
| 一个逻辑资源有多个句柄 | 分离句柄引用与底层端点状态 | fd → open-file → pipe |
| 空状态可能暂时也可能永久 | 把生产者存活状态纳入条件 | 空且有 writer 时等待;无 writer 时 EOF |
| 等待者可能错过状态变化 | 在同一临界区检查、入队,醒后重查 | pipe 的两条 wait queue |
| 创建要同时返回多个资源 | 延迟发布并统一回滚 | pipe() 的两个 fd |
| 并发流程中途失败 | 先解除依赖,再等待退出 | consumer spawn 失败先 close 后 wait |
下载与复现
下载第 24 篇完整源码。附件 SHA-256:
1 | |
解压后执行:
1 | |
附件包含第 01–24 篇的累计工程和打包前形成的 docs/day24-* 证据,不含 build/ 产物。冻结后会在新的空目录中重新构建并复跑专项与累计回归;复验收据不反向写回它描述的 ZIP。
练习
- 暂时保留 Shell 的写端,运行
echo x | wc,用 GDB 找出 consumer 为何无法观察 EOF,再恢复正确关闭顺序。 - 把 64 字节容量改成 1、63、65 和 256,比较
rblock/wblock;不要用阻塞次数推导调度公平性。 - 将固定单管道槽扩展为小型对象池,补充“两个管道同时存活”和中途分配失败的事务测试。
上一篇:23 - 用户态 Shell。
下一篇:25 - 从字符到像素。
参考资料
- xv6-riscv
kernel/pipe.c:固定提交的管道对象、关闭唤醒和满空等待对照;本项目使用自己的单 CPU 等待队列与系统调用 ABI。 - xv6-riscv
user/sh.c:用于对照父进程启动两侧、关闭端点再等待的所有权顺序;本项目没有fork/dup。 - xv6-riscv
kernel/file.c:用于对照 open-file 引用与底层端点关闭的分层。

