【 Linux入门 】之 手搓 命令行解释器 bash(带源码)

目的

主要目的在于进一步了解 Linux 系统下使用进程相关的系统调用 及 shell 工作的基本原理

本篇文章适合有一定C语言基础,及基本了解 Linux操作 和 Linux进程同学编写

为减少废话,我基本不会解释简单语句以及所有函数用法,我相信大家既然要写这个命令行解释器 bash对语法等相关知识肯定是有了一定了解

最终目的实现一个基本能用的bash

主要内容

  1. 获取命令行
  2. 解析命令行
  3. 建立一个子进程(fork)
  4. 替换子进程(execvp)
  5. cd / ecport / env / echo 等特殊命令单独处理,使其运行在父进程中
    1. 父进程等待子进程退出(waitpid)

基本结构

首先在Linux命令行上我们是可以不断输入的,所以其一定是一个循环,在这我们就设置一个死循环吧,最后程序退出使用 CTRL + C 退出即可
如下:
在这里插入图片描述
运行效果
如下输出了我想要的命令行提示字符串,想要不一样的自己可以改printf中的内容
由于是死循环,所以最后输入CTRL + C 退出程序
请添加图片描述
注意:在做下一步时上述的的sleep(1)及命令行提示字符串后面的 \n 可以删掉了,这些只是测试用的

提取输入命令

在命令行输入的命令需先存入数组,供后续处理,可打印测试自己是否将命令存入了数组,但后续需删掉打印代码
在这里插入图片描述
运行如下
请添加图片描述

fgets的使用

通过man手册查询得知上述用到的 fgets资料如下
在这里插入图片描述
在这里插入图片描述
![在这里插入图片描述](https://img-blog.csdnimg.cn/e6f86e72af62437aacc94048f354664

fgets()从流中读取最多一个小于size的字符,并将它们存储到s指向的内存中,读取到EoF或换行符后停止。且换行符也会被存到s指向的内存中,最后会在最后一个数据后一位填充字符串终止符(\0)

fgets()读取成返回s,失败返回NULL

所以读取命令行之后需要检查一下fgets()是否读取失败
在这里插入图片描述

命令初步处理

上面说到,fgets()会把\n也存到字符串中。刚好在命令行输入时就会用回车表示输入结束
所以我加了第19行处理了一下
在这里插入图片描述
没处理之前,printf中并未加换行符但打印出的代码却自动换了,说明存命令输入的数组末尾确实存有\n不然不会自动换行
测试如下:
请添加图片描述
处理之后,打印出的代码不会自动换行了。请添加图片描述

命令的本质

Linux中执行命令的本质就是进程,进程就是执行某个程序,所以执行命令就是执行程序

但是命令基本都是以子进程的形式进行,官方bash 在执行命令时也是多以子进程形式进行的,这样能保证bash的稳定性

子进程的好处:子进程执行命令时出错不会影响父进程并且在发生错误后还会将错误返回给父进程,父进程只需要报错即可

创建子进程

上面讲述了,为保证bash的稳定性可用子进程来执行命令,我也采用这一方法
这次代码,增加了两个头文件
在这里插入图片描述
怎加代码如下
在这里插入图片描述

重要知识补充

说明一下:其实我们平时在命令行执行的 ls -l 其实 ls 就是程序文件名 -l 是程序参数表示我们要怎么执行。

如 ls 等命令程序文件的路径都是在默认环境中的,在默认环境路径下的程序直接输入程序文件名即可运行,但不在默认环境路径下的程序需 ./ 执行

进程替换

上面讲述了执行命令就是执行特定的程序
但是要执行其他程序我们需要进行进程替换,这里我用的进程替换为execvp()介绍如下:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

参数中字符串 file是程序文件名如 ls , argv 是以NULL结尾的指针数组,argv 里存的是 程序文件名,程序参数等 ,execvp()只有错误时才会返回-1

命令处理

上述讲述要执行进程替换execvp需传程序名文件名 ,及以NULL结尾的指针数组
我们想要的:

char* argv[] = {
    
    "ls","-l",NULL};
execvp(argv[0],argv);

但是咱读取命令行得到的是这样的如在命令行输入:ls -l
实际得到的:

char* command = {
    
    "ls -l"}; 

所以咱得处理一下所获得的字符串,并用指针数组储存起来
处理思路:
1 将command 数组中数据以空格为分割,将数据存入指针数组argv中
2 最后在 argv 数组有效元素末尾添加NULL

字符串分割我用的是这个函数
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

strtok()函数的作用是:将一个字符串分解为0个或多个非空标记的序列。在第一次调用strtok()时,要解析的字符串应该在str中指定。在后续的每个应该解析相同字符串的调用中,str必须为NULL。
delim参数指定了一组字节,用于分隔parsedl字符串中的标记。

如果找到了指定分割符则返回其分割后的字符串,注意每次只能找一个分割符并返回一个字符串,失败返回NULL

则代码如下编写

处理字符串的分割函数
在这里插入图片描述
主函数增加了五句
在这里插入图片描述

编辑好后,直接退出vim编译一下运行即可执行ls pwd ps 命令 还能创建文件 等

简单 bash 完成及演示

请添加图片描述

优化bash

ls颜色输出颜色

测试发现自己写的bash,ls输出的文件列表没有配色
在这里插入图片描述

查询得知 ls 只是别名其调用时实际时 ls - -color=auto,所以实际是系统bash在调用ls时同时也调用了配色方案
在这里插入图片描述
在自己的bash中查询 ls 没有带 --color=auto
在这里插入图片描述
经测试在命令行结尾 添加–color=auto即可调用配色方案
在这里插入图片描述
但每次执行 ls 都要手动加上 --color=auto那太麻烦了吧,所以咱直接优化代码
添加代码如下
在这里插入图片描述
再执行就已经有了颜色搭配
在这里插入图片描述

实现cd命令

在自己编写的bash中执行 cd 发现并不作用,这是因为 cd 也在子进程执行了执行完cd后子进程又推出了,改变的是子进程当前目录,但咱们的bash作为父进程并没有改变
在这里插入图片描述
改进代码使 cd 命令在 bash 进程中运行

改进前,咱先了解一下一个函数
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
chdir()将调用进程的当前工作目录更改为path中指定的目录,如果成功,返回0。出错时,返回-1

所以咱要改变当前进程工作目录直接调用chdir即可,但咱需要先筛选出 cd 命令
在这里插入图片描述
查找函数
在这里插入图片描述
编辑好后退出vim,编译运行即可正常使用cd命令
请添加图片描述

ecport 命令

ecport是定义环境变量,env 是查询环境变量

和上述cd一样如果不做特殊处理,ecport和env命令也是被子进程运行了,细想我们自己写的bash每次执行命令都会创建子进程执行,假设先执行 ecport 程序开辟子进程定义 ecport命令 后进程就退出了(进程被销毁ecport定义的环境变量也没了)

之后又想执行 env 命令,程序开辟子进程执行了env命令肯定是查询不到刚刚定义的环境变量,因为他们在执行命令时都不是一个进程,不可能被查询到

但是只要ecport是在父进程运行的,子进程就会继承父进程的环境变量,也就能查询到了

这里就不演示了,直接修改代码吧
思路:ecport 和cd命令一样需要特殊处理,执行在父进程下
添加环境变量的函数如下
在这里插入图片描述
在这里插入图片描述
putenv()函数的作用是:添加或更改环境变量的值。参数字符串的形式为name=value。如果name在环境中不存在,则将string添加到环境中。如果name存在,则将环境中name的值更改为value。string所指向的字符串成为环境的一部分,因此改变字符串也改变了环境。

putenv()函数成功时返回零,如果发生错误则返回非零。如果发生错误,则设置errno来指示错误的原因
添加代码如下
在这里插入图片描述

但是经测试env还是查询不到刚刚定义的变量,环境列表很长我就不截全了
在这里插入图片描述
这是因为在bash中定义的环境变量,需要自己去维护存储环境变量的内存,保证其不被覆盖且一直存在

所以咱在putenv()之前得把要声明的环境变量存储到一个在当前进程结束前不会被覆盖的位置。

更改后代码如下:
将之前的putenv命令等代码放到main函数中
在这里插入图片描述

在这里插入图片描述
这样就改完啦
在这里插入图片描述
在这里插入图片描述

env

env是用于查看环境变量的命令,由于之前我没有对env命令进行处理所以现在执行env肯定是被执行在子进程中的

但是我们在执行env的时候,是想看自己的环境变量还是子进程的环境变量呢?
不用质疑肯定是自己的,所以env命令也需要特殊处理

首先在int find_command(char* argv[])函数中加入红框中代码,用于筛选env命令
在这里插入图片描述
在这里插入图片描述
主函数也有所改变,两个 if 语句相较于之前被调换了上下位置
在这里插入图片描述
跟改完后,编译运行即可。打印出来的每个环境变量之前有编号是因为我的打印命令自己加的
在这里插入图片描述

echo $

这个命令也需要特殊处理 ,要不然也会去提取子进程中的内容。使用该命令时用户肯定是想要查看当前进程内容的
在int find_command(char* argv[])函数中加入红框中的内容即可对echo$命令做处理
在这里插入图片描述

执行例如:
echo $PATH命令
在这里插入图片描述
echo $USER命令
在这里插入图片描述

echo $?

这个命令是提取进程退出码,但目前我的My_bash并未实现这一功能。执行后居然是打印说明功能缺失了。
需要特殊处理一下
在这里插入图片描述

思路:在筛选出echo命令后在筛选$后面紧跟的?
于是在之前筛选echo命令的if中加入了一个判断是否是?号,为打印退出码函数也怎加了一个退出码的形参。
在这里插入图片描述
主函数也有改变,怎加了一个存储退出码的变量
在这里插入图片描述
怎加括号中内容,提取退出码
在这里插入图片描述
函数传参也多传了一个退出码
在这里插入图片描述
测试如下,已经能正常输出退出码
在这里插入图片描述

小伙伴们,到这我就演示完成了可能还有其他功能没有实现,需要自己扩展哦
下面是本人源码

#include<stdlib.h>
#include<stdio.h>
#include<assert.h>
#include<string.h>
#include<unistd.h>//sleep的头文件

#include<sys/types.h>
#include<sys/wait.h>

#define max 100


int DisposeStr(char* _command ,char* _argv[])//字符串分割处理
{
    
    
  int i = 0;
   _argv[i] = strtok(_command," ");
   if(_argv[i] == NULL){
    
     return -1; }//如果一个字符串都没有直接返回
   while(_argv[i++])//当等于NULL退出循环
   {
    
       
   _argv[i] = strtok(NULL," ");//连续调用用NULL

   }
   if(strcmp(_argv[0] , "ls") == 0)//如果输入的是ls命令
   {
    
    
    _argv[--i] =(char*)"--color=auto";//在NULL位置改为 --color=auto
    _argv[++i] = NULL;//在最后以NULL结尾
   }
    return 0;
}

int find_command(char* argv[], int quit)
{
    
    
  
  if(strcmp(argv[0],"echo")==0)//匹配env命令
  {
    
    
    const char* pp = NULL;
    if(argv[1][0] == '$')//保证其是 echo $.....
    {
    
    
      pp = getenv(argv[1]+1);//$之后的字符串
      if(argv[1][1] == '?'){
    
    printf("%d\n", quit );}//提取$后面是否是?
      else  if(pp != NULL){
    
     printf("%s=%s\n",argv[1]+1 , pp) ; }
       else{
    
    return 1;}//匹配失败
    }
    else{
    
    return 1;}//匹配失败
    return 0;//执行结束返回0
  }
  if(strcmp(argv[0],"cd")==0)//匹配cd命令
  {
    
    
    int i =  chdir(argv[1]);
    if(i == -1)
    {
    
    
      printf("%s\n",strerror(2));//cd执行出错则报错
    }
    return 0;//执行结束返回0
  } 

  if(strcmp(argv[0],"env")==0)//匹配env命令
  {
    
    
    int i = 0;
    extern char** environ;
    for(i = 0 ; environ[i]; i++ )
    {
    
    
      printf("%d:%s\n",i ,environ[i]);//循环打印环境变量
    }
    return 0;//执行结束返回0
  }
  
  return 1;//返回1代表没匹配上对应指令,需要执行else
}

int main()
{
    
    
  char export_str[30][100] = {
    
    {
    
    0}};
  int port = 0;
  int _quit = 0;//储存退出码
  while(1)
  {
    
    
    char* argv[10] = {
    
    NULL};//初始化为NULL
    char command[100] = {
    
    0};//将数组初始化\0
    printf("[ZhuGeBin made the bash]$");//命令行提示符
    char* tmp = fgets(command, 100 , stdin);//从输入流中输入到command数组中
    assert(tmp);//确保命令读取成功
    (void)tmp;//保证编译不报错
  
    command[strlen(command)-1] = '\0';//将字符串末尾的\n去掉
    int cur = DisposeStr(command , argv);//字符串分割处理
    if(cur == -1){
    
    continue ;}//如果输入空字符串重新输入

    if(strcmp("export",argv[0])==0)//匹配exprot命令
    {
    
    
        strcpy(export_str[port++] , argv[1]);//存储环境变量
        putenv(export_str[port-1]);//添加环境变量
        continue;
    }
    else if(find_command(argv , _quit)==0) {
    
     }//查找命令
    else
    {
    
    
      pid_t it = fork();//创建子进程
      if(it == 0)
       {
    
    
        int i = execvp(argv[0],argv);
        printf("%s\n",strerror(i));
        exit(1);
       }

      int status = 0;//需初始化为0
      int cur = waitpid(it , &status , 0);//阻塞式等待子进程
      if(cur> 0 )//等待成功
      {
    
    
        _quit = WEXITSTATUS(status);
      }
    }
  }
  return 0;
}


猜你喜欢

转载自blog.csdn.net/ZhuGeBin26/article/details/129644689