手写自定义Shell:从fork/exec到管道重定向的Linux进程实战
发布时间:2026/9/30 12:32:00 锦皓数字建站

1. 为什么要在2025年亲手写一个Shell这个项目比你想的更值钱先别急着关页面。我知道你的第一反应是Shell不是现成的吗Bash、Zsh、Fish随便挑一个都比自己写的强我干嘛要自讨苦吃这个反应没错但恰恰是问题所在。现成的Shell把你该学的东西都藏起来了。你在终端里敲ls -l | grep txt的时候Bash替你做了三件事解析字符串、创建管道、拉起子进程。这三件事每一个都是操作系统核心机制的实战考点而你平时根本看不见它们。我见过太多人Linux命令敲得飞起但一问fork()和exec()的区别就卡壳能写出几百行的Shell脚本却不理解为什么管道两端的命令是“同时”运行的。倒不是说他们笨而是工具太好的时候人对底层机制会失去感知。这就跟开自动挡开久了不会开手动挡一样不是技术问题是习惯问题。自定义Shell这个项目本质上就是把你平时用的黑盒打开让你看看里面是什么。你不需要做出一个能和Bash抗衡的产品你只需要做出一个能跑ls、cd、cat file | grep foo、./program out.txt的“玩具”——但这趟走下来你对Linux进程模型、文件描述符、字符串处理的理解会比你读三章APUE来得扎实。而且实用性也很强。很多公司面试会问“设计一个迷你Shell”或者让你在纸上写fork exec wait的组合代码。你亲手写过一遍这种题就是送分题。退一步说就算不为了面试理解Shell的工作方式对你写配置脚本、排查启动问题、理解Docker容器的进程行为都有直接帮助——因为你终于知道那些“神奇”的操作底下到底发生了什么。下面我直接把这套实现拆开讲从项目结构、核心机制到具体代码、踩坑记录一条线走完。2. 动手前的核心知识储备进程模型、系统调用与解析器设计的底层逻辑写Shell之前必须先建立三个底层认知。这三点如果不提前搞清楚写出来的东西大概率是“看起来像Shell跑起来全是病”。2.1 进程模型Shell其实是个“包工头”Linux下的进程模型一句话概括就是进程是“复制”出来的不是“创建”出来的。所有用户态进程都是从一个叫init的祖先进程一路fork()复制出来的。关键认知是一个程序跑起来之后你通常有两个实体——父进程和子进程。父进程负责“管理”子进程负责“干活”。Shell就是父亲的典型代表它接收你输入的命令然后fork()出一个子进程再在子进程里用exec()把命令真正换上去。打个比方。Shell是包工头你让他盖一栋楼执行命令他不会自己上脚手架而是打电话叫一队工人fork()一个新进程然后告诉工人具体干什么活exec()把程序加载进来。包工头自己在旁边蹲着等工人干完wait()好汇报给你。这跟我们平时跑程序的感觉完全不一样。我们直觉上觉得“程序是被操作系统创建出来的”但Linux底下的语言是先复制一份当前状态再替换成新程序。这两个步骤必须拆开理解否则你会对整个Shell的实现逻辑产生疑惑。2.2 三大系统调用fork、exec、wait这是Shell的基石必须吃透。fork()调用一次返回两次。一次在父进程里返回子进程的PID一次在子进程里返回0。返回值是负则说明创建失败。这段代码pid_t pid fork(); if (pid 0) { // 子进程这里继续跑的是从父进程复制过来的代码副本 } else if (pid 0) { // 父进程pid 就是子进程的进程ID } else { perror(fork); }很多人不理解“返回两次”是怎么回事。换个说法fork()完成那一刻系统里凭空多了一个进程这个进程和父进程几乎一模一样连程序计数器PC的位置都一样。也就是说子进程从fork()返回的那一行开始继续执行同一份代码。唯一能区分两人的就是返回值——父进程拿到的是子进程的PID子进程拿到的是0。exec()注意它不是函数而是一族函数——execl、execv、execvp等等。它们的作用是一个进程把自己“换掉”把当前进程的代码段、数据段、栈全部替换成新程序的然后从头开始跑新程序。成功的话不会返回失败才返回 -1。我们后面统一用execvp()因为它的第二个参数直接接受一个字符串数组argv形式对解析命令行特别方便。wait()/waitpid()父进程调用这个就会阻塞直到某个子进程结束。这个“阻塞”很重要——Shell必须等命令执行完才能显示下一行提示符这跟你在终端里的直觉体验一致。三者合起来的完整流程父进程读入一行命令fork()出子进程子进程调用execvp()变成那条命令本身父进程调用waitpid()等子进程退出子进程退出后父进程打印提示符继续读下一条命令。这个循环永远不会变Shell的一切都是围绕它展开的。2.3 解析器设计把“字符串”变成“参数数组”Shell读到的是一行字符串比如ls -l /home/user。但execvp()需要的是char *argv[] {ls, -l, /home/user, NULL};所以你要做的是把char *line变成char **args。这个过程叫命令行解析是整个项目里最考验“耐烦心”的部分——它不涉及什么高深算法但全是边界条件。一个常见的误区是别只用空格拆分。因为Shell命令里可以有引号echo hello world和转义echo hello\ world粗暴按空格拆会把hello world拆成两个参数。我们第一版可以不管引号但你要知道这个边界存在否则后续扩展时容易翻车。我会用strsep()这个不太常见的函数来做拆分原因后面正文里说。它比strtok()安全——strtok()内部有静态变量线程不友好而且会把连续分隔符跳过有时候这不是你想要的。解析器可以分两步走先用一个函数把整行命令按空白拆成独立单词再把结果整理成NULL结尾的指针数组。任何哈希、树结构、复杂状态机在这个项目里都是过度设计——你需要的只是循环、指针、和一点点耐心。3. 第一个能跑的Shell骨架从main函数到循环框架好基础打完了开始写代码。我不会一次性给你一个900行的完整项目而是从最小的骨架开始一步步长出一个能用的Shell。3.1 用getline()读入命令比你想的要注意更多细节读取命令行的标准做法是getline()它自动管理缓冲区比你用fgets()满省心得多。核心代码#define _GNU_SOURCE #include stdio.h #include stdlib.h #include string.h char *line NULL; size_t bufsize 0; ssize_t nread getline(line, bufsize, stdin); if (nread -1) { // EOF 或出错 free(line); exit(0); } // 去掉末尾换行符 line[strcspn(line, \n)] \0;三个易踩的坑坑1getline()返回的是ssize_t不是int。无符号类型和有符号类型比较时会有隐式转换问题编译器会报警告。养成用ssize_t的习惯。坑2去掉换行符千万别用strlen(line) - 1。如果文件最后一行没有换行符strlen减一就把合法字符删掉了。用strcspn(line, \n)找到换行符的位置安全得多。坑3命令行过长时getline()会自动扩容缓冲区不用你操心。但这也意味着你必须free()否则每次循环都泄漏一段内存。别偷懒。3.2 主循环框架“读命令 - 解析 - 执行 - 等待”的无限循环Shell的本质就是一个死循环退出条件只有两个用户敲exit或者输入流遇到EOF比如按CtrlD。int main(void) { char *line NULL; size_t bufsize 0; ssize_t nread; while (1) { printf(mysh ); fflush(stdout); // 一定要有 nread getline(line, bufsize, stdin); if (nread -1) { printf(\n); break; // EOF 退出 } line[strcspn(line, \n)] \0; // 空命令直接跳过 if (strlen(line) 0) continue; char **args parse_command(line); if (args[0] NULL) { free(args); continue; } // 检查是不是内建命令是的话直接执行不是再fork if (is_builtin(args[0])) { run_builtin(args); } else { run_external(args); } free(args); } free(line); return 0; }这段代码展示了整体骨架。注意fflush(stdout)这一行——没有它提示符可能不会立即显示尤其在管道或脚本环境下。这是初学者最容易忽略的细节之一后面会说更多。3.3parse_command()我为什么推荐strsep()而不是strtok()解析函数要把ls -l /home变成{ls, -l, /home, NULL}。我推荐用strsep()char **parse_command(char *line) { int bufsize 64; int index 0; char **args malloc(bufsize * sizeof(char *)); char *token; if (!args) { perror(malloc); exit(EXIT_FAILURE); } char *rest line; while ((token strsep(rest, \t\r\n)) ! NULL) { if (*token \0) continue; // 跳过连续空白 args[index] token; index; if (index bufsize) { bufsize 64; args realloc(args, bufsize * sizeof(char *)); if (!args) { perror(realloc); exit(EXIT_FAILURE); } } } args[index] NULL; return args; }为什么不用strtok()三个理由每一个都够你在生产环境里吃一次亏1. 线程安全。strtok()内部维护了一个静态变量来保存剩余字符串位置多个线程同时调用会串数据。Shell虽然是单线程的但这个坏习惯不值得养成。strsep()用局部变量rest手动控制。2. 连续空白符的处理粒度不一样。strtok()默认把连续分隔符当成一个strsep()会返回空字符串需要你手动跳过。看起来是多写了一行但给了你更多控制权。3. 解析失败时便于排查。因为你每一步都在自己的局部变量上进行断点调试时可以看到全部中间状态。这里有个关键细节args数组里的指针直接指向原line字符串里的位置。也就是说解析没有strdup没有新分配内存只是把原来的空格替换成\0然后记录每个单词的起点。这样解析速度极快但代价是——你必须保证line在args使用期间不被释放或修改。在我们的主循环里args在run_external()返回后才被释放而execvp()在子进程里已经完成加载所以没问题。3.4 执行外部命令fork execvp waitpid 的标准模板run_external()是核心中的核心void run_external(char **args) { pid_t pid fork(); if (pid -1) { perror(fork); return; } if (pid 0) { // 子进程直接替换成目标程序 if (execvp(args[0], args) -1) { perror(execvp); exit(EXIT_FAILURE); // 必须exit否则会继续跑父进程的循环 } } else { // 父进程等待子进程完成 int status; if (waitpid(pid, status, 0) -1) { perror(waitpid); return; } // 可以在这里检查 status判断命令是否正常退出 } }这段代码你倒背如流也不过分。但我必须强调一个致命细节子进程里调用execvp()失败后必须立即exit(EXIT_FAILURE)。如果漏掉这行子进程就会继续往下执行进入父进程的循环逻辑然后子进程也跑去读命令、fork进程——你就获得了一群裂变出来的Shell。这是新手最容易犯、后果最严重的错误。在子进程分支里永远记住exec一族函数成功就不返回失败也必须立即退出。还有一点execvp()找可执行文件依赖环境变量PATH。它内部会自动搜索PATH目录下的可执行文件。你传入的args[0]如果是简单的ls它就会去/usr/bin等目录找。如果想执行当前目录下的程序用户必须输入./a.out像真正的Shell一样。4. 内建命令的处理为什么必须自己实现cd、exit、echo你可能想问cd不也是一个可执行文件吗我直接execvp(cd, args)不就行了不行。原因得从进程模型里找——你还记得吧子进程是父进程的“复制品”子进程里改了什么都不会影响父进程。cd的本质是改变进程的当前工作目录这属于进程自身状态。如果你fork()一个子进程去执行cd子进程的工作目录变了但父进程Shell本体的工作目录纹丝不动。等你回到主循环打出下一个提示符目录还是原来那个。这就是“内建命令必须由Shell进程自己执行”的根本原因。内核态的表现也是如此改变工作目录的是chdir()系统调用它只对发起调用的进程起作用。没有一条路能让子进程去改父进程的工作目录某些调试器玩法另说但那不是普通命令的路径。4.1cd的实现比你想的复杂一点点int mysh_cd(char **args) { if (args[1] NULL) { // 没有参数回到主目录 const char *home getenv(HOME); if (home NULL) { fprintf(stderr, mysh: cd: HOME not set\n); return 1; } if (chdir(home) ! 0) { perror(mysh: cd); return 1; } } else { if (chdir(args[1]) ! 0) { perror(mysh: cd); return 1; } } return 0; }两个容易忽略的细节第一cd不带参数时应该回到HOME目录。这是POSIX标准约定也是用户在交互式Shell中的直觉。别嫌麻烦加上去。第二参数多于一个时怎么处理标准cd只接受一个参数加上可选的-P/-L标志。我们做个简化版只取args[1]多余参数报错即可。4.2exit的实现要有返回值意识int mysh_exit(char **args) { if (args[1] ! NULL) { // 如果带了退出码参数就解析它 int code atoi(args[1]); exit(code); } exit(0); }注意这里有个小诀窍你可以在主循环里让run_builtin()返回一个特殊值提示“该退出Shell了”而不是在mysh_exit()里直接exit()。这样设计更灵活以后你想加“退出前保存历史记录”“打印最后一条统计信息”之类的功能就还有机会拦截。直接exit()相当于把后门焊死了。4.3echo的一个隐藏问题参数已经解析好了要不要处理引号echo在处理参数时要多个心眼。我们已经说了第一版解析器不会处理引号所以用户输入echo hello world时parse_command()会拆成{echo, \hello, world\, NULL}。那echo打印出来就是hello world带引号的两个单词。这是整个项目最折磨人的部分也最能体现“做玩具和做产品的差别”。如果我们想支持引号就得升级解析器让它识别...内的空格不算分隔符。但注意优先级先让核心跑起来再考虑完善。第一版的echo就是这么简单int mysh_echo(char **args) { for (int i 1; args[i] ! NULL; i) { printf(%s, args[i]); if (args[i1] ! NULL) printf( ); } printf(\n); return 0; }等核心功能都跑通了你还想支持引号我们再单独写一个quote_aware_parse()替换掉parse_command()。这样项目迭代顺序清晰每一轮都有可验证的成果。4.4 内建命令的状态管理用一个字符串数组和函数指针表内建命令多了以后代码别写成一大串if-else。用表驱动的方法更清爽struct builtin_t { char *name; int (*func)(char **args); }; struct builtin_t builtins[] { {cd, mysh_cd}, {exit, mysh_exit}, {echo, mysh_echo}, {help, mysh_help}, // 可以自己加一个帮助命令 {NULL, NULL} }; int is_builtin(char *cmd) { for (int i 0; builtins[i].name ! NULL; i) { if (strcmp(cmd, builtins[i].name) 0) return 1; } return 0; } int run_builtin(char **args) { for (int i 0; builtins[i].name ! NULL; i) { if (strcmp(args[0], builtins[i].name) 0) { return builtins[i].func(args); } } fprintf(stderr, mysh: %s: not a builtin\n, args[0]); return 1; }以后想加pwd、export、history各自写个函数然后在表里加一行就行主循环代码一行不用改。代码要写到“加功能比加分支更容易”的程度这才叫好的项目结构。5. 重定向与管道让命令真正组合起来的Unix哲学外壳跑通了但现在的Shell是“单命令执行器”。你没法写出ls out.txt也没法写出cat file | grep foo。这两种操作涉及操作系统里最核心的一个概念文件描述符。5.1 文件描述符与重定向的底层原理一切都是文件Linux下“一切都是文件”进程通过文件描述符整数编号访问文件。0号是标准输入1号是标准输出2号是标准错误。键盘输入、屏幕输出本质上都是通过这几个文件描述符完成的。ls out.txt的意思就是把1号文件描述符指向的文件换成out.txt。这样ls写往标准输出的内容就进了文件。实现用的系统调用是dup2()int fd open(out.txt, O_WRONLY | O_CREAT | O_TRUNC, 0644); dup2(fd, STDOUT_FILENO); // 把标准输出重定向到 fd close(fd);dup2(fd, STDOUT_FILENO)的意思是“把fd复制到1号位置”即让1号描述符指向fd指向的那个打开文件描述。之后printf输出的内容就去文件里了。注意dup2()之后必须close(fd)——因为1号和fd都指向同一个文件表项你只需要保留1号就够了多一个引用只会造成资源泄漏。5.2 支持、、的解析三个运算符三种打开方式修改解析逻辑的步骤扫描args数组看有没有、、或2之类的标记标记后面的那个参数就是文件名在子进程执行execvp()之前先打开文件调用dup2()重定向然后执行命令args里要把重定向操作符和文件名剔除只留下真正的命令参数。实现上我建议在run_external()里增加一个预处理步骤void run_external(char **args) { pid_t pid fork(); if (pid 0) { // 子进程 for (int i 0; args[i] ! NULL; i) { if (strcmp(args[i], ) 0) { int fd open(args[i1], O_WRONLY | O_CREAT | O_TRUNC, 0644); dup2(fd, STDOUT_FILENO); close(fd); args[i] NULL; // 截断命令参数 break; } // 同理处理 和 } execvp(args[0], args); perror(execvp); exit(EXIT_FAILURE); } // 父进程照旧 }有一个必须想清楚的细节为什么重定向在子进程里做而不在父进程里做因为在父进程里改文件描述符会影响到下一轮主循环而且重定向是“这一次命令”的行为不是Shell自己的行为。所以必须在fork()之后、execvp()之前在子进程内部完成重定向——此时子进程有独立的文件描述符表随便改都不影响父进程Shell。用O_RDONLY用O_WRONLY | O_CREAT | O_APPEND。三个操作符映射到三种打开方式记住这个表就行。5.3 管道的实现|操作符的本质是把一个进程的输出接到另一个进程的输入管道比重定向更微妙。ls | grep txt的逻辑是创建管道pipe()系统调用得到一个读端和一个写端ls的标准输出重定向到写端grep的标准输入重定向到读端两个进程可以同时运行数据从ls流往grep。代码实现void run_pipeline(char **left_args, char **right_args) { int pipefd[2]; pipe(pipefd); // pipefd[0] 读端, pipefd[1] 写端 pid_t p1 fork(); if (p1 0) { // 左命令输出到写端 close(pipefd[0]); // 用不到读端 dup2(pipefd[1], STDOUT_FILENO); close(pipefd[1]); execvp(left_args[0], left_args); perror(execvp left); exit(EXIT_FAILURE); } pid_t p2 fork(); if (p2 0) { // 右命令从读端读入 close(pipefd[1]); // 用不到写端 dup2(pipefd[0], STDIN_FILENO); close(pipefd[0]); execvp(right_args[0], right_args); perror(execvp right); exit(EXIT_FAILURE); } // 父进程关闭两端 close(pipefd[0]); close(pipefd[1]); waitpid(p1, NULL, 0); waitpid(p2, NULL, 0); }这里有几个反直觉的点我当年全踩过第一父子进程必须关掉没用的一端。假设ls进程没关读端grep进程没关写端会发生什么管道还有“活着的引用”grep等待输入时系统不会发EOF于是grep永远等下去整个管道挂死。必须让管道两端的进程都关掉不需要的那一端才能保证数据流结束时有EOF信号。第二管道和重定向可以叠加吗可以但顺序很重要。ls | grep foo out.txt的结构是“左命令输出进管道右命令的输出进文件”。实现时先按管道创建子进程然后在右子进程里先做标准输入的重定向管道读端再处理重定向。这个组合逻辑会越写越复杂所以更优雅的方案是逐步扩展成“命令表”command table结构这里先不展开。第三管道的进程是同时还是先后创建记住一个原则先创建所有进程再等待所有进程。不要让前一个进程先跑完再创建下一个——那样的话数据还没流到就关闭了而且会复制两次fork()的环境效率更低。上面代码先fork()左命令再fork()右命令两个子进程都建好之后父进程统一waitpid()。5.4 关注点转移管道实现中容易忽略的“僵尸进程”问题写管道的时候有个概念你一定会遇到waitpid()的第二个参数status。我们说父进程“必须”调用waitpid()等待子进程但如果子进程是管道的一端你可能不知道应该等哪一个先结束。更隐蔽的问题如果父进程在waitpid(p1)上阻塞而p2已经退出p2就变成了僵尸进程——它占着PID和进程表项只有父进程waitpid()收割它系统才会彻底回收。长期积累僵尸进程会耗尽系统进程表。所以更稳妥的写法是waitpid(p1, NULL, 0); waitpid(p2, NULL, 0);两个都等待确保一个都不漏。或者用wait(NULL)交替收割。这个细节在生产环境的Shell里是必须处理的但大多数教学代码都会略过——我在这儿写出来是希望你在做项目的时候主动去碰一碰这个问题。6. 交互体验与常见坑提示符、输入缓冲、CtrlC、历史记录项目做到这里已经是个能用的Shell了。接下来这一节是纯“从实战中踩出来的经验”你在任何教科书上都不太容易凑齐这些细节但平时敲命令一定会遇到。6.1 提示符的学问交互模式下的fflush和颜色交互式Shell的提示符必须立即显示。如果你用printf(mysh )标准输出默认是行缓冲的——只有当遇到换行符或缓冲区满才真正写出去。提示符后面没有换行所以你不fflush(stdout)的话用户看到的就是一个干瞪眼的空白终端敲了命令才有反应。这个问题的表现很反直觉按回车之后提示符才出现又跟着你刚敲的命令终端显示是乱序的。我第一版Shell就载在这里排查了半天还以为是多线程问题结果就是少了一行fflush。顺带一提给提示符加颜色会让Shell看起来更友好但要注意非交互模式下不要用颜色。判断方法很简单Shell启动时看isatty(STDIN_FILENO)是否为真。文件输入或管道输入时用了颜色反而要命。生产级Shell如Bash就是这么做的。6.2 信号处理为什么按CtrlC会退出你的Shell这是另一个大坑。默认情况下CtrlC会向前台进程组发送SIGINT信号默认动作是终止进程。你的Shell如果没做任何处理用户在终端按CtrlCShell进程本身就会挂掉。你手写的Shell必须自己处理SIGINT#include signal.h void handle_sigint(int sig) { printf(\n); fflush(stdout); } int main() { signal(SIGINT, handle_sigint); // ... 主循环 }这样用户按CtrlC时Shell只是换个行、重新打印提示符而不是整个进程被杀掉。但注意子进程执行期间按CtrlC信号会发给整个前台进程组。你的Shell和子进程都在前台需要决定是Shell处理还是子进程处理。更精细的做法是Shell忽略SIGINT而子进程保留默认行为。所以signal(SIGINT, SIG_IGN)放在 fork 之前的父进程里然后 fork 出来的子进程里再signal(SIGINT, SIG_DFL)恢复默认。这个细节跟真实Shell的行为一致你在Bash里跑sleep 10按CtrlC能杀掉sleep但Bash还活着。这里面的机制跑通之后你会对“进程组”和“信号传递”有更扎实的理解。6.3 历史记录用readline库还是自己撸到了这一节你已经实现了一个可以用于基本命令执行、重定向、管道的Shell。如果还想加“上下键”翻历史命令纯粹靠自己写会非常麻烦——因为全终端控制terminal raw mode的细节比你想的多得多。两个选择选择一用 GNUreadline库。这是最实际的选择。它提供了readline()函数替代getline()并且自带历史记录功能。代码从char *line readline(mysh ); add_history(line);两行就完成了。优点显而易见缺点是这个库要额外链接依赖。教学演示项目可以不用靠近生产项目时值得引入。选择二自己实现简单的历史记录。如果你想锻炼文件操作能力可以用一个数组存最近N条命令上下键通过终端原始模式读入按键序列。这条路适合练手但“终端raw mode”会花掉你不少时间。我建议先把前述核心功能写好、跑通历史记录用简单数组版本即可——不要一上来就撸终端控制。6.4 一个容易被忽略的需求处理空命令和注释符空行、只含空格的行、以#开头的行应该被Shell跳过。这在脚本执行场景下尤其重要因为脚本文件里经常有空行和以#开头的注释。第一版可以先忽略但主循环里面对args[0] NULL的情况要直接continue。还有个细节把Shell的“退出码”返回给操作系统。exit命令后你可以echo $?验证退出码是否正确。在主循环里每次执行完外部命令从status里提取退出码WEXITSTATUS(status)存到一个全局变量last_status里。后面实现、||或者$?变量时就靠这个值。7. 一个值得再走一遍的扩展清单这个项目能长成什么样写完核心Shell之后你可以继续往这几个方向扩展。这些扩展每一个都能单独成文而且会逼你解锁更多Linux机制。1. 通配符展开ls *.txt是怎么变成ls a.txt b.txt的你需要理解Shell的glob展开机制可以自己调用glob()函数实现也可以自己写递归匹配。2. 环境变量赋值前缀VARvalue command这种写法需要临时修改子进程的环境变量。用setenv()在子进程内部改但注意父进程环境不能变。3.$?特殊变量获取上一条命令的退出码。这个我们上面提过是通往条件执行、||的第一块砖。4. 作业控制CtrlZ暂停进程、jobs列出后台任务、fg恢复前台运行。这会把高深的前后台进程组管理引入项目——做完包你理解“作业控制”是什么。5. 脚本执行模式mysh script.sh从文件读取命令执行。这个做起来不难把getline的输入源换成文件但会触发一堆“是否打印提示符”等边界判断——跟真实Shell一致非交互模式不打印提示符。6. 引号处理完整支持单引号和双引号。单引号内一切字符按字面意义处理双引号内做变量替换。这需要把简单的strsep解析器升级成带状态机的解析器。做完你就会明白为什么Shell的解析器一直是“复杂主题”。每做完一个扩展建议你做一个小对比用自己的Shell跑ls -l /tmp再去Bash里跑同样的命令看看行为差异有多大。你会感受到从“模拟”到“理解”之间的距离。最后说一点个人体会。磨这个项目的时候我最深的感触不是“哦原来Shell是这么工作的”而是“需要一个好用的测试工具”——自己写Shell意味着你手里的每个命令都是被自己实现的代码执行的。当你发现自己写的Shell居然能正确跑grep -r hello /usr/include | wc -l时那种“操作系统的拼图在自己手里一块块拼上”的感觉特别踏实。而且这是一个适合长期维护的小项目。它不会太大但每次往里加一个功能、处理一个bug都能让你对Linux的理解再深一圈。过三个月拿出来看看你会发现自己写的代码其实菜得不行——那恰恰说明你成长了。建议从今天开始动手先把3.3小节的parse_command()跑起来剩下的自然就有动力磨下去了。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。