跳到主要内容
自定义 Shell 命令行解释器的实现与进程协作实践 | 极客日志
Shell / Bash
自定义 Shell 命令行解释器的实现与进程协作实践 综述由AI生成 如何从零实现一个自定义 Shell 命令行解释器。内容包括构建框架、输出提示符、读取输入、解析命令字符串、执行命令(fork/exec)、处理内建命令(cd、echo)以及更新环境变量路径。此外还展示了父子进程协作备份数据的示例。通过该实践深入理解进程控制、环境变量及系统调用接口。
ArchDesign 发布于 2026/3/24 更新于 2026/5/1 18K 浏览一、自定义 shell 命令行解释器
学习了前面进程概念,进程控制的相关知识,我们对进程已经有了理性的认识。下面我们一起来实现一个自定义 shell 把这些知识串联起来,能对进程概念及进程相关各种用法、函数调用接口有一个更深刻的理解和记忆。
实现自定义 shell 的目标:能处理普通命令、能处理内建命令、能帮助理解内建命令/本地变量/环境变量这些概念、能帮助理解 shell 的运行原理。
构建框架
首先我们把要用到的所有文件创建出来,采用头源分离。未来方便,主要用 C++ 编写。
#ifndef __MYSHELL_H__
#define __MYSHELL_H__
#include <iostream>
void Debug () ;
#endif
#include "myshell.h"
void Debug () {
printf ("hello shell!\n" );
}
myshell: main.cc myshell.cc
g++ -o $@ $^
.PHONY : clean
clean:
rm -f myshell
输出命令行提示符
实现 shell 第一步是打印命令行提示符。除了一些符号外有三个主要变量,这三个变量可以直接通过系统调用获取,但为了复习学过的知识,这里采用从环境变量中间接获取。系统的最后一个字符是$,为了区分我们用#。
由于这里是 C 语言和 C++ 混编,需要注意一些细节,比如 C 语言 printf 字符串的时候不能直接打印 string 变量,因为它的字符串格式说明符 %s 要求传入的参数是 C 风格字符串(const char* 类型),所以需要用 c_str 把 string 类型变量转换为 C 风格字符串再打印。
static std::string GetUserName () {
string username = getenv ("USER" );
username. () ? : username;
}
{
string hostname = ( );
hostname. () ? : hostname;
}
{
string pwd = ( );
pwd. () ? : pwd;
}
{
std::string username = ();
std::string hostname = ();
std::string pwd = ();
( , username. (), hostname. (), pwd. ());
}
return
empty
"None"
static std::string GetHostName ()
getenv
"HOSTNAME"
return
empty
"None"
static std::string GetPwd ()
getenv
"PWD"
return
empty
"None"
void PrintCommandPrompt ()
GetUserName
GetHostName
GetPwd
printf
"[%s@%s %s]# "
c_str
c_str
c_str
GetUserName、GetHostName、GetPwd 这三个接口我们不想暴露给外部使用,所以可以加 static 修饰使其只能在 myshell.cc 文件内部使用。
读取用户输入 因为我们要读取用户输入的整个字符串,所以还需要把空格字符串的空格读进去,所以不能用 cin 和 scanf,因为它们遇到空格都会停止读取。这里选用 C 语言接口 fgets,getline 也可以,但 fgets 对于后续一些操作更友好。
首先需要在 main 函数里创建一个字符数组 commandstr 作为参数存放读取用户输入的字符串。
#include "myshell.h"
#define SIZE 1024
int main () {
char commandstr[SIZE];
while (true ){
PrintCommandPrompt ();
GetCommandString (commandstr, SIZE);
printf ("%s\n" , commandstr);
}
return 0 ;
}
然后实现 GetCommandString 的内部逻辑,首先要判断传入的参数是否合法。接着通过 fgets 读取字符串,读取失败返回 false,因为至少会读取一个回车键('\n'),所以不会读取为空。最后把读取到的最后一个字符'\n'置为'\0',因为长度为 10 的字符串最后一个字符下标为 9,所以需要 strlen(cmdstr_buff) - 1。若删掉'\n'后字符长度为 0 说明只读取到了'\n',返回 false。
bool GetCommandString (char cmdstr_buff[], int len) {
if (cmdstr_buff == NULL || len <= 0 ){
return false ;
}
char * res = fgets (cmdstr_buff, len, stdin);
if (res == NULL ){
return false ;
}
cmdstr_buff[strlen (cmdstr_buff)-1 ] = 0 ;
return strlen (cmdstr_buff) == 0 ? false : true ;
}
解析命令字符串 经过前面两步后下一步需要解析用户输入的命令字符串,解析命令字符串本质就是创建命令行参数表,并把用户输入的字符串按空格分开,依次放入命令行参数表中。而且系统中的命令行参数表原本就是由 bash 创建并维护的,我们自定义 shell 其实也是在一定程度上在模拟实现一个 bash。
我们知道系统的命令行参数表是 main 函数的局部变量,而这里我们自定义 shell 时希望子进程能继承父进程的命令行参数表,所以我们这里需要把命令行参数表定义在全局。
有许多函数可以分割字符串,我们选取一个最简单的来使用:strtok。
开始提取子串写入命令行参数表 gargv,第一次调用 strtok 得到的子串放入 gargv[0], 然后循环取子串放入,最后提取子串完毕 strtok 返回 NULL 写入 gargv 最后一个位置,正好命令行参数表要以 NULL 结尾。
但是目前解析命令字符串逻辑还有两个 bug,其一因为 gargv 和 gargc 都是全局变量,所以在 main 函数死循环逻辑的开头需要初始化全局变量,gargc 直接置为 0 就行了,gargv 数组可以用 memset 初始化更方便。
其二是如果用户啥都不输入直接按回车键,那么第二步读取用户命令什么都读取不到,commandstr 数组将为空,第三步提取时会把 strtok 返回的 NULL 写入 gargv[0],所以我们在主逻辑 main 函数的第二步多加一个判断,如果用户没有输入,直接回车,此时直接 continue 跳过此轮循环的后续逻辑。
char * gargv[ARGS] = {NULL };
int gargc = 0 ;
void InitGlobal () {
gargc = 0 ;
memset (gargv, 0 , sizeof (gargv));
}
bool ParseCommandString (char cmd[]) {
if (cmd == NULL ){
return false ;
}
#define SEP " "
gargv[gargc++] = strtok (cmd, SEP);
while (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 ;
}
#include "myshell.h"
#define SIZE 1024
int main () {
char commandstr[SIZE];
while (true ){
InitGlobal ();
PrintCommandPrompt ();
if (!GetCommandString (commandstr, SIZE)) continue ;
ParseCommandString (commandstr);
ForkAndExec ();
}
return 0 ;
}
执行命令 首先我们要知道执行命令不能由 shell 本身来做,因为执行命令会发生程序替换,一旦 shell 被替换那么就无法继续输出命令行提示符和读取用户输入了,所以执行命令需要交由子进程来做。
大体思路是先在 main 函数逻辑里 fork 一个子进程,子进程执行程序替换并运行命令,执行完毕后子进程直接退出。父进程等待子进程,不论等待成功还是失败都会继续循环执行 shell 主逻辑。
然后来选择使用哪个程序替换接口,因为我们要执行的命令没带路径所以要有 p,命令行参数已经被我们维护成了表结构,所以要有 v,子进程可以通过虚拟地址空间继承到环境变量,所以程序替换时可以不传,那么我们的最佳选择就是 execvp。
void ForkAndExec () {
pid_t id = fork();
if (id < 0 ){
perror ("fork" );
return ;
} else if (id == 0 ){
execvp (gargv[0 ], gargv);
exit (0 );
} else {
pid_t rid = waitpid (id, nullptr , 0 );
}
}
内建命令
cd 我们目前已经实现了一个最基本的 shell,还有许多优化工作需要我们做。首先当前的 shell 运行 cd 指令时无法切换 shell 进程的当前工作路径,因为 cd 命令交给子进程去执行了,改变的也只是子进程的工作路径,运行 cd 指令的子进程退出后不会对父进程有影响,再执行 pwd 时负责执行 pwd 的子进程还是继承原先父进程的工作路径,所以我们肉眼看到路径没有变化。而系统的 cd 路径切换本质是 bash 自己在切换,切换后创建的子进程继承了父进程的路径,再 pwd 就会看到切换后的路径。
下面我们来实现 cd 命令的运行逻辑,首先在主逻辑执行命令步骤之前添加一个检查内建命令、若为内建命令则执行的步骤(BuildInCommandExec),如果是内建命令,则执行完该步骤后直接 continue,若不是则继续执行后续逻辑。
然后编写 BuildInCommandExec 的内部逻辑,首先判断 gargv[0] 是不是"cd",注意不能直接比较,直接比 gargv[0] 和"cd"是比的两个指针是否相同,我们需要比两个字符串是否相同。需要先将其中一方转换为 string,然后再比较,这时另一方就会被隐式转换为 string,然后就可以调用 string 的 operator== 比较两个字符串内容了。
接着通过父进程调用 chdir 改变当前工作路径,chdir 系统调用是 cd 指令的底层实现的一部分,我们要自己实现 cd 功能就需要让父进程自己调用 chdir 来切换自己的工作路径。
这里补充一点,当我们只输入"cd"时功能和"cd ~"一样,会使当前工作路径返回家目录。所以我们实现时要考虑这两种情况,若为这两种情况,则需要从环境变量中获取家目录并跳转,若不是则跳转到 gargv[1] 指定的目录下,绝对路径、相对路径均可。
最后处理返回值,该接口默认认为提取到的命令不是内建命令返回 false,只有是内建命令并且父进程执行了该指令后才返回 true。
static std::string GetHomePath () {
std::string home = getenv ("HOME" );
return home.empty ()? "/" : home;
}
bool BuildInCommandExec () {
std::string cmd = gargv[0 ];
bool ret = false ;
if (cmd == "cd" )
{
if (gargc == 2 ){
std::string target = gargv[1 ];
if (target == "~" ){
chdir (GetHomePath ().c_str ());
ret = true ;
} else {
chdir (gargv[1 ]);
ret = true ;
}
} else if (gargc == 1 ){
chdir (GetHomePath ().c_str ());
ret = true ;
} else {
}
}
return ret;
}
echo echo 命令也是一个内建命令,因为"echo $?"可以打印出上一个子进程的退出码,而退出码不是环境变量是本地变量,子进程是拿不到父进程的本地变量的,所以 echo 是由父进程直接执行的。所以 echo 指令也需要进 BuildInCommandExec 接口。
首先定义一个全局变量 lastcode 存子进程的退出码,在执行命令接口 ForkAndExec 的子进程逻辑中获取子进程的退出码写入 lastcode 中。当用户输入"echo $?"指令时就把 lastcode 的值打印出来,lastcode 里存的就是上一个子进程的退出码,所以 InitGlobal 不用初始化 lastcode。当输入"echo $(环境变量)"时就通过 getenv(const char* name) 接口查找环境变量并打印。当打印其它字符串时就把字符串原封不动的打印出来。
int 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);
}
}
}
bool BuildInCommandExec () {
std::string cmd = gargv[0 ];
bool ret = false ;
if (cmd == "cd" )
{
if (gargc == 2 ){
std::string target = gargv[1 ];
if (target == "~" ){
chdir (GetHomePath ().c_str ());
lastcode = 0 ;
ret = true ;
} else {
chdir (gargv[1 ]);
lastcode = 0 ;
ret = true ;
}
} else if (gargc == 1 ){
chdir (GetHomePath ().c_str ());
lastcode = 0 ;
ret = true ;
} else {
}
} else if (cmd == "echo" ){
if (gargc == 2 ){
std::string args = gargv[1 ];
if (args[0 ] == '$' ){
if (args[1 ] == '?' ){
printf ("%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" , gargv[1 ]);
lastcode = 0 ;
ret = true ;
}
}
}
return ret;
}
更新命令行提示符中的当前路径 代码写到这里还有问题,我们 cd 后再 pwd 确实看到当前工作路径已经变了,但是为什么输出的命令行提示符的当前路径一直没变呢?为什么 pwd 后看到的路径却变了呢?
我们一步一步来,先解决命令行提示符的当前路径一直不变的问题。我们在讲环境变量时提到过,环境变量有两个来源,一个是从 bash 从配置文件中获取,一个是 bash 启动后自己动态获取并创建,就比如 PWD,当用户执行 cd 命令切换目录时,bash 会先通过 chdir() 系统调用修改自身的 cwd,然后立即调用 getcwd() 获取新的 pwd 路径,更新到 PWD 环境变量中。到目前为止我们自定义的 bash 已经实现了 chdir() 的功能,接下来还需要我们实现 getcwd() 的功能。
命令行提示符的当前路径是通过 GetPwd 接口获取的,所以我们需要修改原来的 GetPwd 接口,不再直接 getenv 获取当前工作路径。
下面是初版代码,并没有更新当前的进程的环境变量中的 PWD。
static std::string GetPwd () {
char pwd[1024 ];
getcwd (pwd, sizeof (pwd));
return pwd;
}
接下来我们需要更新环境变量表中的 PWD,首先需要在全局定义一个字符数组 pwd 用来存储环境变量表的内容,因为我们知道环境变量表是一个字符指针数组,指向一个一个的字符串或者字符数组,而 snprintf 可以把拿到的 tmp 数组格式化输出到字符串中,用法和 printf 类似,只不过 printf 是往显示器上输出,而 snprintf 是往字符串中输出。然后通过 putenv 环境变量表修改环境变量表,我们之前已经介绍过了。
char pwd[1024 ];
static std::string GetPwd () {
char tmp[1024 ];
getcwd (tmp, sizeof (tmp));
snprintf (pwd, sizeof (pwd), "PWD=%s" , tmp);
putenv (pwd);
return pwd;
}
现在我们已经把基本功能实现完毕,还有最后一步,我们看到 xshell 的命令行提示符中只打印了一个类似"myshell"的路径,而不是"/home/fdb/lesson21/myshell"这样的长路径,所以需要截取子串,步骤如下:
static std::string GetPwd () {
char temp[1024 ];
getcwd (temp, sizeof (temp));
snprintf (pwd, sizeof (pwd), "PWD=%s" , temp);
putenv (pwd);
std::string pwd_label = temp;
const std::string pathsep = "/" ;
size_t pos = pwd_label.rfind (pathsep);
if (pos == std::string::npos){
return "None" ;
}
pwd_label = pwd_label.substr (pos + pathsep.size ());
return pwd_label.size () ? pwd_label : "/" ;
}
自定义 shell 源码 #include "myshell.h"
#define SIZE 1024
int main () {
char commandstr[SIZE];
while (true ){
InitGlobal ();
PrintCommandPrompt ();
if (!GetCommandString (commandstr, SIZE)) continue ;
ParseCommandString (commandstr);
if (BuildInCommandExec ()) continue ;
ForkAndExec ();
}
return 0 ;
}
#include "myshell.h"
using namespace std;
char * gargv[ARGS] = {NULL };
int gargc = 0 ;
char pwd[1024 ];
int lastcode;
void Debug () {
printf ("hello shell!\n" );
}
void InitGlobal () {
gargc = 0 ;
memset (gargv, 0 , sizeof (gargv));
}
static std::string GetUserName () {
string username = getenv ("USER" );
return username.empty () ? "None" : username;
}
static std::string GetHostName () {
string hostname = getenv ("HOSTNAME" );
return hostname.empty () ? "None" : hostname;
}
static std::string GetPwd () {
char temp[1024 ];
getcwd (temp, sizeof (temp));
snprintf (pwd, sizeof (pwd), "PWD=%s" , temp);
putenv (pwd);
std::string pwd_label = temp;
const std::string pathsep = "/" ;
size_t pos = pwd_label.rfind (pathsep);
if (pos == std::string::npos){
return "None" ;
}
pwd_label = pwd_label.substr (pos + pathsep.size ());
return pwd_label.size () ? pwd_label : "/" ;
}
static std::string GetHomePath () {
std::string home = getenv ("HOME" );
return home.empty ()? "/" : home;
}
void PrintCommandPrompt () {
std::string username = GetUserName ();
std::string hostname = GetHostName ();
std::string pwd = GetPwd ();
printf ("[%s@%s %s]# " , username.c_str (), hostname.c_str (), pwd.c_str ());
}
bool GetCommandString (char cmdstr_buff[], int len) {
if (cmdstr_buff == NULL || len <= 0 ){
return false ;
}
char * res = fgets (cmdstr_buff, len, stdin);
if (res == NULL ){
return false ;
}
cmdstr_buff[strlen (cmdstr_buff)-1 ] = 0 ;
return strlen (cmdstr_buff) == 0 ? false : true ;
}
bool ParseCommandString (char cmd[]) {
if (cmd == NULL ){
return false ;
}
#define SEP " "
gargv[gargc++] = strtok (cmd, SEP);
while (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 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 BuildInCommandExec () {
std::string cmd = gargv[0 ];
bool ret = false ;
if (cmd == "cd" )
{
if (gargc == 2 ){
std::string target = gargv[1 ];
if (target == "~" ){
chdir (GetHomePath ().c_str ());
lastcode = 0 ;
ret = true ;
} else {
chdir (gargv[1 ]);
lastcode = 0 ;
ret = true ;
}
} else if (gargc == 1 ){
chdir (GetHomePath ().c_str ());
lastcode = 0 ;
ret = true ;
} else {
}
} else if (cmd == "echo" ){
if (gargc == 2 ){
std::string args = gargv[1 ];
if (args[0 ] == '$' ){
if (args[1 ] == '?' ){
printf ("%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" , gargv[1 ]);
lastcode = 0 ;
ret = true ;
}
}
}
return ret;
}
#ifndef __MYSHELL_H__
#define __MYSHELL_H__
#include <stdio.h>
#include <iostream>
#include <string>
#include <cstdlib>
#include <cstring>
#include <cstdbool>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#define ARGS 64
void Debug () ;
void InitGlobal () ;
void PrintCommandPrompt () ;
bool GetCommandString (char cmdstr_buff[], int len) ;
bool ParseCommandString (char cmd[]) ;
void ForkAndExec () ;
bool BuildInCommandExec () ;
#endif
二、子进程备份 我们前面实现的自定义 shell 创建子进程都是让它程序替换后执行与父进程完全不同的代码,下面小编再展示一份让父子进程分工合作的代码,让子进程运行父进程代码的一部分。
代码的业务逻辑是保存随机数据到全局数组并备份到文件中,让父进程负责保存数据,让子进程负责备份父进程的数据,这样就可以使保存数据和备份数据并发执行,提高效率。
因为有写时拷贝的存在,即使父子进程操作的是同一份全局数组,也互不影响。
#include <stdio.h>
#include <time.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int garray[100 ];
pid_t backup (const char * filename) {
pid_t id = fork();
if (id == 0 ){
FILE* pf = fopen (filename, "w" );
for (int i = 0 ; i < 100 ; i++){
fprintf (pf, "%d " , garray[i]);
}
fclose (pf);
exit (0 );
}
return id;
}
int main () {
srand (time (NULL ));
for (int i = 0 ; i < 100 ; i++){
garray[i] = rand ()%10 ;
}
pid_t sub1 = backup ("log1.txt" );
for (int i = 0 ; i < 100 ; i++){
garray[i] = rand ()%10 ;
}
pid_t sub2 = backup ("log2.txt" );
for (int i = 0 ; i < 100 ; i++){
garray[i] = rand ()%10 ;
}
pid_t sub3 = backup ("log3.txt" );
waitpid (sub1, NULL , 0 );
waitpid (sub2, NULL , 0 );
waitpid (sub3, NULL , 0 );
return 0 ;
}
相关免费在线工具 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