第 2 章 · 01 REPL 主循环与命令读取


文档摘要

第 2 章 · 01 REPL 主循环与命令读取 本节摘要:这一节是造 Shell 的第一块砖——你要先有一个能跑起来的「骨架」。Shell 的骨架是一个 REPL(Read-Eval-Print Loop):读取一行、执行、回到读取。但它和 Python/Lua 那种 REPL 有个关键区别——Shell 的「Print」段几乎是空的,因为命令自己负责输出。本节导读读哪一行用什么读( 还是 )、提示符怎么打印(它不是简单的 ,背后藏着环境变量、退出码、彩色转义)、EOF 怎么处理(Ctrl+D 该让 Shell 干净退出)、以及为什么「读一行」这件看似简单的事,其实是整个 Shell 与用户交互的全部界面。

第 2 章 · 01 REPL 主循环与命令读取

本节摘要:这一节是造 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」等)。

学习目标

阅读完本节,你应当能够:

  1. 说清 REPL 的四个阶段(Read、Eval、Print、Loop),以及 Shell 的「Print」段为什么几乎为空。
  2. 写出一个能编译运行的最小 Shell 骨架:循环里打印提示符、读一行、回显、回到读取。
  3. 解释为什么用 fgets 是入门最简单的选择,而真实 Shell 会用 readline/libedit——差别在行编辑、历史、自动补全。
  4. 正确处理 EOF:用户按 Ctrl+D 时,fgets 返回 NULL,你的 Shell 应当干净退出并换一行。
  5. 实现一个基本的交互式提示符,理解提示符字符串(如 user@host:~$)里的每个字段从哪来。
  6. 区分「交互模式」(有终端、打印提示符)与「脚本模式」(从文件或管道读、不打印提示符)。
  7. 说清 stdout 缓冲(fflush)对提示符显示的影响——这是 REPL 类程序最常见的坑。

一、学习价值:为什么先搭骨架

造任何工具,第一件事是让它「能跑起来」——哪怕它什么都不做,只是回显你输入的内容。这一步看似无足轻重,却极其重要,因为它带给你三样东西:

  • 可调试性:有了骨架,你后续每加一个功能(解析、fork、管道),都能立刻在真实交互里验证它对不对。没有骨架,所有功能都是空中楼阁。
  • 反馈感:造 Shell 最折磨人的是「写到一半看不到结果」。先有一个能 printf 提示符、能读一行的循环,你就有了「我的 Shell 活着」的即时反馈,这种反馈是支撑你写完后续六节的动力。
  • 架构基底:REPL 不是 Shell 独有的模式——Python 的 >>>、Lua 的 >、Redis CLI、psql 都是 REPL。学会搭一个 REPL 骨架,你就掌握了「交互式工具」的通用架构。本书第 9 章(造编译器 REPL)会复用这个模式。

更重要的是,REPL 揭示了 Shell 的一个本质特征:Shell 永远是一个循环,不是一个「跑完就退」的程序。普通程序(比如 ls)跑完就退出;Shell 不同——它要持续等用户输入,直到用户主动 Ctrl+D 或敲 exit。这种「持续在场」的特性,决定了 Shell 后续所有的设计:它必须管多个子进程(因为上一条没跑完时下一条可能就来)、必须维护状态(当前目录、环境变量、历史)、必须有信号处理(不能被一个 Ctrl+C 打死)。

💡 学习建议:不要小看这一节。新手常犯的错是「跳过骨架直接写 fork」,结果发现连「用户敲完一行我怎么拿到这行字符串」都没搞清。先把骨架立起来,哪怕只是读一行打印一行,你的 Shell 就有了「魂」。

骨架还有一层更隐蔽的价值——它帮你建立「心智模型」。当你敲一行命令看到 Shell 给出反应,你脑海里会自然形成「输入 → 处理 → 输出」的画面。后续每一节的内容(解析、fork、管道、信号)都是往这个画面的「处理」框里填东西。没有这个画面,后续的系统调用会变成一堆孤立的 API,你记不住也用不熟。

二、子系统拆解:Read、Eval、Print、Loop

REPL 是什么

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 执行的命令(lsgrepecho)自己会往 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」最大的架构差别。

Read:怎么读一行

读一行,看似最简单,其实有两种主流选择:

选择一:fgetsgetline(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,加上历史和补全——那时你已经熟悉自己的代码结构,改动会很从容。

提示符:不是简单的 printf

提示符(prompt)是那个 user@host:~$ 字符串。看似简单,其实它由多个动态字段拼成:

  • 用户名(user):来自环境变量 USERLOGNAME
  • 主机名(host):来自 gethostname() 系统调用。
  • 当前目录(~):来自 getcwd(),再约定把 $HOME 替换成 ~ 显示。
  • 退出码标记($#):普通用户显示 $,root 用户显示 #——靠 getuid() == 0 判断。
  • 颜色:真实 Shell 还会用 ANSI 转义序列给提示符上色,失败的上条命令会让提示符变红。

这就是《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 重置颜色,否则后续输出全变色。

EOF 与 Ctrl+D:干净退出

用户按 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") 是个常见的礼貌动作。

交互模式 vs 脚本模式

真实 Shell 有两种运行模式:

  • 交互模式:有终端(tty),打印提示符、读一行、执行、循环。这是你日常敲命令的场景。
  • 脚本模式:从文件或管道读(bash script.shcat 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 有个重要的设计约束:它不能被用户误触的信号杀掉。普通程序被 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 的「生命期」视角

把 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,性能稍差。

接下来的演进路径:

  1. 先跑通骨架:用上面的伪代码,让 mysh$ 出现,能读一行回显。
  2. 处理边界:空行跳过、去掉末尾换行符、fflush 提示符、Ctrl+D 干净退出。
  3. 接入提示符动态字段:把固定的 mysh$ 换成 user@host:cwd$ ,用 getcwd/getenv 拼出来。验证:cd(第 03 节实现后)之后提示符里的目录会变。
  4. 加上脚本模式(可选):检查 argv[1] 是不是文件,是就从文件读、不打印提示符。这是第 07 节脚本能力的前置。
  5. 换上 readline(可选):当你想加历史和补全时,把 fgets 换成 readline。注意 readline 自动分配内存,用完要 free

💡 学习建议:实现骨架后,做一个小实验——故意去掉 fflush,看看提示符会发生什么(可能完全不显示,或者和下一行输出挤在一起)。这种「先看到坑、再理解为什么」的过程,比读十遍缓冲理论都管用。

本节要点回顾

  1. Shell 的骨架是 REPL:一个无限循环,每轮读一行、执行、回到读取——Shell 永远是「持续在场」的,不是跑完就退出。
  2. Shell 的 REPL 是 Read-Eval-Loop:Print 段几乎为空,因为命令自己负责输出;Shell 在 Eval 阶段 fork 出子进程后就退居幕后。
  3. 入门用 fgets 读一行:简单、跨平台、足以跑通骨架;想加历史和补全再换 readline
  4. 提示符是动态拼出来的:用户名、主机名、当前目录、$/# 符号——每一轮循环都重新拼接,这就是 cd 后目录立即变化的原因。
  5. 必须检测 fgets 返回 NULL:Ctrl+D 触发 EOF,Shell 应干净退出,否则会陷入死循环。
  6. printf 提示符后要 fflush:stdout 默认行缓冲,提示符末尾没 \n,不手动刷新就看不见。
  7. 交互模式 vs 脚本模式:用 isatty(stdin) 区分,前者打印提示符,后者不打印。

推荐上手顺序

  1. 写出最小骨架:while(1)printf 提示符、fgets 读一行、回显。验证它能在终端里跑起来、Ctrl+D 能干净退出。
  2. 处理边界情况:空行直接跳过、去掉末尾换行符、fflush 提示符。
  3. 动态提示符:把固定字符串换成 user@host:cwd$ ,用 getcwd/getenv 拼出来(为第 03 节 cd 做铺垫)。
  4. 进入下一节(命令解析):骨架有了 Eval 段是空回显,下一步把读到的字符串拆成「命令 + 参数」。
  5. (可选进阶)换上 readline:等 Shell 能跑管道后再回来,加上历史和补全——那时改动会很从容。

下一节(命令解析)处理 REPL 里 Eval 段的第一步:把用户敲的那一行字符串(ls -la | grep foo > out.txt)切成「命令 + 参数 + 管道段 + 重定向目标」。这一步是后续 fork 与 dup2 的前提——你不把字符串拆开,就不知道要 fork 几个子进程、各自接什么管道。


发布者: 作者: 灏天文库 转发
评论区 (0)
U