引言

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

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

正文

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

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

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

#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。平铺的列表记录了"有哪些命令、中间夹着什么符号",唯独没有记录谁和谁先结合,而优先级要的正是这个信息。

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

#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极易算错。

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

#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()之后立刻恢复默认动作。

#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。

#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只剩下创建与启动。

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

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

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

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()出来的子进程完全没有问题,还能顺带获得重定向支持,因为应用重定向的代码本来就在子进程里。

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()
}
#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"会被切成三个记号,引号语义完全丢失。改成逐字符扫描之后,这两件事都能在词法阶段解决:遇到运算符字符先把已累积的记号落盘,再向前看一个字符判断是不是双字符运算符,然后跳过空白并检查后面还有没有内容。

#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 ">>> "里的空格才不会被当成分隔符。

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甚至能当成一条命令执行。

#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[]会在键不存在时插入一个空值,把"未定义"变成"定义为空"。

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越来越发达的情况下,基础的代码编写部分已经不需要太过于操心,但是顶部的项目设计构建的重要性却越来越大。我们认为这也是新的计算机从业者的首要能力,是需要重点培养的方向。

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