我不是那种喜欢把“自己写一个Shell”挂在嘴边的人,但这个项目确实让我对Linux系统编程的理解上了一个台阶。如果你已经学完进程、文件描述符、fork/exec这些基础API,正愁没有项目把它们串起来,那自己动手实现一个Shell就是最经典的综合性训练。它不依赖任何第三方库,直接从终端读取命令、解析、创建子进程、维护环境变量、实现内建命令,一套流程下来,你对进程模型的理解会变得非常具象。
这篇文章会完整拆解我实现Shell的整个过程:从最核心的命令解析器怎么写,到环境变量如何跨进程传递,再到cd、exit、export这些内建命令为什么必须由Shell自己处理而不是fork子进程,最后还会给出完整的实操路径和避坑清单。适合已经会写C语言、了解进程API、但还没有做过完整项目的读者。
1. 整体设计与架构思路:先想清楚Shell到底干了什么事
在写第一行代码之前,我花了比较多的时间在思考一个问题上:Shell的本质是什么?这个问题想清楚了,后面写代码基本就是顺水推舟的事。
Shell本质上是一个交互式命令循环:打印提示符、读取用户输入、解析命令、执行命令、等待结束、再打印提示符。这个循环的骨架用伪代码描述就是:
while (1) { print_prompt(); // 打印提示符 read_command(); // 读取一行输入 parse_command(); // 解析成命令和参数 execute_command(); // 执行命令 }这个主循环看似简单,但每一步都有大量的细节。我在设计时把整个项目分成了四个模块:
| 模块 | 职责 | 关键函数 |
|---|---|---|
| 解析器 | 把输入字符串拆成命令名和参数数组 | parse_line() |
| 环境管理 | 维护环境变量的存取和传递 | set_env()/get_env() |
| 内建命令 | 实现必须由Shell自身完成的命令 | cd/exit/export/env |
| 执行器 | 创建子进程并加载外部程序 | fork()+execvp() |
这样分层的核心原因是:每一层只做一件事,层与层之间通过简单的数据结构接口通信。比如解析器只负责把字符串变成char *argv[]和标志位,它不关心这个命令是内建的还是外部的;执行器只负责fork和exec,它不关心命令参数是怎么来的。
在动手写代码时,我给自己定了一个原则:能用C标准库解决的就不要自己造轮子,但标准库不覆盖的核心机制必须自己实现。比如读取输入用getline(),切分字符串用strtok(),但环境变量的跨进程传递就必须自己理解environ这个全局变量的机制,无法绕开。
1.1 为什么选择C而不是Python或Shell脚本本身
这个问题我相信很多人会问。既然Python那么方便,为什么非要用C来实现一个Shell?我的回答是:Shell本身就是C写的,用C实现Shell是系统编程的自我指涉,能让你看到工具背后的机制。用Python写Shell当然快,但你学不到进程管理、文件描述符继承、信号处理这些底层细节。
另外,C语言在表达进程模型时有一种天然的清晰感。fork()之后的父子进程关系,exec系列函数的参数传递方式,waitpid()的状态获取——这些API本身就是为构建这种系统软件而设计的。用C写一遍,这些API在你的脑子里就不再是孤立的知识点了,而是一个互相配合的完整体系。
1.2 功能范围与迭代节奏
我没有一上来就追求完整复刻 bash,而是分了三步走:
- 第一版:只支持外部命令执行,即
fork+execvp+waitpid,能够运行ls、ps、cat这类程序就算成功。 - 第二版:加入内建命令
cd、exit、export、env,让Shell具备基本的会话管理能力。 - 第三版:补充
$VAR展开、注释处理、空命令忽略等细节,提升健壮性。
这个节奏的好处是:每一步都有一个可运行、可测试的里程碑,而不是攒了一个巨大的代码量再来调试。我强烈建议你也按这个节奏来,别想一口吃成胖子。
2. 命令解析器:从一行字符串到结构化命令
命令解析是整个Shell的入口,也是第一个容易出bug的地方。我在这里踩过不少坑,值得单独拎出来讲清楚。
2.1 读取输入:用getline而非fgets
读取用户输入时,很多人习惯用fgets(),但fgets()存在缓冲区长度固定的问题:如果输入行超过缓冲区长度,会被截断,且残留字符会留在标准输入中污染后续读取。我在第一版就吃过这个亏,输入长命令时总是静默丢字符。
推荐使用POSIX的getline(),它内部会动态分配足够大的缓冲区:
char *line = NULL; size_t bufsize = 0; ssize_t nread; printf("mysh$ "); nread = getline(&line, &bufsize, stdin); if (nread == -1) { // EOF或出错 exit(0); }getline()会返回读取的字符数,并且自动在末尾加\0。注意它不会去掉末尾的换行符,要去掉的话需要自己处理:
if (line[nread - 1] == '\n') { line[nread - 1] = '\0'; }2.2 分词:strtok的陷阱与替代方案
分词就是把ls -la /home变成{"ls", "-la", "/home", NULL}。最直接的方式是strtok(),但它有一个大坑:它会修改原字符串,把分隔符替换成\0。这意味着如果后续还需要原始输入做别的处理,必须在分词前拷贝一份。
另外strtok()不是线程安全的,它内部维护了静态指针。虽然Shell是单线程的,但这是一个编码习惯问题,我最终用strtok_r()来保证可重入性:
char *parse_line(char *line, char **argv, int max_args) { char *token; char *saveptr; int argc = 0; token = strtok_r(line, " \t\r\n", &saveptr); while (token != NULL && argc < max_args - 1) { argv[argc++] = token; token = strtok_r(NULL, " \t\r\n", &saveptr); } argv[argc] = NULL; // execvp需要NULL结尾 return (saveptr); // 返回剩余内容,供后续展开 }这里max_args建议设成64,因为单个命令的参数数量一般不会超过这个值。argv[argc] = NULL非常关键,execvp()要求参数数组以NULL结尾,否则它不知道参数在哪结束,会一直读到内存非法区域。
2.3 变量展开:$VAR是怎么变成值的
用户输入echo $HOME时,Shell要把$HOME替换成/root或/home/username。这一步发生在分词之后、执行之前。
我实现了一个expand_vars()函数,遍历参数中的每个字符串,遇到$开头或包含$的片段就尝试从环境变量表中查找:
char *expand_vars(const char *str) { char *result = malloc(strlen(str) + 1); int i = 0, j = 0; while (str[i] != '\0') { if (str[i] == '$' && str[i+1] != '\0') { // 提取变量名(直到非字母数字字符) int start = ++i; while (isalnum(str[i]) || str[i] == '_') { i++; } char varname[128]; strncpy(varname, str + start, i - start); varname[i - start] = '\0'; const char *val = get_env(varname); if (val != NULL) { strcpy(result + j, val); j += strlen(val); } // 如果变量不存在,展开为空串 } else { result[j++] = str[i++]; } } result[j] = '\0'; return result; }这个实现有几个边界情况要注意:$?这种特殊变量(上一条命令的退出码)没有处理,$HOME$PATH这种连续变量展开需要确保内部指针移动正确。我在测试时专门用了一组极端用例:echo $$、echo $HOME$PATH、echo $,确保不会越界读或死循环。
2.4 注释与空命令的处理
真实Shell里#开头的行是注释,不需要执行。这个逻辑实现起来很简单:在分词前检查第一个非空白字符是不是#,是则直接跳过。
空命令处理则指:用户直接敲回车、或者只输入空格、或者输入#注释时,Shell不应该报错,而应该继续循环。很多初学者会在这里if-else写成一团乱麻,我的做法是:解析函数返回参数量argc,argc == 0时直接continue,不进入执行阶段。
3. 环境变量管理:Shell与子进程之间的“传家宝”
环境变量是Shell里最微妙的机制之一。你在Shell里执行export FOO=bar,之后启动的程序里就能拿到FOO的值。这背后靠的是父子进程之间的环境继承机制。
3.1 理解environ全局变量
在C语言中,环境变量表其实是一个char **类型的全局变量,名字叫environ。它是NULL结尾的字符串数组,每个元素形如"NAME=value"。你可以在自己的代码里直接声明extern char **environ;来访问它。
当fork()创建子进程时,子进程会完整复制一份父进程的环境变量表(内存副本,不是共享)。之后调用exec系列函数时,新程序的main函数会通过第三个参数char *envp[]收到这份环境表,libc 再用它初始化environ。
这就解释了为什么Shell里export的变量能传递给子进程,而普通Shell变量(不加export)不行:只有出现在environ里的变量才会被exec机制传递给新程序。这个认知是整个环境变量管理的基石。
3.2 自己维护环境表:动态数组方案
我一开始直接用setenv()和getenv()这两个标准库函数来管理环境变量。但项目做到内建命令export时遇到了一个问题:export需要列出当前所有环境变量,并且要区分哪些变量还没有值。标准库没有直接提供这种遍历能力(虽然可以遍历environ)。
最终我选择自己维护一张环境变量表:用一个动态数组保存变量名和值,提供get_env()、set_env()、unset_env()三个核心操作。数据结构很简单:
typedef struct { char *name; char *value; } env_entry; static env_entry *env_table = NULL; static int env_count = 0; static int env_capacity = 0;实现set_env()时,先查找是否已存在同名变量,存在则更新值(注意释放旧值的内存),不存在则追加新条目。这里有个内存管理的细节:值更新时,旧值必须free(),否则会内存泄漏。用valgrind跑一遍就能抓到这类问题。
3.3 初始化:Shell进程启动时继承父环境
Shell启动时,需要把继承自父进程的环境变量导入自己的环境表。这一步实现起来很简单——遍历environ,逐条解析NAME=value格式,插入到环境表中:
void init_env(void) { extern char **environ; for (int i = 0; environ[i] != NULL; i++) { char *eq = strchr(environ[i], '='); if (eq == NULL) continue; int name_len = eq - environ[i]; char *name = malloc(name_len + 1); strncpy(name, environ[i], name_len); name[name_len] = '\0'; set_env(name, eq + 1); free(name); } }这样,当你启动自定义Shell时,PATH、HOME、USER等系统环境变量就都在Shell的环境表里了,外部命令才能通过PATH被找到。
3.4 环境变量在fork/exec过程中的生命周期
理解环境变量的跨进程传递,不能只停留在代码层面。我在调试时曾在子进程里打印环境变量,发现莫名其妙少了几个变量,排查了很久才发现是两个问题:一是子进程环境表继承的是fork时刻的父进程环境快照,之后父进程再setenv不会影响已存在的子进程;二是我在exec时手动构造了envp参数,把environ漏掉了。
实际上在写Shell时,最简单的做法是:让子进程直接继承父进程(即Shell)的环境表。父进程调用execvp()时,如果没有显式传envp,系统会使用全局的environ。所以我只需要确保Shell内部的环境表和系统environ保持同步。
这里我踩了一个大坑:我只维护了自己的env_table,没有同步更新系统的environ,结果子进程里getenv("MY_VAR")返回NULL。排查了半天才意识到,execvp用的是environ,不是我自定义的表。解决方案是:每次更新自己的env_table后,同步调用setenv(name, value, 1)更新系统环境表,双写保证一致。
4. 内建命令:为什么cd和exit必须由Shell自己处理
初学Shell时最容易困惑的一个问题是:为什么cd、exit这些命令不是外部程序?看起来它们也是命令啊,为什么Shell要单独处理?
4.1 工作原理差异:子进程无法改变父进程的状态
假设cd是一个外部程序,那么Shell需要fork一个子进程去执行它。子进程执行chdir("/tmp")只是改变了子进程自己的当前工作目录,对父进程(Shell)毫无影响。fork的子进程是父进程的一份内存副本,文件系统上下文(当前工作目录、umask、文件描述符表)也是复制的,子进程的修改不会回传给父进程。
exit的外部程序化就更滑稽了:子进程执行退出,退出的只是子进程自己,Shell还在那里傻等着。所以exit必须由Shell直接调用exit()终止自身。
用一张表总结:
| 命令 | 如果用外部程序实现 | 实际行为 |
|---|---|---|
cd | 子进程cwd改变,Shell cwd不变 | Shell自己chdir() |
exit | 子进程退出,Shell存活 | Shell自己调用exit() |
export | 子进程环境改变,Shell环境不变 | Shell自己更新环境表 |
unset | 同上 | Shell自己删除环境表条目 |
4.2 cd命令的实现与错误处理
cd的本质是调用chdir()系统调用。命令格式是cd [目录],不带参数时回到HOME目录。实现如下:
int builtin_cd(char **args) { const char *path; if (args[1] == NULL) { path = get_env("HOME"); if (path == NULL) { fprintf(stderr, "cd: HOME not set\n"); return EXIT_FAILURE; } } else { path = args[1]; } if (chdir(path) != 0) { perror("cd"); return EXIT_FAILURE; } // 更新PWD环境变量 char *cwd = getcwd(NULL, 0); set_env("PWD", cwd); free(cwd); return EXIT_SUCCESS; }这里有两个细节值得注意。第一个是更新PWD:bash 在cd之后会更新PWD环境变量,否则提示符里显示的当前目录就不对了。第二个是getcwd(NULL, 0)这种用法:GNU扩展会自动分配足够大的缓冲区,用完记得free()。
4.3 export内建命令:两种参数形式的处理
export命令在bash里有两种形式:export FOO=bar和export FOO。前者是设置变量,后者是把已有变量标记为“导出”。在我的实现里,第二种形式简化处理为:如果变量不存在则创建一个空值的条目。
int builtin_export(char **args) { if (args[1] == NULL) { // 无参数:打印所有环境变量 print_env(); return EXIT_SUCCESS; } for (int i = 1; args[i] != NULL; i++) { char *eq = strchr(args[i], '='); if (eq != NULL) { *eq = '\0'; set_env(args[i], eq + 1); // 同步系统environ setenv(args[i], eq + 1, 1); *eq = '='; // 恢复原字符串,避免影响后续处理 } else { // 只有变量名,没有值 const char *val = get_env(args[i]); set_env(args[i], val ? val : ""); setenv(args[i], val ? val : "", 1); } } return EXIT_SUCCESS; }这个实现里我特别注意了不修改args[i]的原内容,因为后续可能还有别的逻辑要读参数。用*eq = '\0'临时截断再加回=是一个取巧的做法,但必须记得恢复。
4.4 内建命令的分发框架:函数指针表
内建命令数量多了以后,一个简单的if-else链会变得很难维护。更好的方式是建立一个“命令名-函数指针”的映射表:
typedef int (*builtin_func)(char **args); typedef struct { const char *name; builtin_func func; } builtin_cmd; static builtin_cmd builtins[] = { {"cd", builtin_cd}, {"exit", builtin_exit}, {"export", builtin_export}, {"env", builtin_env}, {"unset", builtin_unset}, {NULL, NULL} }; int is_builtin(char *cmd) { for (int i = 0; builtins[i].name != NULL; i++) { if (strcmp(builtins[i].name, cmd) == 0) { return 1; } } return 0; } int execute_builtin(char **args) { for (int i = 0; builtins[i].name != NULL; i++) { if (strcmp(builtins[i].name, args[0]) == 0) { return builtins[i].func(args); } } return -1; // 未找到 }这样以后加内建命令只需要添加一个结构体条目和一个函数实现,不需要动执行器的核心逻辑。这是典型的“表驱动”设计,在系统编程里非常实用。
5. 外部命令执行策略:fork、exec、wait的三角关系
外部命令的执行是Shell的核心工作,也是系统编程知识点最密集的部分。这里面的每一环都有讲究。
5.1 fork-exec-wait的标准实践
执行外部命令的标准流程是:先fork()一个子进程,在子进程里调用execvp()加载目标程序,父进程调用waitpid()等待子进程结束。代码框架:
int execute_external(char **args) { pid_t pid = fork(); if (pid < 0) { perror("fork"); return EXIT_FAILURE; } if (pid == 0) { // 子进程 execvp(args[0], args); // exec失败才会走到这里 fprintf(stderr, "mysh: %s: command not found\n", args[0]); exit(EXIT_FAILURE); } // 父进程等待子进程 int status; waitpid(pid, &status, 0); if (WIFEXITED(status)) { return WEXITSTATUS(status); } return EXIT_FAILURE; }这是一个非常标准的模板,但有几个细节需要展开说。
5.2 exec失败时子进程必须exit的原因
如果execvp()执行失败(比如命令不存在),子进程会在execvp返回后继续执行。此时子进程仍然是Shell的一个拷贝,如果不手动exit(EXIT_FAILURE),它会把父进程剩下的代码全跑一遍,Shell会变得错乱。
更严重的情况是:如果忘了exit,子进程会回到Shell的主循环,继续读输入并执行命令。这时系统里会出现两个“Shell”在竞争终端输入,行为完全不可预测。我在初学时踩过这个坑,当时的感觉是“从此Shell像疯了一样”。所以记住:exec之后紧跟exit是防御性编程的基本功。
5.3 exit状态获取:WIFEXITED与WEXITSTATUS
waitpid()拿到的status是一个编码后的整数,必须用宏来解析。常用的组合是:
WIFEXITED(status):判断子进程是否正常退出WEXITSTATUS(status):如果正常退出,获取退出码(0-255)WIFSIGNALED(status):判断子进程是否被信号终止WTERMSIG(status):如果是信号终止,获取信号编号
这些宏在wait.h头文件中定义。不解析直接用status的话,拿到的值不是真正的退出码,这是很多新手搞不清楚的地方。
5.4 fork之前要考虑的边界条件
我在反复测试中发现,执行外部命令前要检查一些边界条件:
- 参数数组是否为空:如果
argv[0]为NULL,直接返回,不要进入fork流程。 - 命令名是否为空字符串:
execvp("", args)会返回ENOENT,但提前检查更清晰。 - 是否内建命令:内建命令不需要fork子进程,直接在Shell进程内执行。
if (args[0] == NULL) return EXIT_SUCCESS; // 空命令 if (is_builtin(args[0])) { return execute_builtin(args); } else { return execute_external(args); }这个“先内建后外置”的检查顺序也是bash的默认行为,不过bash对内建命令的类型有一些优先级规则,我们这里按最简单的覆盖式处理即可。
6. 完整实操指南:搭建项目、编写代码、测试验证
前面已经拆解了各个模块的原理,这一节我会带你走一遍完整的实操流程。这不仅仅是代码的堆叠,还包括项目的组织方式、编译调试技巧和功能验证方法。
6.1 项目文件组织结构
我不会把全部代码塞进一个main.c,那样后期维护会很痛苦。推荐的文件结构:
mysh/ ├── Makefile ├── src/ │ ├── main.c # 主循环、初始化 │ ├── parse.c # 命令解析器 │ ├── parse.h │ ├── env.c # 环境变量管理 │ ├── env.h │ ├── builtin.c # 内建命令实现 │ ├── builtin.h │ └── execute.c # 外部命令执行器 │ └── execute.h每个模块的头文件负责声明对外接口,.c文件负责实现。这样做的价值在于:你可以单独测试每个模块。我写的时候是先把env.c写完,用一个小测试程序验证set_env/get_env正常,再写解析器,最后才组装主循环。
6.2 Makefile的写法与要点
Makefile写起来很简单,但要让它好用。一个基础版本:
CC = gcc CFLAGS = -Wall -Wextra -g -std=c11 SRCS = src/main.c src/parse.c src/env.c src/builtin.c src/execute.c OBJS = $(SRCS:.c=.o) mysh: $(OBJS) $(CC) $(CFLAGS) -o $@ $(OBJS) %.o: %.c $(CC) $(CFLAGS) -c $< -o $@ clean: rm -f mysh $(OBJS) .PHONY: clean-Wall -Wextra开启严格警告,-g保留调试信息。写完代码后先过编译器的关,把警告都消灭掉再运行。
6.3 实现主循环:连接所有模块
主循环是Shell的“胶水”,把所有模块粘在一起。这里给一个完整的实现,我在每行都加了注释:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include "parse.h" #include "env.h" #include "builtin.h" #include "execute.h" int main(void) { char *line = NULL; size_t bufsize = 0; ssize_t nread; init_env(); while (1) { printf("mysh$ "); fflush(stdout); nread = getline(&line, &bufsize, stdin); if (nread == -1) { printf("\n"); break; // EOF(Ctrl+D) } if (line[nread - 1] == '\n') { line[nread - 1] = '\0'; } // 跳过注释和空行 char *trimmed = line; while (*trimmed == ' ' || *trimmed == '\t') { trimmed++; } if (*trimmed == '\0' || *trimmed == '#') { continue; } // 解析命令 char *argv[64]; int argc = parse_line(line, argv, 64); if (argc == 0) { continue; } // 变量展开(跳过首个参数,因为命令名不展开) for (int i = 1; i < argc; i++) { char *expanded = expand_vars(argv[i]); argv[i] = expanded; } // 执行命令 execute_command(argv); // 释放变量展开时分配的内存 for (int i = 1; i < argc; i++) { free(argv[i]); } } free(line); return 0; }这里有个细节:第一版我不做变量展开,expand_vars直接返回原字符串。等所有功能跑通后再加入展开逻辑,降低调试难度。
6.4 用真实场景测试Shell的功能
项目写完后,我设计了一组测试场景,确保各个功能都能正常工作:
| 测试项 | 输入 | 预期结果 |
|---|---|---|
| 外部命令 | ls -la /tmp | 显示目录内容 |
| 环境变量展开 | echo $HOME | 打印家目录路径 |
| 内建cd | cd /tmp→pwd | 显示/tmp |
| 设置变量 | export FOO=bar→echo $FOO | 打印bar |
| 继承测试 | export TEST_VAR=hello→env | 输出中含TEST_VAR=hello |
| 注释处理 | # this is a comment | Shell不报错,继续等待输入 |
| 退出码 | 运行true→echo $? | 打印0 |
| 退出 | exit | Shell退出 |
更严格的测试是:把自定义Shell的PID设置成登录Shell,直接在它里面跑一个复杂的Python脚本或find命令,看会不会崩。
7. 常见问题排查实录:我踩过的那些坑
这个项目做完,我大概踩了五十多个坑。挑一些最有代表性的列出来,帮你提前绕开。
7.1 环境变量丢失:environ和自定义表不同步
现象:在Shell里执行export MY_VAR=test,然后运行一个脚本,脚本里echo $MY_VAR输出为空。
原因:我只更新了自己的env_table,没有同步系统的environ。execvp()加载新程序时,使用的是系统全局变量environ,而不是我的自定义环境表。
解决:每次set_env()都同步调用setenv()更新系统环境表。具体做法在前文已经讲过,关键是双写保持一致。
7.2 命令找不到但execvp却返回成功
现象:输入一个不存在的命令,Shell没有报错,只是没反应。
原因:这是execvp返回后没有立即exit导致的。execvp失败会返回 -1,但代码里如果没有在返回后打印错误并exit,子进程会继续执行父进程的代码,行为就乱了。
解决:严格执行“exec失败立即exit”的约定。这是所有Shell实现的基本要求。
7.3 输入缓冲残留导致多行命令错误
现象:输入一个超长命令(超过缓冲区),后面的字符被当成新命令执行。
原因:如果用fgets且缓冲区太小,会残留字符在输入流里。我后来切换到getline后这个问题消失,因为getline会动态扩容,能完整读取整行。
建议:直接用getline,别再折腾固定缓冲区了。
7.4 子进程导致Shell卡死
现象:运行sleep 100,然后想按Ctrl+C终止它,结果整个Shell也退出了。
原因:这是信号处理的问题。Ctrl+C会向整个前台进程组发送SIGINT,包括Shell本身。真实的bash会用setpgid把子进程放到单独的进程组,然后忽略来自键盘的中断信号。
处理:第一版可以先不处理信号,知道这个现象即可。后续扩展时可以研究signal()和setpgid(),让Shell忽略SIGINT,而把信号转发给子进程。
7.5 变量展开时的内存管理混乱
现象:多次执行echo $HOME后,用valgrind检测发现内存泄漏。
原因:expand_vars()用malloc分配了新字符串,但在主循环里只free了部分参数,有的路径漏记了。
解决:统一在主循环的for循环中释放所有展开后的参数。用了valgrind后,这类问题就无处遁形了。
7.6 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 环境变量在子进程里取不到 | 未同步更新系统environ | 检查setenv是否调用 |
| execvp返回后出现行为错乱 | 子进程exec失败后没exit | 检查子进程分支 |
| 输出多了奇怪的换行 | getline的换行符没去掉 | 检查\n处理 |
| 连续执行命令时Shell崩溃 | 内存泄漏或重复free | valgrind检测 |
| cd到不存在的目录没反应 | chdir错误后没perror | 检查错误处理 |
echo $卡死 | 变量展开逻辑越界 | 检查字符串遍历边界 |
8. 扩展方向:从基础Shell到更完整的实现
到这里,你已经拥有一个能运行的、支持环境变量管理和内建命令的Shell了。但距离生产级的Shell,还差几个关键功能:重定向、管道、通配符展开、信号处理、作业控制。
这些内容每一个都值得单独写一篇长文。如果继续扩展,我建议按这个顺序:
重定向:>、<、>>的实现核心是文件描述符的复制。在子进程里,open一个文件后用dup2把它复制到STDOUT_FILENO,再exec新程序。
管道:|的实现核心是pipe()系统调用和文件描述符的重定向。一个管道连接两个进程,左边进程的标准输出接到管道写端,右边进程的标准输入接到管道读端。
通配符:*.c的展开可以利用glob()函数,或者自己实现一个简单的通配符匹配器。核心是理解模式匹配的字符串算法。
信号处理:让Shell在收到Ctrl+C时不自杀,而是把信号传给前台子进程。核心是进程组控制和sigaction()。
每扩展一个功能,你都会对Linux程序员的设计智慧多一分敬意。这也是从“会用Shell”到“理解Shell”的过程。
如果你在实现过程中卡住了,我建议你先把代码拆成最小复现单元来调试:只留解析函数,用printf打印解析结果;只留fork段,跑一个最简单的ls。模块化调试远比你盯着一千行代码猜问题效率高。这几年我带的实习生里,凡是能独立写完这个小项目的,后面看printf、pipe、dup2这些系统调用的源码,都轻松了很多。希望这篇拆解也能帮到你。