跳到主要内容 手写 C++ Shell 解释器,解密 Bash 背后的进程创建机制 | 极客日志
C++
手写 C++ Shell 解释器,解密 Bash 背后的进程创建机制 通过实现简易 Shell 解释器深入理解 Linux 进程管理。核心涉及 fork 创建子进程执行外部命令,waitpid 等待回收避免僵尸进程,execvp 替换进程映像。针对 cd 等内建命令需在父进程直接修改工作目录,利用环境变量继承特性保持路径一致。代码展示了命令行解析、提示符生成及退出码处理逻辑,完整串联进程生命周期与系统调用机制。
栈溢出 发布于 2026/3/15 更新于 2026/4/18 3 浏览回顾进程
前面我们系统梳理了 Linux 进程管理及相关底层机制的核心知识:从进程的本质(由 PCB 与代码数据构成,内核通过链表管理),到进程的创建(fork 函数的双返回值特性与写时拷贝技术优化内存使用);从进程的生命周期管理(终止的三种场景、return/exit/_exit 的差异,以及退出码的意义),到进程等待的必要性(wait/waitpid 函数避免僵尸进程,非阻塞等待的实现逻辑);同时也清晰了程序替换的原理(exec 函数族在不创建新进程的情况下替换代码数据,底层统一依赖 execve 系统调用),还掌握了命令行参数的传递(argc/argv 的应用)与环境变量的机制(继承特性、PATH 等关键变量的作用)—— 这些知识点看似分散,实则围绕'进程的创建、控制与资源交互'形成了完整的技术链条,而这恰好是实现命令解释器的核心基础。
要手写一个迷你 Shell 解释器,本质上就是实现'接收用户命令→解析命令→创建进程执行命令→等待命令执行完成'的闭环,这正好能把前面的知识串联起来:用户输入的'ls -l''cd ../'等命令,需要用命令行参数解析的逻辑拆分成指令与选项(对应 argc/argv 的处理);执行外部命令时,需通过 fork 创建子进程(利用写时拷贝减少内存开销),再调用 exec 函数族(比如用 execvp 自动从 PATH 中查找命令路径)替换子进程代码;子进程执行期间,父进程(解释器本身)需通过 waitpid 等待其结束,避免僵尸进程;而环境变量(如 PATH、PWD)的继承特性,又能保证命令执行时的环境一致性 —— 可以说,前面掌握的进程管理、程序替换、参数解析等技术,正是搭建迷你 Shell 的'积木',接下来就可以基于这些基础动手实现了。
目标及实现思路
要能处理普通命令
要能处理内建命令
要能帮助我们理解内建命令/本地变量/环境变量这些概念
要能帮助我们理解 shell 的允许原理
用下图的时间轴来表⽰事件的发⽣次序。其中时间从左向右。shell 由标识为 sh 的方块代表,它随着时间的流逝从左向右移动。shell 从用户读⼊字符串"ls"。shell 建⽴⼀个新的进程,然后在那个进程中运 ⾏ ls 程序并等待那个进程结束。然后 shell 读取新的⼀⾏输⼊,建⽴⼀个新的进程,在这个进程中运⾏程序 并等待这个进程结束。 要写⼀个 shell,要循环以下过程:
初始化化数据打印命令行提示符获取用户输入指令解析用户指令检测命令,内建命令,要让 shell 自己来执行!!!执行命令,让子进程来执行!!!
实现原理及代码实现
打印命令行提示符
在 xshell 中,它的命令行提示符如下所示:
egoist@hcss -ecs-3ec8: ~$
基于前面的知识铺垫,我们可以通过环境变量获取命令提示符所需的信息。
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 { std::string pwd = ( ); pwd. ()? :pwd; }
{
std::string user = ();
std::string lang = ();
std::string pwd = ();
( ,user. (),lang. (),pwd. ());
}
微信扫一扫,关注极客日志 微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
相关免费在线工具 Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
Markdown转HTML 将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
HTML转Markdown 将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
JSON 压缩 通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
JSON美化和格式化 将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online
GetPwd
()
getenv
"PWD"
return
empty
"None"
void PrintCommandPrompt ()
GetUserName
GetLangName
GetPwd
printf
"[%s@%s:%s]# "
c_str
c_str
c_str
获取用户输入指令 GetCommandString 函数的核心作用是从标准输入(键盘)读取用户输入的命令字符串,存储到指定的缓冲区中,并处理输入末尾的换行符,为后续的命令解析做准备。
当用户输入完指令后,该函数获取用户输入指令,存在 cmd_str_buff 数组中。
每次输完指令后回车,即敲\n,导致 cmd_str_buff 读取到了\n,因此需要将 \n 修改为 \0。
#define SIZE 1024
char commanstr[SIZE];
bool GetCommandString (char cmd_str_buff[], int len) {
if (cmd_str_buff==NULL ||len<=0 ) return false ;
char *res = fgets (cmd_str_buff,len,stdin);
if (res==NULL ) return false ;
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 作为结束标志。
bool ParseCommandString (char cmd[]) {
if (cmd==NULL ) return false ;
#define SEP " "
gargv[gargc++]=strtok (cmd,SEP);
while ((bool )(gargv[gargc++]=strtok (NULL ,SEP)));
gargc--;
return true ;
}
初始化数据 然而,当进行下一次指令输入时,由于命令行参数表被设为全局变量,且未对其原有数据进行清空操作,这就引发了如下所示的问题:每次新输入指令本应生成独立的参数表,却因全局参数表留存旧数据,使得后续解析填充时,旧数据未被覆盖干净,从而出现参数表内容异常叠加、数据混乱的情况,像第二次执行 ls -a -l 时,参数数量和内容都出现了不符合预期的错误扩展,影响了命令解析与执行的正确性。
因此,每次解析用户指令前都需要将命令行参数表进行清空。
void InitGlobal () {
gargc = 0 ;
memset (gargv,0 ,sizeof (gargv));
}
执行指令 Bash 的核心执行逻辑是通过创建子进程来运行命令 —— 这种设计既让 Bash 得以稳定承担用户与系统的交互中介角色,又能高效管控各命令的执行流程,也因此成为 Linux 系统中至关重要的核心组件。
具体到执行流程:Bash 先创建子进程,由子进程通过程序替换函数(如 exec 系列)执行解析后的命令;与此同时,Bash 会进入阻塞状态等待子进程,直至获取其执行结果。
此外,我们还可以借鉴 echo $? 获取进程退出码的机制,在这里实现类似功能:将子进程的退出码存入 lastcode 变量中,方便后续查看。
void ForkAndExec () {
pid_t id = fork();
if (id<0 ) {
perror ("fork" );
return ;
} else if (id == 0 )
{
execvp (gargv[0 ],gargv);
exit (0 );
} else {
int status = 0 ;
pid_t rid = waitpid (id,&status,0 );
if (rid > 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;
}
bool BuiltInCommandExec () {
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 {
ret = true ;
chdir (gargv[1 ]);
}
} else if (gargc == 1 ) {
ret =true ;
chdir (GetHomePath ().c_str ());
} else {
}
} 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));
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 的工作原理 ,特别是以下几个关键概念:
内建命令 (Built-in Commands) vs. 普通命令 (外部命令)
环境变量 (Environment Variables) 和 本地变量 的作用与生命周期。
进程的独立性 和 进程创建 (fork) / 程序替换 (exec) 机制。
通过这个简单的 myshell 实现,我们清晰地看到了 Shell 的底层工作模型:
Shell 本身是一个死循环程序,它通过解析命令、识别内建命令、并巧妙地利用 fork 和 exec 系统调用来管理所有外部命令的执行,从而扮演了用户与操作系统内核之间的翻译官和管理者的角色。
附源码
main.cc #include "myshell.h"
#define SIZE 1024
int main () {
char commanstr[SIZE];
while (true ) {
InitGlobal ();
PrintCommandPrompt ();
if (!GetCommandString (commanstr,SIZE)) continue ;
ParseCommandString (commanstr);
if (BuiltInCommandExec ()) {
continue ;
}
ForkAndExec ();
}
return 0 ;
}
myshell.h #ifndef __MYSHELL_H__
#define __MYSHELL_H__
#include <stdio.h>
#include <string>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#define ARGS 64
void Debug () ;
void PrintCommandPrompt () ;
bool GetCommandString (char cmd_str_buff[], int len) ;
bool ParseCommandString (char cmd[]) ;
void InitGlobal () ;
void ForkAndExec () ;
bool BuiltInCommandExec () ;
#endif
myshell.cc #include "myshell.h"
int lastcode = 0 ;
char pwd[1024 ];
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 () {
char temp[1024 ];
getcwd (temp,sizeof (temp));
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;
}
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 ());
}
bool GetCommandString (char cmd_str_buff[], int len) {
if (cmd_str_buff==NULL ||len<=0 ) return false ;
char *res = fgets (cmd_str_buff,len,stdin);
if (res==NULL ) return false ;
cmd_str_buff[strlen (cmd_str_buff) - 1 ] = 0 ;
return strlen (cmd_str_buff)==0 ?false :true ;
}
bool ParseCommandString (char cmd[]) {
if (cmd==NULL ) return false ;
#define SEP " "
gargv[gargc++]=strtok (cmd,SEP);
while ((bool )(gargv[gargc++]=strtok (NULL ,SEP)));
gargc--;
#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));
}
void ForkAndExec () {
pid_t id = fork();
if (id<0 ) {
perror ("fork" );
return ;
} else if (id == 0 )
{
execvp (gargv[0 ],gargv);
exit (0 );
} else {
int status = 0 ;
pid_t rid = waitpid (id,&status,0 );
if (rid > 0 ) {
lastcode = WEXITSTATUS (status);
}
}
}
bool BuiltInCommandExec () {
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 {
ret = true ;
chdir (gargv[1 ]);
}
} else if (gargc == 1 ) {
ret =true ;
chdir (GetHomePath ().c_str ());
} else {
}
} 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;
}