☰
用C语言从零实现一个简易shell:进程管理、管道与重定向实战
2026/10/3 14:39:46 网站建设 项目流程

先交代一下背景。很多学校在操作系统、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的原理是一样的。我建议按四步走:

  1. 打印提示符,读取用户输入的一行命令。
  2. 把这一行按空格拆成若干段,第一段是命令名,后面是参数列表。
  3. 判断这个命令是内置命令还是外部命令。
  4. 如果是外部命令,就用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$。但如果你想做得好看一点,可以在提示符里带上当前用户名和当前目录,比如user@host:/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; // 忽略Ctrl+C信号,防止用户按Ctrl+C把整个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时说明输入流已经结束,比如用户按了Ctrl+D,或者程序从文件里读命令读到文件尾。此时应该优雅退出,而不是进入死循环。我代码里在fgets返回NULL时打印一个换行再break,这样终端输出不会粘在一起。

另一个健壮性细节是忽略SIGINT信号。默认情况下,用户按Ctrl+C会把整个前台进程组都杀掉,包括你的shell。但真实bash的行为是:Ctrl+C只中断当前正在运行的子命令,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被当成命令执行没有处理重定向在子进程里用open+dup2
Ctrl+C把shell一起杀了没忽略SIGINT主循环前signal(SIGINT, SIG_IGN)
用strtok处理getenv("PATH")后程序崩溃strtok修改了静态字符串先用strdup()复制再split

5.4 答辩时老师最爱追问的几个问题

最后顺带聊一下答辩准备。这道题的作业往往要当着助教或老师的面上交、演示、回答几个问题。最常被问的其实是下面几个:

  • fork()之后父子进程分别怎么知道自己在哪个分支里?——通过返回值,父进程拿到大于0的子进程PID,子进程拿到0。
  • exec和system有什么区别?——exec是替换当前进程映像,system是启动一个/bin/sh来执行命令,中间多了一层shell解释。
  • waitpid的第三个参数0是什么意思?——阻塞等待,直到指定子进程结束;换成WNOHANG立即返回。
  • 子进程退出之后为什么必须先被父进程wait?——僵尸进程占着进程表项,不去回收的话父进程反复fork可能耗尽系统进程资源。
  • 如果子进程退出码是137,说明什么?——137等于128+9,一般是被SIGKILL杀掉了,常见于内存超限或者外部强行杀掉进程。

这些问题其实都是在验证你是否真正理解了进程模型。如果你能结合自己代码里的具体行号来回答,基本就稳了。

我个人在实际操作中的体会是,这道题最值得花时间的部分不是把代码跑到“能交差”,而是亲手把管道和重定向逐步调试通。你debug的次数越多,对文件描述符、阻塞等待、子进程生命周期这些概念的理解就越牢固。等写完这个简单shell,再回头看bash的手册文档,你会发现以前看不懂的很多概念——比如为什么某些命令必须内建、为什么管道符要在shell层解析——突然就全部对上了。如果后面还有精力,我建议你再给它加一个历史记录功能,或者支持一下环境变量赋值,这些扩展会让你的shell在功能和代码结构上又上一个台阶,而且依然是纯C能搞定的范围。

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

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

立即咨询