---
title: "miniShell初步实现的问题与解决方法"
date: 2026-09-16
category: "操作系统"
tags: ["minishell", "C++", "操作系统"]
summary: "对miniShell从单命令执行到重定向实现过程中采取的策略、暴露的缺陷与对应改进方法的总结"
series:
  name: "miniShell的实现历程"
  order: 0
---

## 引言

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

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

## 正文

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

### 第一版：一行输入一个进程

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

```cpp
#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()失败后必须立刻结束子进程。

```cpp
#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()`。

```cpp
#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()这条路。

```cpp
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()`。这就是*解释器与执行器分离*：前者只产生数据结构，后者只读数据结构，两边不再互相传递中间状态。

```cpp
#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变量取地址，并用宏解析状态字。

```cpp
#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`，每一项记录重定向的模式和文件名，解析时收集，执行时统一应用。

```cpp
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()造成越界访问。

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

```cpp
#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()的次序执行，并让内置命令在这一整条流程之外单独处理，除非某条内置命令确实需要独立的进程环境。
