1. 为什么需要厘清分工?
上一篇建立了概念全景图. 现在聚焦一个非常实际的问题: 每天盯着的那一整个 "黑窗口",哪些事是终端模拟器做的,哪些事是 Shell 做的?
三个最常见的误区:
| 误区 | 真相 |
|---|---|
| "提示符是终端显示的" | 提示符是 Shell 打印的一段文字,终端只负责渲染它 |
| "Ctrl+C 是 Shell 在杀进程" | Ctrl+C 被 内核 Line Discipline 拦截并转成 SIGINT 信号,Shell 没参与 |
| "终端和 Shell 是同一个东西" | 它们是两个独立的进程,通过 PTY 通信. 换终端不影响 Shell,换 Shell 不影响终端 |
把终端和 Shell 想象成浏览器和 Web 服务器的关系:
终端模拟器 = 浏览器--负责渲染(文字, 颜色, 光标位置),处理用户输入(键盘, 鼠标),管理窗口(大小, 滚动, 标签页)
Shell = Web 服务器--负责逻辑(解析命令, 执行程序, 返回结果),完全不知道也不关心 "渲染" 这件事
PTY = HTTP 连接--两者之间的通信通道
换一个浏览器访问同一个服务器,页面内容不变(换终端,Shell 行为不变). 换一个后端服务,同一个浏览器正常渲染新内容(换 Shell,终端照常工作).
2. 终端模拟器的职责
终端模拟器是一个 GUI 程序. 它的全部工作可以归纳为: 把字符渲染到屏幕上,把键盘事件转发给 Shell. 展开来看:
2.1 渲染文字与 ANSI 转义序列
Shell 发来的不只有 "要显示的字符",还有控制指令(ANSI escape codes). 终端模拟器需要:
- 收到普通字符 → 按当前字体和颜色渲染到屏幕
- 收到
\033[31m→ 切换前景色为红色(不显示任何文字) - 收到
\033[2J→ 清屏 - 收到
\033[10;5H→ 把光标移到第 10 行第 5 列
关键认知: 颜色的决定权在谁手里? 比如 ls --color=auto 输出带颜色的文件列表--颜色是 ls 命令自己加的 ANSI 转义序列. 但 ls 不知道终端背景是黑是白,它只是输出 "蓝色" 的指令,具体哪种蓝, 蓝到什么程度,由终端模拟器的配色方案决定.
就像 HTML 和 CSS--ls 写的是 Documents(语义),终端模拟器决定 .directory { color: #3b82f6 }(具体视觉呈现).
同一条 ls 命令,不同终端配色方案下看到的效果完全不同.
2.2 键盘事件 → 字符流
终端模拟器把键盘事件翻译成字符流,写入 PTY master. 在这个过程中它需要处理:
- 普通键: 按 'a' → 发送 ASCII 97(0x61)
- 修饰键: 按 Ctrl+A → 发送 ASCII 1(Ctrl 把字母的 ASCII 码高位置零)
- 特殊键: 按 ↑ 方向键 → 发送
\033[A(三个字符组成的 CSI 序列) - Option/Meta: 按 Option+B → mac 默认发送 '∫' 字符(终端可配成 ESC 前缀模式)
注意: 方向键, 功能键这些 "非字符键" 也是通过 CSI 序列表达的--跟颜色指令用了同一套协议. 终端按 ↑ → 发送 ESC [ A,Shell 读到的就是这三个字符,然后根据绑定规则(bindkey)决定 "这是上一条历史命令".
2.3 窗口大小管理
当用鼠标拖动终端窗口边缘时:
- 终端模拟器检测到新尺寸(比如从 80×24 变成 120×40)
- 通过 PTY master 向内核发出
TIOCSWINSZioctl,更新 slave 的窗口大小记录 - 内核向该终端的前台进程组发送 SIGWINCH 信号
- Shell 收到信号后更新
$COLUMNS和$LINES变量,重新绘制提示符
这不是 Shell 主动 "发现" 的--是终端模拟器告诉内核,内核通知 Shell.
2.4 复制粘贴
这完全是终端模拟器的活. Shell 没有 "选择文本" 的概念--Shell 看到的是一个字符流. 当用鼠标选中一段文字按 Cmd+C,是终端模拟器把它放进了系统剪贴板. 当 Cmd+V 粘贴,是终端模拟器把剪贴板内容当成一串键盘输入,逐字符写入 PTY master.
粘贴的隐患
粘贴的文本对 Shell 来说和键盘敲入完全等价. 如果从网页上复制了一条 rm -rf / 并粘贴到终端里,Shell 会老实执行它--它无法区分 "真的一个个键敲的" 还是 "粘贴进来的". 这也是为什么一些终端提供 "粘贴前确认" 功能: 检测到粘贴内容包含换行符时弹出警告.
3. Shell 的职责
如果终端模拟器是 "前台",Shell 就是 "后台引擎". 它是一个命令行解释器--从 stdin 读字符,解析成命令,然后执行.
3.1 打印提示符(Prompt)
看到的 $ 或 % 是 Shell 打印的--确切说,是 Shell 在每次命令执行完毕后,向 stdout 输出 $PS1 变量的值.
|
提示符里可以嵌入 ANSI 颜色, 当前路径, git 分支, 时间等--流行的 oh-my-zsh 本质上就是预设了一套花哨的 PS1 模板. 这一切都发生在 Shell 内部,终端只负责渲染 PS1 的值.
3.2 命令解析与执行
这是 Shell 的核心工作. 当敲下 ls -l /tmp | grep foo 然后按 Enter,Shell 做的事是:
- 分词:
ls,-l,/tmp,|,grep,foo - 识别管道: 发现
|,需要创建两个子进程,用 pipe 连接 - 展开通配符: 如果命令里有
*.txt,先展开成实际文件名 - 展开变量:
$HOME→/Users/yutong - fork + exec: 创建子进程执行
ls和grep - 等待子进程结束: 收集退出状态码
- 重新打印提示符: 表示 "准备好接受下一条命令了"
3.3 作业控制(Job Control)
Shell 管理着启动的每一个进程. 在 Shell 中启动 sleep 100:
- Shell fork 子进程 exec sleep
- Shell 把这个子进程放入前台进程组
- Shell 自己调用
waitpid(),阻塞等待 - 当按 Ctrl+Z(SIGTSTP),内核挂起前台进程组,Shell 收到子进程状态变化通知
- Shell 打印
[1] + suspended sleep 100并重新显示提示符
注意: 发送信号的是内核,但管理作业状态(谁在前台, 谁在后台, 谁是几号作业)的是 Shell. 内核不知道 "作业编号",那是 Shell 自己维护的概念.
前台进程组具体是什么?
进程组(Process Group)是一组共享同一个 PGID 的进程. 比如运行 ls | grep foo | wc -l--这三个进程属于同一个进程组. 一个终端同一时刻只能有一个前台进程组,这个组 ID 被记在内核 TTY 数据结构的一个字段里: tty->fg_pgrp. 所有其他进程组都是 "后台进程组"--后台进程如果尝试读终端,会被内核发送 SIGTTIN 信号挂起.
|
Shell 在切换前台: 当 Shell 空闲(等待命令)时,它把自己设为前台进程组,这样才能读到键盘输入. 当 Shell 启动 sleep 100 时,它通过 tcsetpgrp() 系统调用主动把前台进程组让给 sleep 的进程组,自己退到后台. sleep 结束后 Shell 再把自己切回来.
内核怎么知道 Ctrl+C 要打断谁?
内核不需要 "知道" 前台在运行什么程序. 它只需要知道一个数字--前台进程组的 PGID. 这个数字是 Shell 写入的:
|
多个终端同时 Ctrl+C 会互相影响吗?
不会. 每个终端窗口有自己独立的 PTY 对,每个 PTY 在内核里有独立的一套 TTY 结构,各自有各自的 fg_pgrp. 互不干扰:
|
一句话总结: 每个终端是一条独立的电话线,内核给每条线各维护一个 "当前通话人"(fg_pgrp). Ctrl+C 挂断的是这条线的当前通话,不会影响其他线上的人.
3.4 补全, 历史与行编辑
按 Tab 补全文件名? 这是 Shell 在读到按下的 Tab 字符(ASCII 9)后,调用了文件系统接口列出候选. 按 ↑ 回到上一条命令? 这是 Shell 收到了 ESC [ A 序列,在自己的历史文件(~/.zsh_history 或 ~/.bash_history)中查找.
但 Shell 的 "行编辑" 能力实际依赖 readline(bash)或 zle(zsh)--这些库通过 termcap/terminfo 数据库知道终端的各种控制序列,以此实现光标移动, 删除, 插入等编辑操作.
4. 四个协作场景 -- 逐帧拆解
光列职责还不够. 选四个最常见的瞬间,逐帧拆解终端和 Shell 各自做了什么.
场景一: 提示符是怎么出现在屏幕上的?
|
整个过程里,终端模拟器没有问 "这是提示符吗?". 它只是一视同仁地渲染了收到的字符. 提示符的 "身份" 只存在于认知里.
场景二: Ctrl+C 到底经过了谁的手?
假设正在运行 sleep 100,然后按下 Ctrl+C:
|
Shell 全程没有参与信号发送. Shell 做的唯一一件事是: 发现子进程死了,收拾残局(更新作业状态),然后重新打印提示符,等待输入下一条命令.
场景三: 拖动窗口边缘改变了大小,然后呢?
|
事件链:
- 终端模拟器: 检测到像素变化,换算出新的字符行列数(120×24)
- 终端模拟器: 通过 PTY master 发
TIOCSWINSZioctl,把新尺寸同步到内核 TTY 层 - 内核: 向 slave 端的前台进程组发送 SIGWINCH 信号
- Shell: 收到 SIGWINCH,更新内部变量
LINES和COLUMNS
如果正在运行一个全屏程序(如 vim),vim 收到 SIGWINCH 后会重新绘制整个界面以适应新尺寸.
场景四: Cmd+V 粘贴了一段多行文本,会发生什么?
|
终端模拟器把这段文本逐字符写入 PTY master,包括每行末尾的换行符. Shell 的 Line Discipline 收到换行符后,把当前缓冲的行提交给 Shell 的 stdin. 于是 Shell 把这三行当成了三条独立的命令,依次执行.
这个过程里:
- 终端模拟器: 只负责把剪贴板内容当键盘输入 "灌入" PTY
- Line Discipline: 照常做行缓冲和回显
- Shell: 完全不知道这是粘贴的--对它来说,就是连续收到了三行命令
安全警示
千万不要从不可信来源粘贴命令到终端. 一个恶意网页可以在复制的文本里插入不可见的控制字符(比如在正常命令后面加上 \rrm -rf ~). Cmd+C 复制到的是表面文字,但粘贴进终端的还包含了藏起来的恶意命令.
快速回顾
- 终端模拟器做的: 渲染字符和 ANSI 转义序列; 键盘 → 字符流转换; 窗口大小管理与 SIGWINCH; 复制粘贴; 字体, 配色, 滚动缓冲区; 多标签页, 分屏
- Shell 做的: 打印提示符(PS1); 解析命令(分词, 展开通配符/变量); fork + exec 执行程序; 作业控制(fg/bg/jobs); 补全(Tab), 历史(↑↓), 别名(alias); 管道(|), 重定向(>, <), 脚本
- 一句话分界线: 终端模拟器管的是**"怎么看, 怎么输入",Shell 管的是"输入后发生什么"**. 界面是终端,逻辑是 Shell. 两者之间,内核 PTY 层负责传递数据和控制信号
动手练习
- 查看提示符模板: 运行
echo "$PS1",看看提示符模板长什么样. 然后PS1="→ "临时改掉它 - 观察 Ctrl+C 行为: 运行
sleep 10,然后按 Ctrl+C--观察: Shell 是否重新打印了提示符? 这说明什么? - 窗口大小感知: 运行
echo "$COLUMNS x $LINES",然后拖动窗口边缘改变大小,再运行一次--观察数值变化 - 方向键在 cat 中的表现: 运行
cat然后直接按 ↑ 方向键--看到的是^[[A还是上一条历史命令? 为什么? - 跨终端写设备: 打开两个终端窗口,
echo $PS1 > /dev/ttysXXX向另一个窗口发 PS1 的值--那个窗口的提示符变了吗?(不会,因为 PS1 只是 Shell 的内部变量,不是 "终端设置") - 观察转义序列: 在终端里运行
printf '\033[31m红色\033[0m\n',然后复制这条命令到文本编辑器--看到的是什么?(纯文本,"红色" 二字加上不可见的 ESC 序列)