【Linux】进程控制(四)—— 手搓自主shell

学习Linux到目前为止,我们都知道命令是由shell执行的,但是具体如何执行的我们看不到,因此我们今天来自己写一个shell来执行我们的指令,让大家对shell的底层有一个进阶的理解,文章的最后会给出完整代码喔~

目录

一、打印命令行提示符

二、获取键盘输入

三、解析字符串

四、执行指令

五、增加内建命令

[1.cd 路径的改变](#1.cd 路径的改变)

[2.echo 退出码](#2.echo 退出码)

六、总结与源码


一、打印命令行提示符

知识前置:shell本质上是一个死循环,因为要不断地处理一条又一条指令,因此编写的shell功能全部都要放进一个死循环中,直到我们主动退出才结束

命令行提示符的格式为 用户名@主机名 路径,因此要打印出来就必须获取这三个数据,很显然它们都属于环境变量,分别对应USER、HOSTNAME和PWD,要获取环境变量,用getenv函数即可,为了方便管理,我们将打印命令行提示符封装成一个函数

cpp 复制代码
//1.打印命令行提示符                                                                                                                        
PrintCommandLine();
cpp 复制代码
void PrintCommandLine()
{
   printf("[%s@%s %s]# ", GetUserName(), GetHostName(), GetPwd()); //用户名@主机名 当前路径
}
cpp 复制代码
const char *GetUserName()
{
  char *name = getenv("USER");
  if(name == NULL)
  {
    return "None";
  }
  return name;
}

const char *GetHostName()
{
  char *hostname = getenv("HOSTNAME");
  if(hostname == NULL)
  {
    return "None";
  }
  return hostname;
}

const char *GetPwd()
{
  char *pwd = getenv("PWD");
  if(pwd == NULL)
  {
    return "None";
  }
  return pwd;
}

至此命令行提示符的打印就完成了,但是shell本质是一个死循环,它还得需要一个等待我们输入指令的功能,否则就会一直打印,导致满屏的命令行提示符

二、获取键盘输入

键盘输入本质就是输入一个字符串,需要用一个字符数组来接收

cpp 复制代码
#define MAXSIZE 128
//...

char command_line[MAXSIZE] = {0};

对于指令的输入我们通常存在两种情况,一种是输入指令后回车,一种是啥也不输入直接回车,因此,要获取键盘输入,我们需要用到系统调用 fgets ,啥也不输入的时候返回NULL, 我们依旧对获取键盘输入的功能封装一个函数,且函数的返回值为输入的指令字符串长度,当返回0的时候,命中我们刚刚说的第二种情况,直接continue即可,否则我们尝试打印出刚刚输入的指令做一个验证测试,看看是否输出的与我们输入的一致

但同时要注意的是,无论哪一种情况,我们都要至少输入一次回车键,回车键相当于换行符\n,因此为了保证字符串和输出的正确性,我们需要将最后的换行符改成'\0'

cpp 复制代码
//2.获取键盘输入
    if(GetCommand(command_line, sizeof(command_line)) == 0)
      continue;
    printf("%s\n", command_line);
cpp 复制代码
int GetCommand(char commandline[], int size)
{
  if(fgets(commandline, size, stdin) == NULL)
    return 0;
  //用户输入的时候,至少会按一次回车\n,改'\0'
  commandline[strlen(commandline)-1] = '\0';
  return strlen(commandline);
}

运行结果如下:

三、解析字符串

前面我们输入进去的指令是一整个字符串,我们要把它们拆分("ls -a -l" -> "ls" "-a" "-l" ),并放入命令行参数表中

对于字符串的拆分,C语言中有一个封装好的函数,strtok

第一个参数为要拆分的字符串,第二个参数为拆分符号,遇到该符号就进行拆分,对于同一个字符串的第二次拆分,则将第一个参数设为NULL,否则会一直拆分第一个而后面的不拆分,具体代码演示如下:

cpp 复制代码
#include<stdio.h>
#include<string.h>

int main()
{
  char str[] = "aaa bbb ccc ddd"; 
  const char* sep = " ";
  char *p = strtok(str, sep);
  printf("%s\n", p);
  while(p)
  {
    p = strtok(NULL, sep);
    if(p == NULL)
    {
      break;
    }
    printf("%s\n",p);
  }
  return 0;
}

拆分的字符串,我们要放到全局的环境变量表中,这是shell内部要维护的第一张表,同时设置一下切割分隔符

cpp 复制代码
#define MAXARGS 32

//shell内部维护的第一张表:命令行参数表
char *gargv[MAXARGS];
int gargc = 0;
const char *sep = " ";

将解析字符串封装函数

cpp 复制代码
//3.解析字符串
    ParseCommand(command_line);
cpp 复制代码
int ParseCommand(char commandline[])
{
  //输入新的指令要重置命令行参数表
  gargc = 0;
  memset(gargv, 0, sizeof(gargv));

  //分割字符串
  gargv[0] = strtok(commandline, sep);
  while((gargv[++gargc] = strtok(NULL, sep)));

  //打印测试
  printf("gargc: %d\n", gargc);
  int i = 0;
  for(; gargv[i]; i++)
    printf("gargv[%d]: %s\n", i, gargv[i]);

  return 0;
}

我们打印出分割结果测试一下效果

四、执行指令

前面我们完成了指令的输入和解析,还差一个执行,要执行指令,就需要fork子进程来进行程序替换

cpp 复制代码
    //4.执行指令
    ExcuteCommand();
cpp 复制代码
int ExcuteCommand()
{
  pid_t id = fork();
  if(id < 0) return -1;
  else if(id == 0)
  {
    //子进程 程序替换
    execvp(gargv[0], gargv);
    exit(1);
  }
  else{
    //父进程
    int status = 0;
    pid_t rid = waitpid(id, &status, 0);
    if(rid > 0)
    {
      printf("wait child process success!\n");
    }
  }
}

有了命令行参数表gargv,我们就可以用程序替换函数execvp了,对于程序替换有问题的可以回看博主的文章《进程程序替换》

【Linux】进程控制(三)------进程程序替换-CSDN博客

我们来看看运行结果

结果如我们所料,我们把所有的测试打印全部注释再试试看

到这里为止,shell的基本框架搭成了

五、增加内建命令

刚刚我们输入的指令都如期由子进程替换执行成功了,但是我们来看下面这种情况

这里演示了两种无法成功执行的情况,echo $?是输出上一个程序执行结束的退出码,这里没有打印出来;cd ..是返回上一个目录,但是我们用pwd查看,却发现路径没有变化。

原因就是它们都属于内建命令,内建命令的特点是由Shell自身解析执行,不需要创建新进程再替换,接下来一一解决这两个问题

执行命令前先写一个函数判断是否是内建命令,如果是则直接执行并返回1,如果不是则返回0,并创建子进程替换执行命令

1.cd 路径的改变

要改变路径,可以使用chdir函数

直接将gargv1放入参数即可,因为gargv0是cd,后面一个必跟路径

cpp 复制代码
//4.执行指令
    if(CheckBuiltinExcute() > 0)
      continue;
    ExcuteCommand();
cpp 复制代码
int CheckBuiltinExcute()
{
  if(strcmp(gargv[0], "cd") == 0)
  {
    //内建命令
    if(gargc == 2)
    {
      //新的目标路径
      chdir(gargv[1]);
    }
    return 1;
  }

  return 0;
}

试一下执行结果

可以发现路径改变了,但是为什么命令行提示符的路径却没有改变呢?

回看我们之前获取路径的函数

cpp 复制代码
const char *GetPwd()
{
  char *pwd = getenv("PWD");
  if(pwd == NULL)
  {
    return "None";
  }
  return pwd;
}

会发现我们获取的是环境变量的PWD记录,但是环境变量的这个值是静态的,即使我们用chdir切换了目录但是没更新PWD环境变量,它依旧会返回旧路径,不准

要实时改变这个路径,就不能依赖环境变量,而需要一个能直接与内核交互的系统调用 getcwd!

这个系统调用的第一个参数属于典型的输出型参数,我们提供数组参数,它会将实时路径给我传递到数组中,那我们就创建一个接收的数组

cpp 复制代码
//我们shell所处的工作路径
char cwd[MAXSIZE];

利用getcwd系统调用优化Getpwd函数

cpp 复制代码
const char *GetPwd()
{
  //char *pwd = getenv("PWD");
  char *pwd = getcwd(cwd, sizeof(cwd));
  if(pwd == NULL)
  {
    return "None";
  }
  return cwd;
}

试试优化后的效果

达到了我们的预期

但到这里还剩下最后一个问题,Linux命令行提示符中的路径只保留了最后一个,而我们是直接显示出了一长串的绝对路径,这是过于冗余的,我们接下来就是要解决这个问题,只取它的最后一个"/"后的路径,这里我用c++来实现

cpp 复制代码
static std::string rfindDir(const std::string &p)
{
  if(p == "/") return p;

  const std::string psep = "/";
  auto pos = p.rfind(psep);
  if(pos == std::string::npos)
  {
    return std::string();
  }
  return p.substr(pos+1);
}

在打印的位置将本来要传的绝对路径先传入这个函数当中,最后截取结束后再打印出来

cpp 复制代码
void PrintCommandLine()
{
  printf("[%s@%s %s]# ", GetUserName(), GetHostName(), rfindDir(GetPwd()).c_str()); //用户名@主机名 当前路径
}

记得对rfindDir的返回值还要加一个c_str(),这是C++为了兼容C语言的打印而设计的接口

来看看成果,为了区分原shell和我们自己写的shell,我们的分隔符是不一样的,前者是$,我的是#

到这里就解决了cd路径的改变问题,所以其实真正的shell还是非常复杂的,我这仅仅是极简版本,还有很多内建命令和其他各种快捷键功能没实现,我们接下来再解决一下echo问题

2.echo 退出码

echo依旧是内建命令,承接实现"cd"的代码继续扩充。

首先先解决获取退出码的问题,先定一个全局变量Last_Exitcode

cpp 复制代码
//上一个进程结束的退出码
int Last_ExitCode = 0;

编写输入指令为echo的情况,并在每个指令执行结束的地方重置退出码

cpp 复制代码
int CheckBuiltinExcute()
{
  if(strcmp(gargv[0], "cd") == 0)
  {
    //内建命令
    if(gargc == 2)
    {
      //新的目标路径
      chdir(gargv[1]);
      Last_ExitCode = 0;
    }
    return 1;
  }

  else if(strcmp(gargv[0], "echo") == 0)
  {
    if(gargc == 2)
    {
      if(gargv[1][0] == '$')
      {
        if(strcmp(gargv[1]+1, "?") == 0)
        {
          printf("lastcode:%d\n", Last_ExitCode);
        }
        Last_ExitCode = 0;
      }
      return 1;
    }
  }

  return 0;
}
cpp 复制代码
//父进程
    int status = 0;
    pid_t rid = waitpid(id, &status, 0);
    if(rid > 0)
    {
      //获取退出码
      Last_ExitCode = WEXITSTATUS(status);
      //printf("wait child process success!\n");
    }

来看看实现成果

六、总结与源码

至此,一个简易的shell被我们手搓出来了,独立完成其实非常考验知识储备和代码能力,可作为一个教学意义极高的训练典例,其实除了命令行参数表以外,shell内部还管理了另一张表就是环境变量表,它也承担着非常重要的角色,我将手搓的源码放在下面供大家自行在此基础拓展

cpp 复制代码
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<iostream>
#include<string>

#define MAXSIZE 128
#define MAXARGS 32

//shell内部维护的第一张表:命令行参数表
char *gargv[MAXARGS];
int gargc = 0;
const char *sep = " ";

//我们shell所处的工作路径
char cwd[MAXSIZE];

//上一个进程结束的退出码
int Last_ExitCode = 0;

static std::string rfindDir(const std::string &p)
{
  if(p == "/") return p;

  const std::string psep = "/";
  auto pos = p.rfind(psep);
  if(pos == std::string::npos)
  {
    return std::string();
  }
  return p.substr(pos+1);
}

const char *GetUserName()
{
  char *name = getenv("USER");
  if(name == NULL)
  {
    return "None";
  }
  return name;
}

const char *GetHostName()
{
  char *hostname = getenv("HOSTNAME");
  if(hostname == NULL)
  {
    return "None";
  }
  return hostname;
}

const char *GetPwd()
{
  //char *pwd = getenv("PWD");
  char *pwd = getcwd(cwd, sizeof(cwd));
  if(pwd == NULL)
  {
    return "None";
  }
  return cwd;
}

void PrintCommandLine()
{
  printf("[%s@%s %s]# ", GetUserName(), GetHostName(), rfindDir(GetPwd()).c_str()); //用户名@主机名 当前路径
}

int GetCommand(char commandline[], int size)
{
  if(fgets(commandline, size, stdin) == NULL)
    return 0;
  //用户输入的时候,至少会按一次回车\n,改'\0'
  commandline[strlen(commandline)-1] = '\0';
  return strlen(commandline);
}

int ParseCommand(char commandline[])
{
  //输入新的指令要重置命令行参数表
  gargc = 0;
  memset(gargv, 0, sizeof(gargv));

  //分割字符串
  gargv[0] = strtok(commandline, sep);
  while((gargv[++gargc] = strtok(NULL, sep)));

 // printf("gargc: %d\n", gargc);
 // int i = 0;
 // for(; gargv[i]; i++)
 //   printf("gargv[%d]: %s\n", i, gargv[i]);

  return 0;
}

int CheckBuiltinExcute()
{
  if(strcmp(gargv[0], "cd") == 0)
  {
    //内建命令
    if(gargc == 2)
    {
      //新的目标路径
      chdir(gargv[1]);
      Last_ExitCode = 0;
    }
    return 1;
  }

  else if(strcmp(gargv[0], "echo") == 0)
  {
    if(gargc == 2)
    {
      if(gargv[1][0] == '$')
      {
        if(strcmp(gargv[1]+1, "?") == 0)
        {
          printf("lastcode:%d\n", Last_ExitCode);
        }
        Last_ExitCode = 0;
      }
      return 1;
    }
  }

  return 0;
}

int ExcuteCommand()
{
  pid_t id = fork();
  if(id < 0) return -1;
  else if(id == 0)
  {
    //子进程 程序替换
    execvp(gargv[0], gargv);
    exit(1);
  }
  else{
    //父进程
    int status = 0;
    pid_t rid = waitpid(id, &status, 0);
    if(rid > 0)
    {
      //获取退出码
      Last_ExitCode = WEXITSTATUS(status);
      //printf("wait child process success!\n");
    }
  }
}

int main()
{
  char command_line[MAXSIZE] = {0};

  while(1)
  {
    //1.打印命令行提示符
    PrintCommandLine();

    //2.获取键盘输入
    if(GetCommand(command_line, sizeof(command_line)) == 0)
      continue;
   // printf("%s\n", command_line);

    //3.解析字符串
    ParseCommand(command_line);

    //4.执行指令
    if(CheckBuiltinExcute() > 0)
      continue;
    ExcuteCommand();
  }
  

  return 0;
}