学习了进程的相关知识后,我们可以试着实践一下,编写一个简单的 shell。我们的目的并不是完美还原一个 shell,而是通过编写 shell 的过程,更好地理解 shell 的工作方式
自主shell
- 输出命令行
- 获取用户输入的命令
- 分割命令行字符串
- 执行命令
- 内建命令
- cd
- echo $?
输出命令行
我们先来看一下 shell 的命令行都有哪些部分组成:[用户名@主机名 + 当前工作目录]提示符
我们可以通过环境变量来获取这些信息,然后拼接为一个字符串打印出来
可以使用getenv函数来获取指定环境变量
#include <stdlib.h>
char *getenv(const char *name);
然后就可以编写输出命令行的函数了
#include <stdio.h>
#include <stdlib.h>
#define SIZE 512
const char* GetUserName()
{
const char* s = getenv("USER");
if (s == NULL) return "None";
return s;
}
const char* GetHostName()
{
const char* s = getenv("HOST");
if (s == NULL) return "None";
return s;
}
const char* GetPwd()
{
const char* s = getenv("PWD");
if (s == NULL) return "None";
return s;
}
void MakeCommandAndPrint()
{
char line[SIZE];
const char* username = GetUserName();
const char* hostname = GetHostName();
const char* cwd = GetPwd();
}
int main()
{
// 输出命令行
MakeCommandAndPrint();
return 0;
}
然后我们要怎么把他们拼起来呢?可以利用snprintf将这些数据格式化输出到 line 中
#include <stdio.h>
int snprintf(char *str, size_t size, const char *format, ...);
// 第一个参数是输出到哪里
// 第二个参数表示要输出多少字节
// 后面就和 printf 用法一样了
我们打印的时候不要加\n
,命令行与用户输入的指令应在同一行。但是不加 ‘\n’ 就不会主动刷新缓冲区,我们需要在 printf 之后加一句fflush(stdout),刷新缓冲区
void MakeCommandAndPrint()
{
char line[SIZE];
const char* username = GetUserName();
const char* hostname = GetHostName();
const char* cwd = GetPwd();
snprintf(line, sizeof(line), "[%s@%s %s]> ", username, hostname, cwd); // 拼接
printf("%s", line); // 打印命令行
fflush(stdout); // 刷新缓冲区
}
先来测试一下看看如何:
看上去还算个样子,只是当前工作目录显示的不太对,我们可以这样处理:
让 cwd 的指针反向遍历字符串,遇到第一个 ‘/’ 就停下,这样就可以定位到最后一层目录的位置了
这里就不写函数了,因为修改指针本身,就涉及到了二级指针传参了,比较麻烦。所以定义宏函数
#define SkipPath(p) do{ p += strlen(p)-1; while(*p != '/') p--;}while(0)
用 dowhile 的目的:这样就可以在 SkipPath 后面加分号或者其他操作,用起来更像一个普通函数
因为 cwd 指向的是 ‘/’,所以cwd+1
才是我们要输出的目录
void MakeCommandAndPrint()
{
char line[SIZE];
const char* username = GetUserName();
const char* hostname = GetHostName();
const char* cwd = GetPwd();
SkipPath(cwd);
snprintf(line, sizeof(line), "[%s@%s %s]> ", username, hostname, cwd+1); // cwd+1
printf("%s", line);
fflush(stdout);
}
此时正常打印出了工作目录,但是有一个小漏洞:当处于根目录时,目录显示应该是/
,但是我们上面把 ‘/’ 跳过了
所以应当做一个判断:
- 当 strlen(cwd) == 1时,输出’/’
- 否则输出 cwd+1
snprintf(line, sizeof(line), "[%s@%s %s]> ", username, hostname, strlen(cwd) == 1 ? "/" : cwd+1);
获取用户输入的命令
获取用户输入的命令之后,还要分割用户输入的命令。所以将获取的命令存入char usercommand[]
中,大小为 SIZE = 512
然后写一个GetUserCommand来获取用户输入命令。如何获取一行数据,可以使用fgets
注意:这样会把换行符也读进去,记得把 usercommand 的最后一个字符换为 ‘\0’
#include <stdio.h>
char *fgets(char *s, int size, FILE *stream);
// 参数:
// 1.要将数据写到哪里
// 2.最大接受多少数据
// 3.数据来源,文件流
// 成功则返回字符串,失败则返回NULL
可以将usercommand作为输出型参数,传给GetUserCommand,最后可以顺便返回命令的长度
int GetUserCommand(char usercommand[], int n)
{
char* s =fgets(usercommand, n, stdin);
if (s == NULL) return -1;
usercommand[strlen(usercommand)-1] = '\0'; // 去除换行符
return strlen(usercommand);
}
// main
// 获取用户输入命令
char usercommand[SIZE];
int n = GetUserCommand(usercommand, sizeof(usercommand));
if (n <= 0) return 1;
printf("%s\n", usercommand);// 测试
测试一下:
分割命令行字符串
得到了命令行字符串后,就可以进行分割了。“ls -a -b -c” -> “ls” “-a” “-b” “-c”,这样得到了一批命令行参数之后,我们可以维护一个命令行参数表 argv。
由于其他函数也会用到命令行参数,比如执行命令,所以直接将命令行参数表设为全局变量,就不用每次都传参了
#define NUM 32
char* gArgv[NUM];
关于如何分割字符串,可以使用 strtok
#include <string.h>
char *strtok(char *str, const char *delim);
//参数:
// 1.要分割的字符串,设置为 NULL 可以接着上次调用后继续分割
// 2.分割符,注意是字符串
以下是CommandSplit函数
void CommandSplit(char usercommand[])
{
gArgv[0] = strtok(usercommand, " ");
int i = 1;
while(1)
{
gArgv[i] = strtok(NULL, " "); // 接着上次分割
if (gArgv[i] == NULL) // argv表应该以 NULL 结尾
break;
i++;
}
}
// main
// 分割命令行字符串
CommandSplit(usercommand);
// 测试
for (int i = 0; gArgv[i]; i++)
printf("%s\n", gArgv[i]);
测试:
执行命令
有了命令行参数表,就可以执行命令了,通过父进程创建子进程,子进程调用 exec* 函数进行进程替换来实现
exec*函数有很多,调用哪个合适呢?根据我们的命令行参数的形式来确定。argv是数组,而且里面并没有存放着文件的路径,所以用execvp很合适
以下是执行命令的函数ExecuteCmd
void ExecuteCmd()
{
pid_t id = fork();
if (id < 0) exit(1);
else if (id == 0)
{
// child
execvp(gArgv[0], gArgv);
exit(errno); // 执行失败
}
// father
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if (rid > 0)
{
// wait sucess
}
}
// main
// 执行命令
ExecuteCmd();
测试结果:
为了可以一直执行命令,将全部模块放到循环之中
int main()
{
int quit = 0;
while(!quit)
{
// 输出命令行
MakeCommandAndPrint();
// 获取用户输入命令
char usercommand[SIZE];
int n = GetUserCommand(usercommand, sizeof(usercommand));
if (n <= 0) return 1;
// 分割命令行字符串
CommandSplit(usercommand);
// 执行命令
ExecuteCmd();
}
return 0;
}
但是执行一些命令时,出了问题,例如cd
这是因为:这里的 cd 命令是由子进程执行的,而我们看的是父进程的工作目录。你子进程改变了工作目录,关我父进程什么事?
像 cd 这种内建命令应该直接由 bash 执行,这就是内建命令
所以在执行命令之前,需要检测要执行的命令是不是内建命令
内建命令
检测命令是不是内建命令,可以把我们要执行的命令 argv[0] 与内建命令匹配,可以用 strcmp 匹配两个字符串
strcmp(argv[0], "cd");
如果是内建命令,就让父进程执行,不再创建子进程执行。如下是检测函数CheckBuildin
int CheckBuildin()
{
const char* enter_cmd = gArgv[0];
int yes = 0;
if (strcmp(enter_cmd, "cd") == 0)
{
yes = 1;
Cd();
}
return yes;
}
// main
// 检测内建命令
n = CheckBuildin();
if (n) continue; // 父进程执行内建命令后,就不用子进程执行了
// 执行命令
ExecuteCmd();
cd
接下来我们来写 Cd 函数,模拟 cd 命令的实现
我们先得到要跳转的目录 path = gArgv[1]
,然后检测 path 是否存在
- 如果不存在,就表示用户只输入了 cd,没有输入目录。那就要默认跳转到家目录,getenv(“HOME”)可以得到家目录
- 存在,就改变当前进程的工作目录
经过以上操作,path一定不为空,这时就可以改变工作目录了,可以使用 chdir 函数
void Cd()
{
const char* path = gArgv[1];
if (path == NULL)
path = GetHome();
//到这里 path 一定存在
chdir(path);
}
测试一下:
这时我们发现,虽然当前目录确实切换了,但是命令行显示却没刷新。这是因为,我们命令行的当前目录是从环境变量中获得的,虽然目录改变,但是环境变量中的PWD
没变,所以命令行打印出来的仍然是旧的目录
所以,在改变当前工作目录后,我们还要修改对应的环境变量,可以使用 putenv 来实现。注意,环境变量是"name=value"形式的
#include <stdlib.h>
int putenv(char *string);
我们可以维护一个全局变量 cwd 用来储存当前的环境变量PWD,每次调用Cd时,都要更改它,然后把它作为 putenv 的参数,修改环境变量PWD
char cwd[SIZE];
void Cd()
{
const char* path = gArgv[1];
if (path == NULL)
path = GetHome();
//到这里 path 一定存在
chdir(path);
// 修改当前工作目录后
snprintf(cwd, sizeof(cwd), "PWD=%s", path);
putenv(cwd);
}
到这里还没结束,因为 path 可能是相对路径,例如..
,这样修改环境变量后,就变成了 PWD=..
,显然是不合理的。所以我们要先获得当前工作目录的绝对路径,可以通过 getcwd 获得
#include <unistd.h>
char *getcwd(char *buf, size_t size);
可以开一个临时的字符数组 tmp,来存放绝对路径,然后将 tmp 的内容以环境变量的形式输出到 cwd 中
// 改变环境变量
char tmp[SIZE*2];
getcwd(tmp, sizeof(tmp));
snprintf(cwd, sizeof(cwd), "PWD=%s", tmp);
putenv(cwd);
测试:
虽然比较麻烦,但最终还是实现 cd 功能了
echo $?
我们想要查看上一个命令执行成功还是失败,可以使用 echo $? 查看上一个进程的退出码。而 echo 也是一个内建命令,所以需要我们多加一个检测echo
的情况。因为这里只会用到 echo $?,所以就简化一下,如下
int CheckBuildin()
{
const char* enter_cmd = gArgv[0];
int yes = 0;
if (strcmp(enter_cmd, "cd") == 0)
{
yes = 1;
Cd();
}
else if(strcmp(gArgv[0], "echo") == 0 && strcmp(gArgv[1], "$?") == 0)
{
// ...
}
return yes;
}
我们可以创建一个全局变量lastcode,用于储存上一个进程的退出码。在子进程执行完命令时,父进程通过 waitpid 获取子进程退出码,存于 lastcode。根据退出码的值,如果子进程执行命令失败,父进程就将相关错误信息打印出来
int lastcode = 0; // 全局变量
void ExecuteCmd() // 子进程执行命令
{
pid_t id = fork();
if (id < 0) exit(1);
else if (id == 0)
{
// child
execvp(gArgv[0], gArgv);
exit(errno); // 执行失败
}
// father
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if (rid > 0)
{
// wait sucess
lastcode = WEXITSTATUS(status); // 设置退出码
if (lastcode)
printf("%s:%s:%d\n", gArgv[0], strerror(lastcode), lastcode); // 打印错误信息
}
}
有了lastcode,我们使用echo $?
就可以知道上一个命令是否执行成功了
int CheckBuildin()
{
const char* enter_cmd = gArgv[0];
int yes = 0;
if (strcmp(enter_cmd, "cd") == 0)
{
yes = 1;
Cd();
lastcode = 0;
}
else if(strcmp(gArgv[0], "echo") == 0 && strcmp(gArgv[1], "$?") == 0)
{
yes = 1;
printf("%d\n", lastcode);
lastcode = 0;
}
return yes;
}
注意,内建命令也是命令,因此执行成功后,要将 lastcode 置零
测试:
至此,就完成了一个十分简陋的 shell 的编写,虽然功能不完善,但是重在加深对 shell 的理解