~~~~
- 前言
- 解决的问题
- 为什么shell要以子进程的方式执行我们的命令?
- 为什么直接使用程序名ls,而不是路径/usr/bin/ls?
- 头文件包含
- 命令行提示符
- 接受用户命令行输入
- 解析用户的输入
- 内建命令&&特殊处理
- ls 时目录等文件不带高亮颜色
- cd时目录不变的问题
- echo
- echo命令能显示本地变量而env命令获取不到的原因
- echo $?显示上一次进程的退出码
- 创建子进程
- 子进程执行进程程序替换
- 父进程等待
- myshell.c 源码
- 结语
前言
本文将根据进程创建fork()、进程替换exec系列函数、进程等待waitpid()实现一个简单的命令行解释器。
解决的问题
为什么shell要以子进程的方式执行我们的命令?
shell也是一个进程,shell会提取用户在命令行输入的内容以空格字符作为分隔符切割成一个个的子串,然后执行exec程序替换函数。如果没有子进程。shell进程本身会被替换,shell也就结束运行了,但是我们需要shell一直运行,持续解析命令行的,所以shell通过fork创建子进程,让子进程执行程序替换,父进程shell然后等待子进程退出,之后shell将再次等待命令行的输入。
为什么直接使用程序名ls,而不是路径/usr/bin/ls?
shell以fork子进程的方式,通过exec替换子进程执行其他程序。子进程继承了shell的环境变量,使用exec函数时不需要制定替换程序的路径,使用程序名即可,操作系统会在PATH包含的路径下自动寻找。
echo的问题 : 内建命令
头文件包含
#include<stdio.h>
#include<stdlib.h>// exec系列替换函数
#include<string.h>// 字符串函数
#include<assert.h>// 断言判断
#include<unistd.h>// fork创建子进程
#include<sys/types.h>// 进程等待
#include<sys/wait.h>// 进程等待
命令行提示符
首先我们登录shell时左侧会提示我们进行输入的提示符,包含了当前登录的用户名、主机名和当前所在目录。
我们仿照xshell的写法即可:
printf("[用户名@主机名 路径]# ");
接受用户命令行输入
定义接收用户输入的长度为NUM
的字符数组commandLine
;
#define NUM 1024
char commandLine[NUM];
我们需要接受用户的一行输入,这里使用fgets函数。
fgets函数声明
char *fgets(char *s, int size, FILE *stream);
从标准输入stdin中读取最多数组长度-1个字符到commandLine数组中。
空出来的一个位置是为了放’\0’,防止出可能的越界问题。
char* s = fgets(commandLine, sizeof(commandLine) - 1, stdin);
assert(s);
(void)s;
使用字符指针s接收fgets的返回值,需要判断一下是否读取成功;
![外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传](https://img-home.csdnimg.cn/images/20230724024159.png
去除commandLine多读取到的换行符\n
commandLine[strlen(commandLine) - 1] = 0;
解析用户的输入
读取的用户输入都在字符数组commandLine
中,且以空格分隔,所以需要先把commandLine
按空格分隔成多个子串。
为了保存分隔的子串,定义一个字符指针数组argv_
,按顺序依次指向分割的子串,且以NULL
空指针结尾。
假定最多分隔的子串不超过63个;
#define OPT_NUM 64
char* argv_[OPT_NUM];
分割字符串的方法很多,这里采用库函数strtok进行commandLine
的分割;
strtok函数声明
char *strtok(char *str, const char *delim);
使用strtok时,第一次分割需要指明要分割的是哪个字符串,后续我们还需要继续切割,所以第一个参数填NULL
,循环切割,直到strtok函数返回NULL
结束。
正巧的是,strtok返回NULL
时正好也是argv_
所需要的结束,所以while循环简写了。
argv_[0] = strtok(commandLine, " ");
int i = 1;
while(argv_[i++] = strtok(NULL, " "));
内建命令&&特殊处理
解析完commandLine
长串为多个子串之后,可以知道argv_[0]
是用户期望执行的程序名,而之后的所有子串都是执行该程序的选项。
ls 时目录等文件不带高亮颜色
我们使用ls命令时,一些文件没有高亮,对此,除了我们每次显式的输入"--color=auto"
之外,直接在父进程内部进行特殊处理即可:
if(argv_[0] && strcmp("ls", argv_[0]) == 0){// strcmp传入的参数确保是有效的,否则结果未定义
argv_[i - 1] = (char*)"--color=auto";
argv_[i] = 0;
}
cd时目录不变的问题
为什么我们的shell,cd的时候,路径没有变化呢?
shell以子进程的方式执行cd命令,子进程有自己的工作目录,cd更改的是子进程的目录,而子进程执行完毕就退出了,继续运行的是父进程shell,而父进程的工作目录从始至终都没有更改。
所以解决方法是cd命令时特殊判断,父进程直接执行cd命令,本次循环的后续代码不再执行(称之为自建命令)。
解决方法是:特殊判断cd
,直接在父进程中执行实现cd
命令的效果–更改进程的当前工作目录。
我们使用chdir()
函数实现:
#include <unistd.h>
int chdir(const char *path);
if(argv_[0] && strcmp("cd", argv_[0]) == 0){
if(argv_[1]){
chdir(argv_[1]);
}
continue;
}
改变完父进程myshell的工作目录之后,已经完成了cd
的功能,后续代码无须执行,直接continue开始下一次循环,继续等待用户下一次命令行输入。
echo
echo命令能显示本地变量而env命令获取不到的原因
echo其实是bash的内建命令,不是fork创建子进程去执行的,而是bash亲自执行的,本地变量就在bash内,当然bash能够获取;
而env不是内建命令,是bash通过fork创建子进程然后进程替换(exec)为env进程,然后env进程再查找环境变量的。env是bash的子进程,继承了bash的环境变量,但是bash的本地变量(没有导入到bash环境变量中)没有被env继承,所以env当然就找不到bash的本地变量了。
echo $?显示上一次进程的退出码
既然是内建命令,那么就需要myshell父进程邵本身进行特殊判断和处理:
if(argv_[0] && strcmp("echo", argv_[0]) == 0){
if(argv_[1] && strcmp("$?", argv_[1]) == 0){
printf("sig: %d, exit code: %d\n", lastSig, lastExitCode); lastSig = 0;
lastExitCode = 0;
continue;
}
}
创建子进程
我们使用fork
函数为myshell程序创建子进程,让子进程执行程序替换exec
从而执行用户期望的程序。
pid_t id = fork();
assert(id != -1);
如果子进程创建失败,fork返回-1,后续程序不再执行。
子进程执行进程程序替换
fork函数创建子进程之后函数返回之前,就有了两个执行流:父进程myshell和子进程。
通过父子进程fork返回值的不同,让父子进程执行后续代码的不同部分。
对于子进程,fork函数返回0。
子进程需要进行程序替换,进程替换函数(或者说加载函数)exec有多个,我们选择哪一个呢?
我期望用户直接输入程序名执行而不是路径名,所以需要带p(path),系统自动在PATH中帮我找程序位置; 我期望传递字符指针数组,而不是可变参数列表,所以需要v(vector);
我期望子进程继承默认环境变量就行,即我不想显式传递环境变量,所以没有e(environ);
所以我选择的是execvp
函数
int execvp(const char *file, char *const argv[]);
if(id == 0){
execvp(argv_[0], argv_);
exit(1);// 到这一步,程序替换失败,进程退出,且退出码设置为-1
}
父进程等待
父进程阻塞式等待子进程,知道子进程退出。
pid_t waitpid(pid_t pid, int *status, int options);
使用watpid函数,第一个参数表示等待的子进程id,第二个参数是输出型参数(为NULL时不接受),接收子进程退出状态,第三个参数为0表示父进程阻塞式等待子进程。
我们先不接首子进程状态,第二个参数设置为NULL
int ret = waitpid(id, NULL, 0);
assert(ret != -1);
(void)ret;
waitpid函数返回如果是-1表示等待失败,需要判断一下,等待失败就不再继续执行。
现在我们想要实现xshell中echo $?显示上一次进程运行退出码,怎么实现呢?
其实很简单,定义全局变量lastSig
记录子进程退出信号和lastExitCode
记录子进程退出码。
int lastSig = 0;
int lastExitCode = 0;
每次父进程等待成功都根据status设置一次lastSig
和lastExitCode
即可。
int status = 0;
int ret = waitpid(id, &status, 0);
assert(ret != -1);
(void)ret;
lastSig = status & 0x7f;// 0~6位表示信号
lastExitCode = (status >> 8) & 0xff;// 低8~15位表示退出码
myshell.c 源码
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<assert.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#define NUM 1024
#define OPT_NUM 64
char commandLine[NUM];// 获取用户输入
char* argv_[OPT_NUM];// 存放按空格切割的字符串的多个子串
int status = 0;
int lastSig = 0;
int lastExitCode = 0;
int main(){
while(1){
// 输出命令行提示符
printf("[用户名@主机名 路径]# ");
// 用户输入
char* s = fgets(commandLine, sizeof(commandLine) - 1, stdin);
assert(s);
(void)s;
commandLine[strlen(commandLine) - 1] = 0;// 处理用户输入的\n
#ifdef DEBUG
printf("test: %s\n", commandLine);
#endif
// strtok切割字符串
argv_[0] = strtok(commandLine, " ");
int i = 1;
while(argv_[i++] = strtok(NULL, " "));
#ifdef DEBUG
for(int i = 0; argv_[i]; i++)
printf("argv_[%d]:%s\n", i, argv_[i]);
#endif
// 命令行带颜色
if(argv_[0] && strcmp("ls", argv_[0]) == 0){
argv_[i - 1] = (char*)"--color=auto";
argv_[i] = 0;
}
// cd命令父进程直接执行,改变的是父进程shell的当前工作目录,而不是 更改子进程的工作目录。如果子进程执行cd命令,更改完自己的工作目录就退出了, 父进程工作目录并没有改变。
if(argv_[0] && strcmp("cd", argv_[0]) == 0){
if(argv_[1]){
chdir(argv_[1]);
}
continue;
// echo $? 查看最近一次进程运行结果信息
if(argv_[0] && strcmp("echo", argv_[0]) == 0){
if(argv_[1] && strcmp("$?", argv_[1]) == 0){
printf("sig: %d, exit code: %d\n", lastSig, lastExitCode);
lastSig = 0;
lastExitCode = 0;
continue;
}
}
// fork子进程执行新程序
pid_t id = fork();
assert(id != -1);
if(id == 0){
execvp(argv_[0], argv_);
exit(1);
}
// 父进程waitpid子进程
int ret = waitpid(id, &status, 0);
assert(ret != -1);
(void)ret;
lastSig = status & 0x7f;
lastExitCode = (status >> 8) & 0xff;
}
return 0;
}
结语
T h e E n d TheEnd TheEnd