手写一个 Linux Shell:从死循环到内建命令,彻底搞懂 Shell 工作原理
2026/8/11 4:20:18 网站建设 项目流程

你有没有好奇过,每天敲的lscdecho $?在底层到底发生了什么?这篇文章带你从零开始,用 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 自己调用函数完成cdechoenvexport
外部命令fork 子进程 + exec 替换lsgreptopvim

为什么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 子进程。cdecho $?这些内建命令必须由 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

对于lsgrep等外部命令,创建子进程,用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 的构建过程。

如果觉得有帮助,欢迎点赞收藏,评论区交流讨论!

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询