miniShell初步实现的问题与解决方法
引言
学会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()的次序执行,并让内置命令在这一整条流程之外单独处理,除非某条内置命令确实需要独立的进程环境。
$ 正在加载评论…