上一章实现了文件描述符,进程可以用 open/read/close 访问 ext2 文件。但两个进程之间还没有办法通信。shell pipeline cmd1 | cmd2 的本质是:cmd1 的输出直接流进 cmd2 的输入,中间不落磁盘。

这一章实现 pipedup2,打通进程间通信的最小路径。


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_readvfs_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) 一次性做完所有初始化:

  1. pipes[] 数组里分配一个空闲 slot
  2. 在 VFS fd_table 里分配两个 VFile(一个 PIPE_R,一个 PIPE_W)
  3. 在进程 fd_table 里分配两个 fd 指向这两个 VFile
  4. 把 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 的核心是三个设计决策:

  1. 引用计数管理写端:EOF 信号不是显式发送的,而是写端全部关闭后自动产生
  2. VFS 统一抽象:管道、文件对用户程序来说是同一套 fd 接口
  3. dup2 实现重定向:fork 之后用 dup2 把标准 fd 重定向到管道两端,再 exec 新程序

有了 pipe 和 dup2,shell pipeline 的基础就齐了。