【Linux系统部分】在Linux命令行中写一个简单的shell外壳

12.在Linux命令行中写一个简单的shell外壳

文章目录

  • 12.在Linux命令行中写一个简单的shell外壳
      • 一、介绍
      • 二、编写代码
        • 总体结构
        • 初步实现
          • 头文件、宏定义、全局变量
          • PrintComLine
          • GetComLine
          • ProcessComLine
          • ExcuteComLine
        • 改进
          • 内建命令
          • 环境变量改变
          • 新增其他内建命令
      • 总结

一、介绍

Shell是Linux操作系统的核心交互界面,作为用户与系统内核之间的桥梁,它接收用户输入的命令、解释并执行这些命令。无论是系统管理员还是开发人员,每天都会使用Shell来完成各种任务。但你是否曾好奇过Shell是如何工作的?它是如何解析命令、创建进程并管理执行环境的?

本文将带您深入探索Shell的内部机制,通过实际编写一个简单的Shell外壳,您将了解:

  • Shell的基本工作原理和核心组件
  • 命令解析与执行流程的实现
  • 进程创建、管理和通信机制
  • 环境变量的维护与管理
  • 内建命令的特殊处理方式

通过亲手实现一个支持基本命令(如lscdpwdexport等)的Shell,您将获得对操作系统底层机制(如进程控制、环境变量传递)的深刻理解,这些知识对于系统编程、性能优化和高级脚本编写都至关重要。

无论您是Linux新手还是经验丰富的开发者,这个项目都将帮助您:

  1. 揭开命令行界面的神秘面纱
  2. 理解进程创建和环境继承的核心概念
  3. 掌握系统调用(如fork()exec()wait())的实际应用
  4. 构建对操作系统工作原理的整体认知

接下来,让我们从零开始,一步步构建一个功能完整的Shell外壳!

二、编写代码

总体结构

⽤下图的时间轴来表⽰事件的发⽣次序。其中时间从左向右。shell由标识为sh的⽅块代表,它随着时间的流逝从左向右移动。shell从⽤⼾读⼊字符串"ls"。shell建⽴⼀个新的进程,然后在那个进程中运⾏ls程序并等待那个进程结束

【Linux系统部分】在Linux命令行中写一个简单的shell外壳_第1张图片

然后shell读取新的⼀⾏输⼊,建⽴⼀个新的进程,在这个进程中运⾏程序 并等待这个进程结束。 所以要写⼀个shell,需要循环以下过程:

获取命令行
解析命令行
建立一个子进程 fork
替换子进程
父进程等待子进程退出

其中有一些细节需要注意,但在前面先不着急。了解了命令行的基本操作之后我们把shell的功能拆分为几个不同的板块:

main 主函数
PrintComLine 命令行提示符打印
GetComLine 获取用户命令
ProcessComLine 分析命令
ExcuteComLine 执行命令
初步实现
头文件、宏定义、全局变量
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

using namespace std;

#define SIZE 128
int argc;
char *argv[SIZE];

仿照main函数的命令行参数,我们定义了全局变量argcargv来储存用户输入命令的数量和内容。

PrintComLine

要实现命令行提示符的打印,我们观察其结构可知,提示部分主要由:用户名、主机名、当前路径组成;在环境变量中分别对应:USER、HOSTNAME、PWD。因此我们可以从程序执行之后的环境变量中获取。

代码部分:

string GetUsername()
{
    return getenv("USER");
}

string GetHostname()
{
    return getenv("HOSTNAME");
}

string GetPWD()
{
    return getenv("PWD");
}

string MakeComLin()  
{
    char line[SIZE] = {0};
    snprintf(line, SIZE, "[%s@%s %s]%% ", GetUsername().c_str(), GetHostname().c_str(), GetPWD().c_str());
    return line;
}

void PrintComLin()
{
    printf("%s", MakeComLin().c_str());  
    fflush(stdout);
}

将获取环境变量的函数重新进行封装,使用string作为返回值

GetComLine

输入命令时会带有不同的选项,选项的长度以及数量都是不确定的,而且选项之间会有空格间隔,这样就导致不能使用scanf以及cin等输入方式,这些输入方式会把空壳视为一次输入结束的标志。因此我们这次使用fgets这个函数指定输入流为stdin

bool GetComLin(char arr[], int size)
{
    char *result = fgets(arr, size, stdin);                                                                                                                            
    if(!result)
      return false;
    arr[strlen(arr) - 1] = '\0';
    if(strlen(arr) == 0) return false;
    return true;
}
  1. 这个函数我们给他带来一个bool类型的返回值,用于判断获取用户输入是否成功;
  2. 获取到的字符串存入arr中,由于用户每一次输入命令都是通过回车输入,所以每一次获取到的字符串后面都会有一个\n,不利于我们后面处理指令,因此在这里将strlen(arr)-1这个位置的字符替换成\0来去掉\n
  3. 这里多加了一个判断如果回车之后没有命令,返回false
ProcessComLine

处理用户输入的字符串,将这一整个字符串打散为命令和选项,存入前面定义的全局变量argcargv

bool ProcessComLin(char arr[])
{
    const char *s = " ";
    memset(argv, 0, sizeof(argv));
    argc = 0;
    argv[argc] = strtok(arr, s);

    while((argv[++argc] = strtok(nullptr, s)) != 0);
    return true;                                                                                                                                                       
}
  1. 同样,这个函数我们给他带来一个bool类型的返回值,用于判断是否成功;
  2. 使用memset这次输入之前的指令,并把argc置零
  3. 我们使用strtok来拆分字符串,第一次拆分需要输入被拆分的字符串后面如果继续拆分则输入nullptr
ExcuteComLine

在这里我们创建子进程用于运行用户输入的命令,父进程等待子进程运行结束后继续运行,子进程使用进程程序替换,替换为对应命令的程序。

bool ExcuteComLin()
{
    pid_t id = fork();
    if(id < 0)  return false;
    else if(id == 0)
    {
        execvp(argv[0], argv);
        exit(1);
    } 

    pid_t pid = waitpid(id, nullptr, 0);
    if(pid < 0) 
        return false;
  	return true;
}
  1. 同样,这个函数我们给他带来一个bool类型的返回值,用于判断是否成功;
改进

到这一步这个简单的shell外壳大体的框架已经搭好了,一些最基本的指令:ls pwd env可以运行了。但是还有很多地方需要改进。

内建命令

如果我们按照之前的想法,当用户输入命令之后去创建一个新的子进程执行输入的命令在一般情况下是没有问题的,但是如果执行与路径切换有关的命令时就并不正确了。在之前的学习中我们知道了子进程的环境变量继承自父进程的环境变量但是如果我们在子进程中调用cd等路径切换的命令,他只会影响子进程的当前路径,当子进程运行结束退出,对父进程没有影响,导致执行完命令之后父进程的工作路径并没有发生改变。所以路径切换应在父进程下改变。shell⾃⼰执⾏命令,本质是shell调⽤⾃⼰的函数,这叫做内建命令

代码更改:

// shell⾃⼰执⾏命令,本质是shell调⽤⾃⼰的函数
bool CheckAndExecBuiltCommand()
{
    if(strcmp(gargv[0], "cd") == 0)
    {
        // 内建命令
        if(gargc == 2)
        {
            chdir(gargv[1]);
        } 
        return true;
    }
    return false;
}
环境变量改变

虽然经过上面的修改可以执行cd命令了,使用pwd指令也可以看到路径的切换。但是会发现进程的环境变量并没有随着进程的路径改变而改变。这是因为环境变量是需要shell程序维护的,并不是自动更新的,环境变量表需要shell自己更新。之前我们使用Linux的shell帮我们更新了环境变量,所以我们不需要手动修改。

[lisihan@hcss-ecs-b735 /home/lisihan/lession16/myshell]% ls
makefile  myshell  myshell.cpp
[lisihan@hcss-ecs-b735 /home/lisihan/lession16/myshell]% pwd
/home/lisihan/lession16/myshell
[lisihan@hcss-ecs-b735 /home/lisihan/lession16/myshell]% cd ..
[lisihan@hcss-ecs-b735 /home/lisihan/lession16/myshell]% pwd
/home/lisihan/lession16									#用pwd查发现路径已经改变了
[lisihan@hcss-ecs-b735 /home/lisihan/lession16/myshell]% env
XDG_SESSION_ID=881
HOSTNAME=hcss-ecs-b735
TERM=xterm
SHELL=/bin/bash
HISTSIZE=10000
SSH_CLIENT=218.77.77.250 22487 22
SSH_TTY=/dev/pts/1
USER=lisihan
#..........#
PATH=/usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/home/lisihan/.local/bin:/home/lisihan/bin
PWD=/home/lisihan/lession16/myshell						#环境变量中的PWD没有改变
#..........#
[lisihan@hcss-ecs-b735 /home/lisihan/lession16/myshell]% ^C
[lisihan@hcss-ecs-b735 myshell]$ 

所以现在我们不建议从环境变量中获取当前路径(用户名和主机名当前情况不会修改暂时可以用),因为shell是维护环境变量的,所以我们需要从系统重获取(系统调用),然后再把环境变量进行更新。

修改后的代码:

char pwd[SIZE];					//新增全局变量
char pwdenv[SIZE];


string GetPWD()
{
    getcwd(pwd, sizeof(pwd));
    sprintf(pwdenv, "PWD=%s", pwd);
    putenv(pwdenv);
    return pwd;
}

修改后执行

[lisihan@hcss-ecs-b735 myshell]$ ls
makefile  myshell  myshell.cpp
[lisihan@hcss-ecs-b735 myshell]$ make
g++ -o myshell myshell.cpp -std=c++11
[lisihan@hcss-ecs-b735 myshell]$ ./myshell 
[lisihan@hcss-ecs-b735 /home/lisihan/lession16/myshell]% ls
makefile  myshell  myshell.cpp
[lisihan@hcss-ecs-b735 /home/lisihan/lession16/myshell]% pwd
/home/lisihan/lession16/myshell										#用pwd查发现路径已经改变了
[lisihan@hcss-ecs-b735 /home/lisihan/lession16/myshell]% cd ..
[lisihan@hcss-ecs-b735 /home/lisihan/lession16]% pwd
/home/lisihan/lession16
[lisihan@hcss-ecs-b735 /home/lisihan/lession16]% env
XDG_SESSION_ID=881
HOSTNAME=hcss-ecs-b735
TERM=xterm
SHELL=/bin/bash
HISTSIZE=10000
SSH_CLIENT=218.77.77.250 22487 22
SSH_TTY=/dev/pts/1
USER=lisihan
#..........#
PATH=/usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/home/lisihan/.local/bin:/home/lisihan/bin
PWD=/home/lisihan/lession16											#环境变量中的PWD也改变了
#..........#
[lisihan@hcss-ecs-b735 /home/lisihan/lession16]% ^C
[lisihan@hcss-ecs-b735 myshell]$ 

注意:

  1. 由于我们写的shell并没有配置文件,它是Linux的shell的子进程,所以我们写的shell最开始的环境变量是继承自Linux的shell。所以这并不是一个完整的shell
  2. 环境变量是由shell自己维护的
  3. 环境变量是一张表,是一个指针数组,存放指向字符串的指针。由于我们写的shell环境变量表继承自Linux的shell,这张表属于系统,如果我们在自己的shell中不修改这张表,这张表就是系统的;当我们使用putenv修改环境变量的时候并不会修改系统的环境变量表,而是会进行写时拷贝,重新生成一张表。这也解释了为什么子进程环境变量的改变为什么不会影响父进程

目前我们不考虑直接读取环境变量的配置文件,直接用从父进程导入环境变量的方法为我们的shell创建一个环境变量表,注意==环境变量表的最后一个元素是nullptr==在最后要手动加上

char *genv[SIZE];				//新增全局变量

void EnvInit()
{
  extern char **environ;
  int index = 0;
  while(environ[index])
  {
    genv[index] = (char*)malloc(strlen(environ[index]+1));                                                                                                                                                    
    strncpy(genv[index], environ[index], strlen(environ[index]));
    index++;
  }
  genv[index] = nullptr;
}
新增其他内建命令

export和env命令都需要改变shell自身的环境变量因此属于内建命令:

void AddEnv(const char *item)
{
  int index = 0;
  while(genv[index])
  {
    index++;
  } 
  genv[index] = (char*)malloc(strlen(item)+1);
  strncpy(genv[index], item, strlen(item)+1);
  genv[++index] = nullptr;
}
bool CheckAndExecBuiltCommand()
{
    if(strcmp(argv[0], "cd") == 0)
    {
        // 内建命令
        if(argc == 2)
        {
            chdir(argv[1]);
        } 
        return true;
    }
    else if(strcmp(argv[0], "export") == 0)
    {
      if(argc == 2)                                                                                                                                                                                           
      {
        AddEnv(argv[1]);
      }
      return true;
    }
    else if(strcmp(argv[0], "env") == 0)
    {
      for(int i = 0; genv[i]; i++)
      {
        printf("%s\n", genv[i]);
      } 
      return true;
    }
    return false;
}

这样使用这两个命令就可以直接使用我们自己shell的环境变量了。

但是我们创建这样的环境变量目的是使用它,因此shell创建出来的子进程也要继承我们shell自己的环境变量,因此我们需要对前面创建子进程的代码进行一下修改:只需要把之前的execvp函数改成使用execvpe函数就可以给子进程导入我们自己的环境变量了

bool ExcuteComLin()
{
  if(!CheckAndExecBuiltCommand())
  {
    pid_t id = fork();
    if(id < 0)  return false;
    else if(id == 0)                                                                                                                                                                                          
    {
      execvpe(argv[0], argv, genv);
      exit(1);
    } 

    pid_t pid = waitpid(id, nullptr, 0);
    if(pid < 0) 
     return false;
  }
  return true;
}

echo命令:

这个命令在之前的笔记中已经讲过了,echo 命令在 Linux 中用于在终端显示一行文本。它可以用来输出字符串、变量的值等。如果只是从定义中理解可能很难理解这个命令为什么会是一个内建命令。但是我们曾经学过,当我们使用系统的shell时我们可以直接在命令行中创建变量,但我们知道这个变量不在环境变量表中而是在shell的一个本地变量表中,因此当我们使用echo命令查询本地变量的时候是不能创建子进程的,因为本地变量表不会被子进程继承,因此ceho命令是一个内建命令。

[lisihan@hcss-ecs-b735 myshell]$ a=100
[lisihan@hcss-ecs-b735 myshell]$ echo $a
100

如何在我们自己的shell中实现echo命令呢?由于需要实现进程的退出码的打印所以就直接放出整个代码了。

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

using namespace std;

#define SIZE 128
int argc;
char *argv[SIZE];
char pwd[SIZE];
char pwdenv[SIZE];
char *genv[SIZE];
int lastcode = 0;

string GetUsername()
{
    return getenv("USER");
}

string GetHostname()
{
    return getenv("HOSTNAME");
}

string GetPWD()
{
    getcwd(pwd, sizeof(pwd));
    sprintf(pwdenv, "PWD=%s", pwd);
    putenv(pwdenv);
    return pwd;                                                                                                                                                                                               

}

tring MakeComLin()
{
    char line[SIZE] = {0};
    string tmp = GetPWD();                                                                                                                                                                                    
    if(tmp != "/")
    {
      size_t pos = tmp.rfind("/");
      tmp = tmp.substr(pos + 1);
    }
    snprintf(line, SIZE, "[%s@%s %s]%% ", GetUsername().c_str(), GetHostname().c_str(), tmp.c_str());
    return line;
}

void PrintComLin()
{
    printf("%s", MakeComLin().c_str());  
    fflush(stdout);
}

bool GetComLin(char arr[], int size)
{
    char *result = fgets(arr, size, stdin);
    if(!result)
      return false;
    arr[strlen(arr) - 1] = '\0';
    if(strlen(arr) == 0) return false;
    return true;
}

bool ProcessComLin(char arr[])
{
    const char *s = " ";
    memset(argv, 0, sizeof(argv));
    argc = 1;


    argv[argc] = strtok(arr, s);

    while((argv[++argc] = strtok(nullptr, s)) != 0);
    return true; 
}

void debug()                                                                                                                                                                                                  
{
  printf("argc:%d\n", argc);
  for(int i = 0; i < argc; i++)
  {
    printf("argv[%d]:%s\n", i, argv[i]);
  }
}

void AddEnv(const char *item)
{
  int index = 0;
  while(genv[index])
  {
    index++;
  } 
  genv[index] = (char*)malloc(strlen(item)+1);
  strncpy(genv[index], item, strlen(item)+1);
  genv[++index] = nullptr;
}

bool CheckAndExecBuiltCommand()
{
    if(strcmp(argv[0], "cd") == 0)
    {
        // 内建命令
        if(argc == 2)
        {
            chdir(argv[1]);
            lastcode = 0;
        }
        else 
        {
          lastcode = 1;                                                                                                                                                                                       
        }
        return true;
    }
    else if(strcmp(argv[0], "export") == 0)
    {
      if(argc == 2)
      {
        AddEnv(argv[1]);
        lastcode = 0;
      }
      else 
      {
        lastcode = 1;
      }
      return true;
    }
    else if(strcmp(argv[0], "env") == 0)
    {
      for(int i = 0; genv[i]; i++)
      {
        printf("%s\n", genv[i]);
      } 
      return true;
    }
    else if(strcmp(argv[0], "echo") == 0)
    {
      if(argc == 2)
      {
        if(argv[1][0] == '$')
        {
          if(argv[1][1] == '?')
          {
            printf("%d\n", lastcode);
            lastcode = 0;
          }
          else 
          {
            //查询变量暂时不写
          }
        }
      }
      else                                                                                                                                                                                                    
      {
        lastcode = 2;
      }
      return true;
    }
    return false;
}

bool ExcuteComLin()
{
  if(!CheckAndExecBuiltCommand())
  {
    pid_t id = fork();
    if(id < 0)  return false;
    else if(id == 0)
    {
      execvpe(argv[0], argv, genv);
      exit(1);
    } 
    int status;
    pid_t pid = waitpid(id, &status, 0);
    if(pid > 0)
    {
        if(WIFEXITED(status))
        {
          lastcode = WIFEXITED(status);
        }
        else 
        {
          lastcode = 2;
        }
        return true;
    }
    return false;
  }
  return true;                                                                                                                                                                                                
}

void EnvInit()
{
  extern char **environ;
  int index = 0;
  while(environ[index])
  {
    genv[index] = (char*)malloc(strlen(environ[index]+1));
    strncpy(genv[index], environ[index], strlen(environ[index]));
    index++;
  }
  genv[index] = nullptr;
}

int main()
{
  char comlin_data[SIZE] = {0};
  EnvInit();
  while(1)
  {
    //命令行提示符打印
    PrintComLin();

    //获取用户命令
    if(!GetComLin(comlin_data, SIZE))
    {
      continue;
    }

    //分析命令
    ProcessComLin(comlin_data);
    //debug();

    //执行命令
    ExcuteComLin();
  }

  return 0;

}

总结

两层概念,第一层在命令行当中,一个命令究竟是怎么执行的?其实对普通命令来讲,它就是解析命令行,通过调用exec系列的函数进行fork创建子进程程序替换。第二点,什么叫做命令行参数表,环境变量表?命令行参数表是从命令行依次获取的,是被shell自己解析自己维护的环境。变量表是从系统配置文件里读取进来,也是由shell自己维护的,维护好之后,这两个表我们就理解成是全局的两个指针数组,通过esecvpe这样的系统调用接口,把环境变量传递给所有的子进程,因为所有的程序都有命令和参数都有环境变量,所以我就可以把我的环境变量交给它,这就是为什么环境变量具有全局性的根本原因。

你可能感兴趣的:(【Linux系统部分】在Linux命令行中写一个简单的shell外壳)