声明:本文代码由C语言实现,环境是CentOS 7.9
Shell是什么?
简单来说,Shell 是一个命令行解释器。它是用户与计算机操作系统内核(Kernel)之间的桥梁和接口。
- 内核 (Kernel):操作系统的核心。它直接与计算机硬件(CPU、内存、硬盘等)进行交互,管理着所有底层的、关键的操作。用户一般不直接与内核打交道,因为这会非常复杂且危险。
- Shell:包裹在内核外面的“外壳”。它接收用户输入的命令,将其解释并转换为内核能够理解的语言(系统调用),然后将内核执行的结果输出给用户。
一个生动的比喻:
内核是发动机、变速箱和底盘,而 Shell 是方向盘、油门踏板和仪表盘。作为司机(用户),你通过操作方向盘和踏板(在 Shell 中输入命令)来控制汽车(计算机)的运行。
在Linux中,我们常见的shell类型有bash和sh。
实现一个简易的Shell
在过去我们学习了进程概念和进程控制,内容繁多,真叫人苦不堪言呐!
实现一个简易的shell,可以很好的将这些知识揉在一起,综合运用,特别是进程控制。
1. 获取命令行
打印提示语
在输入命令行之前,都有一条提示语打印在前面,如下图(xshell 8):

提示语的内容是什么呢?
依次为:
[ + 用户名 + @ + 主机名 + 当前所在目录 + ] + $
这里的用户名、主机名、当前目录可以在环境变量中找到,并用getenv()函数提取出来。

现在我们仿照着用printf打印出来:
#define LEFT "["
#define RIGHT "]"
#define STYLE "#"
printf(LEFT"%s@%s %s"RIGHT""STYLE, getenv("USER"), getenv("HOSTNAME"), getenv("PWD"));
将一些符号定义为宏,方便日后我们可以随意更换风格。
这里的当前所在目录我直接采用路径了,如果想要获取当前所在目录,截取路径最后一个/后的子串即可。
输入命令行
首先scanf是肯定麻烦的,因为命令行有许多空格。
最好的选择应该是只需要换行中断输入的函数,fgets()。如果是用C++的话可以用getline()。
另外,有一些细节需要注意:
- 命令行不可能为空(最少都应该有一个
\n,否则一定是出现了异常) ,assert断言解决 - 命名行被用过后,不能被再次使用,需要刷新后才能使用,强转类型阻止重复使用
- 结束输入命令行时,需要回车结束,这样在尾部会留下
\n,用\0覆盖即可
#define LINE_SIZE 1024
char commandline[LINE_SIZE];
char* s = fgets(commandline, LINE_SIZE, stdin);
assert(s);//命令行不可能为空
(void)s;//表示该命名行已被用过,强转类型阻止重复使用
commandline[strlen(commandline)-1] = '\0';//结束输入命令行时,需要回车结> 束,这样在尾部会留下\n,需要除去
最后我们用函数将这些逻辑封装起来,在主函数调用即可:
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<assert.h>
#define LEFT "["
#define RIGHT "]"
#define STYLE "#"
#define LINE_SIZE 1024
char commandline[LINE_SIZE];//存储命令行
void interact(char* cline, int size)
{
printf(LEFT"%s@%s %s"RIGHT""STYLE, getenv("USER"), getenv("HOSTNAME"), getenv("PWD"));
char* s = fgets(cline, size, stdin);
assert(s);//命令行不可能为空
(void)s;//表示该命名行已被用过,强转类型阻止重复使用
commandline[strlen(commandline)-1] = '\0';//结束输入命令行时,需要回车结束,这样在尾部会留下\n,需要除去
}
int main()
{
//1. 获取命令行
interact(commandline, sizeof(commandline));
//测试命令行
for(int i = 0; i < sizeof(commandline); ++i ) printf("%c", commandline[i]);
printf("\n");
return 0;
}
运行结果:

2. 解析命令行
我们需要将输入的命令行的内容提取出来,关键点在于命令行每一部分内容由空格隔开。我们目的在于将每部分用数组存储起来,届时我们需要哪部分时直接通过数组访问即可。
因此,我们的问题转化为:提取字符串以空格为分隔符的各部分子串。
这个问题在C++中非常简单,但在C语言中就略微复杂了。
此处strtok函数较为合适,这个函数用法特殊,此处简单介绍一下:
strtok函数
函数原型:
#include<string.h>
char *strtok(char *str, const char *delimiters);
功能:strtok 函数用于将字符串 str 分割成一系列由分隔符 delimiters 分隔的子字符串。
核心思想:你第一次调用它时,传入要分割的字符串。之后每次调用,它都会返回下一个子字符串的指针,直到没有更多子字符串可找,此时返回 NULL。
参数说明:
- str:
- 第一次调用时:传入要被分割的字符串的指针(例如 char *)。
- 后续调用时:传入 NULL。这是因为 strtok 函数内部有一个静态指针,它会记住上次分割结束的位置。传入 NULL 就是告诉函数:“继续从上一次的位置开始分割下一个子字符串”。
- delimiters:
- 一个字符串,里面包含了所有被视为分隔符的字符。
- 例如," ,.-" 表示空格、逗号、句点和连字符都是分隔符。注意:分隔符是字符集合,而不是一个完整的子字符串。
返回值:
- 成功找到令牌时:返回一个指向该子字符串的指针。
- 无法再找到更多令牌时:返回 NULL。
掌握了strtok了后,问题就很简单了。
核心逻辑:首次调用strtok获取第一个子串,再循环调用strtok依次获取后续子串。当子串为NULL时,循环结束并将NULL也赋值给目标子串数组的结尾。另外我们可以将循环使用的数组索引处理一下,变成我们数组的大小用作返回,表示子串个数,
代码如下:
cline为命令行;_argv为目标数组。
int splitstring(char cline[], char* _argv[])
{
int i = 0;
_argv[i++] = strtok(cline, DELIM);//先获取第一个子串
while(_argv[i++] = strtok(NULL, DELIM));//后传NULL作为参数,循环获取后续子串。
return i - 1;//返回子串个数
}
我们知道,我们在使用shell时,shell进程是不中断的,在输入完命令行执行后又会提示输入命令。
怎么做到的呢?
很简单,死循环就好了!
当前main函数代码:
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<assert.h>
#define LEFT "["
#define RIGHT "]"
#define STYLE "#"
#define DELIM " /t"//分隔符
#define LINE_SIZE 1024
#define ARGV_SIZE 32
char commandline[LINE_SIZE];//存储命令行
char* argv[ARGV_SIZE];//存储解析好的子串集
int quit = 0;
int main()
{
while(!quit)
{
//1. 获取命令行
interact(commandline, sizeof(commandline));
//2. 解析命令行(字符串分隔问题)
int argc = splitstring(commandline, argv);
if(argc == 0) continue;//若解析命令行为空,重新输入命令行
//测试
for(int i = 0; i < argc; ++i) printf("%s ", argv[i]);
printf("\n");
}
return 0;
}
有如下结果,就说明成功了!

3. 指令的判断
我们知道,指令分为两类:
- 普通命令:创建子进程并替换相应指令的程序完成
- 内建命令:Shell进程内部调用函数来完成
这两类命令处理方式完全不同,因此我们需要判断并分类讨论。
如何判断?
- 判断该命令是否是我们创建的内建命令,如果是,就在执行内建命令的函数中返回真。反之,返回假。
- 如果为真,就不运行普通命令函数,反之,就运行。
//判断命令
//1 内建命令
int n = buildcommand(argv, argc);
//2 普通命令
if(!n) commoncommand(argv);
3.1 内建命令
所谓内建命令,顾名思义就是我们自己创建命令。
内建命令有许多,需要我们一一实现,本文只实现cd、export、echo。
我们只需检查命令行第一个子串(argv[0])即可,因为它才是命令的核心,后面都是它的选项罢了。
如何检查?利用strcmp的返回值判断即可。
对于cd命令,作用是修改工作目录
在Linux中,存在一个修改工作目录的系统调用函数——chdir()
函数原型:
#include <unistd.h>
int chdir(const char *path);
绝对路径和相对路径均可做参数。
另外,我们还需更新环境变量,核心逻辑如下:
用一个字符数组存储当前工作目录的绝对路径,然后用该数组覆盖原环境变量PWD即可。
- 如何获取当前工作目录的绝对路径?
用系统调用接口getcwd:#include <unistd.h> char *getcwd(char *buf, size_t size); - 如何用数组覆盖原环境变量
PWD?
用函数sprintf:#include <stdio.h> int sprintf(char *str, const char *format, ...);
代码如下:
if(_argc == 2 && strcmp(_argv[0], "cd") == 0)
{
//切换目标工作目录
chdir(_argv[1]);
//更新环境变量
getcwd(pwd, sizeof(pwd));//获取当前工作目录绝对路径
sprintf(getenv("PWD"), "%s", pwd);
return 1;
}
对于export命令,作用是导入环境变量、
这里非常容易出错,这里不能直接将_argv[1]直接作为putenv的参数
原因:
- 环境变量表存储的并非环境变量本身,它是一个指针数组,存储的都是各个环境变量的地址。
- 如果我们将
_argv[1]的地址添加进环境变量表,而_argv[1]内容并不是固定的。 - 当我们输入下一条命令时,
_argv[1]就会改变,就会从环境变量表中消失。
解决方法:
用一个全局数组存储目标环境变量,然后用该数组作为putenv的参数即可
//自定义环境变量
char myenv[LINE_SIZE];
else if(_argc == 2 && strcmp(_argv[0], "export") == 0)
{
strcpy(myenv, _argv[1]);
putenv(myenv);
return 1;
}
注意:这里有很大的优化空间,比如
- putenv不安全,可以换成setenv。
- 使用putenv,可以动态申请空间,这样就可以添加更大的环境变量。但可能会有内存泄漏。
对于echo命令,作用是回显
- 回显上一个进程的退出码(
echo $?) - 回显环境变量(
echo $) - 回显输入的内容(
echo)
代码如下:
int lastcode = 0;//存储上一个进程的退出码
else if(_argc == 2 && strcmp(_argv[0], "echo") == 0)
{
if(strcmp(_argv[1], "$?") == 0)//查看上一次退出码
{
printf("%d\n", lastcode);
lastcode = 0;
}
else if(*_argv[1] == '$')
{
char *val = getenv(_argv[1]+1);
if(val) printf("%s\n", val);
}
else
{
printf("%s\n", _argv[1]);
}
return 1;
}
另外,我们还可以使我们ls出来的文件带颜色:
// 特殊处理ls,使字体有颜色
if(strcmp(_argv[0], "ls") == 0)
{
_argv[_argc++] = "--color";
_argv[_argc] = NULL;
}
效果图:

完整代码:
int buildcommand(char* _argv[], int _argc)
{
//内建命令cd
if(_argc == 2 && strcmp(_argv[0], "cd") == 0)
{
//切换目标工作目录
chdir(_argv[1]);
//更新环境变量
getcwd(pwd, sizeof(pwd));//获取当前工作目录绝对路径
sprintf(getenv("PWD"), "%s", pwd);
return 1;
}
//export
else if(_argc == 2 && strcmp(_argv[0], "export") == 0)
{
strcpy(myenv, _argv[1]);
putenv(myenv);
return 1;
}
// echo
else if(_argc == 2 && strcmp(_argv[0], "echo") == 0)
{
if(strcmp(_argv[1], "$?") == 0)//查看上一次退出码
{
printf("%d\n", lastcode);
lastcode = 0;
}
else if(*_argv[1] == '$')
{
char *val = getenv(_argv[1]+1);
if(val) printf("%s\n", val);
}
else
{
printf("%s\n", _argv[1]);
}
return 1;
}
// 特殊处理ls,使字体有颜色
if(strcmp(_argv[0], "ls") == 0)
{
_argv[_argc++] = "--color";
_argv[_argc] = NULL;
}
return 0;
}
3.2 普通命令
核心逻辑:
- 用fork创建子进程
- 用if将父子进程分流
- 子进程执行命令(进程替换)
- 父进程回收子进程并接受子进程的运行结果(进程等待)
代码如下:
void commoncommand(char* _argv[])
{
int id = fork();
if(id < 0)//fork错误
{
perror("fork");
return;
}
else if(id == 0)//child
{
//进程替换,让子进程执行命令
execvp(_argv[0], _argv);
exit(EXIT_CODE);
}
else//father
{
//阻塞等待子进程
int status = 0;
int ret = waitpid(id, &status, 0);
if(ret == id)
{
lastcode = WEXITSTATUS(status);//获取最近一个进程的退出码
}
}
}
4. 完整代码
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<assert.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#define LEFT "["
#define RIGHT "]"
#define STYLE "#"
#define DELIM " \t"//分隔符
#define LINE_SIZE 1024
#define ARGV_SIZE 32
#define EXIT_CODE 44
char commandline[LINE_SIZE];//存储命令行
char* argv[ARGV_SIZE];//存储解析好的子串集
char pwd[LINE_SIZE];//存储当前工作目录的绝对路径
int quit = 0;
int lastcode = 0;//存储上一个进程的退出码
extern char **environ;
//自定义环境变量
char myenv[LINE_SIZE];
void interact(char* cline, int size)
{
printf(LEFT"%s@%s %s"RIGHT""STYLE, getenv("USER"), getenv("HOSTNAME"), getenv("PWD"));
char* s = fgets(cline, size, stdin);
assert(s);//命令行不可能为空
(void)s;//表示该命名行已被用过,强转类型阻止重复使用
commandline[strlen(commandline)-1] = '\0';//结束输入命令行时,需要回车结束,这样在尾部会留下\n,需要除去
}
int splitstring(char cline[], char* _argv[])
{
int i = 0;
_argv[i++] = strtok(cline, DELIM);//先获取第一个子串
while(_argv[i++] = strtok(NULL, DELIM));//后传NULL作为参数,循环获取后续子串。
return i - 1;//返回子串个数
}
int buildcommand(char* _argv[], int _argc)
{
//内建命令cd
if(_argc == 2 && strcmp(_argv[0], "cd") == 0)
{
//切换目标工作目录
chdir(_argv[1]);
//更新环境变量
getcwd(pwd, sizeof(pwd));//获取当前工作目录绝对路径
sprintf(getenv("PWD"), "%s", pwd);
return 1;
}
//export
else if(_argc == 2 && strcmp(_argv[0], "export") == 0)
{
strcpy(myenv, _argv[1]);
putenv(myenv);
return 1;
}
// echo
else if(_argc == 2 && strcmp(_argv[0], "echo") == 0)
{
if(strcmp(_argv[1], "$?") == 0)//查看上一次退出码
{
printf("%d\n", lastcode);
lastcode = 0;
}
else if(*_argv[1] == '$')
{
char *val = getenv(_argv[1]+1);
if(val) printf("%s\n", val);
}
else
{
printf("%s\n", _argv[1]);
}
return 1;
}
// 特殊处理ls,使字体有颜色
if(strcmp(_argv[0], "ls") == 0)
{
_argv[_argc++] = "--color";
_argv[_argc] = NULL;
}
return 0;
}
void commoncommand(char* _argv[])
{
int id = fork();
if(id < 0)//fork错误
{
perror("fork");
return;
}
else if(id == 0)//child
{
//进程替换,让子进程执行命令
execvp(_argv[0], _argv);
exit(EXIT_CODE);
}
else//father
{
//阻塞等待子进程
int status = 0;
int ret = waitpid(id, &status, 0);
if(ret == id)
{
lastcode = WEXITSTATUS(status);//获取最近一个进程的退出码
}
}
}
int main()
{
while(!quit)
{
//1. 获取命令行
interact(commandline, sizeof(commandline));
//测试
// for(int i = 0; i < sizeof(commandline); ++i ) printf("%c", commandline[i]);
// printf("\n");
//2. 解析命令行(字符串分隔问题)
int argc = splitstring(commandline, argv);
if(argc == 0) continue;//若解析命令行为空,重新输入命令行
//测试
//for(int i = 0; i < argc; ++i) printf("%s ", argv[i]);
//printf("\n");
//3. 判断命令
//3.1 内建命令
int n = buildcommand(argv, argc);
//3.2 普通命令
if(!n) commoncommand(argv);
}
return 0;
}


1837

被折叠的 条评论
为什么被折叠?



