从零实现一个可用Shell:进程管理、管道与变量展开全解析
发布时间:2026/9/26 23:38:17来源:尧图网络
把ls、grep这些命令的调用权从系统/bin/sh手里抢过来亲手实现自己的Shell可能是学完进程管理之后最有成就感的一件事。我刚开始写的时候以为这只是一道读命令、启动程序的练习题做到后面才发现Shell本质上是一个进程调度器、一个文本解析器、一个脚本解释器还藏着一堆进程管理细节。这篇文章完整记录了我从零实现一个可用Shell的过程包含主循环、内置命令、重定向、管道、变量展开、for循环和shift这些功能也把我踩过的一些Shell常见坑一并讲清楚。想练系统编程、想把命令行原理真正搞明白的读者可以直接照这个路线走。1. 从为什么说起自己写Shell到底在写什么1.1 Shell不是命令解释器而是进程调度器很多人的第一反应是Shell不就是把命令行字符串拆开然后去执行对应程序嘛。这个理解不能说错但太表面了。你输入ls -lShell做的事远不止找到ls程序并启动它。你要先判断这个ls是内置命令还是外部命令要在PATH环境变量列出的目录里查找可执行文件要fork出一个子进程要在子进程里用execve替换成ls程序然后在父进程里等着子进程退出回收它的退出状态码。如果命令行里有管道和重定向还要在fork之前准备好文件描述符的连通关系。这个过程的核心就是Unix进程模型里的fork、exec、wait三件套。Shell在这中间扮演的角色不是一个字符串处理器而是一个按用户的描述来编排进程的调度器。我之前偷懒用过system(ls -l)来实现执行命令。第一版跑通了但之后就后悔了——system()会再启动一个/bin/sh去解释那一行命令相当于我的程序套壳了另一个Shell压根没达到练习目的。而且system()内部自己做了fork和wait你想插手重定向、管道、环境变量完全没有机会。所以我的结论是真想实现自己的Shell老老实实用fork()execvp()waitpid()别绕过核心。1.2 这个项目我会做到什么程度既然要写博文先把我这个项目的目标边界说清楚。我做的这个迷你Shell不是什么Bash的完整复刻而是一个能写正常脚本、能跑日常命令的可用版本主要支持交互式命令行循环和从脚本文件读取命令两种模式cd、exit、export、shift、echo这些内置命令、、重定向多段管道a | b | c$VAR、$?、$$这些变量展开for var in list; do ...; done循环美元符号之外的分词处理包括带引号的参数。暂时没有做通配符展开、作业控制CtrlZ那套、命令别名、here-doc、$(...)、复杂的引号嵌套。这些不是不想做而是如果把精力全砸在这些功能上核心的进程管理和解析逻辑反而会被稀释。语言选型上我用的C环境是Linux。原因很简单fork、exec、dup2、pipe这几个系统调用天然就是C语言接口。用Python的subprocess库虽然也能拼一个Shell出来但对理解底层进程模型帮助不大你感受不到那些fd在fork之后怎么继承、怎么共享怎么关闭的微妙之处。这篇博文里的代码片段我都按Linux环境写你也可以照着抄。2. 第一版骨架读取-解析-执行这个死循环怎么做2.1 三段式结构任何一个Shell哪怕Bash核心都是同一个结构打印提示符读取一行解析成命令然后执行。所以第一版不妨先做一个最简单的REPL。C语言里读取一行最稳妥的是fgets()。注意不要用gets()那是缓冲区溢出重灾区编译器都会警告。这里有个细节如果用fgets()读进来的字符串末尾带着换行符\n做命令匹配前要记得去掉。交互模式下提示符mysh只在标准输入是终端的时候打印非交互模式下不应出现提示符否则会污染脚本输出。判断方式很简单int interactive isatty(STDIN_FILENO);核心循环长这样while (1) { if (interactive) printf(mysh ); if (!fgets(line, sizeof(line), stdin)) break; line[strcspn(line, \n)] \0; struct cmd *c parse_line(line); if (!c || c-argc 0) continue; if (is_builtin(c-argv[0])) { run_builtin(c); } else { run_external(c); } free_command(c); }parse_line()的初版我用的是strtok()按空格和制表符切分。别急着写复杂解析先让ls -l /tmp能跑起来后面再慢慢补引号支持。run_external()里就是fork exec wait三连。子进程里调用execvp()它会自己按PATH环境变量查找命令如果找不到就perror()并_exit(127)。这个127不是随便写的Bash里command not found的退出码就是127很多脚本依赖这个值做判断。2.2 fork、exec、wait外部命令的完整生命周期直接看代码void run_external(struct cmd *c) { pid_t pid fork(); if (pid 0) { perror(fork); return; } if (pid 0) { // 子进程 execvp(c-argv[0], c-argv); perror(execvp); _exit(127); } // 父进程 int status; waitpid(pid, status, 0); last_status WEXITSTATUS(status); }这里有几个很容易踩的坑。第一execvp()失败之后子进程一定要调用_exit()退出不能直接return。你想想如果是子进程继承的代码从run_external()里返回了它会回到主循环里继续打印提示符、读命令——问题是这个进程还是原来那个进程但它的内存里可能已经有了两个祖父级的栈帧。真到那一步程序行为会变得极其诡异。第二execvp()成功时不会返回失败时才返回。所以perror()和_exit(127)都在同一个if块里不需要额外的返回值判断。第三父进程里调用waitpid(pid, status, 0)是阻塞等待这个状态下Shell无法响应其他输入这符合我们平时对前一个命令没跑完、后一个命令没法执行的预期。等后面支持命令 后台任务时这里就要改成非阻塞回收。还有一个很容易忽略的问题WEXITSTATUS(status)拿到的退出码是被截断到低8位的因为wait返回的status里还包含信号编号、core dump标志这些信息。如果子进程是被信号杀掉的WEXITSTATUS的结果没意义应该用WIFSIGNALED(status)判断一下。这个在测试阶段不太明显等处理CtrlC的时候才会碰到。2.3 退出状态与初始化环境Shell里有个特殊变量$?表示上一条命令的退出状态码。既然我们的Shell有echo $?这种需求那就必须在主循环里用变量保存上一条命令的结果。我定义了一个全局变量last_status内外置命令执行完都更新它。execvp()还会自动继承父进程的环境变量因为C程序的入口可以拿到extern char **environexecvp执行新程序时会把这个environ传过去。所以我们第一版就没额外做环境变量的传播后续实现export时再说。这一版跑起来之后试着输入这些命令每一条都应该正常工作ls ls -l /tmp pwd wc -l /etc/passwd如果这些都没问题恭喜你你已经有了一个乞丐版Shell的雏形。接下来要做的就是把内置命令和脚本模式补上。3. 内置命令与脚本模式为什么cd必须在Shell内部执行3.1 内置命令的本质最初我写cd /tmp的时候发现命令执行之后当前目录完全没变。原因很容易想到cd如果也走fork exec它修改的是子进程的当前目录子进程退出后父进程Shell的目录纹丝不动。所以cd必须由Shell进程自己调用chdir()。这就是内置命令的核心价值某些操作天生关于Shell自己而不是关于Shell启动出来的程序。比如cd要改变Shell进程的当前目录export要修改Shell进程自己的环境变量表shift要修改Shell进程的位置参数数组exit要直接终止Shell进程。没想明白这点的人会把cd实现成内置命令却把export实现成启动一个外部程序去setenv()结果子进程里设置的环境变量父进程根本拿不到等于白做。内置命令的执行时机也很关键应该在fork()之前。也就是说主循环里先检查命令名是不是内置命令是的话直接在一个普通C函数里执行不走进程创建。这样cd、export、shift才能立刻生效。我维护了一张静态内置命令表用if/else或函数指针数组都可以static struct builtin_t { char *name; int (*func)(char **args); } builtins[] { {cd, builtin_cd}, {exit, builtin_exit}, {export, builtin_export}, {shift, builtin_shift}, {echo, builtin_echo}, };echo为什么也做成内置因为后面实现$VAR展开后echo $name非常常用而且会有很多Shell脚本用到echo -n这类非标准选项。外部/bin/echo也能用但内置的实现完全由我控制解析$?、$$更方便。很多人觉得echo简单等做完就明白一个内置版的echo能省掉大量环境变量交互的麻烦。3.2 从交互模式到脚本模式一个只能交互的Shell没法和脚本联系起来。所以第二步我加了批处理模式当标准输入不是终端就一行接一行地读取并执行直到EOF。具体实现很简单把读取行的逻辑改成while (1) { char *line read_line(interactive); if (!line) break; if (interactive) printf(mysh ); execute_string(line); }同时支持命令行参数./mysh test.sh。如果传给Shell一个文件路径就打开这个文件作为输入来源。脚本文件里一般有几行命令按顺序执行执行完直接退出。这里我做一个统一设计不管命令行来自交互标准输入、脚本文件还是以后可能支持的-c参数最终都走同一个execute_string()函数。这个函数完成解析、变量展开、执行三步。后面写for循环的时候这个统一的入口能省很多事因为循环体本身就是一段字符串可以递归地扔给execute_string()处理。脚本模式加上之后我能用真正的脚本来测试Shell了比如写一个test.shecho hello world cd /tmp pwd跑./mysh test.sh理论上应该输出hello world /tmp看起来平淡无奇但这已经是Shell支持批处理的基石。后续的for循环、shift、位置参数都是在这个模式上生长的。4. 重定向与管道把数据流接起来才是真正的Shell4.1 重定向的解析和落地没有重定向和管道的Shell只能算命令启动器。重定向的本质是在exec新程序之前把目标文件准备好用dup2()把它复制到标准输入/输出/错误对应的文件描述符上。这样exec出来的程序根本不知道自己在写文件它只往fd 1写fd 1已经指向目标文件了。我在解析阶段遇到了一个设计问题ls out.txt到底先解析出ls还是先处理重定向我的做法是在分词阶段把、、识别出来把后面的文件名记到命令结构体的重定向字段里。比如struct redirect { int type; // REDIR_OUT, REDIR_APPEND, REDIR_IN char *file; }; struct cmd { int argc; char **argv; struct redirect redir; };然后在子进程执行execvp之前完成重定向设置if (cmd-redir.type REDIR_OUT) { int fd open(cmd-redir.file, O_WRONLY | O_CREAT | O_TRUNC, 0644); if (fd 0) { perror(open); _exit(1); } dup2(fd, STDOUT_FILENO); close(fd); }这里的几个细节值得多说一句。第一为什么必须用O_TRUNC因为 out.txt的语义就是覆盖写把旧文件内容清空。如果你想实现追加flag里就得用O_APPEND而不是O_TRUNC。第二为什么open()返回的fd要dup2()之后立刻close(fd)因为fd 1原本可能被占用dup2把fd复制到了1上原fd的引用已经多余不关的话子进程里会带着多个指向同一个文件表项的描述符。虽然大多数场景下没区别但严谨的程序不应该放任fd泄漏。第三重定向最好在子进程里做不要放在父进程。如果在父进程里就把标准输出改了后面别的命令会一起遭殃。很多人一开始图方便把重定向逻辑放在fork之前结果每次执行完都要恢复fd非常容易漏。4.2 管道的实现多进程协作管道比重定向绕一点。ls | wc -l的意思是让ls的标准输出连到管道写端让wc的标准输入连到管道读端。两个进程同时运行数据从ls流向wc。先看两个进程之间的管道int fd[2]; pipe(fd); pid_t pid1 fork(); if (pid1 0) { // ls dup2(fd[1], STDOUT_FILENO); close(fd[0]); close(fd[1]); execvp(ls, ...); } pid_t pid2 fork(); if (pid2 0) { // wc dup2(fd[0], STDIN_FILENO); close(fd[0]); close(fd[1]); execvp(wc, ...); } close(fd[0]); close(fd[1]); waitpid(pid1, ...); waitpid(pid2, ...);这里有个非常经典的坑写完端和读端的fd之后父子进程要各关各的。如果父进程不关写端wc的进程会一直等着可能的输入——因为管道写端还有一个引用在父进程手里读端不会收到EOFwc会卡住。如果子进程不关另一端同样会出问题。所以在每个fork出来的子进程里第一件事就是把不需要的管道fd全部close掉。多管道的实现稍微复杂一点。比如a | b | c需要两个pipe三个子进程。我的做法是循环创建N-1个管道每次处理一段连接。伪代码思路如下int prev_fd STDIN_FILENO; // 上一段管道的读端 for (int i 0; i n; i) { int fd[2]; if (i n - 1) pipe(fd); pid_t pid fork(); if (pid 0) { if (prev_fd ! STDIN_FILENO) { dup2(prev_fd, STDIN_FILENO); close(prev_fd); } if (i n - 1) { dup2(fd[1], STDOUT_FILENO); close(fd[0]); close(fd[1]); } execvp(cmd[i].argv[0], cmd[i].argv); _exit(127); } if (prev_fd ! STDIN_FILENO) close(prev_fd); if (i n - 1) { close(fd[1]); prev_fd fd[0]; } } // 父进程最后不要忘了等待所有子进程 for (int i 0; i n; i) waitpid(pids[i], status, 0);你能看到整个管道链是递推式的第一个进程的标准输入来自Shell原本的stdin标准输出接到第一个管道写端第二个进程标准输入接到第一个管道读端标准输出接到第二个管道写端最后一个进程标准输出维持Shell原本的stdout。到第4章为止这个Shell已经能处理绝大多数日常命令了。有个排序上的细节我之前没注意到最好先fork所有子进程再统一wait。因为管道数据量很大时如果串行执行先等第一个命令跑完然后启动第二个中间只能靠管道缓冲区撑着缓冲区满。并发启动可以缓解但不是说绝对不会卡至少在写这种教学Shell时统一启动更接近真实Shell的并发模型。5. 变量扩展、for循环与shift给Shell加上脚本脑子5.1 变量表和展开时机一个真正的Shell脚本必须能存变量、用变量。所以我加了自己的变量表——一开始用一个足够大的数组加线性查找就够了命令少时根本感觉不到性能差异没必要一上来就写哈希表。变量表长这样struct shell_var { char *name; char *value; }; struct shell_var vars[MAX_VARS]; int var_count;设变量、取变量都是简单操作。但真正关键的问题不是怎么存而是什么时候展开。我在这个项目里把展开时机定为分词之后、执行之前。也就是说echo $name这一行先通过解析得到一个参数数组{echo, $name}然后遍历每个参数看到$开头或者$出现在词中间就去变量表里查。找到了就替换成值找不到就替换成空字符串。这里有个很重要的区别变量展开不能太早。如果你在原始字符串层面做替换比如把整行里的$name先替换掉再拿去分词一旦变量值里有空格整个命令结构就乱套了。比如filemy file.txt cat $file如果先展开再分词cat my file.txt会被解析成三个参数cat、my、file.txt这其实是Bash里的无引号展开会将值按空白拆开的语义。我第一版为了简单统一按这个方式处理。但注意如果变量值本身带有特殊字符这个处理会引发各种奇奇怪怪的错误。这也是为什么真实的Shell要在分词阶段就融合变量展开而不是事后String替换。单引号内的变量不需要展开双引号内要展开但保留空白。我第一版不做双引号里的保留先把单引号挡住不展开。这是简化但作为一个教学项目完全够用。特殊变量要单独处理$?上一命令退出状态保存为上一个wait结果$$当前Shell进程PID直接getpid()。这俩建议在最开始就支持因为后面写脚本调试时$?几乎每一条命令都用得上。5.2 for...in...done的解析策略支持for循环时我犯过愁。完整的Shell脚本解析需要做AST否则循环嵌套和条件分支就是一团乱麻。但教学项目可以取巧只支持for var in w1 w2 w3; do ...; done这个固定形态不处理嵌套循环解析方式用简单的字符串模式匹配。我把一行内嵌的for拆成下面几个字段循环变量名for后面的第一个词列表in后面;之前的所有词循环体do和done之间的字符串。处理逻辑是这样的for each item in item_list: set_variable(var_name, item); execute_string(loop_body);关键在于execute_string(loop_body)直接复用整个Shell的执行链路。循环体里可以有管道、重定向、内置命令、外部命令。这意味着我们不需要为循环单独实现一套执行机制只需要把循环体当作一个字符串重新交给Shell去解析执行。这里有个效率问题每循环一次就要重新解析一遍循环体。教学项目没关系但要在注释里写清楚真实的Shell会先把循环编译成内部结构再重复执行同一份AST。如果想优化可以把循环体预先解析成命令序列再把变量绑定进去。我偷懒了不过胜在代码直观。for循环里变量的作用域也要说清楚。循环变量在循环结束后仍然存在也就是说for i in 1 2 3; do ...; done执行完$i的值是3。这个行为跟Bash一致很多新人在自己实现时容易把循环变量误当成临时量循环一结束就删掉。5.3 shift与位置参数脚本只有循环没有参数还是太死板。Shell脚本最常见的参数处理方式就是位置参数$1是第一个参数$2是第二个$是全部$#是参数个数。我们的Shell支持脚本模式所以当我执行./mysh mytest.sh a b c时Shell需要把a、b、c放进一个位置参数数组里。这个数组独立于main函数的argv因为脚本里可能有多个命令每个命令都有自己的argv。在脚本里写echo $1该怎么实现很简单变量展开时发现$后面跟的是数字就去位置参数数组里取对应索引。$0则保留脚本文件名。shift内置命令的作用是把位置参数整体左移一位原来的$2变成$1$3变成$2。C实现就是数组元素往前挪for (int i 1; i positional_argc; i) { positional_argv[i - 1] positional_argv[i]; } positional_argc--; positional_argv[positional_argc] NULL;有了shift脚本里可以这样循环处理参数while [ $# -gt 0 ]; do echo $1 shift done不过我们的Shell还没有实现[这个测试命令所以你暂时没法运行上面代码。我的测试方式是直接写一个固定次数的for循环for arg in $; do echo $arg done注意这里有个微妙点$在无引号时会被拆成多个词刚好被for的列表解析拆成每一项。如果加了双引号$的语义是保留每个位置参数为独立整体。我的简化版就没有完全实现这一层所以我测试时都避免在循环里用带引号的$。这属于已知的限制如实写在博客里反而比假装完美更有价值。6. 我在这个项目里踩过的Shell常见坑完整排查链路6.1 僵尸进程ps里的一片defunct写第一版时我增加了后台执行支持也就是sleep 100 不等待直接回到提示符。一开始看起来很正常但跑过几个后台命令后我用ps一看一堆defunct进程挂在Shell下面。排查链路是这样的现象sleep 100 然后不断ps -eo pid,ppid,stat,cmd | grep sleep看到sleep进程退出后状态变成defunct父进程还是我的mysh。猜测1可能是我没调用waitpid()。但我明明是只要子进程一退出就会调用的。回头看代码发现我确实只在run_external()里对同步执行的前台命令调用了waitpid()后台命令压根没人管。验证往主循环里加了打印确认后台命令返回后父进程再也没有任何wait操作。根因子进程退出后如果父进程不调用wait子进程的退出状态就一直留在进程表里变成僵尸。僵尸进程不占用CPU但会占用一个PID长期跑会耗尽系统进程号。修复给Shell注册SIGCHLD信号处理函数。当收到子进程退出信号时循环调用waitpid(-1, NULL, WNOHANG)把已经退出的子进程全部回收掉。注意在信号处理函数里不能随便调printf因为信号处理器可能在任意时刻打断主流程最安全的方法是设置一个全局标志位回到主循环再统一回收。我后来更倾向用sigaction()配合flag因为signal()在不同平台上的语义有差异还是sigaction更可控。总之经过这次折腾我对僵尸进程产生于父进程没有wait这句话有了刻骨铭心的理解。6.2 带空格的路径与引号解析第二个坑出现在输入cat /tmp/my file.txt的时候。第一版用strtok()按空白切分结果得到的是cat /tmp/my file.txt显然不对。这个问题在真实场景里很常见凡是你想处理的参数里包含空格就必须解决引号解析。排查思路是把strtok()去掉自己写一个逐字符扫描的分词函数。每遇到一个字符判断它是在引号内部还是外部。遇到双引号时切换in_quote状态引号内部遇到空格不切分同时最终把引号字符本身丢掉。伪代码如下char *p line; while (*p) { if (*p ) { in_quote !in_quote; p; continue; } if (isspace(*p) !in_quote) { token_end(); // 当前token结束 p; continue; } append_char(*p); p; }单引号也要类似处理而且单引号内部整个都不做变量展开。这算是一个正确的基础。我在做这一点时最开始还漏了引号内转义符的情况比如\这导致某些参数永远解析不对。后来我统一把反斜杠转义也接了进来\表示一个普通双引号\\表示一个反斜杠。虽然很少用但解析器有这层处理健壮性会好很多。6.3 PATH查找为什么输命令时经常出现诡异的command not found第三阶段我决定不再依赖execvp()想自己实现PATH查找这才遇到一个之前完全没注意的坑。execvp的作用是如果命令名里包含/就把它当成直接路径去执行否则遍历PATH环境变量里的每个目录拼接出/usr/bin/ls这样的路径逐个检查是否存在且可执行。自己写查找逻辑时我第一版忘了判断命令名是否包含斜杠。于是输入./a.out时我的代码试图去PATH目录里找a.out当然找不到于是报command not found。排查半小时后才反应过来POSIX规则是只要命令名里有/就跳过PATH搜索直接尝试这个路径。还有一个细节是PATH可能为空。在真实Shell里PATH为空字符串时表示当前目录。也就是说export PATH之后你执行任何一个命令Shell会在当前目录里找。这个行为很多人不知道我自己实现时也忽略了导致设置了空PATH后什么命令都执行不了。加上这个判断后行为就和Bash一致了。这里给一个对照表总结我遇到的情况情况之前错误处理正确行为命令名包含/也进PATH搜索直接作为路径执行命令名不包含/只找第一个PATH目录按顺序搜完所有PATH目录PATH为空认为没有可执行目录视为当前目录目录不可执行直接忽略需要检查路径可执行权限7. 测试自己的Shell脚本化自测与几个趁手工具7.1 用一堆预期输出做回归测试Shell这种项目改一个地方很容易把另一个功能带崩。我在实现for循环时就曾不小心把管道解析弄断过一次测试脚本立刻报警。所以从第二版开始我建了一个tests/目录里面每一个用例都是一对文本文件input和expected。测试脚本大概长这样fail0 for input in tests/*.input; do name${input%.input} ./mysh $input $name.got 21 if ! diff -u $name.expected $name.got; then echo FAIL: $name fail1 fi done exit $fail用例覆盖内容基础命令ls、pwd、echo重定向echo hello out.txt然后cat out.txt管道ls | grep mysh变量xhello; echo $xfor循环for i in a b c; do echo $i; doneshift脚本里连续两次shift后打印$1。刚开始写expected时我不小心把ls -l的结果硬编码了进去后来发现Unix上ls输出因为时间戳、权限、排序和机器环境不同没法完全一致。所以我只对输出可预测的用例做严格diff对ls这类命令要么用grep过滤要么只比关键行。7.2 grep与shell脚本的配合检查自测脚本里最常用的辅助命令就是grep。比如我想验证for循环有没有正确展开变量我不会去diff整段输出而是用grep -qx expected-line去匹配某一行必须存在。-q表示静默模式只做匹配不打印输出非常适合在if判断里用。if grep -qx hello $name.got; then echo OK: for loop produced hello else echo FAIL: missing hello figrep -c还可以用来统计输出中某个模式出现的次数比如检查for i in 1 2 3应该输出三行我可以断言grep -c item got等于3。这种用法写测试脚本很顺手。7.3 用expect模拟交互输入有些行为只有在标准输入是终端时才会触发比如打印提示符、响应CtrlC信号。普通管道输入测不到这些。如果想做自动化交互测试expect是个趁手的工具它专门用来启动一个程序等它输出然后发输入。一个简单的expect片段expect EOF spawn ./mysh expect mysh send echo hello\r expect hello send exit\r expect eof EOF这个能验证提示符出现、命令执行、再退出整条链路走通。不过我的经验是expect脚本本身很容易受终端宽度、转义字符影响维护成本高。项目里只用来覆盖交互式场景平时的回归测试还是以管道输入为主。7.4 一点小扩展方向到这一步我的Shell已经能跑不少日常命令也能执行一些简单脚本了。如果还想继续延伸比较自然的路线是支持通配符*、?这个可以在分词阶段自己展开目录项支持$(command)命令替换把子命令的标准输出拼回命令行支持alias和函数定义支持作业控制比如CtrlZ挂起任务、jobs命令支持here-doc也就是cat EOF。但我个人的建议是别急着把这些都堆上去。先把当前这个版本稳定住多拿真实脚本跑一跑看看哪里有解析错误、哪里有进程回收遗漏。每次加功能之前先把对应的回归测试写好功能验证完就用测试用例兜底。写Shell项目最大的收获不是写出多牛的命令行工具而是把进程、文件描述符、信号、环境变量这些系统编程里最难啃的概念全部在亲手调试中揉碎了理解。我自己做完之后再回来看Bash手册那些为什么这个命令要这样写的困惑很多都能自动解开。
网站建设高端定制企业官网