拓冰建站拓冰建站
首页 / 资讯中心 / 正文

用C语言从零实现简易Linux Shell:原理、代码与踩坑分享

搞了这么多年Linux每天在终端敲命令敲得飞起你有没有那么一瞬间想过这个能执行ls、能接管道、能重定向的Shell到底是怎么被做出来的我之前一直以为它是某种神秘的系统组件直到自己动手写了一个迷你版才恍然大悟——原来Shell就是一个“读取输入、创建进程、等待结束”的循环把系统调用和命令行串起来的胶水程序。这篇文章我想完整拆解一下自己用C语言实现一个简易Linux Shell的全过程。从原理层面的“命令如何变成进程”出发到具体功能选型、代码结构、解析器实现、fork/exec/wait怎么配合再到重定向和管道这种进阶功能怎么一步步加进去最后把我踩过的坑和排查思路全部分享出来。如果你正在学操作系统、系统编程或者单纯好奇Shell怎么能执行命令、想造一个自己的小工具这篇文章能让你少走很多弯路。1. 开工之前先搞清楚Shell到底在干什么1.1 三分钟看清Shell的本质你敲下ls -l /home然后回车中间发生了什么很多人会笼统地回答“执行了ls命令”但再问细一点是谁把ls这个字符串变成了一个活生生的进程又是谁负责把结果打印到屏幕上答案就是Shell。从操作系统视角看Shell本质上是用户与内核之间的中间层一个普通的用户态程序。它做的事可以用一句话概括循环读取用户输入的命令行字符串解析成程序名和参数然后交给操作系统去创建并运行新进程最后等待进程结束输出提示符进入下一轮循环。这个“循环模型”就是Shell的骨架while (1) { 打印提示符; 读取一行输入; 解析输入得到命令名和参数列表; 创建子进程并执行命令; 等待子进程结束; }我在动手之前一直有个误会以为ls这种命令是Shell内部实现的函数。但其实绝大多数命令都是独立的可执行文件Shell根本没实现它们的功能。Shell只是那个“帮你把字符串翻译成进程”的翻译官。1.2 为什么推荐用C语言来实现做简易Shell语言选择上可以很自由Python、Go、Rust都能做但我强烈建议你试一把C语言。原因很简单Shell的核心逻辑全都是对系统调用的直接操作fork()、exec()、wait()、pipe()、dup2()这几个函数就是Shell的命根子。用C写你能用最直接的方式触碰内核提供的原语理解进程从无到有、从有到无的完整生命周期。用Python也能写比如用subprocess.run()几行就完事但如果这样你大概率会错过最重要的东西——进程是怎么被创建的文件描述符是怎么被复制的程序是怎么被替换的。这些恰恰是系统编程的核心素养。所以学原理用C做工具用Python这是我在踩过很多坑后总结出来的判断。本文的实现基于C语言编译环境是LinuxUbuntu 22.04gcc 11.3用POSIX标准的系统调用不依赖任何第三方库拿到手就能编译跑起来。1.3 功能选型第一版要做到什么程度一个“简易Shell”的边界要划清楚不然做着做着就掉进功能深渊。我给第一版定的目标是能执行外部命令比如ls、ps、grep支持带参数的命令比如ls -l /etc支持内建命令cd、exit后续可以扩展到export、echo支持、、重定向支持单管道cmd1 | cmd2有错误处理命令找不到、目录不存在要能给出提示进阶目标可以有多管道、后台执行、环境变量展开、引号解析、历史命令。但第一版不建议贪多先把基本功练扎实后面的功能其实是同一个套路不断加判断而已。我开始动手前写了一句代码注释放在主函数顶部也是整个项目的心法/* The shell is just a loop: read - parse - fork - exec - wait - repeat */2. 核心原理拆解命令从输入到执行的完整链路2.1 读取与解析把字符串拆成“词”Shell拿到输入后第一步是把“原始文本字符串”变成“结构化的命令数据”。这一阶段在编译原理里叫词法分析在Shell里叫解析。ls -l /home是一个带空格的字符串我们要把它拆成三个词ls、-l、/home存进一个字符串数组里比如char *args[64]。解析算法不复杂核心逻辑是扫描字符串按空白字符分隔把每个非空子串保存成参数。这里有几个容易忽略的细节连续空格、开头结尾空格要能正确处理不能把空字符串当成命令空行直接跳过重新打印提示符这比报错更符合用户预期注释号#真实Shell会忽略#开头的内容简易版可以选择不支持但至少要保证不会崩溃引号echo hello world中hello world应该作为一个整体参数而不是两个。第一版可以不做引号解析但要在代码里留好注释说明这是已知限制解析完成后args[0]就是程序名args[1]到args[n-1]是参数args[n]必须是NULL因为execvp()要求参数数组以NULL结尾这是C语言风格的约定。2.2 forkexec进程创建与程序替换解析获得命令之后Shell要做的是创建一个新进程来运行它。创建进程的系统调用是fork()它的特点是调用一次返回两次父进程得到子进程的PID子进程返回0。如果返回-1说明失败。刚fork出来的子进程几乎是父进程的“复印件”它和Shell跑着相同的代码。要让子进程变成ls就必须执行exec系列系统调用来替换当前进程映像。我常用的函数是execvp()。中间的v表示参数以向量vector数组方式传递末尾的p表示会在PATH环境变量指定的目录中搜索命令。这意味着后台运行命令的时候如果它能跑起来就不会报错找不到命令时execvp返回-1这时要用perror(execvp)打印错误信息并让子进程退出。这里有个我对初学者特别强调的点exec成功后原来的代码就不执行了新程序从头开始跑exec失败后代码继续执行后面的语句。所以exec之后一定要加错误处理而且要小心不要无限fork子进程。在子进程里exec失败时应该用exit(127)退出这个退出码是“命令找不到”的说法惯例。2.3 wait与进程状态Shell如何知道命令跑完了子进程执行期间Shell理论上可以做别的事但对于普通的交互式命令我们的预期是“输入命令等它执行完再回到提示符”这叫前台执行。Shell可以通过waitpid(pid, status, 0)同步等待指定子进程退出。waitpid返回后状态信息里包含了子进程是正常退出还是被信号杀掉、退出码是多少。常用宏有WIFEXITED(status)判断是否正常退出WEXITSTATUS(status)取正常退出时的退出码WIFSIGNALED(status)判断是否被信号终止WTERMSIG(status)取终止该进程的信号编号进程退出时它会变成僵尸进程占用着内核的进程表项直到父进程调用wait回收它。如果父进程不wait僵尸进程就会一直挂在系统里。这个知识点在ps里可以看到状态为Z的进程就是没人收尸的僵尸进程。Shell如果不wait你的系统跑一会儿就会堆满僵尸这是个非常经典的系统编程问题。2.4 内建命令与外部命令的分流思路不是所有命令都能靠forkexec解决。比如cd。如果你在子进程里执行chdir(/tmp)改变的是子进程的工作目录子进程一退出就什么都没了你看到的Shell当前目录根本没变。所以cd必须在Shell进程自身内部执行也就是不能fork必须直接调用chdir()系统调用。类似的还有exit——如果exit是通过子进程执行的那么退出的只是子进程Shell还活着。所以exit也要放在Shell进程直接处理直接break主循环。这引出了Shell的一个重要设计思想命令分为内建命令builtin和外部命令external。内建命令在Shell进程内部处理不创建新进程外部命令先检查是不是内建如果不是再forkexec执行。判断的时机放在解析之后、fork之前。这个分流单体看起来简单但对Shell整体结构影响很大。命令类型典型命令处理方式内建命令cd、exit、export、history在Shell进程内直接执行对应逻辑不fork外部命令ls、grep、ps、vimfork子进程execvp在PATH中查找并执行混合命令echo、test简易版先当外部命令处理进阶版可优化为内建3. 动手实现用C语言写一个可用的简易Shell3.1 工程结构与代码骨架我建了一个目录叫mysh里面就两个文件shell.c和可选的Makefile。项目虽然简单但我按真实工程的思路来组织代码解析函数、执行函数、内建命令处理函数分开写主循环保持干净。mkdir mysh cd mysh touch shell.c Makefile第一版Makefile很简单CC gcc CFLAGS -Wall -Wextra -g mysh: shell.c $(CC) $(CFLAGS) -o $ $^ clean: rm -f mysh整个程序的主循环长这样int main(void) { char line[MAX_CMD_LEN]; while (1) { printf(mysh$ ); fflush(stdout); if (fgets(line, sizeof(line), stdin) NULL) { printf(\n); break; } line[strcspn(line, \n)] \0; if (strlen(line) 0) continue; if (strcmp(line, exit) 0) break; run_command(line); } return 0; }这一步有几个细节我特别说一下fflush(stdout)必须加因为printf到终端默认是行缓冲不换行时它可能不输出导致提示符看不到fgets读EOF用户按CtrlD时会返回NULL这时代码要优雅退出我打印一个换行再breakline[strcspn(line, \n)] \0是去掉fgets读进来的换行符strcspn找第一个换行位置空行直接continue跳过不产生任何执行逻辑3.2 实现命令解析器解析器的职责是把line拆成args数组。我选择strtok_r()来做因为它是线程安全版本能正确处理连续空格。void parse_line(char *line, char **args, int *argc) { char *saveptr; char *token; *argc 0; token strtok_r(line, \t\r\n, saveptr); while (token ! NULL *argc MAX_ARG_NUM - 1) { args[(*argc)] token; token strtok_r(NULL, \t\r\n, saveptr); } args[*argc] NULL; }这里限制MAX_ARG_NUM - 1是确保最后一个位置永远是NULL给execvp用。strtok_r的一个不足是它会修改原字符串把分割符替换为\0所以我们传进去的line会被破坏。解决办法是前面用strdup复制一份字符串或者干脆接受这个副作用——既然line每轮都会被新输入覆盖被改就改了。我第一版也想过自己手写一个按空格切分的解析器不用strtok。它的好处是把引号处理、转义处理加进来更容易坏处是代码量翻倍。后来我总结的经验是第一版先上strtok_r跑通主流程后再替换成自定义解析器。你每轮对line的使用是一次性的strtok_r的破坏性完全不是问题。3.3 实现命令执行器执行器是Shell的核心。拿到args之后先检查是不是内建命令第一版只有cdexit已经在主循环里处理了然后fork子进程子进程exec父进程wait。int run_external(char **args) { pid_t pid; int status; pid fork(); if (pid 0) { perror(fork); return -1; } if (pid 0) { execvp(args[0], args); fprintf(stderr, mysh: command not found: %s\n, args[0]); exit(127); } if (waitpid(pid, status, 0) 0) { perror(waitpid); return -1; } return 0; }run_command的责任是统一分流我是这么写的void run_command(char *line) { char *args[MAX_ARG_NUM]; int argc; parse_line(line, args, argc); if (argc 0) return; if (strcmp(args[0], cd) 0) { run_cd(args); return; } run_external(args); }这里有个易错点execvp(args[0], args)中args[0]是程序名也是PATH搜索的依据。如果你想执行当前目录下的程序./a.out那么args[0]必须包含./前缀否则execvp不会在当前目录搜索因为当前目录一般不在PATH里。这个行为我当初费了不少时间才弄明白。3.4 加入内建命令支持cd的实现很直接关键是它必须发生在Shell进程自身。void run_cd(char **args) { const char *path; char *home; if (args[1] NULL || strcmp(args[1], ~) 0) { home getenv(HOME); path home ? home : /; } else { path args[1]; } if (chdir(path) ! 0) perror(cd); }这里处理了两种情况没有参数时回到用户主目录~也当成主目录。getenv(HOME)从环境变量里读主目录路径如果为空则回退到/。至于参数多余的情况比如cd /tmp /etc我们直接忽略args[2]之后的内容这在简易版里是可以接受的简化。exit不需要单独函数主循环里strcmp(line, exit) 0就break了。但实际用的时候会发现问题如果用户输入exit 0我们就会把exit 0当成一个外部命令去执行然后报错找不到。这个情况我也踩到了。解法是把exit的判断放到parse_line之后检查args[0]是否为exit这样exit 0也能被正确处理。我后来重构时就用args[0]作为统一判断入口主循环里不再用整行字符串比较。到这里一个能跑外部命令、能cd、能exit的迷你Shell就完成了。编译运行效果大概是这样的$ ./mysh mysh$ ls shell.c Makefile mysh mysh$ pwd /home/user/mysh mysh$ cd /tmp mysh$ pwd /tmp mysh$ exit看到自己写的程序跟bash一样“工作”那种成就感是纯使用Shell完全体会不到的。4. 从“能用”到“好用”重定向与管道的实现4.1 先理解文件描述符一切皆文件的基石Linux里“一切皆文件”这句话不是空洞的理念文件描述符就是它的工程落地。每个进程都有一个文件描述符表0号是标准输入1号是标准输出2号是标准错误。printf往1号写scanf从0号读。有了这个基础重定向的原理就非常朴素把目标文件的fd复制到1号或0号上替换标准输入/输出。Linux提供了dup2(oldfd, newfd)系统调用作用是让newfd指向oldfd所指向的同一条“打开文件描述”。比如dup2(fd, STDOUT_FILENO)之后所有往标准输出写的内容就变成了往fd对应的文件写。4.2 重定向的实现dup2的妙用解析阶段我们需要识别、、这些符号把它们后面的文件名提取出来然后在fork出的子进程里执行dup2。我采用的策略是在parse_line之后、run_external之前扫描args数组找到重定向符号记录文件名并把符号和文件名从args中剔除设为NULL。这样execvp执行时就看不到重定向符号了。void parse_redirect(char **args, redirect_t *redir) { for (int i 0; args[i] ! NULL; i) { if (strcmp(args[i], ) 0) { redir-out_file args[i 1]; redir-out_append 0; args[i] NULL; if (args[i 1]) args[i 1] NULL; } else if (strcmp(args[i], ) 0) { redir-out_file args[i 1]; redir-out_append 1; args[i] NULL; if (args[i 1]) args[i 1] NULL; } else if (strcmp(args[i], ) 0) { redir-in_file args[i 1]; args[i] NULL; if (args[i 1]) args[i 1] NULL; } } }真正执行重定向的地方在子进程里、execvp之前if (redir-in_file ! NULL) { int fd open(redir-in_file, O_RDONLY); if (fd 0) { perror(open); exit(1); } dup2(fd, STDIN_FILENO); close(fd); } if (redir-out_file ! NULL) { int flags O_WRONLY | O_CREAT; flags | redir-out_append ? O_APPEND : O_TRUNC; int fd open(redir-out_file, flags, 0644); if (fd 0) { perror(open); exit(1); } dup2(fd, STDOUT_FILENO); close(fd); }有一个我一开始没注意的严重问题和的识别顺序。我们解析时用的是strcmp(args[i], ) 0如果用户输入的是那么strcmp(args[i], ) 0先被检查所以会被匹配。检查的顺序不能反。如果你先判断那么会被拆成和逻辑就乱了。所以解析函数里必须把放在前面检查。4.3 管道让进程之间“对话”管道的实现比重定向复杂一步。pipe()系统调用创建一对文件描述符pipefd[0]是读端pipefd[1]是写端。我们可以把第一个命令的标准输出重定向到写端把第二个命令的标准输入重定向到读端两个进程就能一条线串起来。两个命令的管道流程大致如下int pipefd[2]; pipe(pipefd); pid_t pid1 fork(); if (pid1 0) { dup2(pipefd[1], STDOUT_FILENO); close(pipefd[0]); close(pipefd[1]); execvp(cmd1[0], cmd1); perror(execvp); exit(127); } pid_t pid2 fork(); if (pid2 0) { dup2(pipefd[0], STDIN_FILENO); close(pipefd[0]); close(pipefd[1]); execvp(cmd2[0], cmd2); perror(execvp); exit(127); } close(pipefd[0]); close(pipefd[1]); waitpid(pid1, status1, 0); waitpid(pid2, status2, 0);这个代码里有个排雷要点就是父子进程对管道fd的关闭。pipe创建后返回两个fd父进程持有这两个每个子进程也会拷贝这两个。如果不处理写端的fd没关完读端会因为还有写入者而一直等待。父进程不关闭管道两端442个后续逻辑会因为管道没关闭而卡死。我的做法是在子进程里dup2之后立即close它不需要的那个fd在父进程里也把管道两端close掉。只有所有写端都关闭了读端的read才能返回0管道才算结束。一个非常隐蔽的坑是如果第一个子进程fork失败、第二个子进程fork失败、或者exec失败管道两端fd可能会泄露卡死后面的waitpid。进阶版本可以用poll或select处理但从实践角度标准做法是把fd的生命周期管理清楚出错路径都走exit让内核回收fd。4.4 一个既有重定向又有管道的命令是如何被处理的真实使用中重定向和管道经常同时出现比如grep keyword file.txt | wc -l result.txt。关键思路是先按管道分割每个管道段内部再处理重定向。管道负责段与段之间的连接重定向负责段与外部文件的连接它们互不干扰。具体到代码实现我会先扫描整行找出|的位置把line分成多个子命令段每个段保存为独立指针。然后挨个fork子进程段内先处理重定向再建立管道连接最后exec。这个方案我实际跑了很多用例逻辑是稳定可靠的。不过这里要提醒一下grep keyword file.txt | wc -l result.txt这种命令的执行顺序是左边先起进程右边再起进程父子进程之间通过管道连接。父进程要等左右两个子进程都结束整个管道才算完成。如果只等最后一个子进程前一个进程可能会变成僵尸时间久了还是会有资源泄漏。5. 踩坑实录与常见问题排查5.1 经典问题为什么exec之后代码不往下走了新手最容易困惑的exec调用成功后后面的代码不该继续执行吗答案是不该。因为exec是“替换当前进程映像”一旦成功当前进程从内存到代码段全部变成新程序原来的代码自然就没了。所以printf(after exec)这种语句永远不会打印出来。这个现象我最初也踩过。调试的时候在exec后面加打印想确认exec是否执行成功却什么都没打出来我一度以为代码挂了。后来才明白打印必须放在exec之前错误处理放在exec调用返回之后因为只有失败才会返回到这里。另外要注意exec失败时返回-1并设置errno这时代码会继续执行一定要用perror打印错误不然用户会看到Shell静默地什么都不做体验感很差。排查思路建议先用strace -f跟踪系统调用确认有没有调用execve再确认参数数组构造对不对。strace是我在调试Shell时用得最多的工具它能把每个系统调用都列出来问题一目了然。5.2 经典问题命令行开头多了空格怎么办strtok_r按分隔符切分连续分隔符会被合并所以开头和结尾的空格不会产生空token。但如果你自己写按空格扫描的解析器而不小心忽略连续空格就会得到空命令args[0]是空字符串execvp()会返回找不到命令。解决办法很简单解析函数里跳过空字符串token或者用strtok_r这种天然合并分隔符的函数。还有一种情况是输入ls -l如果只按单个空格切分最后一个-l后面还会跟一个空串传给execvp的参数数组就会多一个空参数命令可能报错。真实项目里这两个坑我都见过所以解析器一定要容错。5.3 经典问题cd命令为什么没有生效这个基本是所有写Shell的人都会踩的坑。我刚加cd时执行cd /tmp后接着pwd输出还是原来的目录。原因就是我把cd放在子进程里执行了chdir只对当前process working directory有影响子进程一退出一切回到原点。解决办法就是从一开始就明确内建命令必须在Shell进程内处理不允许fork。这是我上面花大篇幅讲内建命令分流的原因。验证一个版本是不是正确实现可以看它执行cd之后pwd的输出是否立即变化。5.4 经典问题管道命令执行顺序错乱做管道时我曾观察到奇怪的现象两个命令的输出交错、或者某个命令根本拿不到数据。这是典型的管道fd关闭时机问题。核心原则是写数据之前确保只有一份合法的写端fd读数据之前确保所有写端都被关闭。父进程持有管道两端的fd时如果不关第二段命令读数据时会永远阻塞因为管道还有另一个写者在即父进程持有的写端读端read永远不会返回EOF。子进程里如果不关远端的fd也会导致数据错误。所以我的规范是每个子进程dup2之后立刻关闭不需要的fd父进程fork出所有子进程后立刻关闭管道两端。5.5 调试技巧速查这几条是我实践下来最有效的Shell调试方法整理成表格方便查阅问题现象检查方向常用排查手法提示符不显示stdout缓冲未刷新在printf后加fflush(stdout)fork后程序崩溃子进程中状态被破坏检查是否有未关闭的fd、信号处理是否被继承execvp返回失败PATH搜索不到命令用perror打印errno确认args[0]是否包含路径前缀重定向没生效错误地用Shell主进程做opendup2检查是否在子进程中执行dup2且用STDOUT_FILENO而非数字1管道命令卡死管道fd未正确关闭用strace -f跟踪观察read/write是否阻塞在pipefd上cd不生效在子进程执行了chdir检查内建命令是否在fork之前拦截僵尸进程堆积没有调用wait/waitpid用ps aux查看Z状态进程补上waitpid还有一个小技巧我在开发期间给Shell加了两种调试输出用#ifdef DEBUG宏控制。解析阶段打印params数组执行阶段打印fork的pid和exec的命令。这个开关在正常使用时关闭出问题时打开能省下大把瞎猜的时间。6. 还能扩展什么从简易版走向更接近bash的Shell一旦跑通了基础循环你会发现Shell这个项目像滚雪球一样可以做下去而且每个方向都能巩固一个系统编程知识点。我个人建议按以下顺序扩展多管道支持cmd1 | cmd2 | cmd3 | cmd4。需要先把整行按|拆成多个子命令段然后逐个fork段与段之间通过管道串起来后台执行与命令末尾遇到时不调用waitpid直接打印子进程PID并回到提示符。但要注意清理僵尸进程可以用signal(SIGCHLD, SIG_IGN)或者用一个循环轮询回收引号与转义解析echo hello world要把带空格的字符串当成一个参数这就要在解析阶段处理引号此时strtok_r就不够用了要换成状态机解析器环境变量展开$HOME、$PATH替换成具体值需要识别$开头的token并查找环境变量通配符展开ls *.c要把*.c展开成所有以.c结尾的文件名这涉及glob模式匹配是glob()函数的应用这些扩展每一个单独拿出来都能写一篇博文。但核心思想不变你是在一个循环里不断处理输入、解析结构、调用系统调用。骨架立住了往上面加肉就是时间和耐心的问题。就我自己的经验来说写这个简易Shell项目最大的收益不是“我有了一个自己的终端工具”而是把所有抽象的知识落到了实处。以前看进程、文件描述符、管道都是书本上的概念亲手实现了fork和exec之后再看到ps aux里的进程列表心里对每个进程是怎么来的、怎么没的有了非常直观的把握。如果你也想验证自己到底懂不懂Linux系统编程我的建议很简单别只背概念去写一个自己的Shell跑起来的那一刻很多问题自然就通了。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门