ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用C语言从零实现一个简易shell:进程管理、管道与重定向实战

用C语言从零实现一个简易shell:进程管理、管道与重定向实战 先交代一下背景。很多学校在操作系统、Linux程序设计这类课上都会布置一道经典作业用C语言实现一个简单的shell终端。我第一次拿到这个题目时也是一头雾水老师只说了句“类似bash但简化一点”具体要做到什么程度、代码怎么组织全靠自己摸索。等工作以后回头看才发现这道题其实非常巧妙——它把Linux下进程管理、文件描述符、字符串解析这些零散的底层知识点一次性全部串了起来。你如果能把这道题写明白说明对Linux系统编程已经有了一个比较完整的框架。这篇文章我会按自己当年踩坑后的思路来拆解从需求分析到完整代码再到扩展功能和排错尽量让正在为这份作业发愁的同学能直接照着复现同时也能理解每一步背后的原因。1. 需求拆解作业描述里没说透的三层要求1.1 为什么演示shell都爱用C语言shell本质上是一个“命令解释器”它做的事情看起来很简单读取你输入的一行字符串把它拆成命令和参数然后启动对应的程序并等待结束。但难点在于“启动对应的程序”这一步在Linux里需要和操作系统内核打交道而C语言是接触这些系统调用最直接、最没有包装的语言。你当然可以用Python、Go甚至Java写一个shell但那些语言都替你封装了底层细节你不需要关心进程是怎么创建出来的。而作业用C语言目的就是逼你去调用fork()、exec()、waitpid()这一组系统调用自己去理解“进程”这个东西到底是怎么回事。所以这道题的核心不是在写UI、不是在解析字符串而是看你有没有真正理解Linux进程的生命周期。1.2 作业里的“简单shell”通常包含哪些隐藏要求大多数版本的作业描述都写得比较模糊但落到最终验收基本逃不出这几条提供一个交互式命令行提示符程序启动后可以反复接收用户输入直到输入exit或quit才退出。对用户输入的命令行做拆分支持类似ls -l /tmp这样带参数的形式。能够执行普通的外部命令也就是去PATH路径里找到可执行文件然后启动它。至少实现两个内置命令cd和exit。有些题目还会要求pwd、echo。命令不存在或者启动失败时要给出合理的报错信息不能直接崩溃。加分项支持输入输出重定向、、支持管道|支持后台运行。我见过不少同学交上去的作业其实只做到了“看起来像一个终端”比如用system()一行函数搞定命令执行。如果老师要求用exec系列这种做法通常拿不到分因为system()实际上是帮你fork了/bin/sh -c再执行命令完全没有展示你对进程管理的理解。1.3 整体实现路线图先把思路理清楚。整个程序就是一套“读入–解析–执行”的死循环专业一点叫 read-eval-exec loop和很多解释器、REPL的原理是一样的。我建议按四步走打印提示符读取用户输入的一行命令。把这一行按空格拆成若干段第一段是命令名后面是参数列表。判断这个命令是内置命令还是外部命令。如果是外部命令就用fork()创建一个子进程在子进程里用execvp()换成新程序父进程用waitpid()等它结束。这个流程本身不难难的是细节。比如提示符为什么打不出来命令带路径时怎么处理PATH怎么查找子进程退出状态怎么拿这些我下面逐个展开。2. 核心概念进程创建、程序替换与等待回收2.1 fork、exec、wait创建一个新进程的“标准动作”在Linux里启动一个新程序靠的其实是两个步骤的组合。第一步用fork()把当前进程复制一份得到几乎一模一样的父子两个进程第二步在子进程里调用exec系列函数把自己这个进程的代码和数据整个替换成要运行的新程序。打个比方为fork()就像复印了一整本练习册复印出来的册子和原册子内容完全一样页码都对得上然后你在复印件上把内容全部擦掉重新写上新的内容这就是exec()。而原来的复印件从此就不存在了它变成了那本新册子。哦不对更准确地说子进程在调用成功exec之后就不再执行原来的main函数了它已经变成了ls、grep这样的新程序直到运行结束。那为什么不能直接在一个进程里替换成新程序因为这样就回不来了。父进程需要保持自己的状态才能继续接收下一条命令、继续管理其他子进程。所以必须制造一个“分身”去干活干完就走父进程安心等着。waitpid()就是用来“收尸”的。子进程运行结束后会成为僵尸进程占用一个进程表项。父进程调用waitpid()后内核会把子进程的退出状态交给父进程同时释放这个表项。如果你只fork()不waitpid()你的shell跑上几十条命令之后系统里就会堆积一堆defunct僵尸进程。2.2 execvp 做了什么事PATH查找的隐藏逻辑很多人第一次看execvp()这个名字会觉得奇怪为什么有个v后缀。其实exec家族的函数命名是有规律的带l的是用列表方式传参带v的用数组方式传参带p的会去PATH环境变量里查找可执行文件带e的可以显式传入环境变量表。所以execvp的意思就是用数组传参并且自动搜索PATH。这意味着你调用execvp(ls, args)时它会在/usr/bin:/bin:/usr/sbin这些目录里依次寻找ls这个可执行文件。找到就执行找不到就返回-1并设置errno。这就是为什么用户不需要输入/usr/bin/ls直接输ls就能跑。如果作业要求你自己实现PATH查找逻辑那就得用getenv(PATH)拿到环境变量按冒号拆成多个目录逐个拼上命令名再用access()检查是否可执行。需要注意的是getenv()返回的是程序内部的静态字符串不能直接用strtok()去拆它因为strtok()会在原字符串里插入\0。正确做法是先strdup()复制一份再拆。2.3 为什么cd必须做在内置命令里这是一个很容易被忽视、但又特别关键的问题。cd其实不是“启动一个程序”而是改变当前shell进程自己的工作目录。如果你用fork()创建一个子进程在子进程里调用chdir()切换目录那么切换的只是子进程的目录父进程的当前目录完全没变命令结束之后shell还在原来的目录。这就好比你在前台喊了一嗓子“大家去3号会议室”结果只有你自己去了其他同事都坐在原地。cd必须由shell这个“领导”自己执行所以必须内置。同理exit也是一样shell收到这个命令应该直接退出自己的主循环而不是创建一个子进程去“假装退出”。pwd、echo、export这类命令从原理上讲内置起来更合理因为它们要么涉及当前shell的环境要么太常用不值得每次fork一次但作业里优先级最高的内置命令就是cd和exit。2.4 环境变量与提示符的小细节作业通常只要求显示一个固定的提示符比如myshell$。但如果你想做得好看一点可以在提示符里带上当前用户名和当前目录比如userhost:/tmp$。用户名可以从getenv(USER)拿到当前目录用getcwd()拿主机名用gethostname()。这三个函数都不难但加进去之后整个程序看起来立刻专业很多很多老师会愿意在这类细节上给一点印象分。不过有一点要提醒在打印提示符之后一定要调用fflush(stdout)否则因为标准输出默认是行缓冲加上当前不是输入终端时是块缓冲提示符可能压根不会显示界面看起来就像卡住了一样。这个问题我当年排查了很久就是少了那一行fflush()。3. 手写实现一个完整能交作业的shell代码3.1 程序整体结构与主循环下面这份代码是我当年作业的精简版能跑通绝大部分验收场景。它不需要任何第三方库纯标准C加上POSIX系统调用在Ubuntu、CentOS这些主流Linux发行版上都能直接编译运行。我会把每一段代码都拆开讲清楚方便你直接抄作业也方便你在答辩时能够应对老师的提问。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/types.h #include sys/wait.h #include fcntl.h #define CMD_BUF_SIZE 1024 #define MAX_ARGS 128 int main() { char cmd[CMD_BUF_SIZE]; char *args[MAX_ARGS]; int status; // 忽略CtrlC信号防止用户按CtrlC把整个shell退出 signal(SIGINT, SIG_IGN); while (1) { printf(myshell$ ); fflush(stdout); if (fgets(cmd, sizeof(cmd), stdin) NULL) { printf(\n); break; } cmd[strcspn(cmd, \n)] \0; if (strcmp(cmd, exit) 0 || strcmp(cmd, quit) 0) { break; } if (strlen(cmd) 0) { continue; } int argc 0; char *token strtok(cmd, \t); while (token ! NULL argc MAX_ARGS - 1) { args[argc] token; token strtok(NULL, \t); } args[argc] NULL; if (argc 0) { continue; } if (strcmp(args[0], cd) 0) { const char *path args[1]; if (path NULL) { path getenv(HOME); } if (chdir(path) ! 0) { perror(cd); } continue; } if (strcmp(args[0], pwd) 0) { char cwd[PATH_MAX]; if (getcwd(cwd, sizeof(cwd)) ! NULL) { printf(%s\n, cwd); } continue; } pid_t pid fork(); if (pid 0) { perror(fork); continue; } if (pid 0) { execvp(args[0], args); perror(execvp); exit(127); } waitpid(pid, status, 0); } return 0; }3.2 命令行读取与解析的细节主循环的核心就是三件事读、拆、做。读取我用的是fgets()它会一次读入一整行包括结尾的换行符。接下来那行cmd[strcspn(cmd, \n)] \0是很多新手看不懂的地方。strcspn()返回字符串中第一个匹配到\n的位置然后我们把那个位置设为\0相当于把换行符删掉。这样做比strlen减去1更安全因为如果这一行末尾没有换行符比如文件结尾也不会越界。解析我直接用了strtok()。要注意strtok()会把原字符串里的分隔符替换成\0也就是说cmd数组本身会被改写。这个问题不大因为读入下一条命令时这个数组会被完全覆盖。另一个注意点是strtok()对连续多个空格的处理——它会自动跳过所以你输入ls -l和ls -l的结果完全一样。这在简单shell里是够用的但如果你以后想支持引号、支持ls my file这样的带空格参数就必须自己写一个更精细的分词器strtok()就搞不定了。参数数组args的最后一个元素一定要是NULL因为execvp()要求参数列表以空指针结束。这个细节漏掉的话程序崩溃是常态而且每次崩的地方还不一样。3.3 fork、exec与wait的执行链条外部命令的执行是我在3.1代码里最核心的一段我单独拉出来再讲一遍pid_t pid fork(); if (pid 0) { perror(fork); continue; } if (pid 0) { execvp(args[0], args); perror(execvp); exit(127); } waitpid(pid, status, 0);fork()返回值有三种情况。小于0说明进程创建失败可能是系统进程数达到上限这种时候继续循环就好。等于0说明当前代码运行在子进程里子进程要做的是调用execvp()把自己替换掉。大于0说明当前代码运行在父进程里父进程拿到的是子进程的PID然后调用waitpid()挂起等待。这里有个很容易被问倒的知识点为什么子进程里execvp()之后还要写perror()和exit(127)因为execvp()只有失败才会返回。如果它成功子进程已经变成了新程序根本不会走到后面的代码只有找不到命令、没有权限的时候它才会带着错误码回到这里。如果不加exit(127)子进程会继续执行原来的代码相当于一个命令失败后又跑回while(1)循环产生两个shell进程同时抢键盘输入的诡异局面。127这个退出码是Linux里“命令未找到”的惯例值/bin/sh也是这么用的。父进程的waitpid()可以让shell等待子进程结束之后再显示下一条提示符。这样用户看到的效果就是输入一个命令、看到输出、然后才出现新提示符和真实bash的同步执行表现一致。status里保存了退出信息通过WIFEXITED(status)和WEXITSTATUS(status)可以判断子进程是否正常退出以及退出码是多少作业如果要求显示退出码这两个宏就用上了。3.4 路径、目录与环境让shell更像一回事上面的代码虽然能跑但有些细节可以再加一下。第一个是PATH_MAX这个宏它定义在limits.h头文件里getcwd()需要它来声明缓冲区大小。如果你在代码里用了PATH_MAX但没加头文件编译时会报“未声明”的错误这一点在写作业时经常遇到。第二个是cd命令的扩展处理。现在我的代码是如果用户只输入cd没有参数就回到HOME目录如果有参数就切换过去。你还可以继续增强比如支持cd -回到上一次的目录但那就需要在循环外维护一个prev_dir变量工作量不大属于加分项。第三个是echo $PATH这样的环境变量输出。如果用execvp执行echo $PATH会被当成普通参数传给/bin/echo而/bin/echo并不会解析$PATH这个符号用户看到的就只是字面上的$PATH。bash的做法是自己在内部做变量展开。实现起来也不难如果args[1]以$开头就getenv(args[1]1)然后把展开后的值拼到输出里。但注意引号、多段参数拼接这些情况会越来越复杂所以很多作业不要求做变量展开你心里有数就行。3.5 退出状态与程序健壮性一个容易被忽略的点是fgets()返回NULL时说明输入流已经结束比如用户按了CtrlD或者程序从文件里读命令读到文件尾。此时应该优雅退出而不是进入死循环。我代码里在fgets返回NULL时打印一个换行再break这样终端输出不会粘在一起。另一个健壮性细节是忽略SIGINT信号。默认情况下用户按CtrlC会把整个前台进程组都杀掉包括你的shell。但真实bash的行为是CtrlC只中断当前正在运行的子命令shell本身不死。所以我在主循环开始前调用了signal(SIGINT, SIG_IGN)让shell忽略这个信号。由于fork()创建的子进程会继承“忽略”这个设置如果想让子进程恢复默认行为需要在子进程里再调用signal(SIGINT, SIG_DFL)。不过实际上exec新程序之后新程序会覆盖进程映像信号处理也会重置为默认所以这一步在简单shell里不写也没问题。4. 功能扩展用管道和重定向做出加分项4.1 重定向的实现思路与代码输入输出重定向在作业里是出镜率最高的加分项。很多老师会现场输入ls out.txt然后打开out.txt检查内容。如果你没实现这个命令会被拆成三个参数ls、、out.txt然后execvp会报“不是一条命令”当场露馅。但其实重定向的底层原理非常简单就是一句话在子进程里把标准输入或标准输出重定向到文件。具体做法分三步先用open()打开目标文件再用dup2()把文件描述符复制到标准输出fd 1最后关闭原文件描述符。dup2(旧fd, 新fd)的意思是把旧fd代表的那个“文件表项”复制到新fd的位置上复制完之后新fd就指向同一个打开文件描述之后任何写入fd1的内容都会进入这个文件。// 在子进程里、execvp之前处理重定向 for (int i 0; i argc; i) { if (strcmp(args[i], ) 0 i 1 argc) { int fd open(args[i 1], O_WRONLY | O_CREAT | O_TRUNC, 0644); if (fd 0) { perror(open); exit(1); } dup2(fd, STDOUT_FILENO); close(fd); args[i] NULL; // 把以及后面的文件名从命令参数里摘掉 break; } // 输入重定向、 追加重定向类似可以照猫画虎 }注意顺序问题。这段逻辑必须放在fork()之后的子进程分支里而且必须在execvp()之前。为什么必须在子进程里因为一旦父进程也调用了dup2那父进程自己的标准输出也会被改变shell自己接下来的printf(myshell$ )就会写到文件里整个交互就全乱了。所以记住一条铁律重定向只发生在子进程里只影响即将运行的那条命令。4.2 管道的实现从零到一连接两个进程管道比重定向稍微难一点因为它涉及两个子进程之间的数据流动。验收场景通常是ls | grep myfile或者cat /etc/passwd | grep root。原理其实就是一个匿名管道pipe()创建一对文件描述符fd[0]用于读fd[1]用于写。你要做的就是把左边进程的标准输出接到fd[1]把右边进程的标准输入接到fd[0]然后两个进程同时启动。一个朴素的实现思路是先用strchr(cmd, |)或自己写分割逻辑把整条命令分成左右两段然后创建一个子进程A执行左边命令在A里把fd[1]复制到标准输出再创建一个子进程B执行右边命令在B里把fd[0]复制到标准输入父进程关闭两端等待两个子进程结束。// 简化的管道实现骨架 int pipefd[2]; pipe(pipefd); // 左子进程 pid_t p1 fork(); if (p1 0) { dup2(pipefd[1], STDOUT_FILENO); close(pipefd[0]); close(pipefd[1]); execvp(left_args[0], left_args); exit(127); } // 右子进程 pid_t p2 fork(); if (p2 0) { dup2(pipefd[0], STDIN_FILENO); close(pipefd[0]); close(pipefd[1]); execvp(right_args[0], right_args); exit(127); } close(pipefd[0]); close(pipefd[1]); waitpid(p1, NULL, 0); waitpid(p2, NULL, 0);这段骨架代码并行创建了两个子进程左进程的输出通过管道进入右进程的输入。很多人在实现的时候会漏掉一个细节父进程必须在fork完两个子进程之后立刻关闭管道两端。如果父进程不关管道的一端还开着右进程可能永远等不到EOF程序就会卡在等待状态。这个坑属于“看代码看不出问题、一跑就卡死”的典型。4.3 后台任务与更多扩展的取舍如果作业写了“支持后台运行”那要做的是解析时识别出参数末尾的把它摘掉然后父进程调用waitpid()时不阻塞直接继续打印下一条提示符。这样用户输入sleep 5 之后提示符会立刻回来。代价是后台进程结束后没有人收尸会留下僵尸进程。最简单的处理办法是在主循环里调用signal(SIGCHLD, SIG_IGN)让内核帮你自动回收子进程或者维护一个子进程列表循环里用waitpid(-1, status, WNOHANG)轮询回收。后台任务这块我建议量力而行。如果你的作业已经实现了管道和重定向那就已经很扎实了后台运行属于锦上添花。相反如果管道代码写得还不太稳那先把基础部分做好不要本末倒置。毕竟作业的核心考察点还是fork/exec/wait这一条主线。5. 调试与踩坑编译、运行、僵尸进程那些事5.1 编译期间最常见的报错我帮人看过很多份作业编译阶段的报错其实就那么几类。一类是没有加头文件比如用了getcwd()但没加unistd.h用了PATH_MAX但没加limits.h编译器会报“隐式声明”或“未定义”的警告/错误。另一类是fork()相关的类型错误比如把pid_t声明成了int在有些环境下也能过但规范做法是直接用pid_t。还有一类是main函数返回值类型必须写成int main()写成void main()在一些编译器上是警告在一些严格模式下直接报错。如果你的代码在中文系统下编译建议源文件统一用UTF-8编码并且不要在代码里写中文注释之外的特殊字符。我个人经验是交作业的代码最好注释写清楚、变量名写清意图比如argc、args、pid、status不要用什么a、b、c这种单字母看起来既不专业也容易让自己答辩时都看不懂。5.2 运行时的诡异现象与排查思路运行时最常见的坑就是我前面反复提到的提示符不显示。现象是程序启动后屏幕一片空白敲命令才有反应。原因就是没fflush(stdout)输出进了缓冲区没刷出来。这个问题遇到一次以后就不会再犯了。第二个常见坑是命令执行完shell直接退出。这种情况多半是execvp()之后没有写exit()或者主循环的判断条件写错了比如用户输入exit时你以为它在子进程里能退出父进程实际上子进程退出后父进程还在。解决思路很简单明确哪些分支是父进程走的哪些是子进程走的用pid值区分清楚。第三个诡异现象是执行一条命令后输出正常但shell的当前目录变了。如果你没有在父进程里调用chdir()那多半是你在主循环里把cd当普通命令fork了。这个我已经在前面讲过了cd必须内置执行。5.3 常见问题速查表为了方便带到实验室或者答辩前突击看我把上面所有提到过的问题整理成一张速查表。每个问题都配有最直接的现象描述、原因和解决办法你可以直接按图索骥。现象原因解决办法提示符不显示程序像卡住stdout缓冲区没刷提示符打印后加fflush(stdout)输入exit后shell还在跑判断逻辑在子进程分支里在主循环内先判断内置命令再fork命令不存在时报错信息重复execvp失败后子进程没exit子进程里exit(127)执行ls -l后目录变成子进程打开的目录cd被当作外部命令fork用chdir()内置处理跑了很多命令后出现僵尸进程父进程没调用waitpid每次fork后waitpid或用SIGCHLD忽略实现管道后程序卡住父进程没关闭管道两端fork完后关闭pipefd[0]和pipefd[1]ls out.txt被当成命令执行没有处理重定向在子进程里用opendup2CtrlC把shell一起杀了没忽略SIGINT主循环前signal(SIGINT, SIG_IGN)用strtok处理getenv(PATH)后程序崩溃strtok修改了静态字符串先用strdup()复制再split5.4 答辩时老师最爱追问的几个问题最后顺带聊一下答辩准备。这道题的作业往往要当着助教或老师的面上交、演示、回答几个问题。最常被问的其实是下面几个fork()之后父子进程分别怎么知道自己在哪个分支里——通过返回值父进程拿到大于0的子进程PID子进程拿到0。exec和system有什么区别——exec是替换当前进程映像system是启动一个/bin/sh来执行命令中间多了一层shell解释。waitpid的第三个参数0是什么意思——阻塞等待直到指定子进程结束换成WNOHANG立即返回。子进程退出之后为什么必须先被父进程wait——僵尸进程占着进程表项不去回收的话父进程反复fork可能耗尽系统进程资源。如果子进程退出码是137说明什么——137等于1289一般是被SIGKILL杀掉了常见于内存超限或者外部强行杀掉进程。这些问题其实都是在验证你是否真正理解了进程模型。如果你能结合自己代码里的具体行号来回答基本就稳了。我个人在实际操作中的体会是这道题最值得花时间的部分不是把代码跑到“能交差”而是亲手把管道和重定向逐步调试通。你debug的次数越多对文件描述符、阻塞等待、子进程生命周期这些概念的理解就越牢固。等写完这个简单shell再回头看bash的手册文档你会发现以前看不懂的很多概念——比如为什么某些命令必须内建、为什么管道符要在shell层解析——突然就全部对上了。如果后面还有精力我建议你再给它加一个历史记录功能或者支持一下环境变量赋值这些扩展会让你的shell在功能和代码结构上又上一个台阶而且依然是纯C能搞定的范围。
返回列表