第 23 篇的 Shell 已经能启动 catwc,却只能让它们各自连接终端。管道要解决的问题,是把一个进程的 fd 1 接到另一个进程的 fd 0,同时让双方在速度不一致、提前退出和创建失败时都能结束。

本篇加入一个 64 字节有界管道和 pipe 系统调用。Shell 现在可以执行一条两段流水线:

1
2
# cat readme.txt | wc
27 1479

README.TXT 有 1479 字节,远大于缓冲区。这个样本会反复经历写满、阻塞、读出和唤醒,不能靠一次复制碰巧通过。专项实验还覆盖读端早退、第二个子进程创建失败、坏用户指针、对象耗尽和重复回收。

管道不只是一段共享内存

环形缓冲只能保存字节。一个可用的管道还要回答四个问题:

  1. 缓冲为空时,读者应该等待还是收到 EOF?
  2. 缓冲已满时,写者怎样让出 CPU?
  3. 哪一次 close 才表示一侧真的消失?
  4. Shell 在哪一步关闭自己的端点,失败时按什么顺序回收?

本篇的完整路径如下:

1
2
3
4
5
6
7
8
9
10
Shell fd 3 ──► read endpoint ─┐

64-byte ring

Shell fd 4 ──► write endpoint ┘

spawn cat: fd 1 → write endpoint
spawn wc : fd 0 → read endpoint
Shell close(3), close(4)
wait(cat), wait(wc)

fd 表、open-file 和管道对象分成三层。fd 是进程内的整数;open-file 保存引用计数和读写能力;管道对象保存字节与等待状态。把三层混在一起,最常见的结果是某个继承 fd 一关闭,整个管道就被误判为没有 writer。

两个端点共享一个有界对象

内核只提供一个固定管道槽,足够完成本篇的一条前台流水线:

1
2
3
4
5
6
7
8
#define PIPE_CAPACITY 64u

struct pipe {
unsigned used, read_at, write_at;
unsigned readers, writers;
struct wait_queue read_waiters, write_waiters;
uint8_t bytes[PIPE_CAPACITY];
};

创建时同时占用两个 open-file 槽。读端只设置 readable,写端只设置 writable,二者都指向同一个 struct pipe。当前实现每一侧只有一个 open-file 对象,所以 readerswriters 是 0/1 状态;父子进程共享端点时增加的是 open-file 的 refs

1
2
3
Shell fd 4 ─┐
├─► write open-file (refs=2) ─► pipe.writers=1
cat fd 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
2
3
4
5
while (!pipe->used && pipe->writers)
task_block_locked(&pipe->read_waiters);

if (!pipe->used)
return 0;

条件检查和入队处在同一个不可抢占区间,写者无法在两步之间写入并发送一次无人接收的唤醒。醒来仍重查条件,则不依赖“每次唤醒只对应一个确定事件”。

短传输把背压送回用户态

一次系统调用最多接收 256 字节,而管道只有 64 字节。写者发现还有空间时,只提交当前能容纳的部分并返回短计数。catwrite_all 必须继续发送剩余字节:

1
2
3
4
5
while (done < length) {
int32_t n = write(fd, bytes + done, length - done);
if (n <= 0) return -1;
done += (uint32_t)n;
}

若缓冲已经满,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
2
3
4
5
6
unsigned wake_all_locked(struct wait_queue *q)
{
unsigned count = 0;
while (wake_one_locked(q)) ++count;
return count;
}

file_put 把 open-file 引用减到零时,才关闭对应 pipe side 并唤醒对面。进程退出仍沿用第 19、22 篇的顺序:先关闭全部 fd,再发布 ZOMBIE。若把关闭推迟到 wait,已经退出却未被回收的 writer 会让 reader 永远等不到 EOF。

pipe() 必须一次交付两个 fd

SYS_PIPE=11 接收一个可写的二元素数组。实现先验证完整的 8 字节用户范围,再准备资源:

1
2
3
4
5
6
验证用户输出范围
→ 找到一个空 pipe slot
→ 找到两个空 open-file slot
→ 初始化读端和写端
→ 原子找到两个进程 fd slot 并安装
→ copy_to_user([read_fd, write_fd])

用户地址在分配前验证,因此坏指针不会留下任何对象。两个 fd 在一次关中断临界区内安装;如果槽位不足,两个端点逆序释放。用户态不会看到“只成功了一半”的管道。

专项命令 pipetest 先传入未映射地址 0x1000,再执行四轮创建、第二次创建、关闭。一个管道存活时再次创建返回 ENFILE;关闭两端后下一轮又能成功。最终输出:

1
PIPE API OK

Shell 的顺序决定流水线能否结束

Shell 的主路径固定为:

1
2
3
4
5
6
7
8
pipe
spawn producer(stdin=Shell stdin, stdout=pipe write)
yield once
spawn consumer(stdin=pipe read, stdout=Shell stdout)
Shell close(pipe read)
Shell close(pipe write)
wait producer
wait consumer

主动让出一次 CPU 是本实验的确定性探针。producer 在 consumer 发布前运行,1479 字节输出会填满 64 字节缓冲并阻塞;Shell 随后恢复并创建 consumer。它没有等待 producer 完成,因此不会形成“producer 等空间、Shell 等 producer、consumer 尚未启动”的环形等待。

两个子进程都发布后,Shell 必须关闭自己的两个 fd。若保留父写端,即使 producer 已退出,writers 仍不为零,consumer 读空后只能继续睡眠。

第二个 spawn 失败时,顺序更敏感:

1
2
3
4
5
producer 已在满缓冲上阻塞
→ Shell 关闭 read end,唤醒 producer
→ Shell 关闭 write end
→ producer 返回 EPIPE 并退出
→ Shell wait/reap producer

若先 wait 再关闭读端,producer 没有机会从永久满缓冲中退出。

解析器只增加一个明确语法

Day 23 把所有元字符都拒绝。现在扫描器把一个 | 识别为分隔符,左右两侧分别构造 argvecho abc|wc 与带空格写法等价。缺少任一侧或出现第二个管道符都会整行拒绝:

1
2
# echo x || wc
shell: invalid pipeline

引号、反斜杠、tab、;&<> 仍未实现。多段管道也没有被半支持。解析成功后,两侧命令名分别经过原有 8.3 转换和 FAT 查找。

运行结果把成功与残留区分开

正常路径的结果与宿主 wc 一致:

1
2
3
4
# cat readme.txt | wc
27 1479
# echo abc|wc
1 4

读端早退使用 head 只取一个字节。producer 已经在满缓冲上等待;consumer 退出关闭最终读端后,producer 被唤醒并收到 broken-pipe:

1
2
3
# cat readme.txt | head
B
shell: left command failed

第二个程序不存在时也能恢复:

1
2
3
4
# cat readme.txt | nosuch
shell: command not found
# echo recovered
recovered

最终串口统计为:

1
2
PIPE STATS creates=8 releases=8 rblock=22 wblock=6 eof=2 broken=2 read=1484 written=1612
SHELL OK prompts=8 pages=16048

creates == releases 说明八次成功创建全部回收。wblock=6 证明缓冲容量确实施加了回压;eof=2 对应两条完整消费的流水线;broken=2 对应读端早退和 consumer 创建失败。读出的 1484 字节等于完整文件 1479 字节、echo 的 4 字节和早退探针的 1 字节。失败流水线留下的已提交字节使 written 大于 read,这是预期清理路径,不是伪造的完整消费。

GDB 在 Shell 退出后的内核循环中再次读取对象状态:

1
2
STATE creates=8 releases=8 rblock=22 wblock=6 eof=2 broken=2 used=0 readers=0 writers=0
PASS: GDB observed blocking, EOF, broken-pipe wakeup and final reclamation

两段管道、早退和失败恢复

完整串口记录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
fa86c15e1ea6721ea9f7d01550dcc1a8ae731937b60c26e68d916474d59a71c6

解压后执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
unzip os-day-24.zip
cd os-day-24
make build
make check-pipe
make check-shell
make check-fd
make check-fat
make check-block
make check-process
make check-syscall
make check-elf
make check-memory
make check-runtime-limit
make check-keyboard

附件包含第 01–24 篇的累计工程和打包前形成的 docs/day24-* 证据,不含 build/ 产物。冻结后会在新的空目录中重新构建并复跑专项与累计回归;复验收据不反向写回它描述的 ZIP。

练习

  1. 暂时保留 Shell 的写端,运行 echo x | wc,用 GDB 找出 consumer 为何无法观察 EOF,再恢复正确关闭顺序。
  2. 把 64 字节容量改成 1、63、65 和 256,比较 rblock/wblock;不要用阻塞次数推导调度公平性。
  3. 将固定单管道槽扩展为小型对象池,补充“两个管道同时存活”和中途分配失败的事务测试。

上一篇:23 - 用户态 Shell

下一篇:25 - 从字符到像素。

参考资料

  • xv6-riscv kernel/pipe.c:固定提交的管道对象、关闭唤醒和满空等待对照;本项目使用自己的单 CPU 等待队列与系统调用 ABI。
  • xv6-riscv user/sh.c:用于对照父进程启动两侧、关闭端点再等待的所有权顺序;本项目没有 fork/dup
  • xv6-riscv kernel/file.c:用于对照 open-file 引用与底层端点关闭的分层。