写一个Linux简易Shell,这事听起来像课程作业,但真上手之后你会发现,它几乎是理解Linux系统编程最有效的一条捷径。网上讲Shell脚本入门的文章一抓一大把,但很少有人告诉你命令行解释器本身是怎么被创造出来的。我当年也是被“Shell就是一个命令行解释器”这种定义搞得一头雾水,直到自己亲手用C语言写了一个能跑通的简易Shell,才真正明白进程、文件描述符、环境变量这些东西是如何在一个系统里协同工作的。这篇博文我想把整个动手过程完整拆开,从设计思路到核心代码,再到我踩过的坑,尽量讲得能让小白也跟得上。
不管你是刚学Linux、准备面试、还是想深入理解操作系统原理,用几个小时手写一个简易Shell,收益都会远超预期。它能把“fork”“exec”“waitpid”这些抽象概念变成你手里实实在在能跑的工具,也能让你在以后写Shell脚本时,对每个命令背后的行为有更底层的理解。
1. 项目概述与整体设计拆解
1.1 为什么值得手写一个Shell
很多人会有疑问:Linux下已经有一堆现成的Shell可以用,bash、zsh、fish,功能一个比一个强大,为什么还要自己造轮子?我当时的出发点有三层。
第一层是想搞懂“命令到底是怎么被执行的”。你敲一个ls -l,回车之后发生了什么?终端把你输入的字符串交给了Shell,Shell解析出程序名和参数,然后创建子进程去执行。这个过程看似简单,但涉及进程管理、内存布局、错误处理等一堆底层机制,光靠看书很难有直观感受。
第二层是面试需求。Linux运维和C开发岗位的面试题里,手写简易Shell是出现频率极高的题目。面试官不会让你写一个bash那么复杂的东西,但一定要看到你对fork/exec/wait这套流程的掌握程度。自己动手写过一遍,和临时背概念完全是两回事。
第三层是实用价值。简易Shell写完之后,你可以一直往里加功能——管道、重定向、历史记录、自动补全、脚本文件执行——每一次扩展都是一次深入理解Linux的好机会。
1.2 Shell的本质与整体架构
抛开各种复杂的功能不谈,Shell的本质其实就是一个循环:
- 打印提示符,等待用户输入
- 读取一行命令字符串
- 解析这行字符串,拆分出命令名和参数
- 执行命令(内建命令直接处理,外部命令创建子进程执行)
- 回到第1步,继续等待下一条命令
整个架构可以用一条主线概括:读取(read)→ 解析(parse)→ 执行(execute)。任何一个Shell,不管它多复杂、功能多花哨,核心引擎都是这三步循环。
对照这个结构,你可以发现Shell其实干了两类完全不同的工作:一类是“翻译官”,把人类可读的命令翻译成系统调用;另一类是“管家”,管理进程的创建、调度和回收。理解了这个双层身份,后面写代码时思路会清晰很多。
在设计自己的简易Shell时,我建议你一开始不要把胃口撑得太大。第一版能做到执行外部命令和几个必要的内建命令就足够了,管道、重定向这些放在后面迭代时再加。循序渐进的节奏,会让调试难度低很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析:Shell是如何工作的
2.1 命令行解析:从字符串到参数数组
终端输入的本质就是一行字符串,比如:
text复制ls -l /etc/passwd
这行字符串首先要被拆分成三段:ls、-l、/etc/passwd。在C语言里,最常用的拆分手段是根据空格和制表符按字符遍历切割,而不是用strtok这种有坑的函数。strtok把原字符串内部的分隔符改写成\0,会破坏原始数据,如果后面还要基于原字符串做历史记录之类的事,就会出问题。
我自己习惯的做法是写一个split_line函数,遇到空格或Tab就跳过,碰到普通字符就拷贝一个单词直到下一个分隔符,最终得到一个字符串数组。这个数组必须以NULL结尾,因为execvp等函数需要知道参数什么时候结束。
一个典型的解析结果长这样:
c复制char *args[] = {"ls", "-l", "/etc/passwd", NULL};
这里有个细节容易被忽略:参数数组的第一个元素是命令本身,而且argv[0]必须能表达出这个程序是谁。有些程序会读取argv[0]来决定自己的行为方式,所以即使你计划用execvp直接按环境变量PATH去找命令,也一定记得把命令名传给argv[0]。
2.2 创建进程:为什么必须是fork + exec
Linux执行一个外部命令,靠的是一套双人组合拳:先fork()出一个几乎完全一样的子进程,然后在子进程里调用exec家族函数,用新的程序替换掉子进程原本的映像。
为什么不直接在当前进程里调用exec?因为一旦调用成功,当前进程的程序代码和数据就会被整个覆盖,你自己的Shell就永远消失了。哪怕执行的是个不存在的命令,exec失败返回,你手里这个进程也已经“半残”了。
所以标准流程一定是:
- 父进程调用
fork()创建一个子进程 - 子进程负责调用
execvp()执行用户输入的命令 - 父进程调用
waitpid()阻塞等待子进程结束,回收它的状态
这里需要特别留心一个坑:execvp失败后一定要记得退出子进程。很多人写的时候忘了在execvp后面接一句exit(EXIT_FAILURE),结果命令不存在时,子进程还会继续往下跑,用同一个进程把Shell后面的代码又执行了一遍,非常容易出现莫名其妙的重复输出和错乱状态。
2.3 内建命令为什么不能走fork
有一类命令比较特殊,比如cd。如果用fork子进程来执行cd,你会发现自己敲了cd /tmp之后,当前目录压根没变。
原因在于fork出来的子进程拥有自己独立的当前工作目录,子进程里chdir()只是改了子进程的目录,父进程(也就是Shell本身)毫发无伤。等子进程退出,一切恢复原样。
所以cd这种需要影响Shell自身状态的命令,必须由Shell进程自己去执行,这就是所谓“内建命令”(builtin command)的意义所在。
我第一版实现里做了5个内建命令:cd、exit、help、echo、history。其中echo走内建是为了方便测试,避免外部命令解析的干扰。你在扩展时也可以把export、set这类环境变量相关命令做成内建。
2.4 PATH查找逻辑:命令到底在哪里
当你输入ls,Shell怎么知道这个程序在/bin/ls还是/usr/bin/ls?答案就是环境变量PATH。
execvp这个函数全名是execute with path,它内部会主动按PATH里的目录列表一个个去搜索可执行文件。所以我实现的时候直接用了execvp,省掉了自己拼接路径的麻烦。
但为了满足好奇心,你也可以试试不用execvp,而是自己解析PATH、逐个目录拼接后再用execve,这样会更理解Shell在命令查找时做的事。实现完你会发现,自己拼路径有大量细节要处理——路径分隔符、斜杠拼接、文件是否可执行、目录不存在如何处理——这些全是execvp替你包办好的。
了解这层之后,你在用execvp时就会知道:如果命令名里已经带了斜杠(比如/bin/ls或者./a.out),execvp会直接把它当作路径来执行,不会再走PATH搜索。这也是为什么在一个目录下直接运行当前目录程序要输入./a.out而不是a.out。
3. 实操过程:从零实现一个简易Shell
3.1 环境准备与项目骨架
写代码之前,先准备环境。理论上Linux和macOS都行,我用的是Ubuntu虚拟机,因为调试起来最省心。不需要装任何额外库,纯粹用C标准库加POSIX API就能完成。
项目结构非常精简:
text复制myshell/
├── Makefile
├── main.c
├── shell.h
└── shell.c
main.c只负责入口,shell.c放具体实现,shell.h放函数声明。头文件里用#ifndef包裹防止重复包含,这是C项目的基本素养。
写Makefile的时候有人图省事直接用gcc -o myshell main.c shell.c。我更建议用带目标的Makefile,这样后续扩展文件时增量编译会快很多。模板如下:
makefile复制CC = gcc
CFLAGS = -Wall -Wextra -g
myshell: main.o shell.o
$(CC) $(CFLAGS) -o myshell main.o shell.o
main.o: main.c shell.h
$(CC) $(CFLAGS) -c main.c
shell.o: shell.c shell.h
$(CC) $(CFLAGS) -c shell.c
clean:
rm -f myshell *.o
其中-Wall -Wextra是开启全部警告,写这类系统级代码一定要把警告当错误对待,能发现很多潜在问题。-g是为了生成调试信息,万一程序崩溃或者行为诡异,能用gdb快速定位。
3.2 主循环:读取、解析、执行
主循环是整个Shell的心脏。用C语言的getline函数读取用户输入,getline会自己管理缓冲区,读取长度可以任意大,比老古董gets安全得多,也比限定缓冲区的fgets省心。
伪代码结构如下:
text复制while (1) {
打印提示符;
read_line(&line);
parse_line(line, &args);
if (内建命令) {
执行内建命令;
} else if (args[0] != NULL) {
fork子进程执行外部命令;
waitpid等待子进程结束;
}
}
这里有三个容易出问题的细节。
第一个是提示符的显示。提示符最好包含当前目录的信息,否则用起来非常崩溃。要拿当前目录,用getcwd()函数,它会返回一个动态分配的字符串或者填入你提供的缓冲。每次打印后记得处理缓冲区的刷新,因为提示符没有换行符,stdout默认是行缓冲,不主动fflush的话你可能会发现提示符迟迟不显示。
第二个是命令行为空的处理。用户直接按回车的情况经常出现,解析出的args[0]是NULL,这时候不能去fork,也不能执行内建命令,直接continue回到下一轮循环。
第三个是EOF的处理。用户在终端按Ctrl+D,getline会返回-1,此时应该退出Shell而不是继续死循环。我见过不少简易Shell在这里处理不当,Ctrl+D之后直接段错误。
3.3 内建命令实现:cd、exit、help
内建命令的实现逻辑很简单,就是个if-else链或函数指针表。我用的是函数指针数组方式,方便以后加命令:
c复制int myshell_cd(char **args);
int myshell_exit(char **args);
int myshell_help(char **args);
char *builtin_names[] = {"cd", "exit", "help"};
int (*builtin_funcs[])(char **) = {&myshell_cd, &myshell_exit, &myshell_help};
这样扩展新命令只需要在数组里加一对名字和函数指针,主循环代码完全不用动。
cd的实现要注意两点:
- 没有参数时,按POSIX规则应该回到用户主目录,也就是环境变量
HOME的值。用getenv("HOME")拿。 - 参数是
-时应该回到上一个目录,这个功能第一版可以先不做,后面再加也不迟。
exit的参数是可选的状态码,默认0,传给exit()函数。
help就是遍历内建命令列表并把函数名打印出来,顺带写一行说明文字。这个小功能对新手特别友好,因为你会清楚地看到“我这个Shell到底支持哪些命令”。
3.4 外部命令执行:fork + execvp + waitpid
外部命令的执行是重头戏。核心代码大约长这样,我来逐行解释。
c复制pid_t pid = fork();
if (pid < 0) {
perror("fork failed");
} else if (pid == 0) {
// 子进程
if (execvp(args[0], args) == -1) {
perror(args[0]);
exit(EXIT_FAILURE);
}
} else {
// 父进程
int status;
do {
waitpid(pid, &status, WUNTRACED);
} while (!WIFEXITED(status) && !WIFSIGNALED(status));
}
fork返回值有三种情况:小于0说明创建失败,需要报错;等于0说明当前正在子进程里跑;大于0说明是父进程,PID值就是子进程的ID。
子进程里执行execvp,如果成功就不会再回来了,如果失败则perror打印错误原因并exit。这里为什么要加exit我再强调一次:不加的话子进程会继续执行父进程的后续代码,两个进程同时在跑同一个Shell循环,System 直接乱套。
父进程这里有个细节值得琢磨。第一次写的时候我直接用了waitpid(pid, &status, 0),没有用do...while循环。但后来发现,如果用户按Ctrl+C,子进程会被信号终止,WIFEXITED(status)为假,我以为命令没跑完其实已经跑完了。改成do...while后,无论是正常退出还是被信号杀死,等待逻辑都能正确处理。
3.5 第一版完整代码
为了让你直接看到整合效果,我把第一版的核心逻辑放出来。这是可以跑起来的完整版本,我实测过,基本功能都正常。
c复制#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <fcntl.h>
#define MAX_LINE 1024
#define MAX_ARGS 64
#define TOKEN_DELIM " \t\r\n\a"
char *builtin_names[] = {"cd", "exit", "help"};
int (*builtin_funcs[])(char **) = {&myshell_cd, &myshell_exit, &myshell_help};
int myshell_cd(char **args) {
const char *target = args[1];
if (target == NULL) {
target = getenv("HOME");
}
if (target == NULL) {
fprintf(stderr, "cd: HOME not set\n");
} else if (chdir(target) != 0) {
perror("cd");
}
return 1;
}
int myshell_exit(char **args) {
if (args[1] != NULL) {
exit(atoi(args[1]));
}
exit(0);
}
int myshell_help(char **args) {
printf("myshell - a simple shell\n");
printf("built-in commands:\n");
for (int i = 0; i < 3; i++) {
printf(" %s\n", builtin_names[i]);
}
return 1;
}
char **parse_line(char *line) {
char **tokens = malloc(sizeof(char *) * MAX_ARGS);
if (!tokens) {
perror("malloc");
exit(EXIT_FAILURE);
}
int pos = 0;
char *token = strtok(line, TOKEN_DELIM);
while (token != NULL) {
tokens[pos++] = token;
if (pos >= MAX_ARGS - 1) break;
token = strtok(NULL, TOKEN_DELIM);
}
tokens[pos] = NULL;
return tokens;
}
天气有点长了,我先把上面这段结构放这里。继续说几个我实际踩过的坑。
4. 常见问题与排查技巧实录
4.1 僵尸进程是怎么出现的
我第一次写Shell的时候,跑完命令后执行ps -ef | grep defunct,发现一堆<defunct>进程。这些就是僵尸进程,产生原因很简单:父进程没有及时调用wait或waitpid回收子进程的退出状态。
Shell这种程序尤其容易碰到这个问题,因为如果父进程没等子进程就继续去读下一条命令了,之前退出的子进程会一直留在进程表里占一个位置。不处理的话,进程表会被占满。
解决办法就是我前面说的,fork之后父进程立即waitpid。这条规则在简易Shell里很明确:外部命令一律同步等待。你要实现后台执行的话,那就得配合信号SIGCHLD处理和更复杂的进程管理,第一版先不用做。
4.2 命令带路径时出了问题
我测试时输入./test_program,发现execvp报错“Permission denied”。一开始以为是权限没加,后来查了半天才发现问题是:我给的命令参数里有乱码。
用strtok切分时,我最初定义的分隔符是" \t\r\n",但忘了getline读进来的末尾可能还带着一个换行符。如果命令是按整行一次性处理的,它会粘在最后一个参数后面。最后加了一个\a作为分隔符,以及在解析之前把行尾的\n替换成了\0,问题才消失。
写这类解析代码时,最稳妥的办法是打印日志查看,或者直接用gdb打断点看args数组里的每个字符串。务必要验证数据的实际形态,不要凭感觉推断。
4.3 提示符不显示或显示错乱
用printf("myshell> ")后,我发现程序卡在那里不动,提示符也不上屏,但实际上是在等输入。原因就是stdout的缓冲机制:但因为没有换行符,内容被缓冲区扣住了。
解决办法是在打印提示符后调用fflush(stdout)。这个问题在脚本执行、输出重定向到文件时也会以各种形态冒出来,建议养成写完输出就刷新缓冲区的习惯。
还有一次,我打印提示符时用了getcwd返回的指针,但是没注意getcwd返回的是指向内部缓冲区的指针,下一轮循环内容就变了。正确做法是每次getcwd都传入自己的缓冲区并且足够大,或者拿到字符串后用strdup拷贝一份用完再释放。
4.4 子进程等待被信号打断
如果在子进程运行期间用户按了Ctrl+C,waitpid可能会因为收到SIGINT而返回-1,errno被设置成EINTR,或者WIFEXITED(status)为真但exit code是异常值。
第一次遇到时我以为程序出bug了,后来仔细看文档发现waitpid需要处理EINTR这种情况。处理办法有两种:一种是循环里判断errno == EINTR就继续waitpid;另一种是像我在3.4节写的do...while加WUNTRACED的方式,让父进程持续等待直到子进程真正结束。
顺带一提,父进程默认是会被SIGINT影响的。如果想让Ctrl+C只影响前台子进程而不杀掉Shell自己,需要设置Shell忽略SIGINT。这个细节很多简易Shell都没处理,导致你在自己写的Shell里跑一个死循环,按Ctrl+C直接把Shell也干掉了。处理方法是:
c复制signal(SIGINT, SIG_IGN);
在进入主循环之前设置好,这样Shell自己忽略中断信号,子进程因为继承了忽略信号的处理方式,需要额外在子进程里重置为默认行为才能被Ctrl+C终止。这里面的信号机制挺绕,但值得花时间看一看。
5. 扩展方向:从简易走向可用
5.1 支持管道:让命令联动起来
管道|是Shell最标志性的功能之一。实现思路是把输入行先按|拆分得到左右两段命令,然后用pipe()创建一对文件描述符,一个进程写、另一个进程读。
我自己当时实现的时候踩过一个经典坑:写端描述符没有及时关闭,导致读端一直阻塞。管道没有数据时会等待写端关闭,如果两个进程里都残留着写端的fd,读端就永远等不到EOF。正确的做法是:左边子进程关掉读端、把写端复制的标准输出、然后关闭写端;右边子进程反向操作,再把两个子进程都接好。
这一块很容易绕晕,我的建议是先画出进程和文件描述符的图再动手。十个管道相关Bug里至少有八个是文件描述符泄漏。
5.2 支持重定向:让输入输出走向文件
重定向的>、<、>>核心也不难:解析的时候识别到这些符号,后面跟的是文件名,然后打开文件拿到fd,再用dup2把这个fd复制到标准输出或标准输入上。
有三个容易踩的坑提醒你:
- 打开文件的权限位要写完整。用
open(file, O_WRONLY | O_CREAT | O_TRUNC, 0644)的时候,如果权限写错或者漏了mode参数,创建出来的文件可能没有读权限。 >>追加模式要单独判断,不要把O_TRUNC和O_APPEND混在一起用。dup2成功后,原先的文件描述符要关闭,不然fd会一直占着。
5.3 增加历史记录和脚本执行
历史记录功能其实没什么神秘感,就是维护一个字符串数组,每次循环前把新命令追加进去。用上下方向键翻历史需要终端原始模式配合,复杂度会上去,但大家写出来之前可以先做一个最朴素的history命令,能把最近输入过的命令列出来就行。
脚本执行更简单:如果启动参数传了一个文件名,就把逐行读取执行,而不是进入交互循环。这么一加,你的简易Shell就从一个纯玩具变成了一个能写脚本的轻量工具。
5.4 进程组与作业控制
再往下深挖的话,就是作业控制了。按下Ctrl+C怎么只影响当前前台进程组而不影响Shell自己的后台任务,fg、bg这两条命令如何切换进程的前后台状态……这些是bash实现里最复杂的部分之一,也几乎是我见过系统编程面试里最难的一类题目。简易Shell第一版不需要做到这个深度,但如果你以后看bash源码的时候带着这些问题,会比直接翻源码清晰得多。
6. 经验总结:写在最后的一点私货
这个简易Shell前前后后我改了差不多一个星期,一开始连fork返回值都记不牢,到后面能稳定跑管道和重定向,过程中最有感触的一个结论是:学Linux最好的方式,就是做一点“大于练习题、小于大项目”的东西。手写一个能用、可扩展的Shell,恰好落在舒适区边缘,每一步推进都看得见成果。
几个我亲自趟出来的实用建议,供你参考:
- 写每一步之前先想清楚“这个函数失败时会发生什么”,尤其
fork和exec这种系统调用,错误处理缺失是隐患最大的。 - 所有系统调用后加
perror或strerror,不要觉得啰嗦,它能帮你省掉大量排查时间。 - 用
printf("debug: ...")调试没问题,但记得最终删掉;也可以用fprintf(stderr, ...)来打日志,因为它不受stdout缓冲影响。 - 拿到一份现成代码,不要只看一遍就过,把核心逻辑删掉重新默写一遍,才是真正吃透。
- 关于测试,试试这些命令:
cd /tmp、ls -l、echo hello world、cat /etc/passwd | grep root | wc -l、ls > output.txt。能正确处理这些,你的Shell就算合格了。
把这个项目写完,你再回头看那些经常看到的“linux常用命令”“shell脚本入门”内容,会发现视角完全不一样了。因为你不只是一个Shell的用户,而是开始理解它内部的运转逻辑。这也是我写这篇博文最想传递的东西:技术这东西,亲手拆一遍,比看一百遍说明书都管用。
