> 技术文档 > 【Linux仓库】超越命令行用户:手写C语言Shell解释器,解密Bash背后的进程创建(附源码)

【Linux仓库】超越命令行用户:手写C语言Shell解释器,解密Bash背后的进程创建(附源码)


🌟 各位看官好,我是

🌍 Linux == Linux is not Unix !

🚀 通过对进程方面系统的学习,接下来可以动手实现一个迷你Xshell解释器!

👍 如果觉得这篇文章有帮助,欢迎您一键三连,分享给更多人哦!

目录

回顾进程

目标及实现思路

实现原理及代码实现

打印命令行提示符

获取用户输入指令

解析用户输入指令

初始化数据

执行指令

检测指令

更新命令行提示符  

总结

附源码


回顾进程

前面我们系统梳理了 Linux 进程管理及相关底层机制的核心知识:从进程的本质(由 PCB 与代码数据构成,内核通过链表管理),到进程的创建(fork 函数的双返回值特性与写时拷贝技术优化内存使用);从进程的生命周期管理(终止的三种场景、return/exit/_exit 的差异,以及退出码的意义),到进程等待的必要性(wait/waitpid 函数避免僵尸进程,非阻塞等待的实现逻辑);同时也清晰了程序替换的原理(exec 函数族在不创建新进程的情况下替换代码数据,底层统一依赖 execve 系统调用),还掌握了命令行参数的传递(argc/argv 的应用)与环境变量的机制(继承特性、PATH 等关键变量的作用)—— 这些知识点看似分散,实则围绕 “进程的创建、控制与资源交互” 形成了完整的技术链条,而这恰好是实现命令解释器的核心基础。

要手写一个迷你 Xshell 解释器,本质上就是实现 “接收用户命令→解析命令→创建进程执行命令→等待命令执行完成” 的闭环,这正好能把前面的知识串联起来:用户输入的 “ls -l”“cd ../” 等命令,需要用命令行参数解析的逻辑拆分成指令与选项(对应 argc/argv 的处理);执行外部命令时,需通过 fork 创建子进程(利用写时拷贝减少内存开销),再调用 exec 函数族(比如用 execvp 自动从 PATH 中查找命令路径)替换子进程代码;子进程执行期间,父进程(解释器本身)需通过 waitpid 等待其结束,避免僵尸进程;而环境变量(如 PATH、PWD)的继承特性,又能保证命令执行时的环境一致性 —— 可以说,前面掌握的进程管理、程序替换、参数解析等技术,正是搭建迷你 Xshell 的 “积木”,接下来就可以基于这些基础动手实现了。

目标及实现思路

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

用下图的时间轴来表⽰事件的发⽣次序。其中时间从左向右。shell由标识为sh的方块代表,它随着时间的流逝从左向右移动。shell从用户读⼊字符串\"ls\"。shell建⽴⼀个新的进程,然后在那个进程中运 ⾏ls程序并等待那个进程结束。然后shell读取新的⼀⾏输⼊,建⽴⼀个新的进程,在这个进程中运⾏程序 并等待这个进程结束。 要写⼀个shell,要循环以下过程:

  

  1. 初始化化数据
  2. 打印命令行提示符
  3. 获取用户输入指令
  4. 解析用户指令
  5. 检测命令,内建命令,要让shell自己来执行!!!
  6. 执行命令,让子进程来执行!!!

实现原理及代码实现

打印命令行提示符

egoist@hcss-ecs-3ec8:~$ 

在xshell中,它的命令行提示符如上所示.基于前面的知识铺垫,我们可以通过环境变量获取命令提示符所需的信息。

static std::string GetUserName(){ std::string username = getenv(\"USER\"); return username.empty()?\"None\":username;}static std::string GetLangName(){ std::string langname = getenv(\"LANG\"); return langname.empty()?\"None\":langname;}static std::string GetPwd(){ std::string pwd = getenv(\"PWD\"); return pwd.empty()?\"None\":pwd;}void PrintCommandPrompt(){ std::string user = GetUserName(); std::string lang = GetLangName(); std::string pwd = GetPwd(); printf(\"[%s@%s:%s]# \",user.c_str(),lang.c_str(),pwd.c_str());}

获取用户输入指令

GetCommanString 函数的核心作用是从标准输入(键盘)读取用户输入的命令字符串,存储到指定的缓冲区中,并处理输入末尾的换行符,为后续的命令解析做准备。

  • 当用户输入完指令后,该函数获取用户输入指令,存在 cmd_str_buff 数组中。
  • 每次输完指令后回车,即敲\\n,导致 cmd_str_buff 读取到了\\n,因此需要将 \\n 修改为 \\0 。 
#define SIZE 1024char commanstr[SIZE];//2.获取用户输入指令bool GetCommanString(char cmd_str_buff[], int len){ if(cmd_str_buff==NULL||len \"ls -a -l\\0\" // cmd_str_buff[strlen(cmd_str_buff)] = 0; //err cmd_str_buff[strlen(cmd_str_buff) - 1] = 0; return strlen(cmd_str_buff)==0?false:true;}

解析用户输入指令

在父进程创建子进程的过程中,子进程会以父进程为模板完成拷贝操作,这其中就包括对命令行参数的复制。基于这一特性,我们特意将命令行参数表设为全局变量 —— 如此一来,当子进程完成创建时,便能自然地继承这份参数表,无需额外的传递操作,从而为后续子进程执行相关命令提供便捷的参数支持。

char *gargv[ARGS] = {NULL};int gargc = 0;

当在 Xshell 中输入类似 “ls -a -l” 这样的指令并回车后,解释器会对该字符串进行解析处理:首先按空格分割出命令与各选项,将其拆分为 \"ls\"、\"-a\"、\"-l\" 三个独立的部分,然后依次存入命令行参数表中,并且按照规范在参数表的末尾添加 NULL 作为结束标志。

//3.解析用户指令bool PraseCommanString(char cmd[]){ if(cmd==NULL) return false;#define SEP \" \" //3.\"ls -a -l\" --> \"ls\" \"-a\" \"-l\" gargv[gargc++]=strtok(cmd,SEP); //最后以NULL结尾 while((bool)(gargv[gargc++]=strtok(NULL,SEP))); //回退一次gargc gargc--; return true;}

初始化数据

然而,当进行下一次指令输入时,由于命令行参数表被设为全局变量,且未对其原有数据进行清空操作,这就引发了如下所示的问题:每次新输入指令本应生成独立的参数表,却因全局参数表留存旧数据,使得后续解析填充时,旧数据未被覆盖干净,从而出现参数表内容异常叠加、数据混乱的情况,像第二次执行 ls -a -l 时,参数数量和内容都出现了不符合预期的错误扩展,影响了命令解析与执行的正确性 。

  

因此,每次解析用户指令前都需要将命令行参数表进行清空。

//0.初始化化数据void InitGlobal(){ gargc = 0; memset(gargv,0,sizeof(gargv));}

执行指令

Bash 的核心执行逻辑是通过创建子进程来运行命令 —— 这种设计既让 Bash 得以稳定承担用户与系统的交互中介角色,又能高效管控各命令的执行流程,也因此成为 Linux 系统中至关重要的核心组件。

具体到执行流程:Bash 先创建子进程,由子进程通过程序替换函数(如 exec 系列)执行解析后的命令;与此同时,Bash 会进入阻塞状态等待子进程,直至获取其执行结果。

此外,我们还可以借鉴echo $?获取进程退出码的机制,在这里实现类似功能:将子进程的退出码存入lastcode变量中,方便后续查看。

//5.执行命令,让子进程来执行!!!void ForkAndExec(){ pid_t id = fork(); if(id 0) { lastcode = WEXITSTATUS(status); } }} 

检测指令

在操作中我们发现一个有趣的现象:当使用 cd .. 命令试图切换目录,之后执行 pwd 查看路径,会发现路径并没有如预期回退。这是因为我们当前采用的是 Bash 创建子进程执行命令 的方式,而 cd 这类命令比较特殊,得从进程工作原理说起:

核心原因:子进程无法改变父进程的工作目录

  • 子进程会独立拷贝父进程(Bash)的运行环境,包括当前工作目录(CWD)。
  • 子进程执行 cd .. 确实会在自己的环境里切换目录,但这一改动 仅作用于子进程自身 ,不会影响到父进程(Bash)的工作目录。
  • 后续执行 pwd 时,依旧创建子进程拷贝的工作目录,这就解释了为什么 cd .. 后 pwd 路径没回退。

因此像cd、echo 这种命令是内建命令,是要由父进程来完成的。

进行路径切换,本质是父进程bash在进行路径切换,路径就会被子进程继承下去,因此pwd时能查到新路径。所以在执行指令之前,需要先进行对指令的检测,如果是内建命令则让bash自己执行.

static std::string GetHomePath(){ std::string homepath = getenv(\"HOME\"); return homepath.empty()?\"/\":homepath;}//4.检测命令,内建命令,要让shell自己来执行!!!bool BuiltInCommanExec(){ std::string cmd = gargv[0]; bool ret = false; if(cmd == \"cd\") { if(gargc == 2) { std::string target = gargv[1]; if(target == \"~\") { ret = true; chdir(GetHomePath().c_str()); } else { //解决 cd .. 问题 ret = true; chdir(gargv[1]); } } else if(gargc == 1) { ret =true; chdir(GetHomePath().c_str()); } else { //BUG } } else if(cmd == \"echo\") { if(gargc == 2) { std::string args = gargv[1]; if(args[0]==\'$\') { if(args[1]==\'?\') {  //打印错误码  printf(\"lastcode:%d\\n\",lastcode);  lastcode = 0;  ret = true; } else {  const char *name = &args[1];  printf(\"%s\\n\",getenv(name));  lastcode = 0;  ret = true; } } else { printf(\"%s\\n\",args.c_str()); ret = true; } } } return ret;}

更新命令行提示符  

上图当中:cd .. 进行回退路径时候,pwd确实验证了我们的路径进行了回退,但是我们也发现一个问题,路径更新的时候命令行提示符的路径并没有更新,为什么会这样呢?并且我们的命令行提示符路径太长了,能不能像xshell实现那样呢?

环境变量的变化,可能会依赖于进程,pwd需要shell自己更新环境变量的值。

static std::string GetPwd(){ char temp[1024]; getcwd(temp,sizeof(temp)); //顺便更新以下shell自己的环境变量pwd snprintf(pwd,sizeof(pwd),\"PWD=%s\",temp); putenv(pwd); std::string pwd_lable = temp; const std::string pathsep = \"/\"; auto pos = pwd_lable.rfind(pathsep); if(pos == std::string::npos) return \"None\"; pwd_lable = pwd_lable.substr(pos+pathsep.size()); return pwd_lable.empty()?\"/\":pwd_lable;}

总结

本文的核心目标是通过亲手实现一个简易的 Shell(myshell),来深入理解 Shell 的工作原理,特别是以下几个关键概念:

  1. 内建命令 (Built-in Commands) vs. 普通命令 (外部命令)

  2. 环境变量 (Environment Variables) 和 本地变量 的作用与生命周期。

  3. 进程的独立性 和 进程创建 (fork) / 程序替换 (exec) 机制。

通过这个简单的 myshell 实现,我们清晰地看到了 Shell 的底层工作模型:
Shell 本身是一个死循环程序,它通过解析命令、识别内建命令、并巧妙地利用 fork 和 exec 系统调用来管理所有外部命令的执行,从而扮演了用户与操作系统内核之间的翻译官和管理者的角色。

附源码

main.cc

#include\"myshell.h\"#define SIZE 1024int main(){ char commanstr[SIZE]; while(true) { //初始化化数据 InitGlobal(); //1.打印命令行提示符 PrintCommandPrompt(); //2.获取用户输入指令 //用户输入指令错误的话重来 if(!GetCommanString(commanstr,SIZE)) continue; //3.解析用户指令 PraseCommanString(commanstr); //4.检测命令,内建命令,要让shell自己来执行!!! if(BuiltInCommanExec()) { continue; } //4.执行命令,让子进程来执行!!! ForkAndExec(); } return 0;}

myshell.h 

#ifndef __MYSHELL_H__#define __MYSHELL_H__#include#include#include#include#include#include#define ARGS 64void Debug();void PrintCommandPrompt();bool GetCommanString(char cmd_str_buff[], int len);bool PraseCommanString(char cmd[]);void InitGlobal();void ForkAndExec();bool BuiltInCommanExec();#endif

 myshell.cc

#include\"myshell.h\"int lastcode = 0;char pwd[1024]; // 全局变量空间,保存当前shell进程的工作路径//命令行参数表故意设为全局,为的是能给子进程继承下来char *gargv[ARGS] = {NULL};int gargc = 0;void Debug(){ printf(\"hello myshell!\\n\");}static std::string GetUserName(){ std::string username = getenv(\"USER\"); return username.empty()?\"None\":username;}static std::string GetLangName(){ std::string langname = getenv(\"LANG\"); return langname.empty()?\"None\":langname;}//static std::string GetPwd()//{// std::string pwd = getenv(\"PWD\");// return pwd.empty()?\"None\":pwd;//}static std::string GetPwd(){ // 环境变量的变化,可能会依赖于进程,pwd需要shell自己更新环境变量的值 char temp[1024]; getcwd(temp,sizeof(temp)); //顺便更新以下shell自己的环境变量pwd snprintf(pwd,sizeof(pwd),\"PWD=%s\",temp); putenv(pwd); std::string pwd_lable = temp; const std::string pathsep = \"/\"; auto pos = pwd_lable.rfind(pathsep); if(pos == std::string::npos) return \"None\"; pwd_lable = pwd_lable.substr(pos+pathsep.size()); return pwd_lable.empty()?\"/\":pwd_lable;}static std::string GetHomePath(){ std::string homepath = getenv(\"HOME\"); return homepath.empty()?\"/\":homepath;}//1.打印命令行提示符void PrintCommandPrompt(){ std::string user = GetUserName(); std::string lang = GetLangName(); std::string pwd = GetPwd(); printf(\"[%s@%s:%s]# \",user.c_str(),lang.c_str(),pwd.c_str());}//2.获取用户输入指令bool GetCommanString(char cmd_str_buff[], int len){ if(cmd_str_buff==NULL||len \"ls -a -l\\0\" cmd_str_buff[strlen(cmd_str_buff) - 1] = 0; return strlen(cmd_str_buff)==0?false:true;}//3.解析用户指令bool PraseCommanString(char cmd[]){ if(cmd==NULL) return false;#define SEP \" \" //3.\"ls -a -l\" --> \"ls\" \"-a\" \"-l\" gargv[gargc++]=strtok(cmd,SEP); //最后以NULL结尾 while((bool)(gargv[gargc++]=strtok(NULL,SEP))); //回退一次gargc gargc--;////打印验证//#define DEBUG#ifdef DEBUG printf(\"gargc: %d\\n\", gargc); printf(\"----------------------\\n\"); for(int i = 0; i < gargc; i++) { printf(\"gargv[%d]: %s\\n\",i, gargv[i]); } printf(\"----------------------\\n\"); for(int i = 0; gargv[i]; i++) { printf(\"gargv[%d]: %s\\n\",i, gargv[i]); }#endif return true;}//初始化化数据void InitGlobal(){ gargc = 0; memset(gargv,0,sizeof(gargv));}//5.执行命令,让子进程来执行!!!void ForkAndExec(){ pid_t id = fork(); if(id 0) { lastcode = WEXITSTATUS(status); } }} //4.检测命令,内建命令,要让shell自己来执行!!!bool BuiltInCommanExec(){ std::string cmd = gargv[0]; bool ret = false; if(cmd == \"cd\") { if(gargc == 2) { std::string target = gargv[1]; if(target == \"~\") { ret = true; chdir(GetHomePath().c_str()); } else { //解决 cd .. 问题 ret = true; chdir(gargv[1]); } } else if(gargc == 1) { ret =true; chdir(GetHomePath().c_str()); } else { //BUG } } else if(cmd == \"echo\") { if(gargc == 2) { std::string args = gargv[1]; if(args[0]==\'$\') { if(args[1]==\'?\') {  //打印错误码  printf(\"lastcode:%d\\n\",lastcode);  lastcode = 0;  ret = true; } else {  const char *name = &args[1];  printf(\"%s\\n\",getenv(name));  lastcode = 0;  ret = true; } } else { printf(\"%s\\n\",args.c_str()); ret = true; } } } return ret;}