第 2 章 · 01 REPL 主循环与命令读取 本节摘要:这一节是造 Shell 的第一块砖——你要先有一个能跑起来的「骨架」。Shell 的骨架是一个 REPL(Read-Eval-Print Loop):读取一行、执行、回到读取。但它和 Python/Lua 那种 REPL 有个关键区别——Shell 的「Print」段几乎是空的,因为命令自己负责输出。本节导读读哪一行用什么读( 还是 )、提示符怎么打印(它不是简单的 ,背后藏着环境变量、退出码、彩色转义)、EOF 怎么处理(Ctrl+D 该让 Shell 干净退出)、以及为什么「读一行」这件看似简单的事,其实是整个 Shell 与用户交互的全部界面。
本节摘要:这一节是造 Shell 的第一块砖——你要先有一个能跑起来的「骨架」。Shell 的骨架是一个 REPL(Read-Eval-Print Loop):读取一行、执行、回到读取。但它和 Python/Lua 那种 REPL 有个关键区别——Shell 的「Print」段几乎是空的,因为命令自己负责输出。本节导读读哪一行用什么读(
fgets还是readline)、提示符怎么打印(它不是简单的printf,背后藏着环境变量、退出码、彩色转义)、EOF 怎么处理(Ctrl+D 该让 Shell 干净退出)、以及为什么「读一行」这件看似简单的事,其实是整个 Shell 与用户交互的全部界面。理解 REPL,你就理解了 Shell 的「心跳」——后续所有功能(解析、fork、管道)都是塞进这个循环里的。
内容来源:基于原索引「Build your own X」Shell 域相关条目整理的导读,原始教程为外部资源(如「Tutorial - Write a Shell in C」「Write a shell in C」等)。
阅读完本节,你应当能够:
fgets 是入门最简单的选择,而真实 Shell 会用 readline/libedit——差别在行编辑、历史、自动补全。fgets 返回 NULL,你的 Shell 应当干净退出并换一行。user@host:~$)里的每个字段从哪来。fflush)对提示符显示的影响——这是 REPL 类程序最常见的坑。造任何工具,第一件事是让它「能跑起来」——哪怕它什么都不做,只是回显你输入的内容。这一步看似无足轻重,却极其重要,因为它带给你三样东西:
printf 提示符、能读一行的循环,你就有了「我的 Shell 活着」的即时反馈,这种反馈是支撑你写完后续六节的动力。>>>、Lua 的 >、Redis CLI、psql 都是 REPL。学会搭一个 REPL 骨架,你就掌握了「交互式工具」的通用架构。本书第 9 章(造编译器 REPL)会复用这个模式。更重要的是,REPL 揭示了 Shell 的一个本质特征:Shell 永远是一个循环,不是一个「跑完就退」的程序。普通程序(比如 ls)跑完就退出;Shell 不同——它要持续等用户输入,直到用户主动 Ctrl+D 或敲 exit。这种「持续在场」的特性,决定了 Shell 后续所有的设计:它必须管多个子进程(因为上一条没跑完时下一条可能就来)、必须维护状态(当前目录、环境变量、历史)、必须有信号处理(不能被一个 Ctrl+C 打死)。
💡 学习建议:不要小看这一节。新手常犯的错是「跳过骨架直接写 fork」,结果发现连「用户敲完一行我怎么拿到这行字符串」都没搞清。先把骨架立起来,哪怕只是读一行打印一行,你的 Shell 就有了「魂」。
骨架还有一层更隐蔽的价值——它帮你建立「心智模型」。当你敲一行命令看到 Shell 给出反应,你脑海里会自然形成「输入 → 处理 → 输出」的画面。后续每一节的内容(解析、fork、管道、信号)都是往这个画面的「处理」框里填东西。没有这个画面,后续的系统调用会变成一堆孤立的 API,你记不住也用不熟。
REPL 是 Read-Eval-Print Loop 的缩写。它是一个无限循环,每一轮做四件事:
while (true) { line = Read() // 读一行输入 result = Eval(line) // 执行这行 Print(result) // 打印结果 } // Loop: 回到 Read
这是 Lisp 时代的经典模型。Python、Ruby、Node 的交互式解释器都是 REPL。它们的共同特征是:Eval 阶段算出一个「值」,Print 阶段把这个值显示出来——比如你在 Python 里敲 1+1,Eval 算出 2,Print 把 2 显示在屏幕上。
但 Shell 的 REPL 有个关键变体:它的 Print 段几乎是空的。为什么?因为 Shell 执行的命令(ls、grep、echo)自己会往 stdout 打印——Shell 不需要再「替」命令打印什么。Shell 在 Eval 阶段 fork 出子进程跑命令,命令自己边跑边输出,Shell 只需 wait 它结束。所以 Shell 的循环其实是 Read-Eval-Loop(R-E-L),Print 段只剩一个「更新提示符」的动作。
普通 REPL (Python): Shell 的变体 REPL: Read ──> 读取 "1+1" Read ──> 读取 "ls -la" Eval ──> 计算 = 2 Eval ──> fork 子进程跑 ls, wait 它结束 Print ──> 显示 "2" Print ──> (空, ls 自己已经输出了) Loop ──> 回到 Read Loop ──> 回到 Read
关键概念:Shell 的 REPL 是 Read-Eval-Loop,因为「Print」由命令自己完成。Shell 在 Eval 阶段把控制权交给子进程,子进程直接写屏幕,Shell 不参与输出。这是它与 Python/Lua 这类「纯解释器 REPL」最大的架构差别。
读一行,看似最简单,其实有两种主流选择:
选择一:fgets 或 getline(C 标准库)。这是入门推荐。fgets(buf, size, stdin) 从标准输入读一行(遇到换行或 EOF 停止),存进 buf。简单、跨平台、足以让你跑通骨架。缺点:用户没法用左右键移动光标、没法用上下键翻历史、没法自动补全——只能「敲一行、回车」。
选择二:readline/libedit(GNU 库)。这是真实 Shell(bash、zsh)的选择。它接管终端的原始模式,提供行编辑、历史(↑/↓)、Tab 补全、Ctrl+R 反向搜索。但代价是要链接额外库,API 更复杂。
入门推荐路径: fgets ──> 先让骨架跑通 (10 行代码) │ ▼ readline ──> 当你想要历史和补全时再换 (加几十行胶水)
为什么 bash 用 readline 而不用 fgets?因为日常用 Shell 时,「快速编辑上一条命令」「Tab 补全长路径」「Ctrl+R 翻历史」是高频需求,这些都需要接管终端的输入处理——fgets 做不到。但对学习造 Shell 的人来说,这些是「锦上添花」,不是核心机制。第一遍用 fgets 把骨架立起来,等核心功能都跑通了再回来换 readline。
💡 学习建议:第一遍造 Shell,用
fgets就够了。等你的 Shell 能跑管道、能 fork,再回来把fgets换成readline,加上历史和补全——那时你已经熟悉自己的代码结构,改动会很从容。
提示符(prompt)是那个 user@host:~$ 字符串。看似简单,其实它由多个动态字段拼成:
user):来自环境变量 USER 或 LOGNAME。host):来自 gethostname() 系统调用。~):来自 getcwd(),再约定把 $HOME 替换成 ~ 显示。$ 或 #):普通用户显示 $,root 用户显示 #——靠 getuid() == 0 判断。这就是《Linux 命令》第 1 章讲的「提示符」的真相——它背后是 Shell 在每一轮循环开始时,动态拼一段字符串打印出来。bash 里那个 PS1 环境变量,就是控制这个拼接格式的模板(比如 PS1="\u@\h:\w\$ " 里的 \u/\h/\w 是占位符,Shell 替换成实际值)。
伪代码:打印提示符 print_prompt(): user = getenv("USER") gethostname(host, ...) cwd = getcwd() 显示目录 = 把 cwd 里的 $HOME 替换成 ~ if getuid() == 0: 符号 = "#" else: 符号 = "$" printf("%s@%s:%s%s ", user, host, 显示目录, 符号)
入门时你完全可以只打印一个简单的 > 或 mysh$ ,等骨架跑通了再加这些字段。但理解「提示符是动态拼出来的」这点很重要——它解释了为什么 cd 之后提示符里的目录会立刻变(因为每一轮都重新拼),以及为什么 $?(退出码)也能塞进提示符里显示。
⚠️ 难点预警:提示符的彩色转义 ANSI 序列(如
\033[32m表示绿色)会让很多人卡住。这些序列会被 Shell 算进「字符串长度」,影响光标对齐。入门时不要加颜色,等骨架稳定了再处理——而且记得用\033[0m重置颜色,否则后续输出全变色。
用户按 Ctrl+D 时,终端往 stdin 发一个 EOF(文件结束)。fgets 遇到 EOF 会返回 NULL。你的 Shell 必须检测这个返回值,然后干净退出——通常打印一行 goodbye 或干脆什么都不说,直接 exit(0)。
这里要理解 Ctrl+D 的真实行为:它不是一个字符,而是触发终端往 stdin 发 EOF 标记的条件。具体规则是「Ctrl+D 在行首按下时,产生 EOF;在行中按下时,把当前已输入的内容立刻提交(不等待换行)」。对入门来说,只需记住「fgets 返回 NULL 就是 EOF,退出即可」。
⚠️ 难点预警:新手常忘记检测
fgets的返回值,结果 Ctrl+D 后 Shell 进入死循环(因为fgets返回 NULL,但 buf 里还残留着上一行的内容,Shell 把它当成「新的输入」无限重复执行)。记住:每次fgets后立刻检查返回值是不是 NULL,是就退出。
另一个细节:Ctrl+D 应当先换一行再退出,否则提示符和退出的 Shell 输出挤在同一行,体验很差。在退出前 printf("\n") 是个常见的礼貌动作。
真实 Shell 有两种运行模式:
bash script.sh 或 cat cmds.sh | bash),不打印提示符,逐行读、逐行执行,文件读完就退出。判断当前是哪种模式,靠 isatty(STDIN_FILENO)——如果 stdin 是终端,就是交互模式;否则是脚本模式。入门时你可以先只做交互模式,但理解两种模式的差别很重要——它解释了为什么 bash script.sh 里看不到那些 user@host:~$ 提示符(脚本模式下根本不打印)。
伪代码:判断模式 if isatty(STDIN_FILENO): 模式 = 交互 else: 模式 = 脚本 while (true): if 模式 == 交互: print_prompt() // 脚本模式不打印 line = Read() if line == EOF: break Eval(line)
交互式 Shell 有个重要的设计约束:它不能被用户误触的信号杀掉。普通程序被 Ctrl+C 一按就退出,但 Shell 不能——否则你正在跑的长任务会因为一次手滑全部丢失。
具体做法:Shell 启动时,要把自己对 SIGINT(Ctrl+C)、SIGTSTP(Ctrl+Z)、SIGQUIT(Ctrl+)的默认处理改成「忽略」或「自定义」。这样,这些信号到达 Shell 时不会杀掉它;而 fork 出去的子进程会继承「忽略」状态——但子进程通常 exec 成别的程序,exec 会把可忽略的信号处理重置为默认(这是 exec 的语义),所以子进程能正常被 Ctrl+C 杀掉。
伪代码:Shell 启动时设置信号 int main(): signal(SIGINT, SIG_IGN) // 忽略 Ctrl+C signal(SIGTSTP, SIG_IGN) // 忽略 Ctrl+Z (作业控制细节见第 06 节) signal(SIGQUIT, SIG_IGN) // 忽略 Ctrl+\ ... while (true): ... REPL 循环 ...
这一节的骨架可以先不处理信号(后续第 06 节会深入),但理解「Shell 对信号免疫」这个设计意图很重要——它解释了为什么 bash 不像普通程序那样怕 Ctrl+C。这也是 REPL 类「常驻程序」的共通需求:数据库服务、消息队列、容器运行时,启动后都要屏蔽会误杀自己的信号。
把 REPL 放到整个 Shell 生命期里看,它其实是这样的:
Shell 启动 │ ▼ 初始化: 读启动脚本(.bashrc)、设信号、初始化变量表/历史/别名表 │ ▼ 进入主循环 ───────────────────────────────────────┐ │ │ ▼ │ 打印提示符 (交互模式) │ │ │ ▼ │ 读一行 (fgets / readline) │ Loop │ │ ▼ │ 解析 + 展开 + 执行 (第 02-07 节的内容) │ │ │ ▼ │ 更新 $?、提示符目录等状态 │ │ │ └──────────────────────────────────────────────┘ │ (用户敲 exit 或 Ctrl+D) ▼ 清理: 刷缓冲、写历史文件、释放变量表 │ ▼ exit(0)
后续六节的内容,全是塞进「解析 + 展开 + 执行」这一个框里的。理解这个生命期,你就理解了 Shell 的「形状」——它是一个循环,所有功能都是循环体里的一段代码。
这一节的最小目标,是写一个能编译、能运行、能读一行并回显的 Shell 骨架。大约二十行代码:
伪代码:最小 Shell 骨架 int main() { char buf[1024]; while (1) { // 1. 打印提示符 printf("mysh$ "); fflush(stdout); // 关键!立即刷新,否则提示符卡在缓冲里 // 2. 读一行 if (fgets(buf, sizeof(buf), stdin) == NULL) { printf("\n"); // EOF (Ctrl+D), 换行后退出 break; } // 3. 去掉末尾换行符 buf[strlen(buf)-1] = '\0'; // fgets 会把 \n 也读进来, 去掉它 // 4. Eval(暂时只回显) if (strlen(buf) == 0) continue; // 空行直接跳过 printf("你输入了: %s\n", buf); // 5. Loop: 回到步骤 1 } return 0; }
编译跑一下,你就有了一个会打招呼的「假 Shell」。它什么都不会,但它活着——你能敲、它能响应。这就是骨架的价值。
⚠️ 难点预警:
printf("mysh$ ")之后必须fflush(stdout),否则提示符不会立即显示——因为 stdout 默认是行缓冲,而提示符末尾没有\n,会卡在缓冲里,直到下一条带\n的输出才被冲出来。这是 REPL 类程序最常见的「坑」之一,务必记住。
为什么会这样?C 标准库对 stdout 有两种缓冲策略:终端是「行缓冲」(遇到 \n 才刷新),管道/文件是「全缓冲」(缓冲区满才刷新)。提示符末尾没 \n,自然被卡住。解决办法有两个:要么手动 fflush,要么用 setvbuf(stdout, NULL, _IONBF, 0) 把 stdout 设成无缓冲——后者更彻底,但每次输出都 flush,性能稍差。
接下来的演进路径:
mysh$ 出现,能读一行回显。fflush 提示符、Ctrl+D 干净退出。mysh$ 换成 user@host:cwd$ ,用 getcwd/getenv 拼出来。验证:cd(第 03 节实现后)之后提示符里的目录会变。argv[1] 是不是文件,是就从文件读、不打印提示符。这是第 07 节脚本能力的前置。readline(可选):当你想加历史和补全时,把 fgets 换成 readline。注意 readline 自动分配内存,用完要 free。💡 学习建议:实现骨架后,做一个小实验——故意去掉
fflush,看看提示符会发生什么(可能完全不显示,或者和下一行输出挤在一起)。这种「先看到坑、再理解为什么」的过程,比读十遍缓冲理论都管用。
fgets 读一行:简单、跨平台、足以跑通骨架;想加历史和补全再换 readline。$/# 符号——每一轮循环都重新拼接,这就是 cd 后目录立即变化的原因。fgets 返回 NULL:Ctrl+D 触发 EOF,Shell 应干净退出,否则会陷入死循环。printf 提示符后要 fflush:stdout 默认行缓冲,提示符末尾没 \n,不手动刷新就看不见。isatty(stdin) 区分,前者打印提示符,后者不打印。while(1) 里 printf 提示符、fgets 读一行、回显。验证它能在终端里跑起来、Ctrl+D 能干净退出。fflush 提示符。user@host:cwd$ ,用 getcwd/getenv 拼出来(为第 03 节 cd 做铺垫)。readline:等 Shell 能跑管道后再回来,加上历史和补全——那时改动会很从容。下一节(命令解析)处理 REPL 里 Eval 段的第一步:把用户敲的那一行字符串(ls -la | grep foo > out.txt)切成「命令 + 参数 + 管道段 + 重定向目标」。这一步是后续 fork 与 dup2 的前提——你不把字符串拆开,就不知道要 fork 几个子进程、各自接什么管道。