本章的代码可以访问这里获取。
由于程序代码是一体的,本章在分开讲解各部分的实现时,代码可能有些跳跃,建议在讲解各部分实现后看一下源代码方便理解程序。
制作一个简单的Shell解释器
- 一、观察Shell的运行状态
- 二、简单的Shell解释器制作原理
- 1、获取命令行
- 2、解析命令行
- 3、创建子进程 进行程序替换 父进程等待
- 4、实际运行
- 二、对简单的内建命令进行处理
- 1、给ls命令加上色彩
- 2、支持cd命令
- 3、支持export命令
- 4、支持env命令
- 5、支持echo命令
一、观察Shell的运行状态
我们想要制作一个简单的Shell
解释器,需要先观察Shell是怎么运行的,根据Shell
的运行状态我们再去进行模拟实现。
我们可以先考虑下面的指令与Shell的互动:
我们仔细进行分析可以发现,Shell
执行上面的命令时,可以被理解为下面的过程。
当然上面的命令都是普通命令,所以Shell
都是通过创建子进程的方式来执行的,对于一些内建命令(Shell
自己去执行命令)我们现在还不考虑,在后面的部分我们再进行进一步的讨论内建命令应该怎么去处理。
二、简单的Shell解释器制作原理
通过观察Shell
的运行状态,我们知道然后Shell
读取新的一行输入,建立一个新的子进程,在这个子进程中运行程序并等待这个进程结束。
所以要写一个shell,需要循环以下过程:
- 获取命令行
- 解析命令行
- 建立一个子进程(
fork
) - 替换子进程(
execvp
),执行替换后的程序 - 父进程等待子进程退出(
wait
)
1、获取命令行
我们在在Shell
中输入的命令本质上就是输入一个字符串,因此我们想要获取命令行,可以先创建一个字符数组commandstr
,然后使用C语言的fgets
函数从键盘中进行读取数据到字符数组里面,这样我们就获取了一个命令行了。
注意:
- 这里不能使用
scanf
函数 ,这里的命令会包含空格,会导致scanf
读取不到完整的数据。fgets
函数会将我们输入的命令时的最后一个的\n
符也给读取到字符数组内,我们需要特殊处理将\n
进行用\0
进行覆盖
//这里包含的头文件是我们整个程序需要用到的所有头文件
#include<stdio.h>
#include<unistd.h>
#include<assert.h>
#include<string.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<stdlib.h>
//这里的N用于定义字符数组的大小
#define N 128
int main()
{
//存储命令行的字符数组
char commandstr[N] = "";
//Shell要一直运行接受命令,所以这里必须是死循环!
while(1)
{
//模拟Shell的提示符
printf("[hong@machine MiniShell]# ");
//从标准输入流中读取字符串
char* s = fgets(commandstr, sizeof(commandstr), stdin);
assert(s); //判断fgets是否读取成功
//处理\n 示例字符串:ls -a -l\n\0
commandstr[strlen(commandstr) - 1] = '\0';
}
2、解析命令行
虽然我们通过前一步已经拿到命令行,但是我们还不能直接使用,因为我们拿到的字符串中间可能有许多空格以及一些其他的问题,我们还需要将命令行的字符进行切割提取出我们想要的子串,这样才符合程序替换函数的要求。例如:将 ls -a -l
提取成 ls
,-a
, -l
。
对于字符串的切割,我们可以使用C语言提供的strtok
函数,由于切割以后我们的字符串从一个变成了多个,因此我们需要用一个字符串指针数组argv
,存储每一部分切割后的首地址,同时这个argv
也可以直接传递给execvp
函数进行程序替换了。
//在全局域中 定义切割符
#define SEP " "
//main函数的外部 定义一个命令行切割函数
int split(char commandstr[], char* argv[])
{
assert(commandstr);
assert(argv);
//第一次切割
argv[0] = strtok(commandstr, SEP);
if(argv[0] == NULL)
{
//返回 -1表示异常退出
return -1;
}
//循环切割
int i = 1;
while((argv[i++] = strtok(NULL, SEP)));
return 0;
}
//main函数内部,while循环上面定义切割后的字符指针数组
char* argv[N] ={NULL};
//while循环内部
//切割字符串 例如将"ls -a -l " 变为 "ls" "-a" "-l"
int n = split(commandstr, argv);
if(n == -1)
{
//切割失败就终止本次循环
continue;
}
3、创建子进程 进行程序替换 父进程等待
创建子进程而我们可以使用fork
函数进行创建,创建完以后进程的执行流由一个变成了两个,我们在子进程中进行程序替换可以使用execvp
命令,同时我们的argv[0]
就是程序名,argv
中存储的就是命令按照什么方式进行执行。
最后我们的父进程可以在外面进行阻塞等待,然后获取子进程的退出码和退出信息。
//main函数内部,while循环上面定义退出码变量
int last_status = 0;
//while循环内部
//创建子进程,进行命令处理
pid_t id = fork();
assert(id >= 0);
if(id == 0)
{
//child process
execvp(argv[0], argv);
//如果执行到这里说明程序替换失败
exit(-1);
}
//父进程等待子进程
int status;
int pid = waitpid(id, &status, 0);
//等待成功就提取退出码信息
if(pid >= 0)
{
last_status = WEXITSTATUS(status);
}
}
return 0;
4、实际运行
我们可以执行 ls
pwd
ps -axj
命令 看一看效果。
二、对简单的内建命令进行处理
我们知道内建命令是让Shell
自己执行的命令,而不是让子进程执行的命令,例如cd
命令就是内建命令,因为我们要改变的是Shell
自己的工作目录,而不是子进程的工作目录,类似的命令还有export
env
echo
命令。
由于上面我们写的程序执行命令时都是交给子进程去做的,所以我们上面写的程序是没有办法执行内建命令的,或者说能执行内建命令,但不是我们想要的结果或目的。
所以接下来我们要对这个简单的Shell
进行改造,让它能够执行一些简单的内建命令,还有刚刚我们的ls
命令没有色彩,我们也要进行一些修改。
1、给ls命令加上色彩
在真正的Shell
中我们执行的ls
命令其实是ls --color=auto
,ls
被我们真正的Shell
进行了起别名。
我们在运行我们自己制作的Shell
时也可以加上--color=auto
。
//此段代码应该在切割字符串之后
//argv[0]就是我们的命令名
if(strcmp(argv[0], "ls") == 0)
{
int pos = 0;
//寻找指针数组的结尾
while(argv[pos++]);
//在NULL位置加上 --color=auto
argv[pos - 1] = "--color=auto";
//将后一个位置置空
argv[pos] = NULL;
}
这样以后我们在我们自己制作的Shell
中执行ls
命令时也会由颜色了!
2、支持cd命令
对于cd
命令如果让父进程进行执行,我们可以调用系统调用chdir
我们只需要传递一个参数:路径字符串,当执行成功时会返回0,执行失败会返回-1,并设置错误码。
//此段代码应该在ls添加颜色之后
else if(strcmp(argv[0], "cd") == 0)
{
//argv[1]里面存放的是路径字符串
if(argv[1] == NULL)
{
printf("没有正确的路径!\n");
//设置错误码
last_status = -1;
continue;
}
//执行系统调用改变父进程的工作目录
chdir(argv[1]);
continue;
}
3、支持export命令
export
命令可以将一个本地变量加入到环境变量表中,我们让我们自己制作的Shell
完成expoprt
命令可以用C语言提供的函数putenv
函数,但是在向环境变量表加入新的环境变量时,我们要维护好我们加入到环境变量,这个环境变量不能够被轻易的覆盖,否则环境变量表在找我们的环境变量时就会找不到,所以我们还要创建一个我们自己维护的二维数组。
//在全局域中定义
// 自己维护的二维数组最多能向环境变量表几个自定义的环境变量
#define MAX 64
//main函数内部,while循环上面定义
//指向下一个要添加的环境变量的位置
int env_index = 0;
//要维护的二维数组
char envstr[MAX][N];
//此段代码应该在ls添加颜色之后
else if(strcmp(argv[0], "export") == 0)
{
//声明putenv函数否则会编译器会有警告
extern int putenv(char *string);
//argv[1]位置应该是环境变量
if(argv[1] == NULL)
{
printf("没有输入变量!\n");
last_status = -1;
continue;
}
//将argv[1]位置的环境变量,拷贝到env_str中,否则下一次解析的命令会覆盖环境变量
strcpy(envstr[env_index], argv[1]);
//将环境变量导入环境变量表
putenv(envstr[env_index++]);
}
4、支持env命令
对于env
命令我们只需要写一个打印环境变量表的函数就能完成此命令了。
//main函数的外部 定义一个打印环境变量表的函数
void showEnv()
{
extern char** environ;
int i = 0;
while(environ[i])
{
printf("%d : %s\n", i, environ[i++]);
}
}
//此段代码应该在ls添加颜色之后
else if(strcmp(argv[0], "env") == 0)
{
showEnv();
continue;
}
5、支持echo命令
echo
命令可以用于打印环境变量,也可以打印退出码,这取决于$
后面是不是?
是?
我们就可以打印last_status
,不是我们就用getenv
命令拿到环境变量的内容。
//此段代码应该在ls添加颜色之后
else if(strcmp(argv[0], "echo") == 0)
{
if(*argv[1] == '$')
{
if(*(argv[1] + 1) == '?')
{
printf("process exit code %d\n", last_status);
continue;
}
else
{
char* str = getenv(argv[1] + 1);
printf("%s\n",str);
continue;
}
}
}