Shell 程序是操作系统提供的一个命令行工具,它直接承担了用户与操作系统的交互工作。一般来说,一个 Shell 程序会使用其特有的脚本语言,并通过一个命令解释器解释执行给出的命令语句,进而达到控制整台计算机的目的。
常见的命令行解释器包括 Linux Bash、Windows 的命令提示符和 PowerShell 等。
编写一个 Shell 程序大概是操作系统课程中的一项必备实验,这里仅介绍如何利用 C 语言编写一个最简单的 Shell 程序,并在文末附上 GitHub 仓库。
由于程序源码文件被分为了不同模块的几个文件,因此实际使用时需要配合 make 工具才能编译。
本文中,所有伪代码都以类似 Python 语法、但更接近 C 语言函数调用的形式给出
通过介绍可以得知,Shell 程序的主要功能是接受用户的指令,经过合法性检查后由解释器调用操作系统提供的系统调用,最后再提交给内核执行。
此外,当用户正常使用 terminal 时,shell 程序还会在每个输入行开头打印出当前用户的信息:用户名、主机名和当前工作目录等。对于 Bash 而言,这串提示符的格式是「用户名@主机名:工作目录$ 」。
在处理用户指令时,Shell 程序会将不同类别的指令划分为外部指令与内部指令。
内部指令是由 Shell 本身提供的命令,这些命令实际上是 Shell 程序的一部分,不需要启动新的进程来执行。例如切换当前目录 cd、打印输出 echo 等。外部指令就是需要调用可执行文件执行的命令。
用户并不会关心输入的某条指令到底是内部还是外部的,并且无论是内部还是外部指令执行起来都几乎没有差别。但对于 Shell 来说,当用户需要执行一条命令时,它会先检查其是否是内部指令;如果不是,再去环境变量中寻找对应的可执行文件。如果都失败了,Shell 需要返回错误,提示没找到这条命令。
综上,不难得知若希望编写得到一个简单的 Shell 程序,我们需要实现以下几个基本功能:
我们很容易就能想到,一个 Shell 程序的工作流程应该是这样的:
为了持续监听用户输入,这个工作流程应当是一个头尾相连的死循环,否则在接受一次用户输入后整个 Shell 程序就会退出。故我们可以得到一个伪码框架,如下:
while True:
print_prompt()
listen_command()
if command_parse() is vaild:
command_process()
else:
throw_error()
这个框架只是在整体上看来比较完善,但是很显然还是过于抽象了一点,它甚至没有提示我们如何组织并管理 Shell 运行时所必须的数据结构。
从 Bash 命令行中,命令提示符总与当前登录用户相关可以想到,在我们的 mini Shell 程序中同样需要一个类似「类对象」的实体承载着所有的用户数据;此外,每次调用一个 Shell 程序时,命令提示符总是会独立地记录着不同 Shell 程序的当前工作目录,对于 Shell 本身我们也同样需要一个「对象」实体以保存一些环境信息。
恰好,C 中的结构体 struct 可以帮助我们实现上述目的,显然我们的构想并没有遇上什么阻碍;因此在稍加考虑后,我们便可以得到下面这个相对「完善」一点的伪码框架:
User usr = new User()
Command cmd = new Command()
while True:
usr.print_prompt()
cmd.listening()
if cmd.parse() is vaild:
cmd.process()
else:
throw_error()
usr.update_prompt()
由于当初编写程序时的时间不够充裕,因此整个 Shell 程序只使用到了标准 C 库和系统 C 库中提供的函数,所以一些常见的功能,如字符着色、行编辑等是缺失的。有兴趣的读者可以安装并调用第三方库如 readline 以补足包括但不限于上述提及的功能。
最终的 Shell 程序中,所有输入请求都被设计为由一个函数负责响应;但是输出函数调用则没有如此设计。
前文提及,考虑到降低编写程序时的难度,我们会将两类数据:用户数据和 Shell 运行时环境数据抽象并封装为两个类。前者参照 Linux 中 passwd 结构体的实现,简化掉一些无关数据项,可以得到如下的设计:
typedef struct UserInfoManager_t {
int nameLimit; // 用户名上限,这里指定为与 Linux 相同的 32 个字符
long uid; // 用户 id,这里可以随便设计
CmdType lastCmdType; // 记录上一条指令的类型,默认为 nil
char *cwd, *hostName, *homeWd; // 工作目录、用户名/主机名、家目录名称
ArgsMgr cmdMgr; // 从简化设计上考虑,才把这个东西放在这里
/* 实际上不该由 Shell 程序管理用户对象 */
void (*Destructor)(struct UserInfoManager_t* const);
struct UserInfoManager_t* (*Login)(struct UserInfoManager_t* const);
void (*Update)(struct UserInfoManager_t* const);
} UserMgr;
考虑到 Shell 需要处理传入的命令串,所以后者的设计如下:
typedef struct CommandManager_t {
// private:
int _readLimit; // 只记录非空字符的数量
int _commandsLen; // 同上
char *_originalCmd; // 存储着输入的原始字符串,单独存储是考虑到后续可能存在要用的情况
char *_commands; // 存储经过清洗和预处理后的字符串
// public:
int argc; // 从命令中得到的 token 数量
char **args; // 存储所有的 tokens
void (*Destructor)(struct CommandManager_t* const);
struct CommandManager_t* (*Scanner)(struct CommandManager_t* const, FILE *, const char*);
struct CommandManager_t* (*Paraser)(struct CommandManager_t* const, const char*);
CmdType (*Processor)(struct CommandManager_t* const);
} ArgsMgr;
由于当时写的比较粗糙,并且开发环境比较充裕,所以这里没有在乎一些内存对齐的问题。
从上述代码可以看出,我们这里采用了一种近似「面向对象」的方式组织数据成员,并且显而易见的是,对于出现在面向对象语言中的「类方法」,这里使用了函数指针成员加以模拟。
上述代码中的「类」还需要构造函数以初始化所有的成员,当然这个东西写起来并不困难,照着结构体成员挨个赋值就行了:
/* 位于 UserInfo.c 中 */
UserMgr* CreateUserMgr(UserMgr* const this, int readLimit)
{ // 反正横竖都是用 gcc 编译,这里参数名称直接使用 this 以符合使用惯例
if (readLimit <= 0) {
// TODO: 这里显然是一个错误情况,需要一些函数用于处理
exit(CONSTR_ERROR); // 构造函数错误
}
if (this == NULL) // 保证始终都能返回一个有效的对象实例,刻意制造的内存泄漏不在考虑范围内
return CreateUserMgr((UserMgr*)malloc(sizeof(UserMgr)), readLimit);
this->lastCmdType = nil;
this->nameLimit = 32; // same as linux
this->homeWd = this->cwd = NULL;
this->hostName = (char*)calloc(this->nameLimit+1, sizeof(char));
CreateArgsMgr(&this->cmdMgr, readLimit); // 这个东西放进来就是为了这点方便
// TODO: 在后续函数完成后需要补充函数指针的赋值
return this;
}
/* 位于 CommandManager.c 中 */
ArgsMgr* CreateArgsMgr(ArgsMgr* const this, int readLimit)
{ // constructor
if (readLimit <= 0) {
// TODO: 这里也待补充
exit(CONSTR_ERROR);
}
if (this == NULL) // 保证始终都能返回一个有效的对象实例,刻意制造的内存泄漏不在考虑范围内
return CreateArgsMgr((ArgsMgr*)malloc(sizeof(ArgsMgr)), readLimit);
this->_readLimit = readLimit;
this->_commands = (char*)calloc(readLimit+1, sizeof(char));
this->_originalCmd = (char*)calloc(readLimit+1, sizeof(char));
this->argc = this->_commandsLen = 0;
this->args = NULL;
// TODO: 在后续函数完成后需要补充函数指针的赋值
return this;
}
其余函数定义均可参照文末给出的 GitHub 链接内的源码文件。
为了接近我们先前分析得到的伪码框架,我们需要将所有必要的、且功能单一的部分统统封装为一个可供调用的函数,并将不同功能之间的协调运作转换为函数之间互相的调用与返回(其实这部分有点像是一个状态机)。同时我们需要考虑到命令提示符在实际实现中,需要被存储在一个字符数组中,并且需要被周期性地更新;所以最终我们能够得到的 main 函数就已经相当接近最初的伪码框架:
int main(int argc, char **argv)
{
int commandLimit = 200; // 每条指令的最大字符数,包括空白字符
UserMgr mgr; CreateUserMgr(&mgr, commandLimit);
const char *format = "%s@%x:%s$ "; // 提示符的格式串形式
int cwdLen = strlen(mgr.cwd), promptLen = cwdLen;
char *prompt = (char*)malloc(sizeof(char)*(promptLen+1));
sprintf(prompt, format, mgr.hostName, mgr.uid, mgr.cwd); // splicing prompt
while (1) {
/* 我们可以脱离实现提前写出一个大概的代码框架,并在后续实现时返回修改此处的函数调用 */
mgr.cmdMgr.Scanner(&mgr.cmdMgr, stdin, prompt)->Paraser(&mgr.cmdMgr, " ");
mgr.lastCmdType = mgr.cmdMgr.Processor(&mgr.cmdMgr); // 命令串必然需要被分为不同的类别
if (mgr.lastCmdType == buildinCd) {
mgr.Update(&mgr);
cwdLen = strlen(mgr.cwd);
/* 这里要保证存储提示符的数组始终能够存下变更后的工作目录 */
if (promptLen < cwdLen) {
/* 如果存储不下这里需要扩容 */
promptLen = cwdLen*2;
free(prompt);
prompt = (char*)calloc(promptLen+1, sizeof(char));
}
sprintf(prompt, format, mgr.hostName, mgr.uid, mgr.cwd); // splicing prompt
}
}
free(prompt);
mgr.Destructor(&mgr);
return 0;
}
此时我们的 Shell 程序只是空有一个 main 函数的架子,实际上编译运行后是没有任何内容输出的,这是因为我们还没有准备我们的命令提示符。
我们已经知道,命令提示符可以被总结为一个简单的格式串:「用户名@主机名:工作目录$ 」,其中末尾的特殊字符 $ 通常用来区分用户权限。为了能够获取 Shell 运行时的工作目录、并将这个目录拼凑为提示符输出,我们需要以下几个函数:
GetCwd(); // 获取当前工作目录
GetHostName(); // 获取主机名
GetID(); // 得到用户的 uid 或是其他身份信息
GetCwd 函数会在后续功能的支持中通过封装系统调用的方式实现。
由于我们编写的 Shell 程序是运行在 Bash 上的,所以这里的主机名、用户 uid 之类的可以手动输入,或者直接读取 /etc/ 目录下的 hostname 文件获取真正的主机名。
当然只得到这些东西并不能真的让我们的提示符看起来像真的,我们还需要做一个额外的工作:将出现在提示符中的家目录的名称用「~」替换,就是将类似于「/home」的结构替换为「/~」;这里需要有一个模式匹配函数用于找出并替换家目录串。我们不能每次需要一个提示符时都手写一堆代码再原地处理出来一个,所以我们还需要将这部分功能封装为一个新的函数:
/* MatchSubstr 可以直接使用 glibc 中的 strstr,标准库提供的算法性能显然会优于我们手写 */
int MatchSubstr(const char* mainstr, const char* substr, int pos);
void AdjustDir(char* wd, const char* homeWd);
根据我们的需求,AdjustDir
函数的目的是用「~」字符取代目录串中的家目录名,显然这个函数需要两个字符串参数 wd 和 homeWd;为了替换一个字符串,我们还需要在 AdjustDir
中调用 MatchSubstr
函数,使之在 wd 串中尝试匹配 homeWd,并且一旦成功就马上把主串中的目标串删掉,并替换为字符「~」,再重新整理为我们想要的格式。整个处理过程类似于「匹配 -> 检测是否成功 -> 误匹 -> 重新匹配 -> 直到成功匹配或彻底失匹」。
过程有点复杂,不过我们可以利用伪码框架帮我们搞清楚需要写些什么:
AdjustDir(wd, homeWd)
mainLen = strlen(wd)
subLen = strlen(homeWd)
pos = 0
exitFlag = FALSE
do: // 这个循环至少都需要运行一次
offset = MatchSubstr(wd, homeWd, pos) // 尝试在主串中匹配出现的第一个子串
if wd[offset : subLen] is not homeWd:
pos = offset + subLen // 函数被设计为返回起始地址,所以要跳过一整块子串
continue
else:
/* 这一块表示把家目录名称用字符 「~」 取代
* 并且字符 「~」 必须出现在字符串的最前端 */
newList = ["~"]
wd = newList + wd[offset:]
exitFlag = TRUE
if offset == -1:
break
while (exitFlag == FALSE)
现在看起来清晰多了,于是基于这个框架我们很快就能完善并得到以下代码:
void AdjustDir(char* wd, const char* homeWd)
{ // wd 字符串由后续实现的 GetCwd() 函数获得,以下注释基于需要取代的家目录名称为 home
const char sep = '/'; // linux 的文件分隔符
int mainLen = strlen(wd), subLen = strlen(homeWd);
int pos = 0, exitFlag = FALSE;
do {
int offset = MatchSubstr(wd, homeWd, pos);
if (offset-1>=0 && wd[offset-1]!=sep) {
/* 这里表示匹配到一些类似 'byhome/' 的结构
* 这并不是我们想要的 '/home/' 结构,需要重新匹配 */
pos = offset+subLen;
continue;
} /* 下文的 BLANK 是一个宏,被定义为 #define BLANK '\0' */
if (wd[offset+subLen]==BLANK || wd[offset+subLen]==sep) {
/* 这里匹配成功,然后立刻将家目录名 'home' 变成空字符串 */
for (int i=0; i<subLen; i++)
wd[offset+i] = BLANK;
wd[0] = '~';
/* 然后把空字符串后面的字符往前搬 */
offset += subLen;
strcpy(wd+1, wd+offset);
for (offset=mainLen-1; wd[offset]!=BLANK; offset--);
memset(wd+offset, BLANK, sizeof(char)*(mainLen-offset));
/* 为了避免在串中的非正常位置遗留一些非空字符,把有效字符后面的字符全部置零 */
exitFlag = TRUE;
}
else if (offset == -1)
exitFlag = TRUE;
} while (exitFlag == FALSE);
}
由于这时候 Shell 的功能还不全,所以我们需要手动创建一个新的文件夹,然后把我们的 Shell 程序放进去运行,这样才能检测命令提示符的功能:
由于这时的 Shell 功能实在太少了,所以无论是用户名、uid 都是由我们手动在 main 函数中显式赋值指定的。因此接下来我们就需要着手改变这个情况,让 Shell 能够根据用户输入自己为这些数据赋值。
通常来说,一个完整的 Shell 程序会拥有一套功能相对强大的脚本语言,并且在处理用户指令时还会有多个指令解析阶段,如:词法分析、语法分析和语义分析。虽然我们可以借助 YACC 等工具帮我们编写一个属于我们自己的 Shell 程序的语法分析器,但显然这个工作所需要的精力与背景知识不在本文介绍的范围内,故对于我们将要得到的这个简单的 Shell 程序而言,仅使用简单的字符串处理替代并实现了上述几个过程。
由于我们没有完整的词法分析器帮我们实现命令的 token 识别,因此对于任何用户输入的字符串,我们需要有一系列的字符串处理函数帮我们处理:字符串存入、字符串清洗、token 的识别与拆解三个步骤。当然这并不困难,只是很不巧的是 C 并没有提供一个 string 类型为我们做这些事。
作为一个需要接收用户输入的程序,我们显然不能完全信任任何传入的字符串;因为用户可能将任何东西传输给我们的程序。考虑到空格作为空白字符是没有实际语义的,所以实现时我们可以基于空白字符来处理我们的输入串。
类似 Python 中清洗字符串的函数一样,我们也需要有一个自己的 StrTrim
函数用于清除字符串首尾的空白字符,同时为了避免到处使用 fgets()
搞得函数逻辑混乱不堪,我们还需要把获取输入的函数封装并单独包装进一个 GetInput
函数中;而且注意到:命令提示符的输出通常在等待用户输入时,因此我们还需要将输出提示符的任务交给 GetInput
函数。
/* 用于清洗传入的字符串,即将串中的首尾空格全部丢掉 */
char* StrTrim(char* const str, const size_t terminus);
/* 响应全局输入请求,同时负责参数 prompt 非空的情况下输出命令提示符 */
void GetInput(FILE* src, char* acceptInput, int inputLimit, const char* prompt);
被实现的函数
StrTrim
中,参数 terminus 实质上是一个「超尾偏移量」,这个参数与指针 str 共同构成了一个需要被处理的「左闭右开」区间。
这两个函数的实现都不困难,唯一需要注意的是在 GetInput
函数中,每次获取输入前都需要使用 fflush
或其他任何你喜欢的方式刷新输入缓冲区,以避免潜在的字符串污染。
顺便一提,实现时大多数 IO 函数都使用的是与文件交互的几个函数:
fget
、fputs
等,这只是因为可以通过 FILE 指针简单的重定向程序的输出位置,并且这几个函数能够要求提供最大读取的字符数。
另外,为了便于我们调试,同时也是为了填补上前文提及的「用户对象」中 uid 的数据需求,这里我们插入一个 UserLogin
函数;每次启动 Shell 时都仅会执行一次这个函数,并且这个函数会输出一个字符串「Login as: 」以获取用户 id,这也能提示我们 Shell 程序已经启动。
现在我们将上述两个函数实现并插入程序后,编译运行查看一下 Shell 程序的处理情况:
此时由于其余的函数均是留空状态(即只写了一个空的函数体),所以我们的 Shell 在接受任何输入时都只会接受 -> 换行 -> 重新输出提示符。
到了这一步,我们的 Shell 已经能够支持基本的输入输出了,也能够通过命令提示符反馈一些环境信息。接下来我们需要为 Shell 实装上一些核心的功能。
首先我们需要插入的是 Shell 的报错信息输出函数。显然不是所有的字符串都是我们能够支持的命令,并且一个良好的报错信息输出能够在后续编写的过程中为我们提供有效的信息,告知我们读取的字符串在 Shell 中经历了什么变化。
当然这样一个报错函数相当简单,接受一个字符串参数,然后向 stdout 流中输出这个字符串就行了。
void ThrowError(const char* what)
{ // 一般来说 stderr 也会被重定向到 stdout,所以这里使用 printf 就好
printf("\nError due to: \n\t'%s'\n", what);
// fflush(stdout); 可以立刻刷新 stdout 以实现 stderr 中每次输出都会刷新缓冲区的效果
}
尽管我们可以简单地调用 system("command")
以快速实现指令的执行,但可惜这个函数并不够安全,并且 system
函数无法帮我们完成字符串处理的全部过程,所以这里对命令进行解析后执行更为可靠。
按照我们已经做过几遍的套路:抽象、封装,然后写出实现这个功能的函数定义:
/* 对命令串做词法分析,分析得到的 tokens 会被存储在一个数组中 */
ArgsMgr* CommandAnalyzer(ArgsMgr* const this, const char* delims);
/* 同时对命令串做语义分析并解释执行 */
CmdType CommandProcess(ArgsMgr* const this);
string.h 中的 strtok
函数能够帮助我们实现命令串中的 token 拆分;这个函数接受两个参数:第一个是需要处理的字符串指针,第二个是由分隔符构成的字符串,函数会将出现在第一个参数中的所有分隔符替换为空字符「\0」。所以我们可以用一个 for 循环简单地处理已经经过预处理的命令串:
/* cut tokens by space */
const char *delims = " ";
for (char *str=strtok(this->_commands, delims); str!=NULL;
str=strtok(NULL, delims), this->argc++); // 这里要的 argc 参数负责记录总共有几个 token
不过这个被直接拆分后的字符串中夹杂了大量的空字符,若直接使用的话,每次使用时都既需要控制越界,又需要检测空字符;所以我们还需要另一个数组单独存储所有已被拆分的 token,力争只做一次这个麻烦的操作。此时我们存储 token 时不需要去拷贝字符串,我们只需要保存这个 token 在原数组中的地址就行了:
/* 找出所有 token 在原字符串中的地址,并在另一个数组中保存这些地址 */
for (int i=1, backtrack=1, savedArgsNum=0; // savedArgsNum 用于记录已经保存了几个 token
i<=(this->_commandsLen) && savedArgsNum<(this->argc); i++, backtrack++) {
if (this->_commands[i]!=BLANK && this->_commands[i-1]==BLANK)
backtrack = 0; // backtrack 用来回溯到每个 token 串的起始位置
else if (this->_commands[i]==BLANK && this->_commands[i-1]!=BLANK) {
this->args[savedArgsNum++] = &this->_commands[i-backtrack];
backtrack = 0;
} /* 根据后文会提及的普通命令处理要求,这里对 args 数组做了一个细微的处理 */
}
由于我们的 Shell 支持的命令少、同名命令不多,所以我们可以把每条命令的第一个字符传给 switch 语句,根据首字符的不同快速判断这条命令是内部指令还是外部指令,这个操作能够极大地减少按照字符串匹配结果来判断命令类型所带来的时间开销。
命令类别解析完成后,就可以根据解析结果调用不同的函数;我们稍后再完善相关的命令支持函数,这里先用一些简单的 printf
以检测我们的解析功能是否正常:
看来有效的命令成功响应,并且无效指令也成功地抛出了错误信息。
根据设计,我们需要实现以下几个命令功能:
其中的 help、pwd 包括 cd 和 cp 等指令只需要由 Shell 自己提供、或对系统调用:如 chdir
做一个简单的封装即可实现,故不在这里做过多介绍。
这里补充一下对于不同命令而言需要用到的系统调用函数。
命令 | 系统调用函数 |
---|---|
cd | chdir |
pwd | getcwd |
cp | 创建文件使用 creat ,还有验证文件存在和其访问权限时需要使用 access ,其余的文件复制可以用 C 库的函数自己写,直接用系统调用 read 和 write 也可以 |
touch | creat 和 access |
mkdir | mkdir |
cat | 这个自己用文件读写把文件内容以文本形式写入 stdout 就行 |
ls | 这里需要访问 linux 提供的用于描述目录结构的数据,写起来比较复杂,详细可以参考其他人的文档 |
这里主要需要介绍的是如何实现调用外部可执行文件,以及如何实现管道功能。
如果我们希望在一个 Shell 程序运行时,通过输入一个类似「./HelloWorld」的指令以切换并执行另一个程序,那么我们实际上做的是一个「启用另一个进程取代当前的 Shell 进程」的过程。
这个时候我们就需要使用到 Linux 提供的多个 exec 函数,它们位于头文件 unistd.h 中:
函数名 | 功能 |
---|---|
execl(const char *path, const char *arg, ...) |
执行指定路径的可执行文件,使用可变参数列表传递命令行参数 |
execv(const char *path, char *const argv[]) |
同上,但使用参数数组传递命令行参数 |
execle(const char *path, const char *arg, ..., char *const envp[]) |
使用参数列表和环境变量数组执行新程序 |
execve(const char *path, char *const argv[], char *const envp[]) |
同上,但是是参数数组 |
execvp(const char *file, char *const argv[]) |
在参数 file 和 PATH 环境变量中搜索可执行文件并使用参数数组执行 |
以上函数的返回值类型均为 int,且仅在执行失败时返回,返回值是 -1.
观察这几个函数功能的差异,我们可以发现函数 execvp
既可以在 PATH
中搜索可执行文件,也可以将指定文件路径直接传递给参数 file,故我们可以选择这个函数作为我们的普通命令执行函数。
不过即使这里有许多函数,但是最终都会调用函数 execve
。通过查询函数参数要求,我们还需要注意一点:所有参数列表或参数数组都需要有一个 NULL 元素或指针作为结尾标记,所以我们还需要对前面的命令解析部分做一点小小的修改。
另外还需要特别注意一点:exec 函数调用后会启用另一个进程以替换当前程序,并且 exec 函数只有在执行失败时才会返回值;所以在这部分我们必须使用 fork()
函数切出子进程,再在子进程中调用 exec 函数。
此外还有一点,由于这里我们需要使用多进程的方式运行程序,我们需要一个用于在父子进程之间通信的手段,保证当指令执行失败时我们有办法返回错误信息。我们既可以临时创建一个文件,也可以向系统申请一块共享内存;不过这里直接使用了 Linux 的匿名管道以提供进程间通信方式。
匿名管道是一种用于进程间通信的机制。它可以在父进程和子进程之间创建一个共享且单向的流式管道。这个管道可以通过系统调用
pipe(int[2])
创建,此函数会将传入的数组设置为两个文件描述符,其中首元素用于读取数据,尾元素用于写入数据。这两个文件描述符可以用于在父进程和子进程之间进行通信。
函数的流程并不复杂,只是需要注意的地方不少,简单整理思路后我们就可以得到如下的一个实现:
int fd[2], status = SUCCESS;
if (pipe(fd) == -1)
return FAILED;
/* 由于默认创建的管道读取和写入都是堵塞式的,但 exec 成功执行后不会返回
* 所以这里要将管道设置为非阻塞式读取 */
fcntl(fd[0], F_SETFL, fcntl(fd[0], F_GETFL)|O_NONBLOCK);
pid_t pid = fork();
if (pid < 0) {
ThrowError("Execute error: cannot fork");
return FAILED;
}
if (pid == 0) {
close(fd[0]);
execvp(args[0], args+1);
/* 只有失败了才会进入下面的代码块 */
int message = FAILED;
write(fd[1], &message, sizeof(int));
exit(FAILED); // 强制子进程退出,不然就有另外一个 Shell 程序在等待监听了
} else {
waitpid(pid, NULL, 0); // 指定等待子进程死亡
read(fd[0], &status, sizeof(int));
}
插入实现的代码段后,我们简单的编写一个 HelloWorld.c 程序,编译后让 Shell 程序执行:
我们甚至可以尝试让 Shell 运行 Shell 程序自身:
这里要实现的管道有些类似于上文提及的、用于进程间通信的匿名管道。不过这里的管道是指一种特殊的 Shell 命令操作符(Bash 中 为 |),用于将一个命令的输出连接到另一个命令的输入,实现两个或多个命令之间的数据传递。
这个东西有点像重定向功能,也是将一个进程的输出重定向到另一个地方;但是之所以是「像」而不是「就是」,是因为管道实际上是重定向了 stdout 的文件描述符,将前一个命令连接的 stdout 重定向到后一个命令接受的 stdin 中。
在 Linux 中,每个进程都拥有自己独立的 stdout、stdin 和 stderr 流,通常默认情况下这些流会分别连接到终端和键盘上;并且由于 Linux 将这些流抽象为了一个文件接口,所以我们可以借助操纵文件描述符,进而将这些流重定向到别的文件中。
显然一个有效的进程调度命令能够创建额外且私有的三个流文件。
而重定向则是简单粗暴地将一个进程的 stdout 流写入指定文件中。
通常来说,Linux 中 stdin 和stdout 的默认文件描述符分别为 0 和 1;并且我们能通过 unistd.h 中的 dup2(int fd, int fd2)
函数重定向指定的文件描述符,所以我们要做的只有找出所有需要调用管道功能的指令就行了。
但是这里需要注意,管道操作符两端的指令都有可能是调用可执行文件的指令,而一个成功执行的 exec 函数是不会返回的;所以我们必须同时 fork 出两个子进程分别执行两个指令,并且至少要保证左侧指令输出完成后才执行右侧指令。
识别出某条指令是否是管道指令很简单,我们只需要扫描一遍命令字符串,查看其中是否有特定的管道操作符 | 即可。不过管道功能实现的函数要求比较多,我们需要先写出一个伪码框架帮我们理清一下思路:
PipeExecute(args, argc):
if argc is not enough:
return // 检查参数是否满足要求
statusPipe[2] = pipe() // 返回执行情况的管道
streamPipe[2] = pipe() // 用于实现管道功能的管道
for counter in range(2): // 一共就两条指令,直接用循环写
pid = fork()
if pid < 0:
ThrowError("Fork Failed")
else if pid == 0:
if counter == 0: // 左侧的指令
dup2(fd[1], stdout) // 重定向 stdout
exec(args.command_left)
write(statusPipe[1], FAILED) // 执行失败了
exit(FAILED)
else: // 右侧的指令
dup2(fd[0], stdin) // 重定向 stdin
exec(args.command_right)
write(statusPipe[1], FAILED) // 同上
exit(FAILED)
waitpid(pid, NULL, 0); // 需要保证前一个进程输出彻底完成
可见这部分的实现还是相对复杂和繁琐一点的。接下来根据我们已经实现的内容、还有我们自己的命令解析和解释执行部分,用 C 代码补充并完善上述的伪码框架就行了。
之后我们将这段代码插入回程序中,这里额外编写一个将 fgets()
接受的内容原封不动抛给 fputs()
输出的 C 程序,并与前文提到的 HelloWorld 程序结合进行测试:
其中的 AcceptAndThrow.c 源码如下:
#include
int main()
{
char str[1025];
fputs(fgets(&str, 1024, stdin), stdout);
return 0;
}
需要注意的是,由于我们的 Shell 程序在命令解析功能上的羸弱,从实现难度上考虑,本文中的所有指令都是无法递归调用执行的。为了防止输入递归调用的指令导致 Shell 程序出错,我们可以提前确定命令串中是否有多个不同的命令、或是出现了两个以上的管道符1,进而阻止这条指令执行。
若希望实现命令的递归式调用,则可以将解析所得的 token 串重新组织为波兰式或直接建立语法树,然后解释执行。当然这部分内容不在本文探讨之内。
既然已经实现了管道,那么就顺手将类似的重定向功能也一并实现了。
同样的,重定向功能也是由一个重定向操作符「>」控制,将操作符左侧的输出重定向到右侧的文件中;因此简单地扫描一边字符串就可以知道这条命令是否是重定向命令了。
重定向部分的功能相对简单很多,我们只需要验证目标文件是否存在,然后使用系统调用获取目标文件描述符,再 fork 出子进程,并将子进程的 stdout 重定向为目标文件即可。这里只给出伪码框架:
RedirectExecute(args, argc):
if args.targetFile is inexistence:
return
statusPipe[2] = pipe()
targetFd = open(args.targetFile, WRITE_ONLY)
pid = fork()
if pid < 0:
ThrowError("Fork Failed")
if pid == 0:
exc(args.commands)
write(statusPipe[1], FAILD)
exit(FAILED)
waitpid(pid, NULL, 0)
前面几部分基本上已经实现了我们这个简单的 Shell 程序的主要功能,对于其他丰富功能其实只需要提供满足调用参数需求的函数,然后在对应的解释执行函数中添加相应的调用语句即可。
正如本文开头提及的,若希望提供更丰富的行编辑功能、命令历史回顾功能,或者是简化文中字符串处理部分的函数,都可以引入第三方库加以解决。(当然自己手搓轮子也不是不可以)
以上就是本程序中支持的所有命令。
本文的实现与撰写主要参考了以下博客:
myShell:Linux Shell 的简单实现
文中涉及的 Shell 程序源码文件已打包至 GitHub 仓库 中。
实际源码文件中并没有阻止两个以上的管道符出现。 ↩︎