公司动态
手写一个 Linux Shell:从死循环到内建命令,彻底搞懂 Shell 工作原理
你有没有好奇过每天敲的ls、cd、echo $?在底层到底发生了什么这篇文章带你从零开始用 C/C 一步步手写一个能真正运行的 MiniShell——包含命令行解析、内建命令、父子进程执行、退出码获取、动态提示符等完整功能。目录第一部分Shell 理论基础第二部分从骨架到丰盈——MiniShell 逐步构建Step 0整体骨架——Shell 是一个死循环Step 1用户信息的导入——环境变量加载Step 2打印命令行提示符Step 3用户输入字符串的获取与肢解——strtok 解析Step 4执行命令——内建命令 vs 子进程执行Step 5cd 命令的路径问题Step 6退出码的获取与 echo $?Step 7完整运行演示总结第一部分Shell 理论基础1.1 Shell 的本质Shell 的本质是一个死循环。while (1) { 打印提示符; 读取用户输入; 解析命令行; 执行命令; }Shell 启动后不断重复这个过程直到用户输入exit或按下CtrlD。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此外还有cwdShell 自己的当前工作路径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是系统提供的全局变量指向当前进程的环境变量表。我们逐条mallocstrcpy拷贝过来形成自己的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 一样[whbbite-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); // 循环切割每次传 NULLstrtok 会记住上次切到哪了 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 5cd 命令的路径问题cd是最特殊的内建命令也是面试高频考点。它涉及两个操作cd /home/whb 执行了 ┌── ① 系统调用 chdir(gargv[1])更改内核中进程的当前工作目录 │ → 后续所有该进程创建的文件/执行的命令默认路径都变了 │ └── ② 更新环境变量 PWDsnprintf 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 -stdc11 ./myshell[whbbite-alicloud lesson15]# ls -a -l ... (ls 的输出) [whbbite-alicloud lesson15]# echo $? lastcode: 0 [whbbite-alicloud lesson15]# cd /home/whb/code [whbbite-alicloud code]# pwd /home/whb/code [whbbite-alicloud code]# echo $PATH /usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/home/whb/.local/bin:/home/whb/bin [whbbite-alicloud code]# ls nonexistent ls: cannot access nonexistent: No such file or directory [whbbite-alicloud code]# echo $? lastcode: 2总结完整调用链路回顾main() │ ├── LoadEnv() ← 拷贝父进程环境变量表 │ └── while(1) { │ ├── PrintCommandLine() ← 组装 [userhost 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改环境变量 PWDOS 会自动更新 PWD 吗不会。环境变量是用户数据必须手动修改echo $? 怎么获取父进程 waitpid 后用 WEXITSTATUS 提取存入全局 lastcodeexecvpe 第三参数为什么传 genv保证子进程继承我们更新过的环境变量表为什么 genv 和 cwd 是全局变量putenv 会使环境变量指针指向 cwd 的地址必须是持久存储本文基于自主 Shell 实现学习笔记整理从死循环骨架开始逐步添加环境加载、命令行解析、内建命令、路径切换、退出码获取等功能完整展示了一个 MiniShell 的构建过程。如果觉得有帮助欢迎点赞收藏评论区交流讨论