🌎自定义简单shell制作
(ps: 文末有完整代码)
文章目录:
自定义简单shell制作
简单配置Linux文件
自定义Shell编写
命令行解释器
获取输入的命令
字符串分割
子进程进行进程替换
内建命令处理
cd命令处理
路径显示问题
export命令处理
echo 命令处理
自定义Shell源码
前言:
通过我们之前所学Linux知识以及C语言的知识,到目前为止,我们完全可以独立完成简易shell的制作,那么话不多说,开始今天的话题!
🚀简单配置Linux文件
首先,再开始项目之前,需要先简单配置一下Linux文件,选择一个位置,创建本次项目的目录:
mkdir myshell#名字随意,这里方便区分命名myshell
如图所示在该目录下,我们还需要创建 makefile文件 和 C的源文件:
touch makefile#或者 Makefile
touch myshell.c#其他名字都行,后缀是.c即可
因为我们构建的是C语言项目,所以makefile内文件配置也很简单,使用vim(vim介绍及其使用)打开makefile文件:
vim makefile
配置makefile文件:
cc=-std=c99
mybin:file.cgcc -o $@ $^ -g $(cc)
.PHONY:clean
clean:rm -f mybin
保存退出之后,就可以开始编写我们C语言代码啦,配置还是很简单的。
🚀自定义Shell编写
✈️命令行解释器
首先,我们根据常用的shell行为分析:
常用 shell 都有叫做 命令行解释器 的东西(上图红框),而命令行解释器其实就是 由不同的字符串所构成 的,可以拆分成三部分:
第一部分是用户,随后在@之后是主机名字符串,第三部分是 当前所处工作目录。
我们曾经学过一个获取环境变量的接口 getenv:
因为上述三个部分皆可以在系统的环境变量中找到,所以我们可以使用 getenv 接口,将环境变量导出,拿到字符串作为我们自定义shell的命令行解释器:
#include<stdio.h>
#include<stdlib.h>char* HostName()//获取主机名
{char* hostname = getenv("HOSTNAME");//获取主机名环境变量if(hostname) return hostname;else return "None";
}char* UserName()//获取用户名
{char* hostname = getenv("USER");//从用户名环境变量的获取用户名if(hostname) return hostname;else return "None";
}char* CurrentWorkDir()//获取当前工作目录
{char* hostname = getenv("PWD");//获取当前路径if(hostname) return hostname;else return "None";
}int main()
{//命令行提示符编写printf("[%s@%s %s]$ ",UserName(), HostName(), CurrentWorkDir());return 0;
}
效果展示:
效果还是很不错的,有些细节仍需该进,会在后面慢慢改善。
✈️ 获取输入的命令
有了命令行解释器,我们在 shell 上还有输入命令这一行为,那么我们自定义shell就需要接收输入的命令行字符串。
那么我们需要考虑的就是输入命令的情况:1、单个命令输入。2、命令+选项输入。 其实他们的区别很明显,一种 字符串不带空格,一种字符串 带一个或多个空格,比如:
使用C语言的scanf显然是行不通的,在这里我推荐使用 fgets 接口,可以接收输入的空格:
返回值表示输入的字符串,前两个参数不用说,但是最后一个参数可以能很多人不太理解,我们在学C语言的时候,可能大家学过三个流:stdin、stdout、stderr 流:
当然,不了解也不要紧,我们仅需要知道 我们输入需要从 stdin 流中获取即可,表示从标准输入内获取信息。
函数第一个参数表示 接收字符串的位置,第二个参数表示 接收大小,我们定义一个数组,用来接收输入的命令行参数:
#define CMD_SIZE 1024//定义数组大小
char commandline[CMD_SIZE];//接收命令行参数的数组
那么我们就需要把接收的命令行参数放入到 commandline数组里。在 Shell中,一行命令输入完成之后将直接生效。所以在命令输入完成之后,我们有必要给commandline数组结尾,也就是添加 ‘\0’:
int main()
{char commandline[CMD_SIZE];//命令行提示符编写printf("[%s@%s %s]$ ",UserName(), HostName(), CurrentWorkDir());fgets(commandline, CMD_SIZE, stdin);//获取输入命令,将其放在commandline数组内commandline[strlen(commandline) - 1] = '\0';//结束本行命令,记得包含string头文件printf("cmd line: %s\n", commandline);return 0;
}
将shell运行起来之后,我们输入的命令就可以被检测并输入到字符数组里面了。
为了让代码更具可读性,我们可以将输出命令行解释器和输入命令接收操作封装在一个函数内,再在main函数调用:
void Interactive(char out[], int size)//接口封装
{printf("[%s@%s %s]$ ",UserName(), HostName(), CurrentWorkDir());fgets(out, CMD_SIZE, stdin);out[strlen(out) - 1] = '\0';
}int main()
{char commandline[CMD_SIZE];Interactive(commandline, CMD_SIZE); //使用接口调用即可printf("cmd line: %s\n", commandline);return 0;
}
✈️ 字符串分割
我们平时在shell 中输入的命令选项是不确定的,有时候有多个选项,有时候有一个选项,有时候没有选项,而shell会根据不同的选项来执行不同的动作。
所以我们有必要将字符串切割,而我们之前在学习命令行参数的时候,提到过main函数参数有一个叫做 argv 的 命令行参数表(const char* argv[]),那么我们就可以创建一个命令行参数表来接收每一个子串。
那么如何切割字符串呢?这里有一个C语言的接口可供大家使用 strtok:
第一个参数表示 指向要分割的字符串,第一次调用时需要指定这个参数,以后的调用要继续分割同一个字符串,就应该把参数 str 设置为 NULL。
第二个参数表示 以什么字符或字符串为结尾进行切割,返回值表示 返回切割后的子串,如果查找不到切割点了,就会返回NULL。
而我们命令行都是以 空格作为分隔符 的,所以,空格字符就是该接口的第二个参数了,而这个接口会被频繁调用,所以,我们直接使用宏定义空格:
#define MAX_ARGC 64//argv数组的大小
#define SEP " "//表示空格
argv是一个指针数组,所以每一个元素都可以指向一段字符串,同时,我们希望argv数组下标能一一对应,所以需要一个键值作为索引:
int i = 0;
argv[i++] = strtok(commandline, SEP);
但是,我们输入的命令很可能不止一个空格,所以,我们需要使用循环控制子串的切割,让argv数组的每一个元素都能对应到切割的字符串:
while(argv[i++] = strtok(NULL, SEP));//注意这里用的是=并非==
并且,这样一个好处就是 在argv数组最后是以 NULL结尾的。同样,为了代码的可读性,我们可以将切割子串的功能封装为一个接口,并且 argv数组放在全局位置,因为根据以往的经验,父子进程可能都会需要argv数组:
void Split(char in[], int size)
{int i = 0;argv[i++] = strtok(in, SEP);//进行子串切割while(argv[i++] = strtok(NULL, SEP));
}int main()
{char commandline[CMD_SIZE];Interactive(commandline, CMD_SIZE); //对命令行字符串切割Split(commandline, CMD_SIZE);for(int j = 0; argv[j]; ++j)//测试命令行参数是否切割成功{printf("argv[%d]:%s\n", j, argv[j]);}return 0;
}
✈️ 子进程进行进程替换
前面我们学习过,程序替换成功时,后续程序就不会往下走,又因为进程之间具有独立性,所以需要创建一个子进程来完成进程替换这件事情。
pid_t id = fork();
if(id == 0)
{//子进程,执行程序替换exec*();//进程替换函数,待定exit(0);
}
pid_t rid = waitpid(id, NULL, 0);//阻塞等待子进程
printf("run done, rid: %d\n", rid);
如果要执行命令,那就需要进行程序替换,但是程序替换我们介绍了七个接口,使用哪一个接口会比较好呢?根据前面所写的代码,我们已经有了 argv 这张命令行参数表,所以使用接口一定是要带 ‘v’ 的。
带 ‘v’ 的接口也有三个,execvp 接口是最好的选择,为什么大家可以自己思考一下,很简单:
execvp(argv[0], argv);//根据命令在环境变量里查找,在根据选项做出对应的动作
同样为了代码可读性,我们将其封装为一个接口:
void Execute()
{pid_t id = fork();if(id == 0){//child process execvp(argv[0], argv);exit(1);}pid_t rid = waitpid(id, NULL, 0);printf("run done, rid: %d\n", rid);
}
但是这里我们自定义shell只能运行一次,为了让命令一直能运行下去,我们就得循环执行:
int main()
{while(1){char commandline[CMD_SIZE];Interactive(commandline, CMD_SIZE); //对命令行字符串切割Split(commandline, CMD_SIZE);//执行命令Execute();}return 0;
}
这样,我们的shell就初具雏形了!
✈️内建命令处理
🚩 cd命令处理
我们来看这样一个现象:
命名我已经切换目录很多次了,但是为什么目录没有改变呢?其实这是因为我们一直是在使用子进程执行命令的,所以仅仅是子进程一直在切换目录,父进程的目录却一直不变。
所以向cd 这种命令,我们就不能交给子进程操作,而这样的命令我们称为 内建命令。
为了解决内建命令,我们可以 把cd 命令来单独处理,用一个接口封装。在执行命令之前,检测输入的命令是否是内建命令,如果是,则处理内建命令,如果不是则直接跳过,执行其他命令。
我们根据封装接口的返回值判断是否为cd 命令,在选择跳过还是处理命令,那么在接口内部的实现。
int BuildinCmd()
{int ret = 0;if(strcmp("cd", argv[0]) == 0)//通过字符串匹配检测是不是cd 命令{ret = 1;//处理cd 命令}return ret;
}
处理cd 命令之前我们得先了解cd 命令有哪些特殊表示,cd 命令无外乎:cd -,cd ~,cd /工作目录或文件/,cd。其中只有cd 是不带空格的,其行为是:
如果cd 不带任何选项,那么其行为就是 切换到家目录。知道了这种特殊情况之后就好办了,除了这个不带选型的命令以外,其他的命令全部要根据选项处理,那么就要根据选项切换目录了,我们可以使用 chdir 接口切换目录:
const char* Home()
{return getenv("HOME");//从HOME环境变量获取当前系统的家目录
}int BuildinCmd()
{int ret = 0;if(strcmp("cd", argv[0]) == 0)//通过字符串匹配检测是不是cd 命令{ret = 1;//处理cd 命令char* target = argv[1];if(!target) target = Home();chdir(target);}return ret;
}int main()
{while(1){char commandline[CMD_SIZE];Interactive(commandline, CMD_SIZE); //对命令行字符串切割Split(commandline, CMD_SIZE);//处理内建命令int n = BuildinCmd();if(n) continue;//执行命令Execute();}return 0;
}
这样cd 命令自由的使用了,切换目录也丝毫不费力了。
🚩 路径显示问题
这里还有一个很明显的错误行为,我的命令行解释器的路径从开始就没有变过,其实是因为我们没有更新PWD环境变量,我们可以手动给当前进程更新环境变量,使用一个数组存储当前目录,再使用 putenv 将环境变量导出:
char pwd[CMD_SIZE];//定义全局数组int BuildinCmd()
{int ret = 0;if(strcmp("cd", argv[0]) == 0)//通过字符串匹配检测是不是cd 命令{ret = 1;//处理cd 命令char* target = argv[1];if(!target) target = Home();chdir(target);snprintf(pwd, CMD_SIZE, "PWD=%s", target);//将改变后的路径以 PWD=...的形式写入进pwd数组putenv(pwd);//此时数组内容为PWD=...此时putenv就可以更改环境变量了}return ret;
}
刚才的问题解决了…吗??并没有,我们使用cd …或者cd -这种命令的时候路径就显示不出来了,虽然说我们这么写的代码不对,但是我们思路是对的,更新PWD环境变量,那么我们只好使用 Linux 提供的 getcwd 接口了:
这个接口可以 获取当前工作目录的绝对路径。
int BuildinCmd()
{int ret = 0;if(strcmp("cd", argv[0]) == 0){ret = 1;char* target = argv[1];if(!target) target = Home();chdir(target);char tmp[1024];getcwd(tmp, 1024);//获取当前工作目录snprintf(pwd, CMD_SIZE, "PWD=%s", tmp);putenv(pwd);}return ret;
}
这样就可以正常切换目录了。
🚩 export命令处理
当我们在 自定义 Shell 中导入一个新的环境变量时,也是由子进程进行程序替换完成这件事的,所以,当我们使用hell进行env时,是看不到导入的环境变量的:
所以,export也是一个内建命令,那么我们就需要在对应的接口里处理export命令:
char env[CMD_SIZE];//全局数组,接收环境变量int BuildinCmd()
{int ret = 0;if(strcmp("cd", argv[0]) == 0){//...}else if(strcmp("export", argv[0]) == 0)//处理export内建命令{ret = 1;if(argv[1]){strcpy(env, argv[1]);//将需要导入的环境变量放到数组当中putenv(env);//使用接口导入环境变量}}return ret;
}
此处理方法与cd命令类似,仔细看注释也是很好理解的:
🚩 echo 命令处理
我们曾经在shell中演示过 echo的各种用法,其中有 echo $?
表示上一个进程的退出码,除此之外,还有:echo
、echo $env_name
、echo ...
,这些特殊情况我们依旧需要处理。
首先,比较特殊的就是 echo $?
这个命令,这个命令需要显示上一个进程的退出码,而获取进程的退出码,这个时候我们就需要先在全局范围内设置退出码变量:
int lastcode = 0;//退出码
退出码是在执行完进程之后返回的结果,所以必定要在 execute 接口内接收执行命令的退出码(进程退出码相关知识):
int lastcode = 0;void Execute()
{pid_t id = fork();if(id == 0){//child process execvp(argv[0], argv);exit(1);}int status = 0;pid_t rid = waitpid(id, &status, 0);//使用阻塞等待if(rid == id) lastcode = WEXITSTATUS(status);//保存退出码
}
而echo 命令也是一个内建命令。它是在 shell 程序中提供的命令,用于在终端输出文本或环境变量的值。所以我们也需要在内建命令中处理echo命令:
int BuildinCmd()
{int ret = 0;if(strcmp("cd", argv[0]) == 0){ret = 1;//...}else if(strcmp("export", argv[0]) == 0){ret = 1;//...}else if(strcmp("echo", argv[0]) == 0)//处理echo命令{ret = 1;if(argv[1] == NULL)//单单echo命令{printf("\n");//仅仅换行}else{if(argv[1][0] == '$')//argv是一个指针数组,相当于 *(argv[1]),这样获取对你理解有帮助{if(argv[1][1] == '?')//同理当$后是?的情况{printf("%d\n", lastcode);//输出退出码lastcode = 0;//退出码重置}else //为获取环境变量的字符串{char* e = getenv(argv[1] + 1);if(e) printf("%s\n", e);}}else //单纯对终端进行输出{printf("%s\n", argv[1]);}} }return ret;
}
🚀自定义Shell源码
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<sys/wait.h>
#include<sys/types.h>#define CMD_SIZE 1024
#define MAX_ARGC 64
#define SEP " "int lastcode = 0;char* argv[MAX_ARGC];
char pwd[CMD_SIZE];
char env[CMD_SIZE];const char* HostName()
{char* hostname = getenv("HOSTNAME");//获取主机名环境变量if(hostname) return hostname;else return "None";
}const char* UserName()
{char* hostname = getenv("USER");//从用户名环境变量的获取用户名if(hostname) return hostname;else return "None";
}const char* CurrentWorkDir()
{char* hostname = getenv("PWD");//获取当前路径if(hostname) return hostname;else return "None";
}char* Home()
{return getenv("HOME");
}void Interactive(char out[], int size)
{printf("[%s@%s %s]$ ",UserName(), HostName(), CurrentWorkDir());fgets(out, CMD_SIZE, stdin);out[strlen(out) - 1] = '\0';
}void Split(char in[], int size)
{int i = 0;argv[i++] = strtok(in, SEP);//进行子串切割while(argv[i++] = strtok(NULL, SEP));if(strcmp(argv[0], "ls") == 0){argv[i - 1] = "--color";argv[i] = NULL;}
}void Execute()
{pid_t id = fork();if(id == 0){//child process execvp(argv[0], argv);exit(1);}int status = 0;pid_t rid = waitpid(id, &status, 0);if(rid == id) lastcode = WEXITSTATUS(status);//保存退出码
}int BuildinCmd()
{int ret = 0;if(strcmp("cd", argv[0]) == 0){ret = 1;char* target = argv[1];if(!target) target = Home();chdir(target);char tmp[CMD_SIZE];getcwd(tmp, CMD_SIZE);snprintf(pwd, CMD_SIZE, "PWD=%s", tmp);putenv(pwd);}else if(strcmp("export", argv[0]) == 0){ret = 1;if(argv[1]){strcpy(env, argv[1]);putenv(env);}}else if(strcmp("echo", argv[0]) == 0){ret = 1;if(argv[1] == NULL)//单单echo命令{printf("\n");//仅仅换行}else{if(argv[1][0] == '$'){if(argv[1][1] == '?'){printf("%d\n", lastcode);lastcode = 0;}else {char* e = getenv(argv[1] + 1);if(e) printf("%s\n", e);}}else {printf("%s\n", argv[1]);}} }return ret;
}int main()
{while(1){char commandline[CMD_SIZE];Interactive(commandline, CMD_SIZE); //对命令行字符串切割Split(commandline, CMD_SIZE);//处理内建命令int n = BuildinCmd();if(n) continue;//执行命令Execute();}return 0;
}
自定义Shell目前就到此为止,当然你可以根据你的喜好去在此基础上拓展更多内容,更加完善这个Shell。
如果这篇文章对您有用的话,还望三连支持博主~~