> 技术文档 > 【寻找Linux的奥秘】第九章:自定义SHELL

【寻找Linux的奥秘】第九章:自定义SHELL

【寻找Linux的奥秘】第九章:自定义SHELL

请君浏览

    • 前言
    • 1.目标
    • 2. 运行原理
    • 3. 实现
      • 3.1 打印命令行提示符
      • 3.2 获取命令行参数
      • 3.3 命令行解析
      • 3.4 执行命令
      • 3.5 内建命令
        • 3.5.1 cd
        • 3.5.2 echo
    • 4. 小结
    • 4. 源码
    • 尾声

前言

本专题将基于Linux操作系统来带领大家学习操作系统方面的知识以及学习使用Linux操作系统。前面我们认识并熟悉了进程的基本概念以及操作,那么本章让我们对前面所学进行融会贯通,来自定义编写一下我们使用的命令行解释器,也就是shell。本章我们要学习的是——自定义shell的编写。

1.目标

Shell 是一种用于与操作系统交互的命令行界面程序。它充当用户和操作系统内核之间的中介,通过用户输入的命令来执行操作,提供与操作系统的互动。

具体来说,Shell 可以做以下几件事:

  • 命令解释和执行:Shell 接受用户输入的命令,解析并传递给操作系统的内核执行。例如,用户可以通过 Shell 执行文件操作(如复制、移动、删除文件)、程序启动、系统管理等操作。
  • 脚本编程:Shell 还支持脚本编程,允许用户将一系列命令写入一个文件中,形成一个脚本(如 Bash 脚本)。这些脚本可以自动化任务,执行复杂的操作。
  • 交互式环境:Shell 提供一个交互式环境,用户可以在其中执行命令、查看命令输出、管理系统进程等。

我们在Linux中最常用的shell就是Bash(Bourne Again Shell)。今天我们将在bash上实现一个自定义的shell,不过由于所学知识有限,我们的自定义shell主要可以进行命令解释和执行,来贯通一下我们之前所学的内容,并且对这些知识有更深刻地认识。

下面来介绍一下我们实现自定义shell的目标:

  • 能处理普通命令
  • 能处理内建命令
  • 帮助我们理解内建命令、本地变量、环境变量这些概念
  • 帮助我们理解shell的运行原理

2. 运行原理

既然要实现一个自定义shell,那么我们就需要先知道shell的运行原理,从而搭出一个大致的框架。

下面以这个与shell典型的互动为例:

【寻找Linux的奥秘】第九章:自定义SHELL

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

【寻找Linux的奥秘】第九章:自定义SHELL

因此,shell读取一行输入,建立一个新的子进程,在子进程中运行指定程序并等待子进程结束,然后循环往复。

所以我们要写一个自定义shell,就需要循环下列过程:

  • 获取命令⾏
  • 解析命令⾏
  • 建⽴⼀个⼦进程(fork)
  • 替换⼦进程(exec)
  • ⽗进程等待⼦进程退出(wait)

根据这个过程,我们的框架就有了,更多的细节让我们在实现的过程中再一一发现并解决。

3. 实现

像我们使用的shell底层是用C语言编写的,为了简化一些较为繁琐的操作,接下来我们的编写采用C、C++混编,主体以C语言为主,对于特定的地方也会使用C++的一些语法。

我们实现的shell较为简单,这里就不分文件进行编写,都在一个.cc文件(Linux传统C++文件后缀)中进行编写。

接下来让我们创建好Makefile文件,方便我们进行编译:

【寻找Linux的奥秘】第九章:自定义SHELL

最后,创建我们的myshell.cc文件,开始编写我们的程序:

【寻找Linux的奥秘】第九章:自定义SHELL

3.1 打印命令行提示符

首先观察我们的使用的shell,可以发现shell总是会先打印出命令行提示符,然后处于阻塞状态等待用户输入。所以我们的第一步就是打印命令行提示符:

int main(){ while(true) { //1.输出命令行提示符  PrintCommandPrompt(); } return 0; }

分析一下命令行提示符,我们发现它是由[用户名@主机名 当前工作目录]$组成的,并且用户名、主机名、当前工作目录我们都可以从环境变量中获取,那么打印命令行提示符就变得非常简单了,我们需要用到之前提过的getenv函数:

【寻找Linux的奥秘】第九章:自定义SHELL

//包一下头文件,以C++的风格#include //获得当前工作路径const char* GetPwd(){ const char *pwd = getenv(\"PWD\"); return pwd == NULL ? \"None\" : pwd;}//获得当前主机名const char* GetHostName(){ const char *hostname = getenv(\"HOSTNAME\"); return hostname == NULL ? \"None\" : hostname;}//获得当前用户名const char* GetUser(){ const char *user = getenv(\"USER\"); return user == NULL ? \"None\" : user;}

我们的自定义shell是在bash上创建的,所以当执行时我们的自定义shell就是bash的一个子进程,所以我们自定义shell的环境变量表是继承于bash的。

此时我们的需要把获得的当前工作路径进行拆分,获得当前工作目录,我们借助C++中stringrfind函数和substr函数可以很轻松的做到。其中rfind用于倒着从字符串查找,substr用来裁剪字符串,我们只需要使用rfind找到第一个’/\'的位置,然后用substr把相对应的位置裁剪出来即可:

//对于查找的字符我们可以定义一个宏,便于修改#define SLASH \"/\"std::string GetPwdDir(const char *pwd){ std::string dir = pwd; if(dir == SLASH) return SLASH; auto pos = dir.rfind(SLASH); if(pos == std::string::npos) return \"BUG\"; return dir.substr(pos + 1);}

准备工作都做完了,接下来就可以打印我们的命令行提示符了:

//我们把最后一个字符改为#,方便一会与bash进行区分#define FORMAT \"[%s@%s %s]# \" #define CMDLINE_MAX 1024//C++的模块化设计void MakeCommandPrompt(char *out, int size){ //将命令行提示符制作完后放入字符串中 snprintf(out, size, FORMAT, GetUser(), GetHostName(), GetPwdDir(GetPwd()).c_str());}void PrintCommandPrompt(){ char prompt[CMDLINE_MAX]; MakeCommandPrompt(prompt, CMDLINE_MAX); printf(\"%s\", prompt); fflush(stdout);}

这样,我们的命令行提示符就以及可以打印完成了。

3.2 获取命令行参数

接下来我们就要获取命令行参数了:

int main() { InitEnv();  while(true) { //1.输出命令行提示符  PrintCommandPrompt(); //2.获取命令行参数 char commandline[CMDLINE_MAX]; if(!GetCommandLine(commandline, CMDLINE_MAX)) continue;  return 0; }  

我们将获取命令行参数的返回值设置为bool类型可以在我们直接输入回车时重新进入循环,再次打印命令行提示符并重新输入,与在bash中的行为一致。接下来我们就来实现一下这个函数:

#include bool GetCommandLine(char *out, int size){ //stdin:从键盘中输入(本质是从键盘文件中读取) char *c = fgets(out, size, stdin); if(c == NULL) return false; //去掉换行符\'\\n\' out[strlen(out) - 1] = 0; //只有换行符的话返回false,循环重新开始 if(strlen(out) == 0) return false; return true;}

我们通过fgets函数来获得用户的输入:

【寻找Linux的奥秘】第九章:自定义SHELL

fgets 是 C语言 中安全、可靠的行读取函数,它的安全性更高,只是需要我们手动处理换行符。

我们将处理好的字符串放入commandline中,方便下一步对我们输入的命令行参数进行解释。

3.3 命令行解析

上面我们已经获得了命令行参数,那么接下来就该对命令行参数进行分析:

int main()  { while(true) { //1.输出命令行提示符 PrintCommandPrompt(); //2.获取命令行参数 char commandline[CMDLINE_MAX]; if(!GetCommandLine(commandline, CMDLINE_MAX)) continue; //3.分析命令行参数   if(!CommandParse(commandline)) continue;  return 0; }  

那么如何分析呢?也非常简单,我们只需要将命令行参数,也就是commandline以空格为分隔符,将其填入到我们的命令行参数表中即可:

#define DELIM \" \"//命令行参数表#define ARGV_MAX 1024char *g_argv[ARGV_MAX];int g_argc = 0;bool CommandParse(char *commandline) { g_argc = 0; g_argv[g_argc++] = strtok(commandline, DELIM); if(g_argv[0] == NULL) return false; while((bool)(g_argv[g_argc++] = strtok(NULL, DELIM))); g_argc--; return true; } 

以指定字符分割字符串,这个时候就需要用到我们的strtok函数了:

【寻找Linux的奥秘】第九章:自定义SHELL

使用strtok函数时我们需要注意第一次调用我们需要传入待分割的字符串,后续的调用传入NULL即可,因此我们只需要在一个whlie循环中不断调用即可,当字符串无法再分割时返回NULL,刚好结束while循环。

命令行解析所需要进行的最主要操作就是要将我们的命令行参数分割之后填入到我们的命令行参数表中。

3.4 执行命令

我们有了命令行参数表后就可以开始执行对应的命令了:

int main(){ while(true) { //1.输出命令行提示符 PrintCommandPrompt(); //2.获取命令行参数 char commandline[CMDLINE_MAX]; if(!GetCommandLine(commandline, CMDLINE_MAX)) continue; //3.分析命令行参数 if(!CommandParse(commandline)) continue;  //4.执行命令 Execute(); } return 0; }

我们执行命令时通过创建一个子进程,让子进程进行程序替换来执行对应的命令。那么对于那么多的程序替换函数,我们在这里该选择哪一个呢?这里我们有了命令行参数表,那么最适合我们的就是execvp函数了:

void Execute(){ pid_t id = fork(); if(id < 0) { perror(\"fork false:\"); exit(1); } else if(id == 0) { execvp(g_argv[0], g_argv); exit(1); } pid_t rid = waitpid(id, NULL, 0);}

这样一来,我们的自定义shell的基本逻辑就已经实现了,我们可以在自定义shell上执行像ls、ps这些命令。不过许多地方的细节还没有完善,接下来让我们继续。

3.5 内建命令

在 Linux 系统中,内建命令(Built-in Commands) 是 Shell直接提供的命令,无需调用外部程序,因此执行效率更高。也就是说在执行内建命令时,父进程不会创建子进程去调用外部程序,而是父进程自己去执行。我们目前常见的内建指令有cd、pwd、echo等等这些命令,所以在执行命令前,我们需要先判断命令是否为内建命令:

int main(){ while(true) { //1.输出命令行提示符  PrintCommandPrompt();  //2.获取命令行参数   char commandline[CMDLINE_MAX]; if(!GetCommandLine(commandline, CMDLINE_MAX)) continue; //3.分析命令行参数 if(!CommandParse(commandline)) continue; //PrintArgv();  //4.检查是否为内建命令 if(IsBuiltInCommand()) continue; //5.执行命令 Execute(); } return 0;}  

判断的方法也很简单,我们直接用命令行参数表的第一个元素,也就是命令名去一一进行比较即可,这里我们就简单的对cd、echo这两个简单的命令进行编写:

bool IsBuiltInCommand(){ if(!strcmp(g_argv[0], \"cd\")) {  CommandCd(); return true; }  else if(!strcmp(g_argv[0], \"echo\")) {  CommandEcho(); return true; } //else... return false;} 
3.5.1 cd

在执行cd命令时,我们还有cd -返回上次所在目录和cd ~返回家目录以及cd返回根目录这些特殊的的参数,这些都可以通过环境变量来实现:

const char* GetOldpwd(){ const char *oldpwd = getenv(\"OLDPWD\"); return oldpwd == NULL ? \"None\" : oldpwd;}const char* GetHome(){ const char *home = getenv(\"HOME\"); return home == NULL ? \"None\" : home;}void CommandCd(){ //cd int ret; std::string old_dir = get_current_dir_name(); if(g_argc == 1) { std::string home = GetHome(); if(home.empty()) exit(1); ret = chdir(home.c_str()); } //cd where else { std::string where = g_argv[1]; //cd -/cd ~ if(where == \"-\") { ret = chdir(GetOldpwd());  } else if(where == \"~\") { ret = chdir(GetHome()); } else { ret = chdir(where.c_str()); } } if(ret == -1) { perror(\"cd\"); lastcode = 1; } else { lastcode = 0; setenv(\"PWD\", get_current_dir_name(), 1); setenv(\"OLDPWD\", old_dir.c_str(), 1); }}

对于返回上次所在目录,环境变量中有一个名为OLDPWD的变量专门用来记录,因此我们只需要在执行cd命令前先保存一下当前所处的路径,然后再执行完后用其去更新环境变量OLDPWD即可,同时,在我们cd命令执行完后,我们也需要更新环境变量PWD,使其的值是我们当前所在的目录。

3.5.2 echo

对于echo命令,我们除了可以在显示器上打印对应的字符,还可以查看环境变量对应的值以及通过echo $?查看上一个程序的退出码:

int lastcode = 0;void CommandEcho(){ if(g_argc == 1) { printf(\"\\n\"); } else { std::string opt = g_argv[1];  if(opt == \"$?\") { std::cout << lastcode << std::endl; } else if(opt[0] == \'$\') { std::string env_name = opt.substr(1); const char *env_value = getenv(env_name.c_str()); if(env_value) std::cout << env_value << std::endl; } else { std::cout << opt << std::endl; } } lastcode = 0;}

其他程序的退出码我们可以通过waitpid来获得,只需要更改一下Execute函数即可:

void Execute(){//... int statue; pid_t rid = waitpid(id, &statue, 0); if(rid > 0) lastcode = WEXITSTATUS(statue);}

4. 小结

这样一来,我们的自定义shell大致上就已经是完成了,当然对比真的shell还差的远的远。不过我们编写这个自定义shell的目的还是为了巩固之前所学的知识,对他们有更深刻的认识。

其实我们的进程与我们的函数有很大的相似性:exec/exit就像call/return

  • ⼀个C程序有很多函数组成。⼀个函数可以调⽤另外⼀个函数,同时传递给它⼀些参数。被调⽤的函数执⾏⼀定的操作,然后返回⼀个值。
  • ⼀个C程序可以fork/exec另⼀个程序,并传给它⼀些参数。这个被调⽤的程序执⾏⼀定的操作,然后通过exit来返回值。调⽤它的进程可以通过wait来获取exit的返回值。

这种通过参数和返回值在拥有私有数据的函数间通信的模式是结构化程序设计的基础。Linux⿎励将这种应⽤于程序之内的模式扩展到程序之间。 如下图所示

【寻找Linux的奥秘】第九章:自定义SHELL

4. 源码

#include #include#include  #include#include #include#include  #include#define CMDLINE_MAX 1024#define FORMAT \"[%s@%s %s]# \"#define DELIM \" \" #define SLASH \"/\"//命令行参数表#define ARGV_MAX 1024 char *g_argv[ARGV_MAX];int g_argc = 0;  //环境变量表#define ENV_MAX 1024  char *g_env[ENV_MAX];int g_envs;int lastcode = 0;  void InitEnv() {  extern char **environ; memset(g_env, 0, sizeof(g_env)); g_envs = 0; //1.模仿从配置文件中设置环境变量 for(int i = 0; environ[i]; i++) {  g_env[i] = (char*)malloc(strlen(environ[i]) + 1); strcpy(g_env[i], environ[i]); g_envs++; }  g_env[g_envs] = NULL; //2.导成环境变量 for(int i = 0; g_env[i]; i++) {  putenv(g_env[i]); } environ = g_env; } const char* GetPwd(){ const char *pwd = getenv(\"PWD\"); return pwd == NULL ? \"None\" : pwd;} const char* GetOldpwd(){ const char *oldpwd = getenv(\"OLDPWD\"); return oldpwd == NULL ? \"None\" : oldpwd;}const char* GetHome(){ const char *home = getenv(\"HOME\"); return home == NULL ? \"None\" : home;}const char* GetHostName(){ const char *hostname = getenv(\"HOSTNAME\"); return hostname == NULL ? \"None\" : hostname;}const char* GetUser(){ const char *user = getenv(\"USER\"); return user == NULL ? \"None\" : user;}std::string GetPwdDir(const char *pwd){ std::string dir = pwd; if(dir == SLASH) return SLASH; auto pos = dir.rfind(SLASH); if(pos == std::string::npos) return \"BUG\"; return dir.substr(pos + 1);}void MakeCommandPrompt(char *out, int size){ snprintf(out, size, FORMAT, GetUser(), GetHostName(), GetPwdDir(GetPwd()).c_str());}void PrintCommandPrompt(){ char prompt[CMDLINE_MAX]; MakeCommandPrompt(prompt, CMDLINE_MAX); printf(\"%s\", prompt); fflush(stdout); }bool GetCommandLine(char *out, int size){ char *c = fgets(out, size, stdin); if(c == NULL) return false; out[strlen(out) - 1] = 0; if(strlen(out) == 0) return false; return true;}bool CommandParse(char *commandline){ g_argc = 0; g_argv[g_argc++] = strtok(commandline, DELIM); if(g_argv[0] == NULL) return false; while((bool)(g_argv[g_argc++] = strtok(nullptr, DELIM))); g_argc--; return true;}//testvoid PrintArgv(){ for(int i = 0; i <= g_argc; i++) { printf(\"argv[%d]->%s\\n\", i, g_argv[i]); } printf(\"argc->%d\\n\", g_argc);}void Execute(){ pid_t id = fork(); if(id < 0) { perror(\"fork false:\"); exit(1); } else if(id == 0) { execvp(g_argv[0], g_argv); exit(1); } int statue; pid_t rid = waitpid(id, &statue, 0); if(rid > 0) lastcode = WEXITSTATUS(statue);}void CommandCd(){ //cd int ret; std::string old_dir = get_current_dir_name(); if(g_argc == 1) { std::string home = GetHome(); if(home.empty()) exit(1); ret = chdir(home.c_str()); } //cd where else { std::string where = g_argv[1]; //cd -/cd ~ if(where == \"-\") { ret = chdir(GetOldpwd());  } else if(where == \"~\") { ret = chdir(GetHome()); } else { ret = chdir(where.c_str()); } } if(ret == -1) { perror(\"cd\"); lastcode = 1; } else { lastcode = 0; setenv(\"PWD\", get_current_dir_name(), 1); setenv(\"OLDPWD\", old_dir.c_str(), 1); } }void CommandEcho(){ if(g_argc == 1)  { printf(\"\\n\"); } else { std::string opt = g_argv[1]; if(opt == \"$?\") { std::cout << lastcode << std::endl; } else if(opt[0] == \'$\') { std::string env_name = opt.substr(1); const char *env_value = getenv(env_name.c_str()); if(env_value) std::cout << env_value << std::endl; } else { std::cout << opt << std::endl; } } lastcode = 0;}bool IsBuiltInCommand(){ if(!strcmp(g_argv[0], \"cd\")) { CommandCd(); return true; } else if(!strcmp(g_argv[0], \"echo\")) { CommandEcho(); return true; } //else... return false;}int main(){ InitEnv(); while(true) { //1.输出命令行提示符 PrintCommandPrompt(); //2.获取命令行参数 char commandline[CMDLINE_MAX]; if(!GetCommandLine(commandline, CMDLINE_MAX)) continue; //3.分析命令行参数 if(!CommandParse(commandline)) continue; //PrintArgv(); //4.检查是否为内建命令 if(IsBuiltInCommand()) continue; //5.执行命令 Execute(); } return 0; }

尾声

本章讲解就到此结束了,若有纰漏或不足之处欢迎大家在评论区留言或者私信,同时也欢迎各位一起探讨学习。感谢您的观看!