
手写一个简易shell听起来像是个老掉牙的实验题但真做起来你会发现它把“进程管理、文件描述符、字符串解析、信号处理”这些Linux底层知识全串起来了。我自己当年写这个项目的时候先照着网上的教程用system()糊了一个能跑通的版本结果面试官一问“你解释下shell是怎么找到ls这个命令的”直接卡壳。后来老老实实从fork()、execve()一路写下来才真正明白Linux是怎么把一个命令变成进程的。这篇文章就把我踩过的坑和完整思路整理出来适合正在学Linux系统编程、准备面试或者想搞明白“平时敲的命令到底发生了什么”的读者。1. 动手之前先想清楚简易shell的核心设计思路1.1 先搞清楚shell的本质是什么很多初学者会以为shell只是“用来敲命令的窗口”这个理解不够本质。shell本质是个循环程序打印提示符、读取你输入的一行字符串、把字符串拆成命令和参数、创建子进程去执行、等待子进程结束、回到开头。这个循环叫“读取-解析-执行”read-eval-execloop你平时用的bash、zsh本质上都在做这件事只是功能更多、优化更多。理解这一点特别重要因为它在提示你写shell不是从零发明一套“执行命令的机制”而是复用操作系统已经提供的fork()、execve()、waitpid()这些原语。你要做的主要是“解释”和“组织”。这也决定了整个项目的骨架哪怕你后面要加管道、重定向、后台任务也都是在这个主循环上做文章。从结果反推一个合格的简易shell至少要满足这么几个需求能读取用户输入去掉结尾换行符。能把一行字符串拆成“命令名 若干参数”。能在PATH环境变量指定的目录里找到可执行文件并运行。支持exit、cd这类必须由shell自己处理的内置命令。支持输入输出重定向、更进一步支持管道|和后台运行。我把这些目标拆成了四个模块去写输入处理、解析器、命令执行器、内置命令区。下面每一节都会讲透一个模块。1.2 为什么不用system()非要用forkexecve我知道网上很多“简易shell”教程直接给system(command)两行代码完事看起来特别简单。但这不是实现shell这是在“借壳”。system()内部做三件事启动一个/bin/sh子进程、让这个子进程去解析你的字符串、等它结束。换句话说你的“shell”每执行一条命令实际上是又找来了另一个shell帮你干活的——这话听起来就很讽刺了。真正的shell应该自己解析、自己创建进程、自己加载程序。过程分两步fork()把当前进程复制一份产生一个几乎一模一样的子进程execve()在子进程里用新程序替换掉当前进程映像。父进程则继续留在shell里通过waitpid()打听子进程“干活干得怎么样了”。为什么必须拆两步因为execve()成功时压根不会返回你最多只能通过返回值判断“是不是没找到文件”如果你直接在shell进程里调用它shell自己就被替换成ls了还怎么继续处理下一条命令所以才需要先fork()一个替身让替身去执行替换。这也是“派活”和“干活”必须分开的原因理解了这点整个shell的实现逻辑就顺了。1.3 整体代码结构一个主循环打天下我最终实现的简易shell主循环大概长这样while (1) { print_prompt(); // 打印提示符 $ read_input(buf); // 读取一行 tokens parse(buf); // 拆成参数数组 if (tokens[0] NULL) continue; // 空行就直接下一轮 if (is_builtin(tokens[0])) { // 内置命令不走fork do_builtin(tokens); } else { exec_command(tokens); // 创建子进程执行 } }这个骨架看着简单但值得在正式编码前先想清楚几个问题print_prompt()在管道场景下要判断stdin是不是终端不是的话就别打read_input()用fgets()读到整行包含换行符必须处理掉parse()要支持把多个连续空格合并、支持引号内空格不拆分这个后面细说。把这些问题列出来之后再写代码心里就有数得多不会写一步想一步最后结构乱成一团。2. 输入读取与解析字符串处理的细节全在这2.1 读取一行输入别被换行符坑了读取用户输入这一步看似最简单但恰恰是初学者最容易出问题的地方。我见过有人用gets()这函数在现代Linux的glibc里直接就被警告不要用因为它不知道缓冲区边界随便一长串输入就能把内存写穿。另一个常见方案是scanf(%s)但%s遇到空格就停了你敲个ls -l /tmp它只能读到ls。所以正经做法是用fgets()配合一个足够大的缓冲区或者用getline()让系统帮你自动分配内存。char buf[MAX_INPUT]; if (fgets(buf, MAX_INPUT, stdin) NULL) { // fgets返回NULL说明读到EOF或者出错 break; // 这个分支很重要按CtrlD退出shell } buf[strcspn(buf, \n)] 0; // 去掉末尾换行符关键细节在这里fgets()会把用户敲的回车键也存进缓冲区也就是说你输入ls实际得到的是ls\n。如果不把\n去掉后面解析时参数末尾就拖着个换行符execve()找文件时会拿着ls\n去搜路径结果当然是找不到。strcspn(buf, \n)这个写法比strlen再去判断末尾更稳妥因为它能从字符串里定位到任意一个属于“\n”的位置即使缓冲区里出现多个换行正常情况几乎不会但值得注意也能一次性处理干净。fgets()返回NULL这个分支也千万别漏。用户在终端按CtrlD会产生EOFfgets()返回NULL如果不处理你的循环就会抓着那个未经初始化的缓冲区继续跑行为完全不确定。我通常在读到NULL时检查一遍feof(stdin)确认是EOF就返回这样就能安全退出shell而不是卡死或者段错误。2.2 手动拆分字符串从strtok到一个能处理引号的解析器拆分一行字符串最简单的方案是strtok()按空白字符切。strtok()会修改原字符串把分隔符替换为\0返回值是一个指向每段开头的指针数组。作为快速实现很合适但它有硬伤遇到引号并不会特殊处理。你输入echo hello world它会把hello和world拆成两个参数而真正的bash会把hello world作为一个参数整体传给echo。我一开始出于省事用了strtok后来还是自己写了个基于字符遍历的解析器。核心逻辑不复杂逐个字符读跳过开头和中间的空格遇到非空格字符就记下起始位置直到遇到下一个空格或者字符串结尾把这个区间拷贝成独立字符串。然后判断起始字符是不是双引号或单引号是的话就找到配对的另一个引号把两个引号之间的内容原样作为参数连空格都不拆。我还顺手支持了\转义让\表示一个普通的双引号字符。这个过程有个很常见的困惑新手容易问“为什么要自己写直接strtok不香吗”答案在兼容性上——你的简易shell连引号都不认那别人拿它跑一个稍微复杂点的命令就崩实用性就没了。一个小技巧解析结果不要直接用指针指向原缓冲区而是strdup()每段内容。理由很现实你后面如果要做管道拆分按|再切一次、要做重定向提取把和后面的文件名从命令里摘走原缓冲区会被反复修改直接用原指针容易出悬空问题。strdup()每个参数虽然多点内存拷贝但逻辑边界清晰很多。用完记得free()不然你跑几百条命令后shell就成了个内存黑洞。2.3 解析阶段的“贪吃蛇”拆完参数还要顺手处理重定向我希望让shell支持和但如果单独为“重定向”再写一遍解析器代码就重复了。所以可以把重定向识别也放进解析阶段。具体做法是维护两个额外变量redirect_out和redirect_in还是那套字符遍历逻辑遇到就往redirect_out里存它后面的文件名遇到就往redirect_in里存并且这些部分不进入参数列表。这样子进程执行时只需要判断这两个变量是否为空再决定要不要dup2()完全不用第二次解析。同样的思路也适用于管道先按|把整条输入切成几段每一段再送进上面那个参数解析器。所以解析器写好了重定向和管道都是“复用”的事。这给我一个体会写小项目也要先花半小时规划模块边界哪怕只是两级函数也比一锅炖的代码好改十倍。3. 命令执行核心fork、execve、waitpid、PATH查找3.1 fork和execve怎么配合才能成功运行一条命令到了最核心的部分。当用户输入ls -lshell要做的是先fork()出一个子进程子进程调用execve()把自己替换成/usr/bin/ls然后父进程用waitpid()等它结束。代码骨架如下pid_t pid fork(); if (pid 0) { perror(fork); return -1; } if (pid 0) { // 子进程分支此刻子进程几乎是父进程的完整拷贝 execve(path, args, envp); // execve只有失败才会走到这里 fprintf(stderr, shell: %s: %s\n, args[0], strerror(errno)); _exit(127); } // 父进程分支 waitpid(pid, status, 0);这里有两个细节值得展开。第一execve()成功时是一个“有去无回”的函数整个进程映像都被替换了新程序从自己的main()开始跑只有失败时才返回返回时必须立刻报错并从子进程退出否则子进程会“继续执行”shell剩下的代码变成两个shell打架。我习惯在execve()后紧接着打印错误并_exit()注意是_exit()而不是exit()。exit()会先刷新标准I/O缓冲区、执行atexit钩子而这些操作子进程是从父进程继承来的很可能会把shell的缓冲数据也flush一遍导致输出重复或错乱。_exit()直接进内核干净利落。第二execve()本身不搜PATH。你给它ls它只会在当前目录找ls这个文件。这就是为什么我说必须自己实现PATH查找或者改用简化版的execvp()。很多初学者在这卡住以为exec系列函数都带搜索功能其实只有execvp和execvpe会利用PATH。既然我们目标是“理解原理”就老老实实自己搜索见下节。3.2 手动实现PATH查找为什么ls能变成/usr/bin/lsPATH环境变量长这样/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin里面是多个用冒号隔开的目录。手动查找命令的可执行文件核心逻辑就是一个循环char *find_in_path(const char *cmd) { char *path_env getenv(PATH); if (path_env NULL) return NULL; char *dir strdup(path_env); char *token strtok(dir, :); char full_path[1024]; while (token ! NULL) { snprintf(full_path, sizeof(full_path), %s/%s, token, cmd); if (access(full_path, X_OK) 0) { // 当前目录下这个文件存在且可执行 free(dir); return strdup(full_path); } token strtok(NULL, :); } free(dir); return NULL; // 全部目录找完都没有 }几个实现细节如果命令名本身包含/比如输入./a.out或/bin/ls就不该搜PATH直接判断这个路径是否可执行就行。规则是“含斜杠就用原路径不含斜杠才查PATH”这和bash行为一致。access(full_path, X_OK)检查的是“按当前用户权限是否可执行”。注意这个检查在父子进程之间有竞态条件TOCTOU生产级shell不会只靠access但它对教学项目完全够用。一定要处理PATH为空的场景。有些工具为了安全会临时清空PATH如果你的shell不理解就会所有外部命令都失败。我的做法是PATH为空时按一组默认路径/usr/local/bin:/usr/bin:/bin去搜保证基本体验。实际执行时找到完整路径后就调execve(full_path, args, environ)。environ是个全局变量代表当前进程的环境变量表直接传过去就能让新程序继承环境了。3.3 僵尸进程和等待waitpid的参数怎么选父进程必须等子进程结束否则会出现僵尸进程。什么是僵尸子进程已经退出但它把退出状态留给父进程“收取”在父进程调用wait()或waitpid()之前内核会保留这个已经死掉的进程的进程表项。如果父进程一直不取走它就永远躺在进程表里这就是僵尸。在shell这个场景父进程是个长期运行的循环如果不wait跑1000条命令就攒1000个僵尸直到父进程自己也退出才一次性释放这显然不可接受。int status; pid_t result waitpid(pid, status, 0); if (result pid) { if (WIFEXITED(status)) { // 子进程正常退出退出码是 WEXITSTATUS(status) } else if (WIFSIGNALED(status)) { // 子进程被信号杀死信号编号是 WTERMSIG(status) } }waitpid的第三个参数0表示“阻塞等待直到指定子进程结束”。如果想要非阻塞的“探询”模式传WNOHANG配合SIGCHLD信号处理可以实现后台任务监控。这个我在第5节讲扩展时详细说。这里还要提醒waitpid返回的pid不一定等于你fork出来的那个子进程可能返回-1表示出错比如没有子进程可等所以最好判断一下返回值再处理状态。这条我在编码时吃过大亏想当然以为一定是自己那个子进程结果对着一堆未知状态调试了半天。3.4 内置命令为什么cd和exit不能fork子进程如果cd也走“fork子进程→exec”这条路你会发现敲完cd /tmp后当前目录压根没变。原因不玄乎每个进程有自己的当前工作目录子进程chdir()改的是子的父进程不受影响子进程退出后shell还是留在原目录。所以涉及shell自身状态的操作不能交给子进程必须在shell进程内直接执行。我的简易shell实现了三个内置命令int exec_builtin(char **args) { if (strcmp(args[0], exit) 0) { // 有参数就当退出码没参数就退出码0 int code args[1] ? atoi(args[1]) : 0; exit(code); } if (strcmp(args[0], cd) 0) { const char *target args[1]; if (target NULL) target getenv(HOME); if (chdir(target) ! 0) perror(cd); return 0; } if (strcmp(args[0], pwd) 0) { char cwd[1024]; if (getcwd(cwd, sizeof(cwd)) ! NULL) printf(%s\n, cwd); return 0; } return -1; // 不是内置命令 }cd还有个细节bash的cd在无参数时默认去$HOME这在写简化版时值得补上因为很多用户习惯直接敲cd回home。实现很简单getenv(HOME)拿一下就行。pwd也是同理为的是让用户在没有外部命令的环境下也能探明当前路径。内置命令的返回值我统一用0表示“成功处理了”-1表示“不是我的菜”主循环看到-1就知道要去走fork流程了。这个约定让代码边界特别清晰。4. 重定向与管道让数据流按你的意思走4.1 输入输出重定向的底层逻辑dup2替换文件描述符Linux里每个进程都有文件描述符表通常0是标准输入、1是标准输出、2是标准错误。重定向的底层操作其实是“偷梁换柱”把文件描述符1从“指向用户的终端”改成“指向你要写入的文件”。完成这个操作的系统调用是dup2()代码长这样int fd open(out.txt, O_WRONLY | O_CREAT | O_TRUNC, 0644); if (fd 0) { perror(open); return; } dup2(fd, STDOUT_FILENO); // 让1号和fd指向同一个文件 close(fd); // 原来的fd可以关了因为1号已经替补了注意这个操作的位置必须在fork()之后、execve()之前而且只在子进程分支里做。为什么不是父进程做因为父进程的标准输出关系到用户交互如果你在父进程改了1号那shell自己的提示符、错误信息全会跟进文件里。子进程不一样它exec之后就变成了lsls往标准输出写什么全看1号指向哪。所以标准姿势是fork()→ 子进程里处理、重定向这步会改变1和0 → 调用execve()。还有一个细节是open()时的O_TRUNC标志它让文件在打开时就被清空这对应bash里和的区别——会截断旧文件则追加。要不要支持其实就是在open()的flags里加O_APPEND而已顺手的功夫建议直接支持。4.2 管道的本质让两个子进程一个写、一个读管道|稍微绕一点但理解后会很爽。pipe()会创建一对文件描述符pipefd[0]是读端pipefd[1]是写端。我们要做的是cmd1 | cmd2时让cmd1的标准输出 “接到” 管道的写端让cmd2的标准输入 “接到” 管道的读端。典型代码流程int fd[2]; pipe(fd); pid_t p1 fork(); if (p1 0) { // 子进程1cmd1 dup2(fd[1], STDOUT_FILENO); // 标准输出改到管道写端 close(fd[0]); close(fd[1]); // 关掉自己继承的原本的读端和写端 execve(cmd1_path, args1, environ); _exit(127); } pid_t p2 fork(); if (p2 0) { // 子进程2cmd2 dup2(fd[0], STDIN_FILENO); // 标准输入改到管道读端 close(fd[0]); close(fd[1]); execve(cmd2_path, args2, environ); _exit(127); } // 父进程 close(fd[0]); close(fd[1]); waitpid(p1, s1, 0); waitpid(p2, s2, 0);这里最大的坑是“谁持有管道读端/写端”。所有fork出来的子进程都继承了父进程能访问到的文件描述符表如果父进程不关fd[0]和fd[1]那么三个进程父、P1、P2都持有读端和写端。结果是P2在读管道时永远等不到EOF因为管道还有写端开着呢——不是在P1那里而是在父进程那里这会导致cmd2一直挂起像是死循环一样不返回。解决办法很明确父进程必须立刻关闭两个管道端P1要关闭和它无关的读端和写端P2同理。任何“多余的描述符”没关干净都可能导致对端进程永远等不到EOF或者写阻塞。我自己第一次调管道时程序就是卡在waitpid上深刻体会到这个描述符“所有权”问题。4.3 管道和重定向同时出现时顺序不能乱真实场景经常是cat file.txt | grep foo result.txt这种组合要求先做管道再做重定向或者反过来都行关键是落在子进程分支里的顺序要统一。我的做法是统一的“三步走”解析器已经把整条命令拆成了“管道段数组”每一段里又带有重定向文件名信息。对每个管道段fork()子进程。在子进程里先处理管道重定向如果是第一个段就只改输出最后一段就只改输入中间段两者都改然后处理输入重定向再处理输出重定向最后execve()。这个顺序有个讲究先dup2()管道再dup2()重定向文件如果没有冲突后做的会覆盖先做的如果用户写cmd out | next这种混乱命令规则让后者生效行为可预测。子进程里dup2之后要把用不到的那几个fd逐一close()尤其是原来继承的pipefd两端不然又会出现前面说的EOF等不到的问题。处理完这些进程的IO视图就是标准输入指向管道读端或键盘、标准输出指向最后那个文件或管道写端/终端然后启动程序一气呵成。5. 实际运行中的典型问题与排查手段5.1 我遇到过的高频问题速查表这个过程攒了很多线上排错经验整理成表格放在这里遇到症状直接对号入座。症状根因解决办法命令找不到即使ls就在/usr/bin里没有做PATH查找直接拿ls去execve完善find_in_path()或改用execvp()cd /tmp执行后目录没变cd被fork到子进程执行了cd必须在父进程内调用chdir()不能fork每执行一条命令前面都会重复输出提示符缓冲问题printf内容没及时flush子进程退出时继承了缓冲printf后加fflush(NULL)或改用直接写入的write跑cmd1 | cmd2时cmd2一直卡住管道写端没被完全关闭父进程或cmd1还持有父进程和每个子进程都关闭不需要的fd连续跑1000条命令后进程数暴涨父进程没用waitpid()收尸产生僵尸进程阻塞等待每个子进程或注册SIGCHLD处理函数输入grep -v # /etc/passwd#没了解析时把#当成了注释符号决定是否支持注释要支持就得“仅在命令开头才是注释”strtok方案容易过度截断ls -l /tmp参数多了一个换行符fgets()保留了\n用strcspn去掉末尾换行符5.2 几个值得常备的调试工具和方法写shell的调试和平时调业务代码不太一样因为涉及多个进程普通调试器看不过来。我常用的三件套strace -f -e traceexecve,dup2,pipe,waitpid -o trace.log ./myshell。-f跟follow追踪子进程-e trace过滤想要关注的系统调用。很多诡异问题比如管道卡死在这个日志里一眼就能看出哪个进程没关fd比在代码里加一百个printf管用得多。我记得有一次卡管道strace日志里清晰看到父进程在waitpid而另一个子进程还停留在read系统调用上马上定位到管道写端没关。代码里打印errno要配合strerror(errno)别只打数字。错误码12是ENOMEM、13是EACCES不查表谁记得住打印完整字符串才方便排查。我的习惯是封装一个log_error(const char *msg)函数统一输出日志格式带errno和strerror。善用ps -ef观察进程状态。僵尸进程会显示成Z状态一眼可见。当年我写wait回收逻辑之前跑完一百多条命令后ps里全是defunct被这个震撼到了从此不敢忽略wait。5.3 三个印象深刻的实战教训先说我犯过最蠢的错误提示符重复输出。我的打印提示符用的是printf($ )这个字符串先进了shell的stdio缓冲区没有立刻写到终端。接着fork()子进程拷贝了这份缓冲区里面还留着那个“$ ”。等子进程执行完退出时exit()刷新了缓冲区又把这个“$ ”打了一遍于是用户明明敲了一条命令屏幕上却出现了两次提示符。解决方案是主循环在打印提示符后fflush(NULL)把缓冲清干净或者在子进程里坚持用_exit()不碰缓冲区。这两个方案都有效我都加上了双保险。第二个教训是执行不存在的命令时子进程报错后退出没问题但退出码不能乱给。一开始图省事exit(EXIT_FAILURE)也就是1后来发现不对——bash对“命令找不到”返回127这是个约定俗成的状态码很多脚本和上层工具依赖这个127来判断命令是否可执行。把退出码改成127之后我的shell才算在行为上接近了真实bash。第三个教训和路径搜索顺序有关。有次系统里同时存在/usr/bin/time和shell内置的time用户执行time时我的shell去外部找了程序而没有像bash那样把time当成关键字解析。这提醒我内置命令的识别优先级必须高于外部命令。我在主循环里先查内置命令表匹配到了就直接执行匹配不到才走fork流程。这个顺序看似理所应当写代码时很容易忘记检查。5.4 后台任务与信号处理从0到1的进阶玩法写完基本功能后我很自然想加上后缀支持。后台执行说白了就是fork()后父进程不waitpid()阻塞等它而是立刻返回继续打印提示符。但这里有个坑如果父进程完全不理子进程子进程变成僵尸还是小事更麻烦的是shell退出时后台进程可能变成孤儿然后继续跑这个行为需要明确。我采用的方案是忽略SIGCHLD信号当子进程退出时它能被内核自动回收一部分即signal(SIGCHLD, SIG_IGN)配合waitpid(..., WNOHANG)轮询收割。注意仅仅SIG_IGNwaitpid双管齐下才稳妥。想要实现jobs列表、输出完成通知之类就得维护一个后台进程PID的链表并在SIGCHLD处理函数里waitpid(-1, status, WNOHANG)“摸一圈”看谁结束了。这个设计一旦做好你就能实现bash那套作业控制的基础能力作为学习SIGCHLD和异步调用非常经典。顺带说一句CtrlC的问题。默认情况下前台子进程是shell所在进程组的一员终端发SIGINT会发给整个进程组所以子进程能收到。但如果你没设置好进程组子进程会收不到信号表现就是shell里按CtrlC没反应。解决手段是setpgid()把子进程放进新进程组再把标准输入的控制权交给它这属于作业控制的范畴实现起来不算难但涉及面更广这里提一下给有兴趣的同学留个头绪。6. 还能往哪些方向扩展一个简易shell的升级路线写到这里这个简易shell已经能应付日常命令行操作了外部命令、内置命令、重定向、管道、后台执行该有的基础都有了。但和真正的bash相比它还差得远我把后续可以升级的方向和难度列出来供你按兴趣和精力选扩展方向核心知识点大致难度环境变量展开$PATH、$HOME在解析时的替换简单通配符展开把*.c展开成文件名列表了解glob规则中等历史命令与上下键用termios或者readline库实现交互编辑中等脚本文件执行myshell script.sh逐行读取执行中等作业控制进程组、SIGTSTP、fg/bg命令困难语法高亮与自动补全不了解的话光看看就觉得复杂实现更是大坑极难我个人的建议是先把环境变量展开做掉。因为你迟早要支持类似echo $HOME的需求而实现只是“识别$开头的一段字符串替换成getenv()的结果”代码量不大但使用频率极高带来的满足感非常实在。之后再挑战通配符展开那个能让你更深入理解glob算法和目录遍历。再分享一个我自己的心得写这种“轮子型”项目最有收获的不是把最终代码凑出来能跑而是过程中用strace跟踪系统调用、用ps观察进程状态、手动对比自己的shell和bash行为的差异。这些“旁路”操作反而让你对Linux操作系统的理解上了一个台阶。而且这个项目沉淀下来的东西很持久之后你去读bash源码、看mini shell实现、学ansible这些远程执行工具的机制都会有种“底层原来如此”的通透感。最后提醒两句都是实际干活攒出来的第一解析器是整个项目的杠杆点解析做得好后面加功能都是往上面搭积木解析做得糙连修bug都要拆一堆东西第二不要急着上完整功能先让“执行外部命令”跑顺再逐层加重定向、管道、后台能避免把多个问题的症状搅在一起排查时那种无从下手的感觉真的不想体验第二次。