上一章实现了文件描述符,进程可以用 open/read/close 访问 ext2 文件。但两个进程之间还没有办法通信。shell pipeline cmd1 | cmd2 的本质是:cmd1 的输出直接流进 cmd2 的输入,中间不落磁盘。
这一章实现 pipe 和 dup2,打通进程间通信的最小路径。
pipe 是什么
pipe 是内核里的一块环形字节缓冲区。系统调用 pipe(fds) 返回两个文件描述符:
fds[0] = 读端 fds[1] = 写端
写进程 → fds[1] → [内核 ring buffer 256字节] → fds[0] → 读进程
和普通文件一样,管道也走 fd → VFS → 底层数据 这三层,只不过底层数据不是磁盘,而是内核里的 pipe_t。
dup2 是什么
dup2(oldfd, newfd) 让 newfd 指向和 oldfd 同一个内核文件描述符,如果 newfd 已经打开则先关掉它。
shell pipeline 的核心操作就是 dup2 + exec:
// 子进程(cmd1),stdout → pipe 写端
dup2(fds[1], 1);
close(fds[0]);
close(fds[1]);
exec("cmd1"); // cmd1 以为自己在写 stdout,实际写进了管道
// 父进程(cmd2),stdin → pipe 读端
dup2(fds[0], 0);
close(fds[0]);
close(fds[1]); // 关掉写端,否则 cmd2 永远等不到 EOF
exec("cmd2");
pipe 的 EOF 语义
read 管道什么时候返回 0(EOF)?
答:管道缓冲区为空,且所有写端都已关闭。
这就是为什么 close(fds[1]) 不是可选项。只要有一个写端 fd 还开着,read 就认为"还有数据要来",会等待(或在我们的简化版本里返回 0 字节但不是 EOF)。
实现上用引用计数 write_fds 追踪写端数量:
void pipe_close_write(int idx) {
if (pipes[idx].write_fds > 0) pipes[idx].write_fds--;
if (pipes[idx].read_fds == 0 && pipes[idx].write_fds == 0)
pipes[idx].used = 0; // 两端都关了,释放 pipe slot
}
实现结构
pipe_t 环形缓冲区
typedef struct {
uint8_t buf[PIPE_BUF_SIZE]; // 256 字节
uint32_t read_pos, write_pos, len;
uint32_t write_fds, read_fds; // 引用计数
int used;
} pipe_t;
VFS 层的扩展
VFile 加了 type 字段区分文件和管道:
#define VFILE_FILE 0
#define VFILE_PIPE_R 1
#define VFILE_PIPE_W 2
vfs_read 和 vfs_write 根据 type 分发:
int vfs_read(int fd, void *buf, uint32_t len) {
VFile *f = &fd_table[fd];
if (f->type == VFILE_PIPE_R)
return pipe_read(f->pipe_idx, buf, len);
// 普通文件走 inode
return f->vnode->fops->read(...);
}
pipe_alloc
pipe_alloc(fds, fd_table) 一次性做完所有初始化:
- 在
pipes[]数组里分配一个空闲 slot - 在 VFS fd_table 里分配两个 VFile(一个 PIPE_R,一个 PIPE_W)
- 在进程 fd_table 里分配两个 fd 指向这两个 VFile
- 把 fd 写回
fds[0]和fds[1]
新增的系统调用
| 编号 | 名称 | 作用 |
|---|---|---|
| 8 | SYS_PIPE | 创建管道,返回两个 fd |
| 9 | SYS_DUP2 | 复制 fd |
| 10 | SYS_FWRITE | 向 fd 写数据(支持管道) |
原来的 SYS_WRITE(1) 是直接串口打印,不走 fd 层。新加 SYS_FWRITE(10) 处理 fd 写操作。
遇到的坑:kernel.bin 超出引导加载扇区
加入 pipe.c 之后,内核体积从 19KB 增加到 20.7KB,超过了 boot.asm 里 mov al, 40(40 扇区 × 512 = 20480 字节)的加载限制。
症状是 QEMU 启动只打印 “Kernel loaded!” 就无限重启——CPU 执行了截断的内核代码,立刻 triple fault。
修复:把加载扇区数从 40 改为 50,留出足够余量:
mov al, 50 ; 原来是 40
教训:每次添加新源文件后,用 ls -la kernel/kernel.bin 确认大小没超过 boot.asm 的加载上限。
验证
用户程序在单进程内做一次 pipe 自环测试:
SYS_PIPE → pipe_fds[0]=读端 fd, pipe_fds[1]=写端 fd
SYS_FWRITE(fd_w, "Hello via pipe!\r\n", 17) → 写入 ring buffer
SYS_CLOSE(fd_w) → write_fds 引用计数 → 0
SYS_READ(fd_r, buf, 64) → 从 ring buffer 读出 17 字节
SYS_WRITE(buf) → 串口打印
SYS_CLOSE(fd_r) → pipe 完全释放
SYS_EXIT(0)
输出:
pipe ok
Hello via pipe!
[exit] pid=0x1 code=0x0
小结
pipe 的核心是三个设计决策:
- 引用计数管理写端:EOF 信号不是显式发送的,而是写端全部关闭后自动产生
- VFS 统一抽象:管道、文件对用户程序来说是同一套 fd 接口
- dup2 实现重定向:fork 之后用 dup2 把标准 fd 重定向到管道两端,再 exec 新程序
有了 pipe 和 dup2,shell pipeline 的基础就齐了。