☰
用C语言手写一个简易Shell:从进程模型到核心实现
2026/10/3 14:39:46 网站建设 项目流程

拿到这个作业的时候,很多人其实不是不会写C,而是没搞明白Shell到底该干什么。我当年也一样,对着“用C语言实现一个简单的shell终端”这个题目愣了半天,第一反应是:“Shell不就是那个黑框框吗?怎么用C写?”后来才想明白,这个作业真正考察的是你对Linux进程模型、文件描述符、字符串处理这几个基本功的理解有多深。说白了,就是让你亲手实现一个能接收命令、创建进程、执行程序的命令行解释器。

这个项目适合谁?两类人。一类是正在被这门作业折磨的学生,你需要的是把原理讲透、把代码掰开揉碎,让你能自己写出来而不是抄一份交差;另一类是学完Linux操作但一直停留在“会用命令”阶段、想往底层看一眼的开发者。你不需要很牛才能读懂这篇文章,但需要你愿意动手敲代码。

我不会给你一份完整代码让你直接复制,那样没意思。我会把整个项目从原理到设计,再到核心代码怎么写、坑在哪里,一条线讲清楚。你跟着思路走完一遍,自己从头写一遍,这个作业你就真的吃透了。

1. 动手之前:理解Shell的完整工作流程

Shell在Linux里是个什么角色?你可以把它理解成一个翻译加调度员。坐在终端前敲命令的人是你,内核负责干活,Shell夹在中间——它把你的输入变成内核能理解的进程调用,再把结果回给你。所以Shell的本质就是一个循环程序:等待输入、解析输入、执行命令、等待下一个输入。

1.1 终端、Shell和进程之间的关系

先厘清几个容易混的概念。终端是一个硬件设备的概念,现在是软件模拟的,比如你开的终端窗口。Shell是跑在终端里的那个程序,常见的有bash、zsh、sh。你写一个简单的shell,本质上是写一个普通的用户态进程,只不过这个进程的特殊之处在于:它要能读取用户的键盘输入,还要能创建其他子进程去执行命令。

关键点来了:当你在Shell里敲ls -l然后回车,Shell并不是自己去列文件,而是先去找到一个叫ls的可执行程序,然后创建一个子进程,让这个子进程去执行ls,Shell自己则等待子进程结束,再去读下一条命令。这是整个Linux进程模型最核心的一个场景:一切命令的执行,都离不开fork和exec,Shell则借助waitpid来同步子进程结束的时机。

1.2 简单Shell的完整生命周期

一个最简单的Shell,它的生命周期就是下面这个循环:

  1. 打印提示符,比如mysh$。
  2. 从标准输入读取一行字符串。
  3. 把字符串按空格拆成命令和参数,比如ls -l /home拆成ls、-l、/home。
  4. 判断这个命令是不是内建命令(比如cd、exit),是的话直接处理;不是的话进入下一步。
  5. 调用fork创建子进程,在子进程里调用execvp执行命令,父进程用waitpid等待子进程结束。
  6. 回到第1步,继续循环。

注意我提到“内建命令”这个词。为什么必须有这个东西?比如cd,它改变的是Shell自身的工作目录,如果咱们也用fork一个子进程去执行cd,那改变的只是子进程的工作目录,子进程跑完就没了,Shell自己的工作目录纹丝不动。所以像cd、exit、export这类影响Shell自身状态的命令,必须在Shell内部自己实现,这就是“内建命令”存在的原因。

1.3 你是要做哪个版本的Shell?

“简单的shell终端”这句话其实很模糊,我建议你在动笔之前,先问清楚你自己要做哪个版本。我见过很多同学上来就写,写了一周发现越写越复杂,最后崩了,原因就是没有提前定义功能边界。

我做这个作业时给自己定的版本是:支持任意带参数的外部命令,支持cd、exit、pwd三个内建命令,支持PATH环境变量查找,支持空格和Tab分隔参数。这是基础版。如果你还想拿高分,可以考虑加上重定向(>、<)和管道(|),但这属于进阶版,我会在后面的章节单独讲。你先把基础版跑通,再考虑升级,千万别一上来就想把bash的语法糖全部实现,那不是这个作业的要求,也会把自己劝退。

2. 架构设计:先画好功能边界再动手

这个作业的C语言代码量大概在300到600行之间,不算大,但如果你不先想清楚结构,写起来会非常混乱。我建议分四层来设计:主循环、解析器、执行器、内建命令模块。每一层职责单一,后续调试也方便。

2.1 数据结构的定义:命令怎么表达

在C语言里,execvp需要的是一个char *argv[]数组,数组第一个元素是程序名,最后一个元素必须是NULL。比如执行ls -l,你需要构造出{"ls", "-l", NULL}这个东西。这个约束从头到尾贯穿项目,所以你的解析器输出就应该是这样一个字符串数组。

我当年定义的Command结构体是这样的:

#define MAX_ARGS 64 #define MAX_LINE 1024 typedef struct { char *argv[MAX_ARGS]; int argc; } Command;

其实你会发现,Command结构体有点多余,直接用char *argv[]就行,但封装一下有个好处:以后想扩展,比如加一个redirect_fd字段表示重定向到哪个文件,直接往结构体里加字段就行,不会动到其他代码。

2.2 主循环怎么写:读一答一

主循环我推荐写成这样:

while (1) { print_prompt(); char *line = read_line(); Command cmd = parse_line(line); execute_command(&cmd); }

三个函数各管一件事:read_line负责读入一行并去掉换行符,parse_line负责把字符串拆成参数数组,execute_command负责判断和执行。把逻辑拆开,好处是每段代码都很短,出问题了单步调试就能定位。

这里有个细节容易被忽略:打印提示符的时候,应该输出到标准错误而不是标准输出。为什么?因为如果用户执行了ls > file.txt,标准输出被重定向到了文件,如果提示符也写到标准输出,它会跑到文件里,污染命令输出结果。写到标准错误stderr是Shell常见做法,bash也是这么干的。我见过不少同学程序测试时一切正常,一加重定向功能就发现文件里多出一堆$,就是这个原因。

2.3 功能边界:要做什么,坚决不做什么

很多人在实现时被bash的各种能力带偏了,写了一大堆跟作业无关的功能,还浪费时间。我先帮你划几条清晰的边界:

基础版必须要做的:

  • 读到一行命令,拆成参数数组
  • 能执行普通外部命令,能传入参数给命令
  • 支持cd、exit、pwd内建命令
  • 能正确处理无命令输入(直接回车)的情况

建议不要急着做的:

  • 复杂的语法分析,比如if、for、while这些控制结构,那是写解释器不是写Shell
  • 命令别名、历史记录、Tab补全,这些是体验功能,对原理理解没有帮助
  • 后台执行&和作业控制,涉及信号和进程组,太复杂

先把基础循环跑通。如果连ls -l都不能正常执行,谈再多新特性都是空中楼阁。

3. 核心代码实现:一行一行来

现在进入正题。我会逐段讲关键代码,你不用照抄,理解后自己敲一遍。我会把每个选择背后的原因也讲清楚。

3.1 读取输入:老实的fgets最靠谱

读输入最直接的方式是fgets。注意不能用scanf("%s"),因为遇到空格就断了;也不能用gets,那个函数在C11标准里已经被移除,因为它无法限制读取长度,缓冲区溢出风险极大。正确做法:

char buf[MAX_LINE]; if (fgets(buf, sizeof(buf), stdin) == NULL) { // 读到EOF,比如用户按了Ctrl+D exit(EXIT_SUCCESS); } buf[strcspn(buf, "\n")] = '\0'; // 去掉末尾换行符

这里用strcspn是标准做法,它会返回字符串中第一个匹配到目标字符的下标,把那个位置置为\0就干净地去掉了换行符。注意如果用户输入的行超过MAX_LINE - 1,fgets会截断,后面我讲常见问题时会展开讲这个坑。

还有一个细节:如果用户直接按了回车,读到的是一行空字符串,解析后argc == 0,你应该什么也不做,继续下一次循环,而不是尝试执行一个空命令。

3.2 解析命令:strtok好用,但有个大坑

解析命令我们第一反应是用strtok,它按分隔符把字符串拆成子串,用起来极爽。但有一个大坑:strtok会把字符串本身破坏掉,把分隔符替换为\0,并且它是静态存储的,不可重入。在这个项目里问题不大,后文我会讲怎么处理。

我建议你用手写的方式解析,一方面是绕开strtok的坑,另一方面是你能自由扩展,比如后面想支持引号拼接参数,手写解析更方便。基础版的手写解析思路如下:

char *parse_args(char *line, char *argv[]) { int argc = 0; char *p = line; while (*p != '\0') { // 跳过开头的空白字符(空格、Tab) while (*p == ' ' || *p == '\t') p++; if (*p == '\0') break; // 此时p指向一个参数的开头,记录起始位置 argv[argc++] = p; // 跳过这个参数,直到遇到下一个空白字符 while (*p != ' ' && *p != '\t' && *p != '\0') p++; if (*p != '\0') { *p = '\0'; p++; } } argv[argc] = NULL; return argv[argc]; }

这段代码做的事情很朴素:遇到空白就跳,遇到非空白就当作参数开始,一直走到下一个空白结束,用\0替换空白。整个过程把一行字符串切成了以\0结尾的参数序列。这段代码能处理连续多个空格,比如echo hello world,因为每轮循环开头都跳过了所有空白。

这里我提一个进阶诉求:如果你将来想支持引号,比如echo "hello world"拼成一个参数,你就在跳过参数时加判断:如果遇到"或',就一直读到下一个同样的引号为止,并且跳过引号本身。这个不复杂,基础版可以先不加。

3.3 执行外部命令:fork + execvp + waitpid 三件套

这是整个Shell的核心,理解了这一段,你就理解了Linux进程管理大部分的精髓。

第一步,先判断内建命令。如果是cd、exit、pwd,直接调用对应的函数处理,不创建子进程。检测方法就是把argv[0]和字符串strcmp比较。

第二步,fork创建子进程。

pid_t pid = fork(); if (pid < 0) { perror("fork"); return; }

fork执行成功后,会出现一个和父进程几乎一样的子进程,两者的区别是fork返回值不同:父进程中返回子进程PID,子进程中返回0。所以下面是分支逻辑:

if (pid == 0) { // 子进程:去执行命令 execvp(cmd->argv[0], cmd->argv); // 如果执行成功,execvp不会返回;能走到这里说明执行失败了 fprintf(stderr, "mysh: %s: command not found\n", cmd->argv[0]); exit(EXIT_FAILURE); } else { // 父进程:等待子进程结束 int status; waitpid(pid, &status, 0); }

execvp里的v表示参数用数组传,p表示会去PATH环境变量指定的目录里查找可执行文件。所以你在Shell里敲ls,它能自动在/bin、/usr/bin找到你,你不需要自己拼绝对路径。

还有一点很多人会踩坑:子进程里execvp失败后一定要exit,并且要打印错误信息到stderr。如果漏了exit,子进程会继续往下跑,去读下一条命令,导致出现“你的Shell被复制了一份”的诡异现象,这是经典的僵尸循环问题。

为什么用waitpid而不是wait?waitpid可以指定等待特定子进程,还能配合WNOHANG做非阻塞等待,以后你如果做&后台任务会用到。这里先写成阻塞等待,符合基础版需求。

3.4 内建命令:为什么必须单独伺候

内建命令的处理不需要fork,直接在Shell进程里执行函数。我列出三个核心内建命令:

cd命令:用chdir(argv[1])改变当前工作目录。如果没有参数,记得chdir(getenv("HOME")),回到用户主目录,这是bash的默认行为。注意要处理chdir失败的情况,比如cd /nonexistent,要打印错误到stderr,Shell不能崩。

exit命令:直接break出主循环,或者调用exit(EXIT_SUCCESS)结束进程。注意如果有参数exit 1,应该用exit(atoi(argv[1]))设置退出码。这虽然基础,但有些脚本执行会依赖退出码。

pwd命令:调用getcwd获取当前工作目录并打印,相当于getcwd(buf, sizeof(buf))然后printf("%s\n", buf)。

这三个命令的代码非常简单,但如果你把这些逻辑不单独实现,而是也走fork子进程去执行,就会遇到我前面说的致命问题——cd改不了Shell自己的工作目录。这是Shell设计一个很重要的分水岭:凡是会影响Shell自身状态的命令,都必须是内建命令。

3.5 进阶扩展:重定向和管道

如果基础版你已经跑通了,接下来你可能会想挑战重定向和管道。这两个功能是Linux下最常用的两个特性,也是在面试和评分里最容易加分的点。我给思路,但注意做好心理准备,这部分不需要在做基础版时一步到位。

重定向的实现思路:在fork之前(或子进程里,exec之前),用open打开目标文件,然后用dup2把文件描述符复制到STDOUT_FILENO或STDIN_FILENO,再关闭原始fd。核心代码是:

// 在子进程、exec之前 int fd = open("output.txt", O_WRONLY|O_CREAT|O_TRUNC, 0644); dup2(fd, STDOUT_FILENO); close(fd); execvp(...);

这里的顺序很关键:先dup2把fd复制给标准输出,此时标准输出就指向了文件,然后close(fd)关闭原fd就安全了,因为dup2之后有两个fd指向同一个文件表项,关掉一个还有一个。

管道的实现思路:管道用pipe(fds)创建一对fd,fds[0]是读端,fds[1]是写端。执行cmd1 | cmd2时,要创建两个子进程:

  • 把cmd1的标准输出重定向到fds[1]
  • 把cmd2的标准输入重定向到fds[0]
  • 关掉管道两端,等待两个子进程

这个逻辑比重定向复杂,核心难点在于你需要在父进程里关闭管道两端,否则会因为fd引用计数不为零导致读端读到EOF却一直阻塞,今天先理解思路,真正实现时要有耐心调试。

4. 测试与验收:怎么判断你这个Shell写好了

代码写完了,怎么证明它能用?把这个作业当成一个软件项目,你得自己设计一套测试用例。

4.1 基础功能测试清单

我建议你准备一张测试表,逐项验证:

测试项输入预期结果
简单命令ls正常输出当前目录文件
带参数命令ls -l /tmp输出/tmp下文件的详细信息
内建cdcd /tmp再执行pwd打印出/tmp
内建exitexit 3Shell退出,echo $?能看到3
多空格分割echo hello world输出hello world
空命令直接按回车无任何输出,提示符继续等待
不存在的命令nosuchcmd打印command not found,Shell继续运行
命令路径/bin/ls能正常执行,因为execvp支持含路径的命令

我当年写测试时发现一个很典型的问题:用echo hello world测试多空格,如果输出是hello world那就是对,但如果你用printf "hello %s\n" $var这种方式测试,那是bash的行为,这个Shell不需要支持变量展开。

4.2 自动化测试:把测试脚本跑起来

为了偷懒,我当时写了个简单脚本,批量输进去,对比预期输出。测试命令可以写到文件里,然后重定向你的Shell输入:

echo "ls -l" echo "cd /tmp" echo "pwd" echo "exit"

然后用./mysh < test_input.txt来看输出对不对。但要注意,重定向之后你的提示符也会被输出捕捉到,这是正常的,因为提示符写到了stderr而测试输入重定向的是stdin。这条逻辑我前文埋了个伏笔,这里就通了:提示符写stderr的另一个好处就是自动化测试时不会污染标准输出,你只需要对比stdout就能验证命令执行结果。

建议你用脚本把“预期输出”和“实际输出”做一次diff,这个虽然简陋,但是能让你快速定位回归。养成这个习惯,以后做更大的项目也有用。

5. 常见问题与排查技巧实录

这个作业我踩过不少坑,也帮不少同学排查过问题。这里整理几个出现频率最高的问题,附带排查思路,希望你能少走弯路。

5.1 提示符不显示,按回车没反应

最常见的原因:fgets把换行符\n留在了字符串里,你比较命令时strcmp(cmd, "cd")永远不相等,于是cd被当成外部命令去fork,shell看着像“卡住了”,但其实它在等待子进程结束。排查方法很简单:把读到的字符串打印出来看看,或者用strcspn去掉\n。

我见过更隐蔽的版本:用户按回车Shell就退出,原因是解析时空字符串没有正确处理,argv[0]是空串,execvp("")失败,子进程打印一条错误还退出了。解决方法是解析后判断argc == 0就直接continue。

5.2 出现大量“僵尸进程”(Zombie)

如果你打开另一个终端执行ps aux,看到一堆defunct状态的进程,说明父进程没有回收子进程。这个项目里最大的可能是你 fork 之后没有调用waitpid,或者内建命令分支忘了处理外部命令的等待逻辑。waitpid必须和fork对齐,每个子进程创建后,父进程都必须等待它结束。

顺便提一个技巧:如果你以后想做支持&后台运行的Shell,就需要给父进程注册SIGCHLD信号处理函数,用waitpid(-1, &status, WNOHANG)去回收所有结束的子进程,而不是只等特定一个。这是进阶做法,现阶段用阻塞waitpid就够了。

5.3 输入超长命令就崩了

MAX_LINE设成 1024,这个值不会有问题,但如果用户输入超过1024个字符,fgets只读取一部分,剩下的留在缓冲区里,下次循环又会被读进来,导致莫名其妙的参数。稳妥的做法是检测fgets读取的那行末尾是否还是\n,如果没有换行符,说明缓冲区满了,继续循环清空,或者直接报错。这个边界情况课程测试不一定会覆盖,但养成处理它的意识是好的。

5.4 Ctrl+C 把Shell也杀了

Ctrl+C 会向整个前台进程组发送SIGINT信号,你的Shell和它fork出来的子进程都在同一个进程组,所以按 Ctrl+C 时,你的Shell和正在执行的命令都会收到信号。bash的做法是让Shell忽略SIGINT,只让前台子进程负责处理。实现方式是:

signal(SIGINT, SIG_IGN);

不过这个是进阶话题,你基础版可以先不管,但你要知道:直接用默认行为,Ctrl+C 会把你的Shell和子进程一起终止。如果你做完基础版还有余力,把SIGINT忽略加进去,体验会好很多。这里注意你的Shell不要自己退出,而是要等子进程结束后继续循环。

5.5 常见问题速查表

现象可能原因排查方向
命令执行后多出一行错误exec失败后没有exit,子进程沿父路径继续跑检查子进程分支最后是否有exit
所有命令都报 not foundexecvp 查找不到,可能你忘了让参数数组以NULL结尾打印argv检查最后一个元素是否为NULL
cd 没有效果cd 用fork子进程执行了检查内建命令是否在fork之前处理
提示符跑到重定向文件里提示符写到了stdout改成写stderr
输出没有换行argv[0]里带着\n检查是否做过换行符清理
按回车Shell就退出空命令没有跳过解析后判断argc==0

6. 我做完这个项目后的几点体会

第一,这个作业花了我最长时间的不是写代码,而是理解“父进程到底在干什么”。一开始我总觉得Shell是个高高在上的程序,后来发现自己写一遍才发现,它就是个典型的“队长角色”:负责接收指令、组建小分队(fork)、发任务(exec)、等队员归队(waitpid)。你对这个流程理解得越透,后面学多线程、学网络编程里的多进程模型都会顺手很多。

第二,调试思路比代码本身重要。我建议你在开发时多打印、多观察ps aux和pwd的变化。比如怀疑fork出了问题,就在分为前打印一条标记,在子进程里也打印一条,看执行顺序就能定位问题。千万别蒙头改代码,先用信息确认你的假设。

第三,这个项目最大的隐藏价值在于:等你写完,再看bash那些“自动补全”“历史记录”“作业控制”,你会觉得它不仅仅是个工具,而是一个精心设计的程序。再看Linux系统调用文档时,你对fork、exec、waitpid的理解就不再是死记硬背了。

如果你学完这个还想继续深挖,我建议你下一步去实现“多个管道串联”,或者给Shell加上简单的引号处理和通配符展开。这些改动有一半是字符串处理,另一半还是进程管理,但你会发现自己对Shell的理解又会上一层。这个作业做完以后留着,等以后学了信号处理再回头看,会发现它其实是一扇门,门后是Linux系统编程的一大片后花园。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询