《从零入门Linux系统篇(二十七):进程篇·十一——手写Shell实战:从普通命令执行到内建命令实现》
这是Linux进程篇的收官之战。为了真正看懂命令行背后那套运转逻辑我们要把前面进程篇一到十一积攒的全部家伙什儿都翻出来从零开始手写一个简易Shell。按照深入程度自定义Shell的编写可以拆成四个阶段。本文先带大家啃下前三个阶段把骨架搭起来、把流程跑通。至于第四阶段我们留到Linux文件篇再接着攻坚。好的不废话我们开始。目录一、准备工作——从基础接口到Shell框架1.1 常用接口回顾1.1.1 rfind1.1.2 fgets1.1.3 strtok1.1.4 snprintf1.2 Shell基础架构设计二、第一阶段让Shell运行普通命令2.1 Shell核心代码实现三、第二阶段让Shell支持内建命令3.1 什么是内建命令四、第三阶段加入环境变量支持4.1 第二、第三阶段合并版——Shell完整实现4.2 Shell实现中的几个关键问题4.2.1 为什么读取命令不用scanf而用fgets4.2.2 为什么使用字符数组而不是std::string4.2.3 思考一个悖论我们手写的Shell能执行su -吗4.2.4 为什么需要内建命令——普通命令与特殊内建命令的区别4.2.5 chdir()的小细节——只改变工作目录不改变环境变量一、准备工作——从基础接口到Shell框架1.1 常用接口回顾正式开始前我们得先把几个几乎被遗忘的接口从记忆深处捞回来它们马上就会在Shell里派上大用场。1.1.1 rfindsize_t rfind(const string str, size_t pos npos);str要查找的目标子串或字符。pos从哪个位置开始逆向搜索。默认值是npos也就是从字符串末尾一路往前找。返回值目标子串最后一次出现时首字符所在的下标如果压根没找到返回 std::string::npos。这个函数就是帮我们从后往前找到某个字符或字符串最后一次露面的位置。后面在解析命令行时它会派上关键用场。1.1.2 fgetschar* fgets(char* str, int n, FILE* stream);这个函数就是从输入流里“抓一行”的标准工具。自定义Shell里就是靠它把用户敲进来的命令读出来的。str缓冲区指针。读到的字符串就存进这块地方。n最多能读多少个字符。注意它有点“抠门”实际最多只读n-1个因为最后一位要留给自动补上的\0结束符。stream从哪儿读。Shell里我们固定传stdin也就是标准输入老老实实等着用户敲键盘。返回值成功时返回的就是str本身相当于把缓冲区又原样递回你手里。如果还没读到任何字符就撞上了EOF或者读取过程中出了错那就返回NULL。1.1.3 strtokchar* strtok(char* str, const char* delimiters);这个函数就是用来“切串”的。给它一根长字符串再给它一把“分隔符”它就能把长串一段段切开每次吐出一小段。str待切分的原始字符串。这里有个非常反直觉的规矩第一次调用时要把原字符串传进去之后再想拿剩下的段这个位置必须传NULL。函数自己会记住上次切到哪儿了你只要不停喊它就行。delimiters分隔符集合。比如传一个空格 它就会按空格切传 \t它就连空格带Tab一起当刀使。返回值当前切出来的那一小段字符串的起始指针。如果没有东西可切了返回NULL表示“已经切完了”。strtok切串的时候会直接修改原字符串把分隔符位置改成\0。所以传进去的字符串得是可写的不能是只读的字符串常量。这个细节用过一次就忘不掉。1.1.4 snprintfint snprintf(char* s, size_t n, const char* format, ...);这个函数你可以把它理解成“安全版的sprintf”。它干的事很简单把一堆零散的数据按照你给的格式拼成一句完整的字符串。但比sprintf强的地方在于它永远不会写爆你的缓冲区。参数逐个看s目标缓冲区。拼好的字符串最终就躺在这块数组里。n缓冲区的大小。这是它的安全底线函数最多写入n-1个有效字符剩下一个位置强制填\0。所以哪怕你给的数据再多它也绝不越界。format格式化模板。比如%s%s决定了后面那些参数怎么排、以什么顺序插入。...可变参数列表。模板里挖了几个坑你就得填几个对应的变量进去。返回值它返回的是“假设缓冲区无限大时完整输出本来需要多少字符”不包含结尾的\0。如果这个返回值大于等于n就说明缓冲区给小了输出被截断了。这个特性很实用你可以先用返回值判断空间够不够不够再扩。1.2 Shell基础架构设计动手写代码之前先把整条执行流在脑子里过一遍。核心逻辑说白了就四步一个循环走到底打印提示符 → 获取输入 → 切分字符串 → 识别并执行第一步打印提示符告诉用户“该你说话了”第二步获取输入把用户敲的那一整行命令读进来第三步切分字符串用空格当刀把命令和参数拆成一个个独立片段第四步识别并执行判断这到底是什么命令该内置的内置处理该外部程序的交给fork exec去跑。跑完一圈回到第一步继续打印提示符继续等下一句。Shell的一生就是这四步的无限循环。二、第一阶段让Shell运行普通命令这是Shell的婴儿学步阶段。目标很朴素能执行ls -a -l这种外部命令就足够了。怎么跑父进程负责解析命令子进程负责被execvp替换各司其职。2.1 Shell核心代码实现先把整个代码完整贴出来然后我们再一块一块拆开看。#include iostream #include cstdlib #include unistd.h #include sys/types.h #include sys/wait.h #include cstring #include cstdio using namespace std; #define COMMAND_SIZE 1024 #define COMMANDLINE [%s%s %s]# int g_argc 0; char* g_argv[128] {0}; const char* GetUserName() { const char* name getenv(USER); // 获取当前用户名 return name nullptr ? None : name; } const char* GetHostName() { const char* host getenv(HOSTNAME); // 获取主机名 return host nullptr ? None : host; } string GetDirName(const char* pwd) { #define SEP / string str pwd; if (str SEP) return /; auto e str.rfind(SEP); // 从后往前查找路径分隔符 if (e string::npos) return BUG?; return str.substr(e 1); // 提取当前最后一级目录名 } string GetCwd() { const char* pwd getenv(PWD); // 获取当前绝对路径 if (pwd NULL) return None; return GetDirName(pwd); } void MakeCommandLine(char* commandline, int size) { // 拼接用户名、主机名和当前目录格式化输出至目标缓冲区 snprintf(commandline, size, COMMANDLINE, GetUserName(), GetHostName(), GetCwd().c_str()); } void PrintCommandLine() { char CommandLine[COMMAND_SIZE] {0}; MakeCommandLine(CommandLine, sizeof(CommandLine)); // 构建命令提示符 printf(%s, CommandLine); fflush(stdout); // 刷新标准输出缓冲区 } bool GetCommandLine(char* cml, int size) { char* c fgets(cml, size, stdin); // 从标准输入读取一行 if (c NULL) return false; if (strlen(cml) - 1 0) return false; cml[strlen(cml) - 1] 0; // 剔除最后的换行符 \n return true; } void CommandParse(char* cml) { #define SPL g_argc 0; g_argv[g_argc] strtok(cml, SPL); // 首次切分获取程序名 while ((bool)(g_argv[g_argc] strtok(nullptr, SPL))); // 循环切分获取参数 g_argc--; } void Execute() { pid_t _id fork(); // 创建子进程 if (_id 0) { execvp(g_argv[0], g_argv); // 子进程执行程序替换 exit(0); } pid_t rid waitpid(_id, nullptr, 0); // 父进程阻塞等待子进程退出 (void)rid; return; } int main() { while (true) { PrintCommandLine(); // 1. 打印命令行提示符 char commandline[COMMAND_SIZE] {0}; if (!GetCommandLine(commandline, sizeof(commandline))) // 2. 获取输入 continue; CommandParse(commandline); // 3. 命令行分析 Execute(); // 4. 创建子进程执行 } return 0; }三、第二阶段让Shell支持内建命令第一阶段的Shell只能跑跑外部命令但遇到cd这种命令就露怯了。为什么因为cd改变的是当前工作目录而这件事必须由Shell亲自来做交给子进程去改改的是子进程自己的目录父进程纹丝不动等于没改。像cd、echo、export这类必须由父进程亲自执行的命令在Shell里有个专门的名字内建命令。3.1 什么是内建命令内建命令的处理逻辑跟外部命令走了完全不同的路。父进程在fork之前得先拦住命令看一眼这是不是我该亲自办的如果是就别派子进程了直接在自己的进程内部调用对应的系统接口比如cd就调chdir()原地把目录切了。所以内建命令和外部命令的分界线就在这里外部命令fork exec子进程去跑父进程只负责等。内建命令不fork父进程直接调用系统接口亲手执行。这条线一旦划清楚Shell的行为就变得合理多了。你敲cd /home的时候改变的是Shell自己的工作目录这样下次执行ls时ls继承的才是新目录下的环境。如果cd交给子进程去干子进程退出了目录就改了个寂寞。这就是内建命令存在的根本原因。四、第三阶段加入环境变量支持环境变量具有全局属性子进程可以继承。Shell不能总是指望系统的environ它得自己维护一张环境变量表在启动时把系统的environ导入进来然后在这张表上做文章。这个阶段我们要让Shell拥有自己的“环境变量管家”。4.1 第二、第三阶段合并版——Shell完整实现下面这段代码就是把第二阶段的“内建命令”和第三阶段的“环境变量管理”合并在一起的结果。你把它跑起来就能得到一个能执行普通命令、能处理cd和echo、还能维护自己环境变量表的迷你Shell。#include iostream #include unistd.h #include cstdio #include cstring #include sys/types.h #include sys/wait.h using namespace std; const int PROMPT_SIZE 128; #define PROMPT [%s%s %s ]# #define COMMAND_SIZE 1024 int g_argc 0; char* g_argv[COMMAND_SIZE] {0}; size_t ExitCode 0; // 记录最近一次子进程的退出码 const int ENV_SIZE 100; int g_envs 0; char* g_env[ENV_SIZE] {0}; // Shell 自己维护的环境变量表 const char* GetUserName() { const char* name getenv(USER); return name NULL ? None : name; } const char* GetHostName() { const char* host getenv(HOSTNAME); return host NULL ? None : host; } const char* GetHome() { const char* home getenv(HOME); return home NULL ? None : home; } string GetDirectory(char* _cwd) { string str _cwd; size_t pos str.rfind(/); if (pos string::npos) return None; string ret str.substr(pos 1); return ret; } string GetCwd() { char* cwd getenv(PWD); return GetDirectory(cwd); } void MakePrompt(char* prompt, int size) { snprintf(prompt, size, PROMPT, GetUserName(), GetHostName(), GetCwd().c_str()); } void PrintCommandPrompt() { char CommandPrompt[PROMPT_SIZE] {0}; MakePrompt(CommandPrompt, sizeof(CommandPrompt)); printf(%s, CommandPrompt); } bool GetCommandLine(char* _cmd, int size) { char* ptr fgets(_cmd, size, stdin); if (ptr NULL) return false; _cmd[strlen(_cmd) - 1] 0; // 剥离末尾换行符 if (strlen(_cmd) 0) return false; return true; } bool AnalyseCommandLine(char* _cmd) { #define ESC g_argc 0; g_argv[g_argc] strtok(_cmd, ESC); while ((bool)(g_argv[g_argc] strtok(NULL, ESC))); g_argc--; return g_argc 0 ? true : false; } void Execute() { pid_t _id fork(); if (_id 0) { execvp(g_argv[0], g_argv); exit(0); } int statue 0; pid_t rid waitpid(_id, statue, 0); if (rid 0) ExitCode WEXITSTATUS(statue); // 获取并记录子进程退出码 return; } void CommandCd() { if (g_argc 1) { chdir(GetHome()); // 无参数默认切换到 HOME 路径 } else if (g_argc 2) { if (strcmp(g_argv[1], -) 0) chdir(getenv(OLDPWD)); // 切换到上一次所在目录 else if (strcmp(g_argv[1], ~) 0) chdir(getenv(HOME)); // 切换到家目录 else chdir(g_argv[1]); // 切换到用户指定的路径 } else cout command not found endl; } void CommandEcho() { string What g_argv[1]; if (g_argc 2) { if (What $?) { cout ExitCode endl; // 打印最近一次退出码然后重置 ExitCode 0; } else if (What[0] $) { string sub What.substr(1); printf(%s\n, getenv(sub.c_str())); // 打印指定环境变量的值 } else { cout What endl; } } else cout command not found endl; } bool CheckBuild_inCommand() { string _cmd g_argv[0]; if (_cmd cd) { CommandCd(); // 拦截并执行内建 cd return true; } else if (_cmd echo) { CommandEcho(); // 拦截并执行内建 echo return true; } return false; } void Initenv() { extern char** environ; memset(g_env, 0, sizeof(g_env)); g_envs 0; // 1. 把系统原生环境变量深拷贝进我们自己的表 for (int i 0; environ[i]; i) { g_env[i] (char*)malloc(strlen(environ[i]) 1); strcpy(g_env[i], environ[i]); g_envs; } // 2. 追加一条自定义测试变量 g_env[g_envs] (char*)TEST1234567890; g_env[g_envs] NULL; // 3. 把表里的变量全部导入当前进程环境 for (int i 0; g_env[i]; i) { putenv(g_env[i]); } // 4. 让全局 environ 指向我们自己的表 environ g_env; } int main() { Initenv(); // 初始化环境变量表 while (1) { PrintCommandPrompt(); // 1. 打印提示符 char CommandLine[COMMAND_SIZE] {0}; if (!GetCommandLine(CommandLine, sizeof(CommandLine))) // 2. 获取输入 continue; if (!AnalyseCommandLine(CommandLine)) // 3. 解析命令 continue; if (CheckBuild_inCommand()) // 4. 如果是内建命令就在父进程里执行 continue; Execute(); // 5. 外部命令交给子进程去跑 } return 0; }关键逻辑拆解这段代码相比前两阶段多了两个核心变化内建命令拦截和环境变量自主维护。CheckBuild_inCommand就是那个拦截器。它一看命令是cd或echo就截下来不往Execute送了直接在父进程里调CommandCd或CommandEcho搞定。这就是前面说的内建命令绝不能交给子进程因为它们要改的就是父进程自己的状态。Initenv是环境变量管家的初始化。它先把系统的environ整张表深拷贝进我们自己的g_env数组再追加一条TEST1234567890然后把数组里的变量一条条putenv回当前进程最后把全局environ指针指到我们自己的表上。至此Shell的环境变量就完全接管过来了以后想增删改查直接对g_env动手就行。echo命令也被玩出了花。敲echo hello就打印hello敲echo $PATH就解析出PATH的值敲echo $?就打印上一次命令的退出码。一个小内建命令把环境变量、退出码、普通输出三种能力全串了起来。现在这个迷你Shell已经有模有样了。它能跑外部命令能切目录能看环境变量甚至还能查退出码。下一阶段我们就要往里面加更硬核的东西管道、重定向、后台运行。不过那属于文件篇的活了先把眼前三个阶段啃透地基才牢。4.2 Shell实现中的几个关键问题前三个阶段写完不妨先停下来问自己几个“为什么”。这些问题看着不起眼但想通了就能把从应用层字符串处理到内核进程管理这条链路彻底打通。4.2.1 为什么读取命令不用scanf而用fgetsscanf会被空格截断。scanf读字符串的时候默认把空格、制表符、换行符都当成“到此为止”的结束标志。你让它读一句话它往往只读第一个词就撂挑子了。读不到完整命令。可命令行动不动就带参数ls -a -l这种是家常便饭。用scanf去读它只把ls拿进来后面的-a -l全留在输入缓冲区里Shell解析出来的命令缺胳膊少腿根本跑不对。fgets才是正确姿势。fgets会一口气读满一整行直到撞上换行符\n才罢休。它能把包含空格的完整命令原原本本地搬到你的字符数组里一点不落。这正是Shell最需要的能力。选fgets不是个人喜好而是从命令行输入这个场景倒推出来的必然选择。scanf适合读单个单词或数字但想“听完整句话”还得靠fgets出马。4.2.2 为什么使用字符数组而不是std::string说白了就是为了迁就exec*那一大家子。系统调用和C标准库的底层接口比如execvp、strtok全是吃着char*[]这碗饭长大的它们压根不认std::string这种现代货。如果你硬要上std::string或者vectorstring每回调用底层接口之前都得手动调.c_str()来回倒腾又麻烦又低效。这还不算完频繁的指针转换会打乱你对内存的掌控稍不留神就埋下悬空指针、野指针的雷。维护起来不是省心是添堵。所以在Shell这个紧贴底层的场景里用最朴素的字符数组才是最稳的选择。它跟系统接口无缝对接不用中间商赚差价代码写起来也少了许多花花肠子。等到以后需要更复杂的字符串操作时再局部使用std::string也来得及但主心骨还是得落在char*上。4.2.3 思考一个悖论我们手写的Shell能执行su -吗结论很干脆不能至少不能正常运行。就算勉强执行了状态也维持不住。为什么因为su -的本质不是一次简单的程序替换而是一次彻底的“身份切换”。它需要创建新的会话把当前进程的权限凭证、环境变量、工作目录等家底全部推倒重来相当于给进程换一套全新的“身份档案”。而我们手写的这个Shell本质上只是一个跑在用户态的普通子进程手里并没有操作系统核心的管理权限。当子进程尝试执行su -时它确实会完成程序替换但替换之后呢父进程也就是我们的Shell根本没有维护会话切换的完整机制。子进程生命周期一结束或者环境发生冲突整棵权限树和会话环境就会瞬间崩塌。打个比方这就像让一个普通员工临时披上老板的外套衣服是穿上了可董事会、印章、财务系统全都不在他手里。外套一脱戏就演不下去了。所以su -这种需要动到用户身份根基的命令不是一个用户态小程序能接得住的。我们的迷你 Shell到这里就触碰到了它能力的天花板。这也恰恰说明真正的Shell为什么会设计成会话领导者并且和终端、内核协同得那么紧密因为有些坑不是靠fork exec就能填平的。4.2.4 为什么需要内建命令——普通命令与特殊内建命令的区别要真正理解内建命令存在的意义光知道“它在父进程里执行”还不够。我们把视角拉高一点从三个维度拆开看你就能明白这玩意儿为什么不是可有可无的。1. 改变 Shell 自身状态的绝对必要性有些命令天生就是冲着Shell自己来的。最典型的就是cd。你要是把它交给子进程去执行子进程倒是开开心心把目录切了可它一退出父进程的工作目录还是老样子切了个寂寞。这类命令必须由Shell进程亲自上阵直接调用chdir()这类系统接口把状态改在自己身上。所以内建命令的第一层意义就是有些事别人替不了你。2. 保证命令在任何情况下都可用外部命令有个致命软肋它强依赖$PATH和磁盘上实实在在的文件。一旦这些依赖出了问题外部命令就集体哑火。想象一个极端场景你手滑把/usr/bin目录删了或者磁盘故障导致/bin挂载失败。这时候几乎所有的外部命令都跑不了了Shell是不是就成废物了并不会。因为cd、pwd、echo这些内建命令还活着。它们不靠PATH不靠磁盘文件就住在Shell自己身体里。管理员可以靠着这几个内建命令在崩溃的环境里艰难地挪动、查看、自救比如cd到备份目录去恢复系统。如果连cd都是外置的那路径一坏你连挪个窝都做不到。3. 兼容性与POSIX标准POSIX 标准规定了不少必须存在的工具比如test、[、kill。按照标准这些命令必须在磁盘上有一份独立的、可执行的二进制文件这样任何调用方比如C语言的system()函数或者不带 Shell的环境都能找到它们。但Shell也有自己的算盘。为了效率和特性支持比如 kill 要操作作业控制Shell又必须在内部自己实现一份。于是就出现了“内建版”和“外置版”并存的局面。你在终端敲kill走的是Shell的内建版本但如果你在某个不依赖 Shell 的环境里调 kill磁盘上的外置版本照样能顶上。两边不冲突各有各的用途。所以内建命令不是某些命令“住”在Shell里这么简单。它们是Shell的自救工具、状态操纵杆也是 POSIX 标准下的一种兼容性妥协。理解了这三层你才算真正明白为什么Shell要“脚踏两条船”内建命令和外部命令各有各的主场缺一不可。4.2.5 chdir()的小细节——只改变工作目录不改变环境变量当你敲下cd ..的时候底层真正动起来的是chdir(..)。这个系统调用干得挺利索但也藏了一个特别容易踩的坑。先看内核到底做了什么chdir成功之后内核已经把进程PCB里的cwd当前工作目录指针改掉了。从物理意义上讲进程的落脚点确实变了它已经真的“站”在了上一级目录里。但诡异的地方来了它不会顺手更新环境变量表里那个叫PWD的字符串。PWD还是老样子还指着你进来之前的那个路径。这就坏事了。你的GetCwd()也好getenv(PWD)也好拿到的都是旧路径。结果就是人已经走了身份证上的地址还没改提示符上显示的目录跟实际所在目录对不上号出现了“假移动”的诡异现象。你明明已经cd出去了提示符却还赖在原地。所以一个真正完善的Shell在调用chdir成功后绝不能拍拍屁股就走。它必须手动再补一刀用putenv或setenv把PWD环境变量同步更新过来。这样提示符才能跟实际路径步调一致不会出现“人在新家提示符还留在旧宅”的错位。这也是为什么系统里的正版Shell从不出这种差错因为它们在chdir之后都默默做完了这套“改完路径顺手改变量”的收尾动作。我们手写的迷你Shell如果忽略了这一步迟早会在某个cd之后露馅。3.2.6 命令加不加-f的区别在Linux的命令行生态里-f--force这个选项随处可见rm -f、mkdir -f它代表的是“强制”。但你有没有想过加不加这一个小字母底下的逻辑差别能有多大我们直接看表。场景不加-f加-f文件存在正常删除正常删除文件不存在报错“No such file or directory”静默失败不吭一声只读文件无确认会问你一句等你敲y直接删不废话返回码文件不存在时非0代表失败0居然算成功看明白了吗这一个小-f骨子里做的是两件事把“没成功”伪装成“成功了”把“该问的”全咽回肚子里。拿rm举例最直观。删除一个不存在的文件时不带-f的rm会老老实实告诉你“No such file or directory”返回码也不是0因为它确实没干成。可一旦加了-f它连个响都没有失败了也当成功处理返回码直接是0。对于只读文件也一样。不带-f它会停下来问你“这是个只读文件确定要删吗”你得亲手敲个y它才动手。带上-f这层确认直接跳过说删就删哪怕那是你昨晚刚写完的毕业论文。所以-f的本质是什么它是一把“强行推进”的开关。它把“该报错的”压成无声把“该确认的”变成默认同意。这在写Shell脚本时非常实用脚本无人值守不能指望谁会来敲y所以需要-f来保证流程畅通。但也正因如此rm -rf才会成为人人谈之色变的“自杀指令”。加不加-f天差地别。到这里Linux进程篇就正式收官了。从fork到exec从僵尸到孤儿从优先级到虚拟地址空间再到今天手写的迷你Shell我们一步一个脚印把Linux进程管理的整张版图拼了起来。而这款只有三个阶段的小Shell恰恰是最好的毕业作品它把前面攒下的所有家伙全用上了。最后再回味几个要点Shell的一生就是四步循环打印提示符、获取输入、切分字符串、识别执行。看似简单但所有命令行工具都逃不出这个骨架。内建命令必须在父进程里跑否则cd改了子进程的目录父进程纹丝不动等于白切。环境变量表要自己维护启动时从系统environ导入以后增删改查才随心所欲。chdir 之后PWD不会自动更新得手动putenv同步否则提示符会跟实际路径“闹分居”。一个小小的-f背后是“把失败当成功”和“跳过一切确认”的双重逻辑。它是脚本自动化的利器也是危险操作的源头。这个迷你Shell虽然只完成了前三个阶段但它已经把命令行交互最核心的秘密抖搂得七七八八了。至于第四阶段管道、重定向、后台运行那些功能需要文件描述符的底子来撑所以我们把它安排到Linux文件篇再继续攻坚。进程篇到此告一段落。下一篇我们将踏入文件篇的疆域继续把这套Linux知识体系往下铺。感谢一路看到这里的你。如果这个系列对你有帮助欢迎点赞、收藏、关注三连支持。你的每一次正反馈都是我继续肝下一篇的最大动力。我们文件篇见。