本文是对 How Terminals Work 的中文翻译与交互式复刻,通过可操作的 Demo 帮助你理解终端模拟器和 TUI 的工作原理。
01 网格模型
终端本质上就是一个由等宽单元格组成的网格,就像一块屏幕,只不过像素非常大,每个像素只能显示一个字符。
当程序向终端输出文本时,字符会从左到右、从上到下依次填入这些单元格。到达行尾自动换行,到达底部则整个网格向上滚动。
02 单元格里有什么
每个单元格存储一个字符加上这个字符的样式信息,如前景色、背景色、粗体、下划线等。
终端的颜色系统经历了从 16 色到 256 色再到真彩色(1600 万色)的演进。下面的 Demo 可以让你直观地体验单元格的各项属性,以及不同颜色深度之间的差异。
终端色彩深度
现代终端不只支持 16 色。你可以在下面的区域探索不同的颜色模式
03 转义序列
你有时可能会看到类似 ^[[31m 这样的奇怪字符,这些特殊的字符序列可以控制终端,进行移动光标、改变颜色、清除屏幕等操作,他们就是转义序列字符。
^[[2J清除整个屏幕^[[H移动光标到 (0,0)^[[5;10H移动光标到第 5 行第 10 列^[ 表示 ESC 字符(字节 0x1B)。16 色调色板使用代码 30-37 表示普通色,90-97 表示亮色变体。 04 终端是按键与程序的中介
当你按下一个键时,终端首先接收到这个信息,然后终端会向程序发送字节。方向键和鼠标点击也会变成转义序列。
ESC [ A(三个字节)。不理解这个序列的程序会直接打印出 ^[[A。 ^[[?1000h05 信号
Ctrl+C 不是输入一个字符,它触发了一个信号(signal)。当你按下 Ctrl-C 后,终端发送一个字节(0x03),但在程序接收到之前,系统内核会先拦截它,并生成 SIGINT 信号。
Ctrl+C中断Ctrl+Z挂起Ctrl+C→SIGINT停止正在运行的程序
发送 SIGINT(中断信号)给前台进程。大多数程序会立即停止。这就是你取消一个长时间运行的命令或退出卡住程序的方式。
点击"运行命令"启动模拟进程,然后尝试发送不同的信号。
信号是操作系统与运行中的程序通信的方式。当你按下 Ctrl+C 时,终端会发送一个字节(0x03)到 PTY,但内核会拦截它,在程序接收到之前将其转换为信号。
信号键 vs 普通键
06 输入模式
终端有两种基本的输入模式。在 cooked 模式(行缓冲)下,内核收集你的输入直到按回车;在 raw 模式下,每个按键立即发送给程序。你的 shell 使用 cooked 模式,vim 使用 raw 模式。
"...\n"在 cooked 模式(也称规范模式)下,终端将输入收集到行缓冲区中。你可以用退格键编辑,直到按下回车才会发送给程序。这就是 shell 通常的工作方式。
Cooked vs Raw 对比
| 行为 | Cooked | Raw |
|---|---|---|
| 输入何时发送 | 按回车后 | 立即逐键 |
| 退格键 | 从缓冲区删除 | 只是另一个键 |
| Ctrl+C | 行规程生成 SIGINT | 程序收到 0x03 |
| 方向键 | 行回溯(历史) | 程序自行处理 |
| 使用者 | bash, zsh, cat | vim, htop, ssh, less |
07 完整的往返
从按键到屏幕显示,数据经历了一次完整的往返旅程:你的按键被终端编码为字节,通过 PTY 传给 shell,shell 执行命令并产生输出,输出再沿着相同路径返回,最终被终端渲染为你看到的文字。
终端正在等待。光标闪烁。
08 构建复杂的 TUI
全屏终端应用(如 htop、vim、lazygit)将终端网格划分为多个区域,包括标签栏、侧边栏、内容区。每个区域有自己的坐标、尺寸和焦点状态。这些应用通过转义序列精确控制每个单元格的显示。
高级 TUI 将终端划分为区域。每个区域是一个具有自己内容和边框的矩形。TUI 框架跟踪每个区域在字符网格中的位置和大小。
这些 Unicode 字符构成了你在终端应用中看到的边框。它们是普通字符,终端像渲染其他文本一样渲染它们。
终端维护一个光标位置。TUI 通过转义序列不断移动光标来在不同区域绘制内容。点击下面的单元格查看移动光标到该位置的转义序列。
\x1b[1;1Hprintf("\033[1;1H")位置 (1, 1) — 第 1 行,第 1 列。终端坐标从 1 开始(第 1 行第 1 列是左上角)。
09 备用屏幕缓冲区
终端有两个屏幕缓冲区:普通屏幕和备用屏幕。当你打开 vim 时,它切换到备用屏幕;退出后,你的终端历史完好如初。这就是为什么全屏应用不会弄乱你的滚动历史。
^[[?1049h^[[?1049l同一时间只有一个缓冲区可见,另一个保存在内存中。
终端有两个屏幕缓冲区:普通屏幕(包含你的滚动历史)和备用屏幕(独立画布)。程序可以在它们之间切换。退出时,你的原始屏幕重新出现。
没有备用屏幕会怎样?
10 终端图标
现代终端中的文件图标、语言 Logo、状态指示灯都不是图片,而是 Unicode 私有使用区中的字符,由 Nerd Fonts 等特殊字体渲染为图标字形。
现代终端应用(如文件浏览器、状态栏、开发工具)会显示文件、文件夹和状态指示器的图标。这些并非图片,而是由特殊字体渲染的 Unicode 字符。
Nerd Font 图标画廊
点击任意图标查看其 Unicode 码位和使用方式。
为什么只占一个单元格?
Nerd Fonts 中的图标集
Nerd Fonts 将多个图标集合并到一个字体中。每个集合占据不同的 Unicode 范围。
11 状态管理
终端应用在内存中维护状态(当前模式、输入缓冲区、历史记录等),与 GUI 应用无异。区别在于它们通过在特定位置打印字符来显示状态变化。终端本身并不了解应用的状态,它只是一个字符显示器。
终端应用像 GUI 应用一样在内存中维护状态。区别在于显示方式:通过在特定位置打印字符。状态变化时,重绘屏幕的相关部分。
ESC [ Zmode = nextMode()print(indicator)12 文本选择与光标定位
你无法通过点击来移动光标,因为终端的文本选择与应用的光标是分开处理的。Option+Click 之所以能移动光标,是因为终端在模拟方向键按压,这是一种 hack,而非原生行为。
终端中有两种完全不同的“选择”:终端级文本选择(由终端模拟器处理)和光标位置(应用认为光标在哪里)。它们相互独立,这常常让人困惑。
为什么不能直接点击移动光标?
点击位置 → 光标立即移动,像文本编辑器或浏览器一样。
点击 → 终端显示选择,或发送鼠标事件给应用(如果启用了鼠标追踪)→ 由应用决定怎么做。
13 能力发现
程序如何知道终端支持哪些功能?有两种方式:通过 TERM 环境变量查询 terminfo 数据库,或直接向终端发送查询序列(如 DA1)。特性默认是关闭的,程序需要主动发送转义序列来启用它们。
程序通过检查 TERM 环境变量在 terminfo 数据库中查找能力。它只是一个字符串,终端并不强制执行。
程序也可以直接查询终端。DA1(ESC[c)询问"你是什么",终端以能力代码作为响应。
^[[?1000l将鼠标点击报告为转义序列^[[?1049l切换到独立的屏幕缓冲区^[[?2004l用特殊标记包裹粘贴的文本 特性默认是关闭的。程序通过发送转义序列来启用它们——这就是为什么 vim 启动时发送 ^[[?1049h(进入备用屏幕),退出时发送 ^[[?1049l。
14 术语表
终端、Shell、控制台、CLI——这些词经常被混用,但它们各有所指。理解这些术语的区别,以及它们在整体架构中的位置,能帮助你更好地理解终端的工作原理。
最初是连接到大型机的物理设备,带有屏幕和键盘。如今,这个词通常指终端模拟器。
融合了 bash、ksh 和 tcsh 的特性。以强大的 Tab 补全、拼写纠正和通过 Oh My Zsh 的主题功能著称。
最流行的 macOS 终端。分栏、搜索、触发器、Python 脚本 API。原生 tmux 集成。
你可能是说打开了一个终端模拟器(如 iTerm2),它在内部启动了一个 shell(如 zsh)。
实际上是你的 shell 在 PATH 中搜索命令。终端只是显示 shell 的输出。
交互使用时 zsh 功能更好。写脚本时 bash 更具可移植性。大多数命令在两者中表现完全一致。
终端设置控制外观(字体、颜色、窗口大小)。Shell 配置(.zshrc)控制别名、PATH 和提示符。
那是你的 shell 的功能,不是终端。Shell 维护一个历史文件(如 ~/.zsh_history),并将回忆的命令发回终端显示。
Shell 在启动时只读取一次 .zshrc。已存在的 shell 已经加载了配置。打开新终端会启动新 shell 来读取更新的文件。(或运行 source ~/.zshrc 来重新加载。)