Skip to content

终端是如何工作的

本文是对 How Terminals Work 的中文翻译与交互式复刻,通过可操作的 Demo 帮助你理解终端模拟器和 TUI 的工作原理。

01 网格模型

终端本质上就是一个由等宽单元格组成的网格,就像一块屏幕,只不过像素非常大,每个像素只能显示一个字符。

当程序向终端输出文本时,字符会从左到右、从上到下依次填入这些单元格。到达行尾自动换行,到达底部则整个网格向上滚动。

将鼠标悬停在单元格上
点击单元格可以绘制

02 单元格里有什么

每个单元格存储一个字符加上这个字符的样式信息,如前景色、背景色、粗体、下划线等。

终端的颜色系统经历了从 16 色到 256 色再到真彩色(1600 万色)的演进。下面的 Demo 可以让你直观地体验单元格的各项属性,以及不同颜色深度之间的差异。

A
字符
前景色
背景色
属性
单元格: "A" / green

终端色彩深度

现代终端不只支持 16 色。你可以在下面的区域探索不同的颜色模式

原始 16 色
black
red
green
yellow
blue
magenta
cyan
white
brBlack
brRed
brGreen
brYellow
brBlue
brMagenta
brCyan
brWhite
转义序列格式:
^[[38;5;<0-15>m — 前景色
^[[48;5;<0-15>m — 背景色
颜色数量对比:
16
经典 ANSI
256
扩展
16,777,216
真彩色 (24-bit)

03 转义序列

你有时可能会看到类似 ^[[31m 这样的奇怪字符,这些特殊的字符序列可以控制终端,进行移动光标、改变颜色、清除屏幕等操作,他们就是转义序列字符。

16 色调色板
Normal (30-37)Bright (90-97)
样式序列
光标序列
^[[2J清除整个屏幕
^[[H移动光标到 (0,0)
^[[5;10H移动光标到第 5 行第 10 列
预览
未激活任何转义序列
Hello World
^[ 表示 ESC 字符(字节 0x1B)。16 色调色板使用代码 30-37 表示普通色,90-97 表示亮色变体。

04 终端是按键与程序的中介

当你按下一个键时,终端首先接收到这个信息,然后终端会向程序发送字节。方向键和鼠标点击也会变成转义序列。

按下任意键
试试方向键、Enter、Tab 或字母
当你按下方向键时,终端不会发送"上移",它发送的是 ESC [ A(三个字节)。不理解这个序列的程序会直接打印出 ^[[A

^[[?1000h
点击网格中的任意位置
同理默认情况下终端不会发送鼠标事件。程序需要主动请求鼠标追踪,之后点击会变成带有坐标信息的转义序列。

05 信号

Ctrl+C 不是输入一个字符,它触发了一个信号(signal)。当你按下 Ctrl-C 后,终端发送一个字节(0x03),但在程序接收到之前,系统内核会先拦截它,并生成 SIGINT 信号。

Ctrl+C中断
SIGINT
Ctrl+Z挂起
SIGTSTP
Ctrl+CSIGINT

停止正在运行的程序

发送 SIGINT(中断信号)给前台进程。大多数程序会立即停止。这就是你取消一个长时间运行的命令或退出卡住程序的方式。

示例: 运行 `sleep 100` 然后按 Ctrl+C 会立即停止它。
试一试
$

点击"运行命令"启动模拟进程,然后尝试发送不同的信号。

什么是信号?
1/4

信号是操作系统与运行中的程序通信的方式。当你按下 Ctrl+C 时,终端会发送一个字节(0x03)到 PTY,但内核会拦截它,在程序接收到之前将其转换为信号。

信号键 vs 普通键

普通键
a0x61 (字符发送给程序)
Enter0x0D (回车)
^[[A (转义序列)
信号键
Ctrl+C0x03SIGINT
Ctrl+Z0x1ASIGTSTP
字节被行规程拦截 → 转换为信号

06 输入模式

终端有两种基本的输入模式。在 cooked 模式(行缓冲)下,内核收集你的输入直到按回车;在 raw 模式下,每个按键立即发送给程序。你的 shell 使用 cooked 模式,vim 使用 raw 模式。

输入模式:
输入、编辑,然后回车
$
发生了什么
Cooked 模式(canonical)
1 你输入字符。内核的 行规程 缓冲它们。
2退格键:行规程从缓冲区中删除一个字符。
3回车 将整行发送给程序。
4 程序收到: "...\n"
Cooked 模式(行缓冲)
1/4

在 cooked 模式(也称规范模式)下,终端将输入收集到行缓冲区中。你可以用退格键编辑,直到按下回车才会发送给程序。这就是 shell 通常的工作方式。

Cooked vs Raw 对比

行为CookedRaw
输入何时发送按回车后立即逐键
退格键从缓冲区删除只是另一个键
Ctrl+C行规程生成 SIGINT程序收到 0x03
方向键行回溯(历史)程序自行处理
使用者bash, zsh, catvim, htop, ssh, less

07 完整的往返

从按键到屏幕显示,数据经历了一次完整的往返旅程:你的按键被终端编码为字节,通过 PTY 传给 shell,shell 执行命令并产生输出,输出再沿着相同路径返回,最终被终端渲染为你看到的文字。

Terminal Stack
[kbd]
你(键盘)物理按键
/
[tty]
终端模拟器编码输入,渲染输出
/
[pty]
PTY(伪终端)双向管道
/
[sh]
Shell / 程序bash, zsh 或任意 CLI 程序
Output
$
就绪

终端正在等待。光标闪烁。

08 构建复杂的 TUI

全屏终端应用(如 htop、vim、lazygit)将终端网格划分为多个区域,包括标签栏、侧边栏、内容区。每个区域有自己的坐标、尺寸和焦点状态。这些应用通过转义序列精确控制每个单元格的显示。

持续集成
集成
日志
ci-fe-be-rules
ci-api-test
ci-email-service
ci-auth-core
ci-db-migration
ci-fe-be-rules
Status: success
Updated: 2024-01-15 14:32:00 UTC
尺寸: 60x20
布局系统
1/5

高级 TUI 将终端划分为区域。每个区域是一个具有自己内容和边框的矩形。TUI 框架跟踪每个区域在字符网格中的位置和大小。

TUI 架构揭秘
1 布局引擎
TUI 维护一个区域树(类似 DOM)。每个节点存储位置、尺寸和约束。布局变更时自顶向下重新计算,确保子区域适配父区域。
2 事件分发
输入事件先到聚焦区域。如果未处理,事件冒泡到父区域。全局快捷键(如 Ctrl+C)在分发前被拦截。
3 渲染循环
每个区域渲染到缓冲区。渲染引擎对比新旧缓冲区,只发送差异部分的转义序列,最小化 I/O 开销。
制表符绘制字符
┌ ┐ └ ┘
线
─ │ ═ ║
T 形连接
┬ ┴ ├ ┤
交叉
┼ ╬ ╪ ╫

这些 Unicode 字符构成了你在终端应用中看到的边框。它们是普通字符,终端像渲染其他文本一样渲染它们。

光标定位

终端维护一个光标位置。TUI 通过转义序列不断移动光标来在不同区域绘制内容。点击下面的单元格查看移动光标到该位置的转义序列。

·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
移动光标的转义序列:
\x1b[1;1H
代码写法:
printf("\033[1;1H")

位置 (1, 1) — 第 1 行,第 1 列。终端坐标从 1 开始(第 1 行第 1 列是左上角)。

09 备用屏幕缓冲区

终端有两个屏幕缓冲区:普通屏幕和备用屏幕。当你打开 vim 时,它切换到备用屏幕;退出后,你的终端历史完好如初。这就是为什么全屏应用不会弄乱你的滚动历史。

普通缓冲区
$ ls -la
-rw-r--r-- 1 user staff 847 Jan 8 10:30 package.json
-rw-r--r-- 1 user staff 1205 Jan 8 10:28 README.md
drwxr-xr-x 12 user staff 384 Jan 8 10:30 src
$ git status
On branch main
nothing to commit, working tree clean
$ vim README.md
README.md [+]
1# README
2
3This is a sample project.
4
5## Installation
6
7npm install
~
~
~
~
-- INSERT --1,1   All
转义序列
进入备用屏幕^[[?1049h
保存当前屏幕,清除显示,切换到备用缓冲区。打开 vim、less、htop 等时发送。
退出备用屏幕^[[?1049l
恢复保存的屏幕缓冲区。之前的终端内容完好如初地重新出现。退出应用时发送。
普通缓冲区
滚动历史
命令输出
备用缓冲区
独立画布
全屏应用

同一时间只有一个缓冲区可见,另一个保存在内存中。

什么是备用屏幕?
1/4

终端有两个屏幕缓冲区:普通屏幕(包含你的滚动历史)和备用屏幕(独立画布)。程序可以在它们之间切换。退出时,你的原始屏幕重新出现。

没有备用屏幕会怎样?

没有备用屏幕
$ vim README.md
... 编辑文件 ...
$
(vim 的残留画面混在终端输出中)
~
~
$ ls
package.json README.md src
有备用屏幕
$ vim README.md
... 编辑文件 ...
$
(退出 vim 后,原始屏幕完好恢复)
$ ls
package.json README.md src
$ git status
On branch main

10 终端图标

现代终端中的文件图标、语言 Logo、状态指示灯都不是图片,而是 Unicode 私有使用区中的字符,由 Nerd Fonts 等特殊字体渲染为图标字形。

显示 Nerd Font 字形
src
components
App.tsx
Button.tsx
utils
index.ts
package.json
.gitignore
README.md
Dockerfile
什么是终端图标?
1/5

现代终端应用(如文件浏览器、状态栏、开发工具)会显示文件、文件夹和状态指示器的图标。这些并非图片,而是由特殊字体渲染的 Unicode 字符。

为什么只占一个单元格?

1 单码位
每个 Nerd Font 图标是一个 Unicode 码位。终端将其视为一个字符,与 'A' 或 '中' 一样。一个字符 = 一个单元格。
2 字体字形
字体文件为每个码位包含一个字形(矢量图)。Nerd Fonts 添加了数千个图标字形,尺寸适配终端的单元格大小。
3 单元格尺寸设计
图标字形被设计为适配等宽单元格。它们是正方形或略呈矩形,以完美匹配终端的字符网格。
单宽:
A
B
C
每个 1 格
双宽:
每个 2 格 (CJK)

Nerd Fonts 中的图标集

Nerd Fonts 将多个图标集合并到一个字体中。每个集合占据不同的 Unicode 范围。

Powerline
U+E0A0-E0D4
状态栏分隔符和箭头
Font Awesome
U+F000-F2E0
通用图标
Devicons
U+E700-E7C5
编程语言 Logo
Octicons
U+F400-F532
GitHub 风格图标

11 状态管理

终端应用在内存中维护状态(当前模式、输入缓冲区、历史记录等),与 GUI 应用无异。区别在于它们通过在特定位置打印字符来显示状态变化。终端本身并不了解应用的状态,它只是一个字符显示器。

>
>>accept edits on(shift+tab 切换)
点击此处开始输入,按 Shift+Tab 切换模式
终端应用中的状态
1/5

终端应用像 GUI 应用一样在内存中维护状态。区别在于显示方式:通过在特定位置打印字符。状态变化时,重绘屏幕的相关部分。

状态更新循环
1
输入
用户按下 Shift+Tab
ESC [ Z
2
处理
应用识别序列
mode = nextMode()
3
渲染
重绘模式指示器
print(indicator)

12 文本选择与光标定位

你无法通过点击来移动光标,因为终端的文本选择与应用的光标是分开处理的。Option+Click 之所以能移动光标,是因为终端在模拟方向键按压,这是一种 hack,而非原生行为。

~/projects $ ls -la
total 24
drwxr-xr-x 5 user staff 160 Jan 7 10:00 .
drwxr-xr-x 12 user staff 384 Jan 6 15:30 ..
-rw-r--r-- 1 user staff 234 Jan 7 09:45 README.md
-rw-r--r-- 1 user staff 1024 Jan 7 10:00 index.ts
drwxr-xr-x 3 user staff 96 Jan 5 14:20 src
~/projects $ echo "Hello, World!"
Hello, World!
~/projects $ _
点击并拖动以选择文本。这由终端处理,不是运行中的程序。
两种类型的选择
1/5

终端中有两种完全不同的“选择”:终端级文本选择(由终端模拟器处理)和光标位置(应用认为光标在哪里)。它们相互独立,这常常让人困惑。

为什么不能直接点击移动光标?

你的期望

点击位置 → 光标立即移动,像文本编辑器或浏览器一样。

实际发生的

点击 → 终端显示选择,或发送鼠标事件给应用(如果启用了鼠标追踪)→ 由应用决定怎么做。

13 能力发现

程序如何知道终端支持哪些功能?有两种方式:通过 TERM 环境变量查询 terminfo 数据库,或直接向终端发送查询序列(如 DA1)。特性默认是关闭的,程序需要主动发送转义序列来启用它们。

TERM 环境变量
$TERM=
支持 256 色的现代终端
颜色
256
鼠标
支持
备用屏幕
支持
Unicode
支持

程序通过检查 TERM 环境变量在 terminfo 数据库中查找能力。它只是一个字符串,终端并不强制执行。

查询与响应
程序
vim
ESC[c
ESC[?64;1;4;22c
终端
tty

程序也可以直接查询终端。DA1(ESC[c)询问"你是什么",终端以能力代码作为响应。

启用特性
^[[?1000l将鼠标点击报告为转义序列
^[[?1049l切换到独立的屏幕缓冲区
^[[?2004l用特殊标记包裹粘贴的文本

特性默认是关闭的。程序通过发送转义序列来启用它们——这就是为什么 vim 启动时发送 ^[[?1049h(进入备用屏幕),退出时发送 ^[[?1049l

14 术语表

终端、Shell、控制台、CLI——这些词经常被混用,但它们各有所指。理解这些术语的区别,以及它们在整体架构中的位置,能帮助你更好地理解终端的工作原理。

硬件Terminal
软件Terminal Emulator
软件Shell
软件PTY
ShellBash
ShellZsh
接口类型Console
接口类型CLI
硬件
Terminal
AKA: TTY, Teletype

最初是连接到大型机的物理设备,带有屏幕和键盘。如今,这个词通常指终端模拟器。

示例
VT100VT220IBM 3270
它们如何组合在一起
终端模拟器iTerm2, Ghostty, kitty
PTY内核中的伪终端
Shellzsh, bash, fish
CLI 程序git, npm, vim
数据双向流动: 击键向内传递,输出向外传递。每一层都会对数据进行转换。
Shell 家族谱
Zsh (Z Shell)(1990)

融合了 bash、ksh 和 tcsh 的特性。以强大的 Tab 补全、拼写纠正和通过 Oh My Zsh 的主题功能著称。

配置文件
~/.zshrc, ~/.zprofile
默认于
macOS (自 2019 年起)
终端模拟器一览
iTerm2• 功能丰富

最流行的 macOS 终端。分栏、搜索、触发器、Python 脚本 API。原生 tmux 集成。

主要特性
分栏Tmux 集成自动补全触发器
跟踪一条命令
你输入
ls -la
你在键盘上按下按键
常见混淆
"我打开了终端"

你可能是说打开了一个终端模拟器(如 iTerm2),它在内部启动了一个 shell(如 zsh)。

"终端找不到命令"

实际上是你的 shell 在 PATH 中搜索命令。终端只是显示 shell 的输出。

"bash 还是 zsh——该用哪个?"

交互使用时 zsh 功能更好。写脚本时 bash 更具可移植性。大多数命令在两者中表现完全一致。

"终端设置 vs shell 配置"

终端设置控制外观(字体、颜色、窗口大小)。Shell 配置(.zshrc)控制别名、PATH 和提示符。

"上箭头怎么回忆之前的命令?"

那是你的 shell 的功能,不是终端。Shell 维护一个历史文件(如 ~/.zsh_history),并将回忆的命令发回终端显示。

"为什么编辑 .zshrc 后要重启终端?"

Shell 在启动时只读取一次 .zshrc。已存在的 shell 已经加载了配置。打开新终端会启动新 shell 来读取更新的文件。(或运行 source ~/.zshrc 来重新加载。)

快速参考
# 我在用哪个 shell?
echo $SHELL
# 我在哪个终端里?
echo $TERM_PROGRAM
# 我的 PTY 设备是什么?
tty
# 列出可用的 shell
cat /etc/shells
# 将默认 shell 改为 zsh
chsh -s /bin/zsh