阶段一 · 概念辨析

Terminal 与 Shell 的分工

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 窗口大小管理

当用鼠标拖动终端窗口边缘时:

  1. 终端模拟器检测到新尺寸(比如从 80×24 变成 120×40)
  2. 通过 PTY master 向内核发出 TIOCSWINSZ ioctl,更新 slave 的窗口大小记录
  3. 内核向该终端的前台进程组发送 SIGWINCH 信号
  4. 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 变量的值.

# 看看提示符实际是什么
$ echo "$PS1"
%s
# zsh 默认简洁,bash 通常是 \h:\W \u\$
# 临时改掉它:
$ PS1="请输入命令:"
请输入命令:ls
# Shell 照常工作,只是提示文字变了--这证明提示符仅仅是 Shell 打印的字符串

提示符里可以嵌入 ANSI 颜色, 当前路径, git 分支, 时间等--流行的 oh-my-zsh 本质上就是预设了一套花哨的 PS1 模板. 这一切都发生在 Shell 内部,终端只负责渲染 PS1 的值.

3.2 命令解析与执行

这是 Shell 的核心工作. 当敲下 ls -l /tmp | grep foo 然后按 Enter,Shell 做的事是:

  1. 分词: ls, -l, /tmp, |, grep, foo
  2. 识别管道: 发现 |,需要创建两个子进程,用 pipe 连接
  3. 展开通配符: 如果命令里有 *.txt,先展开成实际文件名
  4. 展开变量: $HOME/Users/yutong
  5. fork + exec: 创建子进程执行 lsgrep
  6. 等待子进程结束: 收集退出状态码
  7. 重新打印提示符: 表示 "准备好接受下一条命令了"

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 信号挂起.

终端 /dev/pts/2 的 TTY 结构:
fg_pgrp = 12345 ← 前台:只有这个组能读终端输入,收到 Ctrl+C
─────────────────
后台进程组: 67890, 11111 ← 这些进程如果尝试读终端,会被 SIGTTIN 挂起

Shell 在切换前台: 当 Shell 空闲(等待命令)时,它把自己设为前台进程组,这样才能读到键盘输入. 当 Shell 启动 sleep 100 时,它通过 tcsetpgrp() 系统调用主动把前台进程组让给 sleep 的进程组,自己退到后台. sleep 结束后 Shell 再把自己切回来.

内核怎么知道 Ctrl+C 要打断谁?

内核不需要 "知道" 前台在运行什么程序. 它只需要知道一个数字--前台进程组的 PGID. 这个数字是 Shell 写入的:

Shell 启动 sleep 100:
1. fork() 子进程
2. Shell 调用 tcsetpgrp() 把 sleep 的 PGID 写入 TTY fg_pgrp
3. Shell 调用 waitpid() 阻塞等待子进程结束

按 Ctrl+C:
1. 内核 Line Discipline 拦截 ASCII 3
2. 向 PGID=12345 的所有进程发送 SIGINT
3. sleep 收到 SIGINT → 终止
4. Shell waitpid() 返回,发现子进程被信号终止
5. tcsetpgrp() 把自己切回前台
6. 重新打印提示符

多个终端同时 Ctrl+C 会互相影响吗?

不会. 每个终端窗口有自己独立的 PTY 对,每个 PTY 在内核里有独立的一套 TTY 结构,各自有各自的 fg_pgrp. 互不干扰:

/dev/pts/1: fg_pgrp = 12345 (sleep)   ← Ctrl+C 只杀这条线上的 sleep
/dev/pts/2: fg_pgrp = 67890 (vim) ← 不受影响

一句话总结: 每个终端是一条独立的电话线,内核给每条线各维护一个 "当前通话人"(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 各自做了什么.

场景一: 提示符是怎么出现在屏幕上的?

Shell: write(1, "$ ", 2)

PTY slave: 字符 $ 和空格进入 slave 缓冲区

PTY master: 终端模拟器的 fd 读到了这两个字符

终端模拟器: 在当前光标位置用默认颜色渲染 $

屏幕上出现 $

整个过程里,终端模拟器没有问 "这是提示符吗?". 它只是一视同仁地渲染了收到的字符. 提示符的 "身份" 只存在于认知里.

场景二: Ctrl+C 到底经过了谁的手?

假设正在运行 sleep 100,然后按下 Ctrl+C:

手指 → Ctrl+C

终端模拟器: 把 Ctrl+C 翻译为 ASCII 3 (ETX),写入 PTY master

内核 Line Discipline: 识别到 ASCII 3 → 不发字符给 Shell,直接向前台进程组发送 SIGINT

sleep 进程: 收到 SIGINT → 默认行为: 终止

Shell: waitpid() 发现子进程被信号终止,打印提示符

Shell 全程没有参与信号发送. Shell 做的唯一一件事是: 发现子进程死了,收拾残局(更新作业状态),然后重新打印提示符,等待输入下一条命令.

场景三: 拖动窗口边缘改变了大小,然后呢?

# 先看一下当前窗口尺寸
$ echo "列=$COLUMNS 行=$LINES"
列=80 行=24

# 用鼠标把窗口拉宽,再查一次
$ echo "列=$COLUMNS 行=$LINES"
列=120 行=24
# 列数变了!这说明 Shell 感知到了窗口尺寸变化

事件链:

  1. 终端模拟器: 检测到像素变化,换算出新的字符行列数(120×24)
  2. 终端模拟器: 通过 PTY master 发 TIOCSWINSZ ioctl,把新尺寸同步到内核 TTY 层
  3. 内核: 向 slave 端的前台进程组发送 SIGWINCH 信号
  4. Shell: 收到 SIGWINCH,更新内部变量 LINESCOLUMNS

如果正在运行一个全屏程序(如 vim),vim 收到 SIGWINCH 后会重新绘制整个界面以适应新尺寸.

场景四: Cmd+V 粘贴了一段多行文本,会发生什么?

# 从网页上复制了以下三行,粘贴到终端:
echo "第一行"
echo "第二行"
echo "第三行"

终端模拟器把这段文本逐字符写入 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 层负责传递数据和控制信号

动手练习

  1. 查看提示符模板: 运行 echo "$PS1",看看提示符模板长什么样. 然后 PS1="→ " 临时改掉它
  2. 观察 Ctrl+C 行为: 运行 sleep 10,然后按 Ctrl+C--观察: Shell 是否重新打印了提示符? 这说明什么?
  3. 窗口大小感知: 运行 echo "$COLUMNS x $LINES",然后拖动窗口边缘改变大小,再运行一次--观察数值变化
  4. 方向键在 cat 中的表现: 运行 cat 然后直接按 ↑ 方向键--看到的是 ^[[A 还是上一条历史命令? 为什么?
  5. 跨终端写设备: 打开两个终端窗口,echo $PS1 > /dev/ttysXXX 向另一个窗口发 PS1 的值--那个窗口的提示符变了吗?(不会,因为 PS1 只是 Shell 的内部变量,不是 "终端设置")
  6. 观察转义序列: 在终端里运行 printf '\033[31m红色\033[0m\n',然后复制这条命令到文本编辑器--看到的是什么?(纯文本,"红色" 二字加上不可见的 ESC 序列)