《简易制作 Linux Shell:详细分析原理、设计与实践》
前引:你是否好奇 Bash 是如何将你输入的命令变成操作系统的实际动作?本项目将一步步教你实现一个支持基本命令执行、管道、重定向和后台运行的 Linux Shell。通过亲手编写代码,你将加深对 Linux 进程模型、文件描述符、信号机制和系统调用的理解,同时提升你的系统编程能力!
目录
简易版:
【一】打印命令行
【二】输入命令行
【三】解析命令行
【四】父子进程创建
【五】进程替换
【六】子进程回收
【七】封装整理
挑战版:
【一】解决cd指令
【二】解决echo命令
简易版:
【一】打印命令行
在原本的shell中,我们每次都可以看见如下的打印,等待你输入指令:
所以可以看到需要获取一些用户当前的环境变量信息,因为这里属于实现,所以我们选择函数调用的方式(getenv(),头文件#include<stdlib.h>)来获取环境变量(获取内容自己个性化设置!):
【二】输入命令行
这里我们要开始输入命令行,在之前我们已经学了命令行的输入其实是一个个字符串,例如:
“ls” “pwd” “touch”等,我们每次输入都是输入的字符串,再根据空格去分割,我们使用 fgets():
函数原型:
char *fgets(char *str, int n, FILE *stream);
第一个参数:一个字符串指针,用来存放从流中读取到的字符串
第二个参数:最多读取的字符数
第三个参数:输入的文件流(
stdin
:标准输入流,通常是键盘输入)
例如:
#define MAX 32char str[MAX];//命令获取
fgets(str,sizeof(str)-1,stdin); //注意去除用户输入的换行符
str[strlen(str)-1]='\0';
效果:将键盘输入的字符串存储到 str 里面,str存储的类似:“pwd ls rm”这样的一整个字符串
【三】解析命令行
现在我们已经利用C语言的库函数 fgets()函数将命令行存储到了 str 数组里面,现在我们通过字符串分割来根据空格和\0将每个命令解析出来,放在一个字符串数组里面:
这里采用的是strtok()库函数,头文件:<string.h>,
分隔符会被替换成 \0
函数原型:
char *strtok(char *str, const char *delim);
第一个参数:从 str 里面拿要分割的字符串
第二个参数:根据delim里面每个字符来进行分割,比如“./-+”
(注意:如果想多次分割,第一次传字符串,后续传 NULL否则会从头重新分割)
返回值:
成功:返回指向当前分割出的子串(token)的指针
没有更多子串:返回 NULL
例如:
#include<string.h> char* argv[MAX]={NULL};
const char* delim=" \0";int i = 0;
//命令行提取
argv[i++]=strtok(str,delim);
while(argv[i-1])
{argv[i++]=strtok(NULL,delim);
}
效果:将刚才的一整个字符串,根据空格截取每段到一个字符串数组里面
【四】父子进程创建
既然我们现在获取了命令行参数,调用就很简单了,可以先分割父子进程:
pid_t d =fork();
if(d==0)
{....
}
else
{....
}
【五】进程替换
将当前子进程的代码数据采用 execvp()进行替换,它的第一次参数只需要是路径就行
注意:如果用户输入的是换行符,需要判断一下!
//argv就是提取的字符串数组//进程替换
if(argv[0]==NULL)
{return 0;
}
int count = execvp(argv[0],argv);
if(count<0)perror("execvp failed");
exit(0);
【六】子进程回收
这里我采用的是阻塞等待,也可以采用非阻塞等待,自定义!
//回收子进程
int count = waitpid(-1,NULL,0);
if(count<0)
{printf("子进程回收失败\n");
}
【七】封装整理
现在我们用函数来封装一下,更加的美观!(注意:拷贝传参自动带清零的效果!)
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<sys/wait.h>#define LEFT "["
#define RIGHT "]"
#define MAX 32int argc=0;
char str[MAX];
char* argv[MAX]={NULL};
const char* delim=" ";//命令行打印
void command_printf()
{printf(LEFT"%s"":""%s"" ""#"RIGHT" ",getenv("USER"),getenv("HOME"));
}
//命令行获取
char* command_get(char str[MAX],int size)
{char* pc=fgets(str,size,stdin);//去除换行符str[strlen(str)-1]='\0';//测试//printf("命令行获取测试:\n");//int i=0;//while(str[i])//{// printf("%c",str[i++]);//}//printf("\n");return pc;
}
//命令行提取
void command_extraction(char str[MAX],const char* delim,char* argv[MAX])
{int i=0;//printf("命令行提取测试:\n");argv[i++]=strtok(str,delim);while(argv[i-1]){argc++;//printf("argv[%d]=%s\n",i-1,argv[i-1]);argv[i++]=strtok(NULL,delim);}//printf("\n");return;
}//命令行参数调用与回收
void command_use(char* argv[MAX])
{pid_t d = fork();if(d==0){//进程替换if(argv[0]==NULL){ return;}int count = execvp(argv[0],argv);if(count<0)perror("execvp failed");//子进程退出exit(EXIT_FAILURE);}else{//回收子进程int count = waitpid(-1,NULL,0); if(count<0){printf("子进程回收失败\n");}}return;
}int main()
{while(1){ //命令行打印command_printf();//命令获取int size=sizeof(str)-1;char* pc=command_get(str,size);if(pc==NULL){printf("读取失败\n");}//命令行提取command_extraction(str,delim,argv);//命令行参数调用command_use(argv);}return 0;
}
效果展示:
挑战版:
现在我们已经完成了基本的shell功能,但是像 echo $PATH、cd ../ 这些内置命令,例如:
原因:在 Linux/Unix 下,shell 命令分为两类:
(1)外部命令例如 ls
、cat
、ps
等,它们是磁盘上的可执行文件。当 shell 执行它们会 fork()
一个子进程,然后 execvp()
加载对应的程序
(2)内置命令(built-in)例如 cd
、echo
(部分实现)、export
、source
、exit
等。这些命令必须由 shell 自己直接执行,不能用 fork()
子进程执行,因为它们会影响 shell 自身的运行环境
【一】解决cd指令
cd 指令的效果就是改变当前的工作目录,而实现的 shell 每轮输出一次的指令效果,然后自己就挂掉了,因此不会影响到下一个子进程,所以 cd 命令不应该给子进程完成,而交给父进程,而父进程本身又是系统shell的子进程,所以我们需要父进程调用 chdir()函数:
补充知识:C语言字符串比较用strcmp()【狗头】continue只能用在循环里面【狗头】
int chdir(const char *path);//参数为目标路径
执行逻辑:
//cd命令判断
if(strcmp(argv[0],"cd")==0 && argc==2)
{/如果是跳到当前目录if(strcmp(argv[1],"./")==0){return;}else{//剩余可以交给chdir函数const char*path=argv[1];int count = chdir(path);if(count==-1){printf("路径执行错误\n");return;}}
}
效果展示:
【二】解决echo命令
shell 不是直接把 $PATH
传给 echo
程序,而是先替成 /usr/local/sbin:/usr/local/bin:...
这样的真实值(命令展开)因此我们需要先判断第二个参数的开头是不是 $ 符号(是则getenv()替换)
原理:先用getenv()获取展开的环境变量,再替换argv[1],就可以直接打印出来
//echo命令判断
if(strcmp(argv[0],"echo")==0 && argc==2)
{//取第二个参数char* pc=argv[1];//防止只有一个¥if(pc[0]=='$' && strlen(pc)>1){//去除¥char* var_name = pc + 1; //获取展开的环境变量 char* value=getenv(var_name);//替换 if(value) { argv[1]=value; } }
}
效果展示:
其它的也可以增加 export 指令,这里就不展示了!正确处理环境变量即可!