自主shell
- 1、搭建架构
- 2、打印命令行
- 3、等待用户进行输入
- 4、解析字符串
- 5、执行命令
- 6、进一步完善
-
- 6.1、路径无法切换?
- 6.2、完善提示符
- [6.3、`echo ?\`提取退出码](#6.3、`echo ?`提取退出码)
- 6.4、环境变量的获取
- 6.5、环境变量的添加
- 7、其它知识点
-
- [7.1、进一步理解:环境变量 vs 本地变量](#7.1、进一步理解:环境变量 vs 本地变量)
- 7.2、一个命令,两种身份?
- 7.3、当前自主shell不支持的功能
自主实现一个简单的shell命令行,可以将我们之前学习过的知识串联起来。其核心思想就是:
- 创建一个父进程(bash),完成基本的命令行操作;
- 对于用户输入的指令,
- 如果是普通命令,父进程将会创建子进程,让子进程执行;
- 如果是内建命令,父进程将会自己执行。
1、搭建架构
c
// myshell.h
#pragma once
#include <stdio.h>
c
// myshell.c
#include "myshell.h"
void Bash()
{
while (1)
{
// ...
}
}
c
// main.c
#include "myshell.h"
int main()
{
Bash();
return 0;
}
这里我们要明确一点:命令行本质上就是一个死循环的进程,只不过需要用户输入指令,命令行才会执行下一步操作。
2、打印命令行
vscode上的命令行是长这样的:

简单分析,我们就需要以下三个要素:
- 用户名
- 主机名
- 当前的工作目录
这三个要素对应的信息,其实都在环境变量里面。所以我们可以使用getenv获取。
c
// myshell.c
#include "myshell.h"
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
static char username[32];
static char hostname[64];
static char pwd[256];
static void GetUserName()
{
char *_user_name = getenv("USER");
strcpy(username, _user_name ? _user_name : "None");
}
static void GetHostName()
{
char *_host_name = getenv("HOSTNAME");
strcpy(hostname, _host_name ? _host_name : "None");
}
static void GetPwd()
{
char *_pwd = getenv("PWD");
strcpy(pwd, _pwd ? _pwd : "None");
}
static void PrintPrompt()
{
GetUserName();
GetHostName();
GetPwd();
printf("%s@%s:%s# ", username, hostname, pwd);
fflush(stdout); // 冲涮缓冲区
}
void Bash()
{
while (1)
{
PrintPrompt();
sleep(1); // 方便观察,避免一下出现一大堆
}
}
c
// myshell.h
#pragma once
#include <stdio.h>
void Bash();
这里有四个细节:
- 声明与定义分离的多文件项目中,一定要在
.h文件中加上声明!!! - 本人曾经习惯将所有头文件声明一股脑地放入
.h文件中,但这其实是不太好的习惯。建议对于某个文件需要某些头文件声明,而其它文件不需要,我们就把这些头文件声明放在这个文件当中。 printf不使用\n时,要使用fflush冲涮缓冲区,否则直到进程结束之前,什么也看不到。- 不想外部使用
myshell.c里的函数,就加上static。
3、等待用户进行输入
用户输入可能包含空格,我们可以使用库方法fgets。
同时,我们可以用一个static字符数组接收用户输入的字符串。
c
// myshell.c
#include "myshell.h"
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
static char username[32];
static char hostname[64];
static char pwd[256];
static char commandline[256];
// ...
static void GetCommandLine()
{
memset(commandline, 0, sizeof(commandline)); // 清除历史记录
if (NULL == fgets(commandline, sizeof(commandline), stdin))
{
perror("fgets failed!");
exit(10);
}
}
void Bash()
{
while (1)
{
PrintPrompt();
GetCommandLine();
}
}
这里有三个细节:
用 sizeof 而不用 strlen
对于这句代码if (NULL == fgets(commandline, sizeof(commandline), stdin)),sizeof()可不能改成strlen(),因为用户的指令还没有输入进来,commandline肯定是一堆\0。
库函数strlen统计字符串的字符个数时,遇到\0就停止,所以strlen(commandline)的结果为0,导致fgets获取输入失败。
而操作符sizeof统计的是变量所占的字节数,sizeof(commandline)的返回结果就是256,因为一个char类型占一个字节,256个就占256个字节。
多了一个空格?

事实上,我们输入内容时,最后还输入了一个回车键,那么commandline末尾最终会多一个\n。
我们可以将这个转义字符覆盖掉:
c
static void GetCommandLine()
{
memset(commandline, 0, sizeof(commandline));
if (NULL == fgets(commandline, sizeof(commandline), stdin))
{
perror("fgets failed!");
exit(10);
}
else
{
commandline[strlen(commandline) - 1] = 0;
}
}
输入了空串
如果用户什么也没输入,只是敲了一下"enter",那么我们也就没必要进行下面的操作了,以免浪费CPU资源:
c
void Bash()
{
while (1)
{
PrintPrompt();
GetCommandLine();
if (0 == strlen(commandline))
continue;
}
}
清除历史记录
向commandline写入前,先清空commandline里的内容。
4、解析字符串
用户输入了一个字符串,我们需要解析字符串:以空格为分界拆成一个个"小串",然后传入指针数组argv中做成一个环境变量表。
c
static int argc = 0;
static char *argv[64];
这里我们要借助一个C语言处理字符串的库函数strtok:

例如,这里有一个字符串char *string = "ls -a -l",要求将空格作为分界delim,将字符串拆分:

对于strtok中的str参数:
- 如果输入的是
string,那么返回字符串(字符指针)"ls"。- 我们可以理解为
strtok在返回的时候,内部有一个"指针"从头开始,读到空格就返回了。 - 以后如果输入的都是
string,那么返回的结果都是一样的。
- 我们可以理解为
- 如果输入的是
NULL,strtok就会往后读取。比如第二次调用,返回"-a";第三次调用,返回"-l";第四次调用,由于后面没有内容了,就返回NULL。- 我们可以理解为,
strtok内部存在static全局变量,能够保存最近一次传入字符串的信息。
然后我们就可以完善解析字符串的函数:
c
static void ParseCommandLine()
{
// 清除原有的记录
argc = 0;
memset(argv, 0, sizeof(argv));
// 填表
argv[argc] = strtok(commandline, sep);
while ((argv[++argc] = strtok(NULL, sep)));
}
首先,我们要清除上一次输入指令留下的记录;其次,我们借助strtok,只用了两行代码,就完成了argc argv的设置,甚至还能在argv的末尾加上NULL。
值得注意的是,while ((argv[++argc] = strtok(NULL, sep)));中我们多加了一个括号。这是因为括号里面进行的是赋值操作,一般赋值操作的返回值都为真,很容易导致死循环;编译器会认为我们在这里进行了误操作,即把 == 错写成了 = ,所以这里触发了编译器的警告。我们加上括号,意在告诉编译器,我们确实在做赋值操作,并且将赋值作为 while 循环的条件。
5、执行命令
执行命令,我们可以让父进程创建子进程,然后让子进程通过程序替换完成命令的执行。
c
static void Execute()
{
pid_t id = fork();
if (id < 0)
{
perror("fork failed!\n");
exit(11);
}
else if (0 == id)
{ // child
execvp(argv[0], argv); // 程序替换
exit(12);
}
else
{ // parent
pid_t rid = waitpid(id, NULL, 0);
(void)rid; // 接收但不处理
}
}
这样一来,我们就做好了一个功能简单的shell命令行。
6、进一步完善
接下来,我们将进一步完善自主实现的shell命令行解释器,以帮助我们加深理解这些概念:
- shell命令行的运行原理
- 普通命令 vs 内建命令
- 命令行参数 & 环境变量
6.1、路径无法切换?
子进程做完路径的切换,修改的只是子进程的副本;子进程直到退出的时候,也没能修改父进程(bash)的路径信息。所以路径切换的工作只能由父进程来做。
实际上,不仅路径切换指令cd,查看环境变量指令env、导入环境变量指令export、以及echo......这些命令,都必须由父进程执行,都是内建命令。
所以我们得到一个解析完的字符串,还应该先判断一下是否是内建命令:
c
if (CheckBuildinAndExecute())
continue;
我们执行内建命令的大致思路是:进入CheckBuildinAndExecute,
- 如果是内建命令,执行内建命令,返回"1",此时命中
if条件,就转而去等待用户的下一次输入; - 如果不是内建命令,返回"0",就不会命中
if条件,而是继续往下,执行普通命令。
做路径切换,我们借助库函数chdir:
c
static int CheckBuildinAndExecute()
{
int ret = 0;
if (0 == strcmp(argv[0], "cd"))
{
ret = 1;
if (2 == argc)
chdir(argv[1]);
}
return ret;
}
6.2、完善提示符

路径切换确实能做了,但是又出现一个问题:
命令行提示符中的路径,看起来没有改变?
实际上,我们是从环境变量中获取的当前工作路径。chdir并没有改变环境变量的PWD。
在这里,我们获取当前工作路径的时候,不从环境变量中获取,而是借助库函数getcwd:
c
void GetPwd()
{
// char *_pwd = getenv("PWD");
// strcpy(pwd, _pwd ? _pwd : "None");
getcwd(pwd, sizeof(pwd));
}

只给出最近的目录名?
有的平台下的命令行解释器,对于当前工作目录,只会给出最近的目录名,例如10_5。
这时我们就可以实现一个简单的算法,提取最近的目录名:
c
void GetPwd()
{
// char *_pwd = getenv("PWD");
// strcpy(pwd, _pwd ? _pwd : "None");
//getcwd(pwd, sizeof(pwd));
char _cwd[256];
getcwd(_cwd, sizeof(_cwd));
if (0 == strlen(_cwd))
{
strcpy(pwd, "None");
}
else if (0 == strcmp(_cwd, "/"))
{
strcpy(pwd, _cwd);
}
else
{
int end = strlen(_cwd) - 1;
while (end >= 0)
{
if ('/' == _cwd[end])
{
strcpy(pwd, &_cwd[end + 1]);
break;
}
--end;
}
}
}

注意这里使用的 while 循环的判断条件,不能用 while(_cwd[end] != '/') ,否则遇到'/'就会结束循环,永远也提取不到目录名。
6.3、echo $?提取退出码
(当前我们只针对普通命令的退出码的提取)
首先我们要完善Execute的进程等待,以及添加一个static全局变量记录退出码:
c
static exit_code = 0;
// ...
static void Execute()
{
// ...
// ...
else
{ // parent
int status = 0;
pid_t rid = waitpid(id, &status, 0);
(void)rid; // 接收但不处理
exit_code = WEXITSTATUS(status);
}
}
我们统一将echo设置为内建命令。echo包括两种功能;
- 打印字符串(会换行)
- 打印退出码
......
c
static int CheckBuildinAndExecute()
{
int ret = 0;
if (0 == strcmp(argv[0], "cd"))
{
ret = 1;
if (2 == argc)
chdir(argv[1]);
}
else if (0 == strcmp(argv[0], "echo"))
{
ret = 1;
if (1 == argc)
{
printf("Nothing to do.\n");
return 1;
}
else if ('$' == argv[1][0])
{
if (0 == strcmp(argv[1], "$?"))
{
printf("%d\n", exit_code);
exit_code = 0;
}
}
else
{
printf("%s\n", argv[1]);
}
}
return ret;
}
6.4、环境变量的获取
对于环境变量,我们有两个问题需要回答:
- 环境变量由谁维护?bash
- 环境变量在哪获取?配置文件
但是从配置文件中读取,我们做不到,就只能从真正的bash中获取环境变量:
c
// 环境变量
static int envc;
static char *env[64];
// ...
static void LoadEnv() // 从真正的bash中读取环境变量
{
extern char **environ;
for (envc = 0; environ[envc]; ++envc)
env[envc] = environ[envc];
env[envc] = NULL;
}
值得注意的是,LoadEnv可不能放到循环内部。因为下一次等待命令的时候,由于又执行了一次LoadEnv,之前对环境变量表的修改就会被覆盖。

至于执行env查看环境变量表,我们可以修改检查内建命令的函数CheckBuildinAndExecute,添加env分支。
6.5、环境变量的添加
即内建命令export的完善。
c
else if (0 == strcmp(argv[0], "export")) // export
{
if (argc == 2)
{
env[envc++] = argv[1];
env[envc] = NULL;
}
}
在CheckBuildinAndExecute中这样写,就可以了吗?
c
else if (0 == strcmp(argv[0], "export")) // export
{
if (argc == 2)
{
env[envc++] = argv[1];
env[envc] = NULL;
}
}


只查到一行空白,怎么回是?
实际上,这种添加环境变量的方式,我们也不陌生:浅拷贝 。env表的最后一个有数据的项是直接指向argv[1]指向的内容的,当Bash()等待用户的下一次输入时,argv会被清空,env表的最后一个有效项就指向了一堆\0,就出现了上面的情况。
其中一个解决的办法,就是深拷贝:
c
else if (0 == strcmp(argv[0], "export")) // export
{
if (argc == 2)
{
// env[envc++] = argv[1];
// env[envc] = NULL;
char *mem = (char*)malloc(sizeof(argv[1] + 1));
strcpy(mem, argv[1]);
env[envc++] = mem;
env[envc] = NULL;
}
}
7、其它知识点
7.1、进一步理解:环境变量 vs 本地变量
对于环境变量,其实就是bash内部隐藏了一个环境变量表,接着用一个二级指针指向环境变量表;用户定义一个同名指针,就可以通过这个同名指针,获取环境变量表中的所有信息。

至于本地变量,其实也是bash内部隐藏了一个本地变量表。只要bash没有识别到export,就会写入本地变量表中。
c
void Bash()
{
// ...
static char *local[1024]; // 本地变量表
// ...
}
7.2、一个命令,两种身份?

上面的学习中,我们默认将echo env等命令当作了内建命令,从而来完善自主shell。实际上,类似于echo env,Linux中也有相当一批命令,既有在bash中 实现的方法,又有在磁盘上可随时调用的程序。
原因是,shell不仅含有解释命令的解释器bash:

与此同时,shell还是一门语言。
意味着shell可以像python一样,既可以做交互式的命令执行工作,又可以对一批写好代码和数据的文件,做语法分析与一系列处理。
对于同一种功能,
- 交互式工作场景下,一般都是bash自己的工作,就需要bash内部实现方法,即内建命令;
- 批量化处理场景下,bash就可以创建子进程执行,就需要磁盘上已经实现好的方法,即普通命令。
7.3、当前自主shell不支持的功能
当前的自主shell不支持:
- 权限的管理。例如
cat打开文件的请求会不会被拦截 > < >>重定向操作- 管道操作
- 取别名(实际上我们也可以创建一个别名表 ,然后别名实际上也是一个变量)
这些不支持的功能,未来大家有兴趣的话也可以尝试完善一下🤝。