引言

学会fork()、exec()、wait()这几个系统调用之后,写一个自己的shell看起来只是把它们按顺序拼起来:读一行输入,fork()出子进程,execvp()替换成目标程序,父进程wait()回收。只执行不带参数的单条命令时这条路走得通,一旦要支持参数、内置命令、多条命令和重定向,进程边界和文件描述符的细节就一处接一处地暴露出来。

根源在于我们把两类东西混在了同一段代码里:属于shell自身的状态(提示符、解析出的命令、退出标志)和属于某个进程的资源(地址空间、文件描述符表)。本文按miniShell的四次结构调整,说明我们采取的策略、它暴露出的缺点,以及相应的改进方法。

正文

shell的骨架是一个读、解析、执行的循环(REPL,read-eval-print loop)。这个循环里有两条不能违反的规则:内置命令要改变shell自身的状态,必须在shell进程里执行;外部程序要替换掉整个地址空间,必须在子进程里exec()。剩下的所有工作,包括重定向和管道,都是在给这两条规则安排次序。

第一版:一行输入一个进程

第一版的策略最直接:主循环读一个单词,交给一个函数,函数内部fork()后在子进程里execvp()。

#include <cstdio>//perror()
#include <string>
#include <unistd.h>//fork(),execvp()
#include <sys/wait.h>//wait()

static void call(const std::string &input)
{
    if (const int rc = fork(); rc < 0) {
        perror("fork error");
    }
    else if (rc == 0) {
        execvp(input.c_str(), nullptr);   // 参数向量为空,execvp拿不到argv[0]
        perror("execvp error");           // 失败后没有结束子进程,它会继续往下执行
    }
    else {
        wait(nullptr);
    }
}

这里有三个问题。第一,cin >> input按空白切断,输入ls -l只会读到ls,参数根本进不来。第二,execvp()的第二个参数是char *const argv[],必须以NULL结尾,传nullptr等于给它一个空的参数向量。第三,也是最严重的一个:execvp()只有失败时才会返回,失败后子进程仍然活着,会一路执行到wait(nullptr),甚至回到主循环继续fork(),一个失败的外部命令就能让进程树失控。

改进的方法是把读输入、分词和收尾分开处理:用getline()读整行,用stringstream分词,用strdup()把string转成execvp()需要的char *,最后补上一个NULL结束标记;execvp()失败后必须立刻结束子进程。

#include <cstdlib>//exit()
#include <cstring>//strdup()
#include <sstream>//stringstream
#include <string>
#include <vector>
#include <unistd.h>//fork(),execvp()
#include <sys/wait.h>//wait()

static std::vector<char *> split(const std::string &input)
{
    std::vector<char *> tokens{};
    std::stringstream ss(input);
    std::string word;
    while (ss >> word) {
        tokens.push_back(strdup(word.c_str()));   // execvp只认char*,string不能直接传
    }
    tokens.push_back(nullptr);                    // 参数向量必须以NULL结尾
    return tokens;
}

static void call(const std::string &input)
{
    if (const int rc = fork(); rc < 0) {
        perror("fork error");
    }
    else if (rc == 0) {
        std::vector<char *> argv = split(input);
        execvp(argv[0], argv.data());
        perror("execvp error");
        exit(EXIT_FAILURE);        // 结束子进程,否则它会返回去执行父进程的分支
    }
    else {
        wait(nullptr);
    }
}

exec()调用成功后不返回,返回就说明失败了;因此exec()之后的那几行代码,是专门给失败路径写的。

单例类:把shell的状态收进对象

第二版的策略是把散落在main()里的状态集中到一个对象里:提示符、当前路径、输入、命令、退出标志都成为成员,REPL循环变成三个成员方法的调用。为了保证整个进程只有一个shell对象,构造函数设为私有,只暴露一个返回静态局部对象的init()。

#include <string>
#include <utility>//std::move()

namespace{                                  // 匿名命名空间:整个类只在main.cpp内可见
    class miniShell{
    public:
        static miniShell &init(std::string path){
            static miniShell shell{};       // 静态局部对象:首次调用时构造,进程内唯一
            shell.m_path = std::move(path);
            return shell;
        }
        void run();
        miniShell(const miniShell &) = delete;
        miniShell &operator=(const miniShell &) = delete;
    private:
        miniShell() = default;              // 构造私有:只能经init()拿到实例
        std::string m_path{"/"}, m_prompt{"$miniShell> "}, m_input{};
        bool m_exit{false};
    };
}

这一版带来的最大收益是内置命令终于有了落脚点。exit必须改变shell自己的状态,而fork()出来的子进程调用exit()只能结束它自己,shell会若无其事地继续打印提示符;因此内置命令要在父进程里先判断、直接返回,外部程序才走fork()加exec()这条路。

void miniShell::executeCommand(const std::vector<std::string> &argv)
{
    if (argv.empty()) { return; }          // 空输入不该fork出一个空转的子进程
    if (argv[0] == "exit") {               // 内置命令:改的是shell自身的状态
        m_exit = true;
        return;
    }
    execCommand(argv);                     // 外部程序:才需要fork()加exec()
}

内置命令与外部命令的分界线是"这条命令要不要改变shell进程自己",而不是命令名字长什么样。

这一版的缺点同样明显。所有错误都交给一个occurError()处理,而它在perror()之后直接exit(-1),于是fork()失败一次,整个shell就退出了,正确的做法是只报告错误并返回主循环,让shell继续可用。另外等待子进程时写成了wait(&rc),把fork()的返回值变量当成状态输出位置复用:wait()写进去的是编码过的状态字,要用WIFEXITED()和WEXITSTATUS()解析,直接读出来是一个毫无意义的高位数值。改进的方法是把状态变量独立出来,并且只在真正需要时才回收。

命令结构体:解释器与执行器分离

第三版的策略是引入一个命令结构体,把"解析的结果"变成数据,同时把流程切成解释器与执行器两半:tokenize()负责把一行输入切成记号,parser()负责把记号填进Command,executeCommands()只管遍历并交给runCommand()。这就是解释器与执行器分离:前者只产生数据结构,后者只读数据结构,两边不再互相传递中间状态。

#include <sstream>//stringstream
#include <string>
#include <vector>

struct Command{
    std::vector<std::string> argv{};      // 参数先用string保存,exec前再转成char*
};

static std::vector<std::string> tokenize(const std::string &line)
{
    std::vector<std::string> tokens{};
    std::stringstream ss(line);
    std::string token;
    while (ss >> token) { tokens.push_back(token); }   // 目前只按空白分词,不认引号
    return tokens;
}

这一版修掉了一个内存上的老问题。前几版在解析阶段就strdup(),参数向量里全是裸指针,一旦解析出的命令没有被执行(比如遇到空输入),这些内存就再也没人释放。把参数改成string保存,只在exec()之前一次性转换,进程马上就要被替换掉了,转换出来的指针不需要长期存活,exec()失败时再逐个free()后退出即可。

剩下的缺点集中在等待与退出状态上。等待子进程时我们写的是野指针:int *status{}是一个空指针,waitpid()会往这个地址写状态字,随后还要解引用它当作返回值,这是彻底的未定义行为。改进的方法是用一个真实的int变量取地址,并用宏解析状态字。

#include <sys/wait.h>//waitpid(),WIFEXITED(),WEXITSTATUS()

int status{0};                                     // waitpid需要一块真实内存写状态
if (waitpid(rc, &status, 0) < 0) { perror("wait error"); return -1; }
if (WIFEXITED(status)) { std::cout << WEXITSTATUS(status) << '\n'; }  // 退出码被编码在状态里

另外tokenize()只按空白切分,echo "hello world"会被切成三个记号,引号语义和带空格的参数都还不支持,这是解析层下一步必须补的债。

重定向:在fork()与exec()之间安排描述符

第四版的策略是把重定向也变成数据:在Command里放一组Redirect,每一项记录重定向的模式和文件名,解析时收集,执行时统一应用。

struct Redirect{
    enum class Mode{ input, output, append };
    Mode mode{};
    std::string filename{};
};

struct Command{
    std::vector<std::string> argv{};
    std::vector<Redirect> redirections{};
};

解析阶段先收集参数,遇到重定向符号就转入重定向解析,并且必须检查符号后面还有没有文件名,否则cat <这种输入会让迭代器越过end()造成越界访问。

#include <iterator>//std::next()

for (auto it = tokens.begin(); it != tokens.end(); ++it) {
    if (*it == "<" || *it == ">" || *it == ">>") {
        if (std::next(it) == tokens.end()) {       // 边界检查:符号后面必须有文件名
            perror("shell: syntax error near unexpected token `newline'");
            return;
        }
        Redirect red{};
        red.filename = *std::next(it);             // 先取文件名再前进,不越界解引用
        ++it;
        command.redirections.push_back(std::move(red));
    }
    else { command.argv.push_back(*it); }
}

应用重定向的代码全部位于子进程。三种模式的区别只在打开方式和复制的目标描述符:

  • 输入<:以O_RDONLY打开,dup2()到STDIN_FILENO。
  • 输出>:以O_WRONLY | O_CREAT | O_TRUNC打开,dup2()到STDOUT_FILENO。
  • 追加>>:以O_WRONLY | O_CREAT | O_APPEND打开,dup2()到STDOUT_FILENO。
#include <cstring>//strdup()
#include <fcntl.h>//open()
#include <cstdlib>//_exit(),free()
#include <unistd.h>//dup2(),close(),execvp()
#include <vector>

void execCommand(const Command &command)
{
    for (const auto &red: command.redirections) {
        int oflags{O_WRONLY | O_CREAT | O_TRUNC};   // 默认按覆盖输出打开
        int fd2{STDOUT_FILENO};
        if (red.mode == Redirect::Mode::append) { oflags = O_WRONLY | O_CREAT | O_APPEND; }
        if (red.mode == Redirect::Mode::input) { oflags = O_RDONLY; fd2 = STDIN_FILENO; }
        const int fd = open(red.filename.c_str(), oflags, 0644);   // O_CREAT必须带权限位
        if (fd == -1) { perror("open error"); _exit(EXIT_FAILURE); }
        if (dup2(fd, fd2) == -1) { perror("dup2 error"); _exit(EXIT_FAILURE); }
        if (fd != fd2) { close(fd); }               // 复制之后原来的描述符就是多余的
    }
    std::vector<char *> argv{};                     // exec前才把string转换成char*
    for (const auto &arg: command.argv) { argv.push_back(strdup(arg.c_str())); }
    argv.push_back(nullptr);                        // 参数向量必须以NULL结尾
    execvp(argv[0], argv.data());
    perror("execvp error");
    for (const auto &arg: argv) { free(arg); }      // 只有失败才会走到这里,先释放再退出
    _exit(EXIT_FAILURE);                            // 子进程用_exit,不重复刷新继承来的缓冲区
}

重定向改的是调用进程自己的描述符表,父进程的0和1要留给下一个提示符,所以只能安排在fork()之后、exec()之前。

这里用dup2()显式指定目标描述符,而不是先close(1)再靠"open()总是返回最小可用描述符"这条规则间接生效:后者依赖分配细节,一旦中间多开一个文件就会悄悄失效。一切改变进程资源的操作,都只能放在 fork 之后、exec 之前的子进程里完成,重定向、管道连接、程序替换莫不如此。

这一版剩下的缺点有三个。多个同类重定向按顺序应用,后一个会覆盖前一个,echo hi > a.txt > b.txt最终只写进b.txt;重定向符号之后的参数不再被收集,echo > out.txt hello里的hello会丢失;权限位用的是S_IRWXU这类只给属主的掩码,实际应该按0644并交给umask处理。改进的方法是把重定向解析成一次遍历就能完成的完整语法结构,让参数与重定向可以在任意位置交错出现,并为将来的管道和&&、||留下统一的表达式节点。

小结

四版改动指向同一个方向:把shell的状态收进对象,把解析的结果做成数据,把进程资源的操作全部推迟到fork()之后的子进程里。命令结构体让解释器与执行器不再共享中间状态,也让管道和逻辑运算符只是在同一个结构上再加字段,而不是再改一遍执行流程。

一个最优的实践是,先解析出与执行无关的命令结构,再按fork()、安排描述符、exec()、waitpid()的次序执行,并让内置命令在这一整条流程之外单独处理,除非某条内置命令确实需要独立的进程环境。