---
title: "miniShell中的项目经验"
date: 2026-09-21
category: "操作系统"
tags: ["minishell", "C++", "操作系统"]
summary: "从管道出发逐步完善miniShell的过程中，对多文件协作与项目前期规划的认识也随之加深。"
series:
  name: "miniShell的实现历程"
  order: 0
---

## 引言

上一篇把一行输入解析成了命令结构体，重定向、内置命令、退出状态这些细节都各就各位，只执行一条命令时这条路走得通。但是真实使用shell时几乎不会只敲一条命令：`cat a.txt | grep hello | wc -l`要把三条命令接起来，`make && ./a.out`要根据上一条的成败决定还走不走，`;`把若干条命令串成一批，Ctrl-C还要能只中断正在跑的那一组进程而不关掉shell自己。

这些功能没有一个属于"单条命令"，它们描述的是命令与命令之间的关系。因此这一轮的改动不再是往执行流程里加分支，而是给命令之间找一种能表达优先级的结构，并把进程资源的操作放进正确的进程里：管道如何串联、信号发给谁、内置命令在哪一侧执行、输入怎么切成记号、变量在哪一步展开，以及这些东西各自该待在哪个文件里。

## 正文

shell的输入其实是一门很小的语言，处理它天然分三层：词法分析把一行字符切成记号，语法分析把记号组织成结构，执行器照着结构办事。运算符的优先级决定了结构的形状，`|`最紧，`&&`与`||`同级且左结合，`;`最松；形状一旦定对，执行就只是对这棵树的一次递归。

### 从重定向到管道：先实现多命令并行

第一版把运算符当成命令上的一个字段：解析时遇到`|`、`&&`、`||`、`;`就记下类型，然后接着解析下一条，最后得到一串命令和一串运算符。

```cpp
#include <string>
#include <vector>

struct Redirect{ std::string filename{}; };

struct Command{
    std::vector<std::string> argv{};
    std::vector<Redirect> redirections{};
    enum class Op{ _non, _and, _or, _pipe };
    Op op{};                          // 运算符挂在命令上，只说明"它后面还有一条"
};
```

这版能跑通`a ; b`，但是跑不通`a | b ; c`。平铺的列表记录了"有哪些命令、中间夹着什么符号"，唯独没有记录谁和谁先结合，而优先级要的正是这个信息。

解决办法是换成树：一个节点要么是一条命令，要么是一个运算符加上它的子节点。

```cpp
#include <memory>//make_unique(),unique_ptr
#include <string>
#include <vector>

struct Command{
    std::vector<std::string> argv{};   // 参数与重定向都已是纯数据
};

struct ASTNode{
    enum class Op{ Command, Pipe, And, Or, Sequence };
    Op op{};                           // 本节点代表的运算
    Command command{};                 // 只有叶子节点用得上
    std::vector<std::unique_ptr<ASTNode>> children{};   // 被这个运算连起来的子表达式
};
```

>把运算符挂在命令上，得到的是一串；把命令挂在运算符底下，得到的才是一棵树。

换成树之后，加一种运算等于加一个`Op`加一个`case`，不必再改动解析出来的数据结构。后来`&&`、`||`、多段管道能一次性补齐，靠的就是这一点。

### 管道：双命令到多命令

双命令管道只需要一次`pipe()`：一个子进程把标准输出接到写端，另一个把标准输入接到读端，父进程把手上的两端都关掉再`waitpid()`。多命令的第一版沿用这个思路，用四个描述符同时保存新旧两条管道，中间的每一段单独`fork()`，最后一段再单独`fork()`一次，段数不同走的代码就不同，边界上的`n-1`极易算错。

改成一条统一的循环之后，这段代码与命令条数不再有关系。

```cpp
#include <cstdlib>//_exit()
#include <unistd.h>//pipe(),dup2(),close()
#include <vector>

std::vector<int> pids{};
int pre_read{-1};                        // 上一段管道留下的读端，-1表示还没有
for (int i = 0; i < n; ++i) {
    int pipefd[2]{-1, -1};
    if (i < n - 1) { pipe(pipefd); }     // 最后一条命令之后不再需要新管道
    const int pid = fork();
    if (pid == 0) {
        if (pre_read != -1) {            // 从上一段的读端读
            dup2(pre_read, STDIN_FILENO);
            close(pre_read);
        }
        if (i < n - 1) {                 // 向这一段的写端写
            close(pipefd[0]);
            dup2(pipefd[1], STDOUT_FILENO);
            close(pipefd[1]);
        }
        execCommand(command);            // 只做程序替换
        _exit(127);                      // exec失败的标准退出码
    }
    if (pre_read != -1) { close(pre_read); }              // 父进程丢掉上一段读端
    if (i < n - 1) { close(pipefd[1]); pre_read = pipefd[0]; }   // 只留下新的读端
    pids.emplace_back(pid);              // 省略：n与command来自外层，以及fork()失败与最后的waitpid()
}
```

父进程关写端这一步不能省。***管道读端要收到EOF，必须关掉所有写端，父进程在fork()之前就持有的那一份也算***，只要还剩一份没关，`cat a.txt | grep hello`里的grep就会一直阻塞在读操作上。

管道的退出码按约定取最后一条命令的，因此父进程照`pids`的顺序`waitpid()`，最后一次写进状态字的就是末端的退出码；被信号终止的进程用`128 + WTERMSIG(status)`表示，与正常的退出码区分得开。

这里还藏着一个后来才暴露的问题：子进程里原本调用的是`executeAST()`，也就是又递归回执行器。它会在已经接好管道的子进程里再`fork()`一次并等待，最后以`EXIT_SUCCESS`退出，既多了一层没有意义的进程，又把exec失败掩盖成了成功。改成直接`execCommand()`并在失败时`_exit(127)`之后，管道的退出码才反映真实情况。

>递归地调用执行器，等于让子进程又充当一次shell；管道里的每一段都应该是"接好描述符然后被替换掉"。

### 信号：从中断子进程到父程序忽略中断

最初的想法很直接：shell自己不该被Ctrl-C杀掉，那就`signal(SIGINT, SIG_IGN)`把它忽略掉。但是信号的忽略动作会被`exec()`继承，于是`fork()`出来的子进程连同它替换成的外部程序全都对Ctrl-C无动于衷，一个`ping`只能靠另一个终端的`kill`结束。

正确的分工是：shell自己装一个处理函数，只把一个标志置位，真正的收尾留给主循环；子进程在`fork()`之后立刻恢复默认动作。

```cpp
#include <csignal>//sigaction(),sigemptyset(),signal()
#include <unistd.h>//getpid(),setpgid()

static volatile sig_atomic_t g_interrupted{0};   // 处理函数里只能写这种类型的变量

static void sigintHandler(int) { g_interrupted = 1; }   // 只置标志，不做别的事

struct sigaction sa{};
sa.sa_handler = sigintHandler;
sigemptyset(&sa.sa_mask);
sigaction(SIGINT, &sa, nullptr);                 // shell接下Ctrl-C，但不退出

if (const int pid = fork(); pid == 0) {
    setpgid(0, getpid());                        // 自成一组，才能整体被设为前台
    signal(SIGINT, SIG_DFL);                     // 撤销继承来的处理方式
    execCommand(command);
    _exit(127);
}
```

>exec()之后，被忽略的信号仍然被忽略，被捕获的信号才恢复为默认；因此在子进程里显式写`SIG_DFL`并不是多余的。

只恢复默认还不够。终端产生的Ctrl-C发给前台进程组的每一个成员，子进程与shell同组的话两者会同时收到，shell还得靠自己的处理函数硬扛。这就是**作业控制**：让每个作业自成一组，父进程也调一次`setpgid(pid, pgid)`（两处都设是为了消掉`fork()`之后的竞态），再用`tcsetpgrp()`把这一组设为终端前台，`waitpid()`返回后把前台还给shell自己的组。后台进程调`tcsetpgrp()`会收到`SIGTTOU`，所以shell要提前忽略`SIGTTOU`、`SIGTTIN`、`SIGTSTP`。

```cpp
#include <cerrno>//errno,EINTR
#include <cstdio>//perror()
#include <csignal>//SIGINT
#include <sys/wait.h>//waitpid(),WIFSIGNALED(),WTERMSIG()
#include <unistd.h>//write()

int status{};
while (waitpid(pid, &status, 0) == -1) {
    if (errno != EINTR) { perror("waitpid error"); break; }   // 被信号打断不算错误，继续等
}
if (WIFSIGNALED(status) && WTERMSIG(status) == SIGINT) {
    write(STDOUT_FILENO, "\n", 1);               // 让提示符另起一行
}
```

读输入的一侧也要配合：`getline()`失败时先看标志，是中断就清标志、输出换行、`cin.clear()`并重新打印提示符，只有真的读到EOF（Ctrl-D）才退出。

### main文件变的冗余，拆分代码为多个文件

拆分依据不是行数，而是*变化方向*：数据结构会随功能一起长，内置命令只跟自身的语义有关，解析与执行各自独立演化。按这个方向切开之后，`database.h`放公共类型（`Token`、`Command`、`ASTNode`），`buildin.cpp`放`pwd`、`cd`、`echo`，`Shell.cpp`放词法、语法、执行与进程管理，`main.cpp`只剩下创建与启动。

```cpp
// Shell.h：只放类型与对外接口
namespace miniShell{
    class Shell{
    public:
        Shell();            // 在这里装好信号处理与初始环境
        static void run();
    };
}
```

```cpp
#include "Shell.h"//Shell

int main(){
    miniShell::Shell shell{};
    shell.run();
    return 0;
}
```

只在某一个`.cpp`里用到的辅助函数不该出现在头文件里，它们要么声明成`static`，要么放进匿名命名空间，拿到内部链接。这一块正好用上了`static`与匿名命名空间那份知识：不给外部使用的东西必须有内部链接，否则同名符号在链接阶段就会撞车。

```cpp
namespace miniShell{
    namespace{                                  // 匿名命名空间：成员只在本翻译单元可见
        int exitStatus(int status);             // 只在Shell.cpp内部使用
    }
    int executeAST(const ASTNode* node);        // 需要跨文件调用的才写进头文件
}
```

切分之后还有一个更实际的收益：改解析不必碰执行，加内置命令不必动进程管理，每一处改动的影响范围是确定的。

### 添加内建命令，区分父进程和子进程命令

判断标准仍然是"这条命令改的是谁的状态"。`exit`改的是shell的退出标志，`cd`改的是shell的工作目录，`export`、`unexport`、`env`读写的是shell自己的变量表，这几条必须在shell进程里执行。而`pwd`、`echo`只是往标准输出写字，交给`fork()`出来的子进程完全没有问题，还能顺带获得重定向支持，因为应用重定向的代码本来就在子进程里。

```cpp
int singleCommand(const Command& command)
{
    if (command.argv.empty()) { return 0; }
    if (command.argv[0] == "exit" || command.argv[0] == "cd" ||
        command.argv[0] == "export" || command.argv[0] == "unexport" ||
        command.argv[0] == "env") {
        return shellFork(command);       // 改的是shell自己的状态
    }
    return execFork(command);            // 外部程序才走fork()加exec()
}
```

```cpp
#include <iostream>//cout
#include <cstdlib>//_exit(),EXIT_SUCCESS

// shellFork()内：cd留在父进程，其余内建命令在fork()出来的子进程里执行
if (command.argv[0] == "cd") {
    const int st{cd(command)};           // 子进程改不了父进程的工作目录
    path = pwd(false);                   // 提示符上的路径要跟着更新
    return st;
}
shellCommand(command);                   // 其余内建命令在子进程里先重定向再执行
cout.flush();                            // _exit()不刷新缓冲区，得先手动刷
_exit(EXIT_SUCCESS);
```

>子进程改不了父进程的工作目录和变量表，这是fork()的地址空间隔离决定的，与命令的名字无关。

另外子进程收尾要用`_exit()`而不是`exit()`，前者不会重复刷新从父进程继承来的缓冲区，代价是`iostream`的缓冲区要自己刷。

### 对输入进行详细解析，区分运算符和引号

用`stringstream`按空白切词有两个硬伤：`cat<input.txt`会被切成一整块，运算符和文件名粘在一起；`echo "hello world"`会被切成三个记号，引号语义完全丢失。改成逐字符扫描之后，这两件事都能在词法阶段解决：遇到运算符字符先把已累积的记号落盘，再向前看一个字符判断是不是双字符运算符，然后跳过空白并检查后面还有没有内容。

```cpp
#include <cctype>//isspace()
#include <iostream>//cout
#include <string>

case '>': {                                    // 向前看一个字符，区分>与>>
    std::string op{">"};
    auto t{c};
    if (++t != line.end() && *t == '>') { op.push_back(*t); ++c; }
    while (++c != line.end() && isspace(*c)) {}   // 运算符后面的空格可以省略
    if (c == line.end()) {
        cout << "bash: syntax error, no command after op >\n";
        return {};
    }
    tokens.push_back(op);
    break;
}
```

引号则一直读到配对的那个引号为止，中间的内容整体作为一个记号，引号本身不进记号流。这样`export prompt ">>> "`里的空格才不会被当成分隔符。

```cpp
case '\'': {                                   // 引号内的内容整体成一个记号
    while (++c != line.end() && *c != '\'') { token.push_back(*c); }
    if (c == line.end()) {
        cout << "bash: syntax error, no end op '\n";
        return {};
    }
    tokens.push_back(token);
    token.clear();
    continue;                                  // 引号本身不进记号流
}
```

>词法阶段能查的只有"符号后面还有没有内容"和"引号有没有闭合"，运算符出现得合不合法属于语法阶段的事。

### 添加内置环境变量功能，miniShell基本完成

变量表就是shell进程里的一个`unordered_map<string,string>`，`export`、`unexport`、`env`三个内置命令直接改它，所以它们和`cd`一样必须留在父进程里执行。

**变量展开**安排在词法之后、建树之前：扫描记号流，把以`$`开头的整词替换成对应的值。展开之后得到的记号流对语法分析来说和普通输入没有区别，于是变量可以出现在命令名、参数、重定向文件名的任意位置，`$cmd`甚至能当成一条命令执行。

```cpp
#include <string>
#include <unordered_map>
#include <vector>

std::unordered_map<std::string, std::string> env{};   // shell自己维护的变量表

for (auto& token : tokens) {                  // 展开在建树之前完成
    if (!token.empty() && token[0] == '$') {
        if (std::string key{token.substr(1)}; env.contains(key)) { token = env.at(key); }
    }
}
```

`prompt`是一个有默认值的特殊变量。读取它时要先用`contains()`判断，直接用`operator[]`会在键不存在时插入一个空值，把"未定义"变成"定义为空"。

```cpp
const std::string defaultPrompt{"$miniShell> "};

const std::string& prompt()
{
    if (env.contains("prompt")) { return env.at("prompt"); }   // 先判存在，避免插入空值
    return defaultPrompt;                                      // unexport之后回退到默认值
}
```

>这张变量表由shell自己维护，与进程的`environ`是两回事，不会自动传给子进程，这点目前还是项目的限制之一。

## 小结

这一轮改动指向同一条原则：先把结构定下来，再往上填功能。命令结构体把解析的结果变成数据，ASTNode把命令之间的关系也变成数据，于是管道、`&&`、`||`、`;`、信号与变量展开都只是在同一套结构上再加一个分支，而不是再改一遍执行流程。

回头看，我们对软件工程的理解正是在这次实现miniShell的过程中加深的。我们学会了多文件协作，并在不断修改代码逻辑的过程中逐渐理解到，一个好的代码项目的实现，必须要在前期对项目整体有一个清晰的认识，并提前规划好项目的框架和实现逻辑。

提前对项目做良好规划可以说是项目中最重要的一个环节了。如果前期没有规划好，就会像我们遇到的情况一样，想要添加新功能却发现当前项目的框架对新功能的支持性不是很好，导致必须修改项目整体的实现逻辑。尽管部分代码可重用，但是重构项目意味着改变大部分函数的接口、实现顺序和内部逻辑。这在minishell这样的小项目中就已经是非常困难的一件事了，更不必说在更大型的项目中，这也许就是屎山代码的来源。

想要做一个优秀项目，必须要对做的目标有一个明确的方向，知道哪里需要什么功能，哪里的实现又需要什么数据结构，哪里添加功能比较困难之类的。

这一块的知识在课本上是学不到的，只有多做项目，在项目的实现过程中逐渐学习和积累经验，才会真正具有这种能力，成为一名工程师而不是普通的程序员。

>插句题外话，在当今ai越来越发达的情况下，基础的代码编写部分已经不需要太过于操心，但是顶部的项目设计构建的重要性却越来越大。我们认为这也是新的计算机从业者的首要能力，是需要重点培养的方向。

一个最优的实践是，动手之前先把数据结构与执行次序定下来，让后来每一个功能都只是往结构上加字段和分支，除非该功能确实要求推翻这套次序本身。
