资讯动态

从 Mini Shell 到 Linux 进程通信:深入理解 fork、pipe 与文件描述符

发布时间:2026/8/31 23:06:48 来源:尧图企业网站定制
一、引言首先看一条shell指令echo hello | grep hello | wc -c我们平时认为这是“一条命令”但实际上 Linux 内核看到的是Shell │ ├── fork → echo │ ├── fork → grep │ └── fork → wc echo ── pipe ── grep ── pipe ── wc那么Shell 到底是如何把一条命令变成多个进程并让这些进程互相通信的。本文将围绕这个话题展开并深入理解fork、pipe等系统调用和对文件描述符的理解。二、实现代码这里我自己实现了一个minishell把最为关键的执行函数粘贴出来供分析和学习void execute_pipeline(Pipeline *pipeline) { // 处理内置命令 (如 cd, exit)如果是单条命令才直接处理(没有管道分隔符) if (pipeline-cmd_count 1 handle_builtin(pipeline-cmds[0])) { return; } int in_fd 0; // 第一个命令默认从标准输入读取 int pipefd[2]; for (int i 0; i pipeline-cmd_count; i) { Command *cmd pipeline-cmds[i]; // 如果不是最后一个命令创建管道 if (i pipeline-cmd_count - 1) { if (pipe(pipefd) 0) { perror(pipe error); return; } } pid_t pid fork(); if (pid 0) { // 子进程 // 1. 处理输入管道连接 if (in_fd ! 0) { dup2(in_fd, STDIN_FILENO); close(in_fd); } // 2. 处理输出管道连接:如果不是最后一条命令默认写数据入管道 if (i pipeline-cmd_count - 1) { dup2(pipefd[1], STDOUT_FILENO); close(pipefd[1]); close(pipefd[0]); // 子进程不需要读端 } // 3. 处理重定向 (会覆盖管道重定向优先级更高) if (cmd-in_file) { int fd0 open(cmd-in_file, O_RDONLY); dup2(fd0, STDIN_FILENO); close(fd0); } if (cmd-out_file) { int flags O_WRONLY | O_CREAT | (cmd-append ? O_APPEND : O_TRUNC); int fd1 open(cmd-out_file, flags, 0644); dup2(fd1, STDOUT_FILENO); close(fd1); } // 4. 执行命令 execvp(cmd-argv[0], cmd-argv); perror(execvp error); exit(1); } else if (pid 0) { perror(fork error); return; } // 父进程逻辑 if (in_fd ! 0) { close(in_fd); // 关闭上一个管道的读端 } if (i pipeline-cmd_count - 1) { close(pipefd[1]); // 关闭当前管道的写端 in_fd pipefd[0]; // 将当前管道的读端留给下一个命令 } } // 处理后台运行 if (!pipeline-background) { for (int i 0; i pipeline-cmd_count; i) { wait(NULL); // 前台命令需要等待所有子进程结束 } } else { printf([Job running in background]\n); } } void debug_pipeline(Pipeline *pipeline) { printf(--- [AST Debug Info] ---\n); printf(Total Commands: %d | Background: %s\n, pipeline-cmd_count, pipeline-background ? YES : NO); for (int i 0; i pipeline-cmd_count; i) { Command *cmd pipeline-cmds[i]; printf( Cmd[%d]:\n, i); printf( Args: ); for (int j 0; j cmd-argc; j) { printf([%s] , cmd-argv[j]); } printf(\n); if (cmd-in_file) printf( In : %s\n, cmd-in_file); if (cmd-out_file) printf( Out %s : %s\n, cmd-append ? : , cmd-out_file); } printf(------------------------\n); }为方便理解把Pipeline的结构体声明也粘贴出来// 单条命令的结构定义 typedef struct { char *argv[MAX_ARGS]; // 参数列表最后以 NULL 结尾供 execvp 使用 int argc; // 参数个数 char *in_file; // 输入重定向文件名 (处理 ) char *out_file; // 输出重定向文件名 (处理 或 ) int append; // 输出是否为追加模式 (1 表示 , 0 表示 ) } Command; // 整个命令行任务由管道连接的多条命令 typedef struct { Command cmds[MAX_CMDS]; // 按顺序存放管道分割的命令 int cmd_count; // 管道中包含的命令总数 int background; // 是否后台运行 (处理行尾的 ) } Pipeline;执行函数拿到处理后的命令切割参数装入参数列表供execvp调用而结构体Pipeline是用来处理一条指令中多个命令的结构体。三、日志分析为了方便理解我做了一些日志打印下面我们就下述命令进行日志分析并理解myshell echo hello | grep hello | wc -c日志如下myshell echo hello | grep hello | wc -c [EXECUTE] Start pipeline execution [EXECUTE] PID825 [EXECUTE] Command count 3 [EXECUTE] Background NO ---------------------------------------------------- [PARENT] Preparing command 0 [PARENT] PID825 [PARENT] Command echo [PARENT] Current in_fd 0 ---------------------------------------------------- [PARENT] pipe created for command 0 [PARENT] pipefd[0] 3 (READ) [PARENT] pipefd[1] 4 (WRITE) [PARENT] Before fork: i0 cmdecho in_fd0 PID825 [PARENT] fork success: child PID848 for command echo [PARENT] PID825 still has in_fd0 [PARENT] PID825: close pipe write fd4 [PARENT] PID825: save pipe read fd3 as new in_fd [PARENT] PID825: new in_fd3 ---------------------------------------------------- [PARENT] Preparing command 1 [PARENT] PID825 [PARENT] Command grep [PARENT] Current in_fd 3 ---------------------------------------------------- [PARENT] pipe created for command 1 [PARENT] pipefd[0] 4 (READ) [PARENT] pipefd[1] 5 (WRITE) [PARENT] Before fork: i1 cmdgrep in_fd3 PID825 [PARENT] fork success: child PID849 for command grep [PARENT] PID825 still has in_fd3 **************** CHILD **************** [PARENT] PID825: close old in_fd3 [PARENT] PID825: old in_fd3 is now closed [PARENT] PID825: close pipe write fd5 [PARENT] PID825: save pipe read fd4 as new in_fd [PARENT] PID825: new in_fd4 ---------------------------------------------------- [PARENT] Preparing command 2 [PARENT] PID825 [PARENT] Command wc [PARENT] Current in_fd 4 ---------------------------------------------------- [PARENT] Before fork: i2 cmdwc in_fd4 PID825 [CHILD] PID848 [CHILD] PPID825 [CHILD] command index 0 [CHILD] command echo [CHILD] inherited in_fd 0 [CHILD] inherited pipefd[0] 3 (READ) [CHILD] inherited pipefd[1] 4 (WRITE) ****************************************** [CHILD] PID848: Redirect stdout: fd 4 - fd 1 **************** CHILD **************** [PARENT] fork success: child PID850 for command wc [PARENT] PID825 still has in_fd4 [PARENT] PID825: close old in_fd4 [PARENT] PID825: old in_fd4 is now closed [PARENT] All children created. PID825 [PARENT] Foreground job. [PARENT] Waiting for children... [CHILD] PID849 [CHILD] PPID825 [CHILD] command index 1 [CHILD] command grep [CHILD] inherited in_fd 3 [CHILD] inherited pipefd[0] 4 (READ) [CHILD] inherited pipefd[1] 5 (WRITE) ****************************************** [CHILD] PID849: Redirect stdin: fd 3 - fd 0 [CHILD] PID849: stdin(fd 0) is now connected to fd 3 [CHILD] PID849: close original in_fd3 [CHILD] PID849: Redirect stdout: fd 5 - fd 1 **************** CHILD **************** [CHILD] PID850 [CHILD] PPID825 [CHILD] command index 2 [CHILD] command wc [CHILD] inherited in_fd 4 ****************************************** [CHILD] PID850: Redirect stdin: fd 4 - fd 0 [CHILD] PID850: stdin(fd 0) is now connected to fd 4 [CHILD] PID850: close original in_fd4 [CHILD] PID850: Ready to execvp(wc) [CHILD] PID850: stdin fd 0 [CHILD] PID850: stdout fd 1 257 [PARENT] wait(): child PID849 finished [PARENT] child PID849 exit code0 [PARENT] wait(): child PID848 finished [PARENT] child PID848 exit code0 [PARENT] wait(): child PID850 finished [PARENT] child PID850 exit code0 [PARENT] All foreground children finished. [EXECUTE] Pipeline execution finished 四、fork()与父子进程模型minishell实现的基本思路是父进程保持循环通过终端不断接受命令拆分好命令后在合适的地方fork()创建子进程由子进程通过execvp()加载并执行目标程序而父进程负责维护执行环境并根据前台/后台模式决定是否等待子进程结束。子进程执行完成后退出父进程重新进入下一轮命令等待。观察下面的日志[PARENT] Before fork: i2 cmdwc in_fd4 PID825 [CHILD] PID848 [CHILD] PPID825 [CHILD] command index 0 [CHILD] command echo [CHILD] inherited in_fd 0 [CHILD] inherited pipefd[0] 3 (READ) [CHILD] inherited pipefd[1] 4 (WRITE) ****************************************** [CHILD] PID848: Redirect stdout: fd 4 - fd 1首先可以看到fork() 创建一个新的进程子进程拥有独立的用户态地址空间PID 848因此父子进程中的普通变量彼此独立。这里的in_fd与父进程不是一个变量但是共享一个索引以指向相同的结构体对象。第二子进程会继承父进程当时打开的文件描述符pipefd[0]pipefd[1]。虽然父子进程拥有各自独立的文件描述符表但继承得到的描述符最初指向相同的内核文件对象。第三正因为这一机制shell 才能够在 fork 后通过dup2()修改子进程的标准输入/输出将不同子进程的 stdin/stdout 分别连接到同一个或不同的管道从而构造出echo | grep | wc这样的进程通信链对于fork()后子进程对父进程的继承做以下总结第一层程序状态子进程获得父进程当时的状态副本变量 栈 堆 代码 程序计数器状态但是之后父子进程各自运行修改互不直接影响。第二层子进程继承父进程已经打开的文件描述符子进程因此可以继续操作父进程已经打开的文件、管道等资源。第三层内核对象父进程 fd 3 ──┐ ├──→ 同一个内核打开文件对象/管道 子进程 fd 3 ──┘文件描述符是独立继承的但它们可以引用同一个内核资源五、dup2()与子进程间的管道通信dup2()的核心作用是修改文件描述符的指向从而实现输入/输出重定向。Linux中程序不是直接操纵的输入输出程序实际上操纵的是文件描述符其中最重要的三个是0 → stdin 标准输入 1 → stdout 标准输出 2 → stderr 标准错误重定向就是让标准的输入的来源或者输出的目的地从终端改为文件举个小例子int fd open(output.txt, O_WRONLY | O_CREAT | O_TRUNC, 0644);打开文件后假设得到新的文件描述符fd 3现在fd 1 → 终端 fd 3 → output.txt执行 dup2(3, 1);就相当于原来 fd 1 ──→ 终端 fd 3 ──→ output.txt dup2(3, 1) 现在 fd 1 ──→ output.txt fd 3 ──→ output.txt执行printf(hello);仍然使用stdout就是 fd1但此时fd1定向到了output.txt所以最终hello不会输出到终端而是在output.txt里管道通信我们现在分析 echo hello | grep hello | wc -c 的例子以学习管道pipe1 pipe2 ┌──────────────┐ ┌──────────────┐ │ │ │ │ echo ──→│ write read ├─────→│ write read ├──→ wc │ │ │ │ └──────────────┘ └──────────────┘ ↓ grep首先echo 作为第一条程序PID 848父进程创建管道得到fd[2]与fd[3]此时fd_in初始化为标准输入fd_in 0[PARENT] Preparing command 0 [PARENT] PID825 [PARENT] Command echo [PARENT] Current in_fd 0 ---------------------------------------------------- [PARENT] pipe created for command 0 [PARENT] pipefd[0] 3 (READ) [PARENT] pipefd[1] 4 (WRITE) [PARENT] Before fork: i0 cmdecho in_fd0 PID825之后调用fork()创建子进程我们看子进程的处理与逻辑[PARENT] Before fork: i2 cmdwc in_fd4 PID825 [CHILD] PID848 [CHILD] PPID825 [CHILD] command index 0 [CHILD] command echo [CHILD] inherited in_fd 0 [CHILD] inherited pipefd[0] 3 (READ) [CHILD] inherited pipefd[1] 4 (WRITE) ****************************************** [CHILD] PID848: Redirect stdout: fd 4 - fd 1此时子进程继承了父进程的索引与内核对象两者共同维护管道子进程讲标准输出重定向到了管道的写端以写入数据而关闭读段此处日志未打印出来可以是因为已经做完标准输出的重定向了日志写进管道里了完成这些操作后子进程execvp()执行完命令后exit()退出。[PARENT] fork success: child PID848 for command echo [PARENT] PID825 still has in_fd0 [PARENT] PID825: close pipe write fd4 [PARENT] PID825: save pipe read fd3 as new in_fd [PARENT] PID825: new in_fd3来到父进程PID 825此时子进程关闭管道的读端口跟父进程无关两者是独立的所以父进程关闭了写端口用in_fd 3保留读段索引供给下一个程序读管道数据。至此第一轮循环结束。第二个命令grep hello :---------------------------------------------------- [PARENT] Preparing command 1 [PARENT] PID825 [PARENT] Command grep [PARENT] Current in_fd 3 ---------------------------------------------------- [PARENT] pipe created for command 1 [PARENT] pipefd[0] 4 (READ) [PARENT] pipefd[1] 5 (WRITE) [PARENT] Before fork: i1 cmdgrep in_fd3 PID825首先父进程创建新的管道fd[4]与fd[5]fd[3]是上一轮父进程未关闭的读端。fork()后进入子进程。[CHILD] PID849 [CHILD] PPID825 [CHILD] command index 1 [CHILD] command grep [CHILD] inherited in_fd 3 [CHILD] inherited pipefd[0] 4 (READ) [CHILD] inherited pipefd[1] 5 (WRITE) ****************************************** [CHILD] PID849: Redirect stdin: fd 3 - fd 0 [CHILD] PID849: stdin(fd 0) is now connected to fd 3 [CHILD] PID849: close original in_fd3 [CHILD] PID849: Redirect stdout: fd 5 - fd 1grep hello 的PID为849首先把此进程的标准输入重定向到第一个管道的读端这样第一个进程PID 848的数据就可以通过管道到第二个进程PID 849了重定向后in_fd 3就可以关闭了接着还是和848进程一样的操作把输出重定向到管道的写端子进程execvp()执行完命令后exit()退出。[PARENT] fork success: child PID849 for command grep [PARENT] PID825 still has in_fd3 **************** CHILD **************** [PARENT] PID825: close old in_fd3 [PARENT] PID825: old in_fd3 is now closed [PARENT] PID825: close pipe write fd5 [PARENT] PID825: save pipe read fd4 as new in_fd [PARENT] PID825: new in_fd4回到父进程依旧关闭写段保留新的读端给最后一个进程使用此时读端索引in_fd更新为4。执行到最后一个wc -c命令时由于是最后一个进程于是不用创建新的管道了**************** CHILD **************** [PARENT] fork success: child PID850 for command wc [PARENT] PID825 still has in_fd4 [PARENT] PID825: close old in_fd4 [PARENT] PID825: old in_fd4 is now closed父进程关闭旧管道的读段子进程完成重定向从上个管道接受数据后关闭读写端口execvp后exit退出**************** CHILD **************** [CHILD] PID850 [CHILD] PPID825 [CHILD] command index 2 [CHILD] command wc [CHILD] inherited in_fd 4 ****************************************** [CHILD] PID850: Redirect stdin: fd 4 - fd 0 [CHILD] PID850: stdin(fd 0) is now connected to fd 4 [CHILD] PID850: close original in_fd4 [CHILD] PID850: Ready to execvp(wc) [CHILD] PID850: stdin fd 0 [CHILD] PID850: stdout fd 1 257 [PARENT] wait(): child PID849 finished [PARENT] child PID849 exit code0 [PARENT] wait(): child PID848 finished [PARENT] child PID848 exit code0 [PARENT] wait(): child PID850 finished [PARENT] child PID850 exit code0 [PARENT] All foreground children finished.最后父进程等待结束所有子进程执行完毕后进行下一轮循环父进程 │ ├── wait() │ ↓ │ echo 结束 │ ├── wait() │ ↓ │ grep 结束 │ └── wait() ↓ wc 结束 execute_pipeline() ↓ 返回 main() ↓ while(1) ↓ 再次显示 myshell fork() │ ┌────────┴────────┐ ↓ ↓ 父进程 子进程 │ │ 管道生命周期管理 dup2() │ │ close() │ │ ↓ │ close() │ │ │ execvp() │ │ └──────┬──────────┘ ↓ wait() ↓ 下一轮命令六、总结总结这次 MiniShell 的实现表面上是在实现一个简单的命令行程序实际上让我把 Linux 中几个非常核心的机制串了起来fork()创建进程、execvp()执行程序、文件描述符管理、dup2()重定向标准输入输出以及pipe()实现进程间通信。其中最开始最容易产生误解的是fork()。子进程并不是简单地“复制一个 PID”而是从父进程当前的执行状态继续运行同时拥有自己独立的进程空间和文件描述符表。虽然父子进程中的文件描述符编号可能完全相同但它们属于不同的进程之后可以独立进行close()、dup2()等操作。在理解管道之后我进一步认识到pipe()本身只是创建了一条数据通道真正让多个程序连接起来的是文件描述符的重定向。例如echo → pipe1 → grep → pipe2 → wc父进程负责创建管道、按照顺序fork()子进程并维护下一轮需要使用的管道读端子进程则负责利用dup2()将管道连接到自己的标准输入或标准输出关闭不再需要的文件描述符最后通过execvp()替换成真正要执行的程序。这里的close()也不只是简单的“释放资源”。在管道通信中正确关闭不需要的读写端是保证程序正常工作的关键。如果某个进程错误地保留了管道写端读取端可能一直无法收到 EOF如果不关闭无用的文件描述符还可能造成文件描述符泄漏。因此管道的正确使用实际上同时包含了“建立连接”和“维护连接生命周期”两个部分。最后父进程通过wait()等待前台子进程执行结束再重新回到 Shell 的命令循环中。至此一个完整的命令执行链路就形成了读取命令 ↓ 解析命令 ↓ 创建 pipe ↓ fork ↓ 子进程 dup2 重定向 ↓ close 无用 fd ↓ execvp 执行程序 ↓ 多个进程通过 pipe 并行通信 ↓ 父进程 wait 等待 ↓ 返回 Shell 主循环通过这次实践我对 Linux 中“进程如何创建、程序如何运行、进程之间如何通信”有了一个更加具体的认识。以前看到fork()、pipe()、dup2()和exec()时它们只是几个独立的系统调用而在 MiniShell 中把它们真正组合起来之后才理解了它们之间的关系fork()负责产生参与者execvp()负责让参与者变成具体程序pipe()提供通信通道dup2()把通道接入标准输入输出close()管理通道的生命周期而wait()负责父进程对整个执行过程进行回收和同步。这也是这次 MiniShell 实践中最有价值的部分不是记住几个 API 的用法而是从一个实际程序出发把 Linux 的进程、文件描述符和进程间通信机制真正串成了一个完整的模型。记录学习、恳请大家指正

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价