你有没有好奇过,每天敲的
ls、cd、echo $?在底层到底发生了什么?这篇文章带你从零开始,用 C/C++ 一步步手写一个能真正运行的 MiniShell——包含命令行解析、内建命令、父子进程执行、退出码获取、动态提示符等完整功能。
目录
- 第一部分:Shell 理论基础
- 第二部分:从骨架到丰盈——MiniShell 逐步构建
- Step 0:整体骨架——Shell 是一个死循环
- Step 1:用户信息的导入——环境变量加载
- Step 2:打印命令行提示符
- Step 3:用户输入字符串的获取与肢解——strtok 解析
- Step 4:执行命令——内建命令 vs 子进程执行
- Step 5:cd 命令的路径问题
- Step 6:退出码的获取与 echo $?
- Step 7:完整运行演示
- 总结
第一部分:Shell 理论基础
1.1 Shell 的本质
Shell 的本质是一个死循环。
while (1) { 打印提示符; 读取用户输入; 解析命令行; 执行命令; }Shell 启动后不断重复这个过程,直到用户输入exit或按下Ctrl+D。
1.2 内建命令 vs 外部命令
并非所有命令都需要创建子进程来执行:
| 类型 | 执行方式 | 典型命令 |
|---|---|---|
| 内建命令 | bash 自己调用函数完成 | cd、echo、env、export |
| 外部命令 | fork 子进程 + exec 替换 | ls、grep、top、vim |
为什么
cd必须是内建命令?因为如果cd创建子进程执行,子进程改了工作路径后退出,父进程(bash)的路径不受影响——你cd了个寂寞。
1.3 Shell 需要维护两张表
Shell 内部需要维护两张核心的表:
① 命令行参数表 gargv[] —— 当前用户输入被解析后的结果(类似 main 的 argv) ② 环境变量表 genv[] —— 从父进程拷贝来的环境变量(类似 extern char **environ)此外还有:
cwd:Shell 自己的当前工作路径lastcode:最近一个命令执行完毕后的退出码(用于echo $?)
1.4 Shell 的两种工作模式
| 模式 | 输入来源 | 场景 |
|---|---|---|
| 交互模式 | 终端(标准输入 stdin) | 用户逐行敲命令 |
| 批处理模式 | 脚本文件 | bash script.sh |
我们本文实现的是交互模式。
第二部分:从骨架到丰盈——MiniShell 逐步构建
下面我们按照"从骨架到完整实现"的顺序,逐步构建一个能真正工作的 MiniShell。代码采用 C/C++ 混编,完整实现约 200 行。
Step 0:整体骨架——Shell 是一个死循环
先搭好主函数的骨架。每一个步骤对应一个函数,各司其职:
#include <stdio.h> #include <string.h> #include <stdlib.h> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> #include <iostream> #include <string> // ========== 全局数据 ========== #define MAXSIZE 128 // 命令行最大长度 #define MAXARGS 32 // 参数最大个数 char *gargv[MAXARGS]; // ① 命令行参数表 int gargc = 0; const char *gsep = " "; // 参数分隔符(空格) char *genv[MAXARGS]; // ② 环境变量表 int genvc = 0; char cwd[MAXSIZE]; // Shell 自己的当前工作路径 int lastcode = 0; // 最近一个命令的退出码(给 echo $? 使用) // ========== 主函数骨架 ========== int main() { // 0. 从父进程拷贝环境变量,填充环境变量表 LoadEnv(); char command_line[MAXSIZE] = {0}; while (1) { // 1. 打印命令行提示符 PrintCommandLine(); // 2. 获取用户输入 if (0 == GetCommand(command_line, sizeof(command_line))) continue; // 3. 解析字符串 "ls -a -l" → {"ls", "-a", "-l"} ParseCommand(command_line); // 4. 判断:是内建命令(bash 自己执行)还是外部命令(fork 子进程) if (CheckBuiltinExecute()) continue; // 5. 让子进程执行这个命令 ExecuteCommand(); } return 0; }骨架非常清晰:加载环境 → 循环(打印提示 → 读输入 → 解析 → 判断内建/外部 → 执行)。下面我们逐个实现这些函数。
Step 1:用户信息的导入——环境变量加载
Shell 启动时,需要从父进程(你打开的那个终端 bash)拷贝环境变量表。这些环境变量用来获取用户名、主机名、当前路径,以组装提示符。
void LoadEnv() { // 正常情况,环境变量表内部是从配置文件来的 // 今天我们直接从父进程拷贝 extern char **environ; for (; environ[genvc]; genvc++) { genv[genvc] = (char*)malloc(sizeof(char) * 4096); strcpy(genv[genvc], environ[genvc]); } genv[genvc] = NULL; // NULL 哨兵结尾(兼容 execvpe 的参数格式) }关键点:extern char **environ是系统提供的全局变量,指向当前进程的环境变量表。我们逐条malloc+strcpy拷贝过来,形成自己的genv[]表。这份拷贝是独立的——后续用putenv修改时不影响父进程。
有了环境变量,就可以获取用户名、主机名和当前路径:
const char *GetUserName() { char *name = getenv("USER"); if (name == NULL) return "None"; return name; } const char *GetHostName() { char *hostname = getenv("HOSTNAME"); if (hostname == NULL) return "None"; return hostname; } const char *GetPwd() { char *pwd = getenv("PWD"); if (pwd == NULL) return "None"; return pwd; }Step 2:打印命令行提示符
我们希望提示符像真正的 Shell 一样:
[whb@bite-alicloud lesson15]#这需要组合"用户名@主机名 当前路径":
// 从完整路径中提取最后一级目录名 // 例如 /home/whb/code → code static std::string rfindDir(const std::string &p) { if (p == "/") return p; const std::string psep = "/"; auto pos = p.rfind(psep); // 从右往左找最后一个 '/' if (pos == std::string::npos) return std::string(); return p.substr(pos + 1); // 截取最后一级 } void PrintCommandLine() { printf("[%s@%s %s]# ", GetUserName(), // whb GetHostName(), // bite-alicloud rfindDir(GetPwd()).c_str());// lesson15(仅最后一级目录) fflush(stdout); // 立即刷新到终端,否则提示符可能不显示 }
fflush(stdout)很重要——printf默认是行缓冲,没有\n不会自动刷新,不加的话提示符会卡在缓冲区里。
Step 3:用户输入字符串的获取与肢解——strtok 解析
3.1 获取输入
从标准输入读取一行。fgets是最合适的——它指定最大长度,防止缓冲区溢出。
int GetCommand(char commandline[], int size) { // fgets 会保留用户敲的回车 \n if (NULL == fgets(commandline, size, stdin)) return 0; // 去掉末尾的 \n → 替换为 \0 // "abcd\n" → "abcd\0" commandline[strlen(commandline) - 1] = '\0'; return strlen(commandline); }3.2 解析字符串:strtok的工作原理
解析命令行的核心是strtok()。它用快慢指针切分字符串,并且内部使用static变量记录切分位置,具有"记忆"功能。
// strtok 使用示例 char str[] = "ls -a -l"; const char *sep = " "; // 分隔符 char *p = strtok(str, sep); // 第一次调用:传原字符串,返回 "ls" while (p) { printf("%s\n", p); p = strtok(NULL, sep); // 后续调用:传 NULL,继续分割 } // 输出: // ls // -a // -l关键:
strtok会在原字符串上直接修改(插入\0来截断),所以传参时不能传const字符串。这正是为什么我们用char commandline[](栈上的可修改数组)而不是char*。
3.3 ParseCommand 实现
int ParseCommand(char commandline[]) { gargc = 0; memset(gargv, 0, sizeof(gargv)); // 清空上次的参数表 // 第一刀:从字符串头部开始切 gargv[0] = strtok(commandline, gsep); // 循环切割:每次传 NULL,strtok 会记住上次切到哪了 while ((gargv[++gargc] = strtok(NULL, gsep))) ; return gargc; // 返回参数个数 }解析效果:
输入: "ls -a -l" ↓ ParseCommand gargv[0] = "ls" gargv[1] = "-a" gargv[2] = "-l" gargv[3] = NULL gargc = 3此刻gargv[]和gargc已经和main函数的argv[]/argc格式完全一致,可以直接传给execvpe。
Step 4:执行命令——内建命令 vs 子进程执行
4.1 内建命令:CheckBuiltinExecute
不是所有命令都需要 fork 子进程。cd、echo $?这些内建命令必须由 bash 自己执行。函数返回 1 表示已处理完毕,主循环直接continue。
int CheckBuiltinExecute() { // ===== 内建命令:cd ===== if (strcmp(gargv[0], "cd") == 0) { if (gargc == 2) { // ① 更改进程内核中的路径 chdir(gargv[1]); // ② 更新环境变量 PWD(后面 Step 5 详细解释) char pwd[1024]; getcwd(pwd, sizeof(pwd)); snprintf(cwd, sizeof(cwd), "PWD=%s", pwd); putenv(cwd); lastcode = 0; } return 1; // 内建命令已处理 } // ===== 内建命令:echo ===== if (strcmp(gargv[0], "echo") == 0) { if (gargc == 2) { if (gargv[1][0] == '$') { // echo $? if (strcmp(gargv[1] + 1, "?") == 0) { printf("lastcode: %d\n", lastcode); } // echo $PATH(访问环境变量表) else if (strcmp(gargv[1] + 1, "PATH") == 0) { printf("%s\n", getenv("PATH")); } lastcode = 0; } } return 1; // 内建命令已处理 } return 0; // 不是内建命令,让子进程执行 }4.2 外部命令执行:ExecuteCommand —— fork + execvpe
对于ls、grep等外部命令,创建子进程,用execvpe替换。
int ExecuteCommand() { pid_t id = fork(); if (id < 0) { return -1; } else if (id == 0) { // 子进程:用 execvpe 替换,执行外部命令 // execvpe(gargv[0], gargv, genv); // gargv[0] = "ls" ← 要执行谁 // gargv = 参数表 ← 怎么执行 // genv = 环境变量表 ← 传递我们维护的环境变量 execvpe(gargv[0], gargv, genv); exit(1); // execvpe 成功不会返回,走到这一定是失败了 } else { // 父进程:等待子进程结束,获取退出码 int status = 0; pid_t rid = waitpid(id, &status, 0); if (rid > 0) { // 保存退出码 —— 给 echo $? 使用! lastcode = WEXITSTATUS(status); } } return 0; }关键细节:execvpe的第三个参数是genv(我们自己维护的环境变量表),而不是系统的environ。这保证了子进程能继承我们可能修改过的环境变量(比如cd后更新的PWD)。
Step 5:cd 命令的路径问题
cd是最特殊的内建命令,也是面试高频考点。它涉及两个操作:
cd /home/whb 执行了: ┌── ① 系统调用 chdir(gargv[1]):更改内核中进程的当前工作目录 │ → 后续所有该进程创建的文件/执行的命令,默认路径都变了 │ └── ② 更新环境变量 PWD:snprintf + putenv → putenv 和 getenv 本质是访问环境变量表 genv[] → 操作系统不会自动改环境变量!必须手动改为什么必须手动更新 PWD?
// cd 内建命令的完整处理: if (gargc == 2) { // ① 更改进程内核中的路径 chdir(gargv[1]); // ② 更新环境变量 char pwd[1024]; getcwd(pwd, sizeof(pwd)); // 获取当前的绝对路径 snprintf(cwd, sizeof(cwd), "PWD=%s", pwd); // 拼成 "PWD=/home/whb" putenv(cwd); // 写入环境变量表 // 注意:这里用的是全局 cwd[], // putenv 会让 genv[] 中某条指针直接指向 cwd 的地址, // 所以 cwd 必须是全局的,不能是局部变量! lastcode = 0; }核心认知:操作系统不会自动帮你更新环境变量。环境变量是在 Shell 启动时从配置文件/父进程拷贝来的,属于"用户数据"——bash 不会主动改,需要我们在
cd时手动同步更新PWD。
两种获取当前路径的方式:
// 方式一:读环境变量 PWD(快,但 cd 后不更新的话会不准) char *pwd = getenv("PWD"); // 方式二:系统调用 getcwd(准,每次都从内核重新获取) char *pwd = getcwd(cwd, sizeof(cwd));Step 6:退出码的获取与 echo $?
6.1 退出码从哪里来?
// 在 ExecuteCommand 中,父进程回收子进程后: lastcode = WEXITSTATUS(status);每次子进程执行完毕,父进程通过waitpid回收,用WEXITSTATUS宏从 status 中提取退出码,存入全局变量lastcode。
6.2 echo $? 怎么实现的?
// 在 CheckBuiltinExecute 中: if (strcmp(gargv[1] + 1, "?") == 0) { printf("lastcode: %d\n", lastcode); }gargv[1]是"$?"→gargv[1] + 1跳过了$,剩下"?"→ 和"?"比较 → 匹配!打印lastcode。
6.3 变量替换的通用模式
if (gargv[1][0] == '$') { // 以 $ 开头 → 这是一个变量引用 // gargv[1] + 1 → 跳过 $,得到变量名 if (strcmp(gargv[1] + 1, "?") == 0) { // echo $? → 打印退出码 } else if (strcmp(gargv[1] + 1, "PATH") == 0) { // echo $PATH → 打印环境变量 } }这是 Shell 变量替换的最简实现。真正的 bash 会对
${VAR}、$VAR做完整的词法分析,但原理完全一致。
Step 7:完整运行演示
把上面所有代码组装起来,编译运行:
g++ -o myshell myshell.cc -std=c++11 ./myshell[whb@bite-alicloud lesson15]# ls -a -l ... (ls 的输出) [whb@bite-alicloud lesson15]# echo $? lastcode: 0 [whb@bite-alicloud lesson15]# cd /home/whb/code [whb@bite-alicloud code]# pwd /home/whb/code [whb@bite-alicloud code]# echo $PATH /usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/home/whb/.local/bin:/home/whb/bin [whb@bite-alicloud code]# ls nonexistent ls: cannot access 'nonexistent': No such file or directory [whb@bite-alicloud code]# echo $? lastcode: 2总结
完整调用链路回顾
main() │ ├── LoadEnv() ← 拷贝父进程环境变量表 │ └── while(1) { │ ├── PrintCommandLine() ← 组装 [user@host dir]# 提示符 │ ├── GetUserName() ← getenv("USER") │ ├── GetHostName() ← getenv("HOSTNAME") │ └── GetPwd() ← getenv("PWD") │ ├── GetCommand() ← fgets 读取用户输入 │ ├── ParseCommand() ← strtok 切割成 gargv[] │ ├── CheckBuiltinExecute() ← 内建命令? │ ├── cd → chdir() + putenv(PWD=...) │ └── echo → $? 打印 lastcode, $PATH 打印环境变量 │ └── ExecuteCommand() ← 外部命令:fork + execvpe + waitpid └── lastcode = WEXITSTATUS(status) }核心认知速查
| 问题 | 答案 |
|---|---|
| Shell 本质是什么? | 一个死循环:读输入 → 解析 → 执行 |
| 为什么 cd 必须是内建命令? | 子进程改路径不影响父进程,必须 bash 自己改 |
| strtok 为什么不能传 const? | 会在原字符串上直接插入\0来截断 |
| cd 做了哪两件事? | chdir(改内核路径)+ putenv(改环境变量 PWD) |
| OS 会自动更新 PWD 吗? | 不会。环境变量是用户数据,必须手动修改 |
| echo $? 怎么获取? | 父进程 waitpid 后用 WEXITSTATUS 提取,存入全局 lastcode |
| execvpe 第三参数为什么传 genv? | 保证子进程继承我们更新过的环境变量表 |
| 为什么 genv 和 cwd 是全局变量? | putenv 会使环境变量指针指向 cwd 的地址,必须是持久存储 |
本文基于自主 Shell 实现学习笔记整理,从死循环骨架开始逐步添加环境加载、命令行解析、内建命令、路径切换、退出码获取等功能,完整展示了一个 MiniShell 的构建过程。
如果觉得有帮助,欢迎点赞收藏,评论区交流讨论!