终端全栈详解:Terminal、Shell、TTY、Console、POSIX、ANSI 转义序列、PTY、Raw/Canonical 模式
原文: The Full Stack of Terminals Explained
作者: Ahmad Awais
发布日期: 2026 年 2 月 17 日
译注: 本文为翻译转载,仅作技术分享用途。所有内容版权归原作者所有。
大多数开发者用了好几年终端,却从未真正搞清楚"终端"到底是什么。他们会用,但定义不出来。这就像大多数人开车不需要懂内燃机原理一样——平时没问题,直到某天出了个跟底层有关的故障,你就傻眼了。
"终端"、"Shell"、"TTY"、"控制台"这几个词经常被混用。严格来说不该这样,但混用本身就说明了一件事:这些概念在实践中耦合得太紧了,以至于几代开发者在不区分它们的情况下也照样活了下来。真正有价值的问题不只是"这些词是什么意思",而是"搞清楚区别之后,你的思考方式会怎么变?"
这篇文章覆盖全栈:从容易混淆的术语,一直到从零构建一个 TUI(文本用户界面)应用。如果你好奇 vim 到底是怎么接管你的屏幕的,Raw Mode 为什么存在,ANSI 转义序列是什么——这篇文章就是给你写的。
它们曾经是同一个东西
这是关键洞察。这四个词最初描述的是同一个物理对象。
1960 年代,你跟一台电脑交互的方式是坐在一台带键盘和打印机的机器前面。这台机器有三个名字,取决于谁在说:
┌─────────────────────────────────────
│ ┌───────────────────────────────
│ │ $ hello world_ │ <-- "控制台"(物理设备)
│ │ │ <-- "终端"(线路的另一端)
│ │ │ <-- "TTY"(它是一台电传打字机)
│ └───────────────────────────────
│ ┌───────────────────────────────
│ │ ┌───┐┌───┐┌───┐┌───┐┌───┐
│ │ │ Q ││ W ││ E ││ R ││ T │...
│ │ └───┘└───┘└───┘└───┘└───┘
│ └───────────────────────────────
└─────────────────────────────────────
│
│ 线缆
│
┌───────┴────────
│ 大型机
└────────────────
三个名字,一个东西。这就是为什么它们总是混在一起。随着硬件演进为软件,每个词慢慢漂向了自己的含义。但漂移得很慢。
第一部分:术语表
控制台(Console)
控制台是计算机的物理输入/输出设备。最初指的是直接连接到机器本身的键盘和显示器。从操作系统的角度来看,它被视为"系统的主标准 I/O 终端"。
在服务器上,就是你走过去插上显示器看到的东西。在笔记本上,就是图形桌面崩溃后你会落到的文本屏幕。它是最后的终端——内核把 panic 消息写到那里,因为当一切都坏了的时候,它保证还在。
远程访问(不是控制台):
You --> laptop --> SSH --> internet --> server
│
┌────┴────
│ server
└────┬────
│
控制台访问(真正的):
│
You --> keyboard ──────────────────────>
monitor <──────────────────────
^ 这才是控制台。直连。物理的。
网络挂了也能用。
这个词的含义后来变宽了。浏览器的开发者工具叫"控制台",macOS 有个日志查看器也叫 Console。但原始含义始终不变:直接的、本地的、硬件级的接口。
终端(Terminal)
终端是你看到的东西。它处理输入和输出。按键进去,文本出来。这就是它的全部工作。
当你打开 iTerm2、Alacritty、Windows Terminal 或 GNOME Terminal 时,你运行的是一个终端模拟器:一个用软件实现旧硬件终端功能的程序。"模拟器"这个词通常被省略,但值得记住,因为它澄清了抽象层次。你的终端应用在一台设备。
让人意外的部分:终端不理解你的命令。它完全不知道 ls 或 git push 是什么意思。它不解析任何东西。它是一个带键盘的显示表面。
┌─ 终端(你看到的部分) ────────────────
│
│ 它负责:
│ - 在屏幕上渲染文本
│ - 颜色和字体
│ - 滚动缓冲区历史
│ - 复制粘贴
│ - 解释 ANSI 转义序列
│
│ 它不负责:
│ - 理解命令
│ - 运行程序
│ - Tab 补全
│ - 你的提示符
│
└─────────────────────────────────────────
各平台主流终端模拟器:
- macOS:Terminal.app、iTerm2、Ghostty、Alacritty、kitty
- Linux:GNOME Terminal、Konsole
- Windows:Windows Terminal、ConEmu
- Web/Node:xterm
这是一个干净的分隔,而干净的分隔值得注意。它们往往是承重的。
Shell
Shell 是运行在终端里面的程序。它是命令行解释器——接收你输入的文本,弄清楚要运行什么程序,运行它,并管理该进程的生命周期。
你输入 git status。终端把这些文字发给 Shell。Shell 解析它,找到 git 程序,生成一个进程,连接 I/O。输出流回终端到你的眼睛。Shell 处理逻辑,终端处理像素。
你输入:git status
│
▼
┌─ 终端 ──────────────────────────
│
│ 把按键转换为字节
│ 把文本发给 Shell
│
└──────────────┬───────────────────
│ "git status\n"
▼
┌─ Shell(bash、zsh、fish) ────────
│
│ 解析命令
│ 找到 /usr/bin/git
│ 生成进程
│ 管理生命周期
│
└──────────────┬───────────────────
│ 输出字节
▼
┌─ 终端 ──────────────────────────
│
│ 接收输出
│ 渲染到屏幕上
│
│ On branch main
│ nothing to commit
│
└──────────────────────────────────
Shell 的主要工作:命令解析(分词、重定向处理)、环境变量管理、进程启动和控制(作业控制)、以及脚本功能。
Shell 有很多种,选哪个是有意义的选择:
- Bash 是默认选项,几乎存在于所有类 Unix 系统上。
- Zsh 是 macOS 的默认选项,以可扩展性著称。
- PowerShell 在 Windows 上走了一条完全不同的路。
关键点:终端和 Shell 是独立的。你可以换掉其中一个而不影响另一个。
任何终端 + 任何 Shell
┌──────────────┐ ┌──────────────
│ iTerm2 │─────────┐ ┌─────│ Bash
│ Alacritty │─────────┼────┼─────│ Zsh
│ Kitty │─────────┼────┼─────│ Fish
│ Win Terminal│─────────┘ └─────│ PowerShell
└──────────────┘ └──────────────
它们互不关心。
CLI(命令行界面)
CLI 是更大的类别。任何命令以文本输入、输出以文本返回的接口都算。Shell、REPL、TUI 应用都是 CLI 的子类型。当有人说"命令行"时,通常指的是你输入单条命令的那一行,比如 ls -l 或 git commit -m "msg"。Shell 解析这个字符串并执行它。
TTY(TeleTYpewriter,电传打字机)
TTY 是四个术语中最技术性的,也是你可以忽略最久的那个。
在现代 Unix 中,TTY 是内核中的一个设备文件,连接你的终端和 Shell。它是类 Unix 系统中终端设备的通用术语。最初用于连接物理电传打字机,现在泛指所有终端设备,包括虚拟终端。
现在输入 tty,你会看到类似 /dev/ttys003 或 /dev/pts/1 的东西。那就是你当前会话的设备文件。
┌──────────────┐ ┌──────────────┐ ┌──────────────
│ │ │ │
│ 终端 │◄───►│ TTY 设备 │◄───►│ Shell
│ (iTerm2) │ │ /dev/pts/1 │ │ (bash)
│ │ │ │
└──────────────┘ └──────────────┘ └──────────────
用户空间 内核 用户空间
TTY 在中间。它是内核里
处理数据流的中间人。
日常使用中,"tty"和"终端"意思一样。只有在你写系统级代码或调试信号相关的奇怪问题时,区别才会显现。
PTY(伪终端)
PTY 是终端模拟器使用的虚拟终端设备。实际上,两个设备文件成对运作:
┌─────────────────┐ ┌──────────────────
│ PTY 主端 │ │ PTY 从端
│ (master side) │◄───────►│ (slave side)
│ │
│ 由终端模拟器 │ │ Shell 和 TUI
│ 操作 │ │ 应用连接到这里
└─────────────────┘ └──────────────────
在 POSIX.1-2024 中,主端叫 "manager",从端叫 "subsidiary"。
由于 Shell 和 TUI 应用把从端当作"真正的终端",它们可以使用跟物理终端一样的 API(termios 等)。应用不需要知道自己在跟物理终端还是虚拟终端对话。
设备文件示例:
- Linux:
/dev/pts/0、/dev/pts/1… - macOS:
/dev/ttys000、/dev/ttys001…
TUI(文本用户界面)
TUI 是一种基于字符的界面,使用整个屏幕提供交互式 UI。与逐行执行命令的普通 CLI(Shell)不同,TUI 控制整个屏幕,通过光标移动、颜色、边框、菜单和表单实现丰富的用户体验。
想想 vim、less、htop 或 nmtui。它们接管你的整个终端窗口并不断重绘。
┌─────────────────────────────────────────────────────
│
│ CLI (Shell) TUI (vim, htop)
│
│ $ ls -la ┌──────────────────────
│ total 48 │ htop - 3.2.1
│ drwxr-xr-x 5 user ... │ CPU[|||||||| 38.2%]
│ -rw-r--r-- 1 user ... │ Mem[||||| 512/8G]
│ $ _ │
│ │ PID USER CPU% MEM
│ 文本逐行向下流动 │ 1234 root 12.3 1.2
│ 就这样。 │ 5678 user 8.1 0.9
│ └──────────────────────
│ 全屏。交互式。
│ 不断重绘。
│
└─────────────────────────────────────────────────────
CLI 和 TUI 的对比:
| 方面 | CLI (Shell) | TUI (vim, htop) |
|---|---|---|
| 输入 | 基于行(按 Enter 确认) | 基于键(按下即响应) |
| 屏幕 | 文本向下流动 | 全屏重绘 |
| 光标 | 自动移到下一行 | 通过 ANSI 转义序列任意移动 |
| 回显 | 开启(输入时显示字符) | 关闭(应用自己绘制 UI) |
| 终端模式 | Canonical Mode | Raw Mode |
| 示例 | bash, zsh, fish, Python REPL | vim, less, htop, nmtui |
第二部分:底层管道
大多数文章到这里就停了。但如果你想构建 TUI 应用,或者哪怕只是理解 vim 对你的终端做了什么,你需要再往下走一层。
POSIX(可移植操作系统接口)
POSIX 是一个标准规范,用于维护类 Unix 系统之间的兼容性。由 IEEE 制定,它定义了系统调用、终端控制、文件 I/O、线程等 API。macOS 和 Linux 中的大多数命令和系统调用都符合 POSIX。
POSIX 兼容的操作系统包括 Linux(Ubuntu、Debian、Red Hat 等)、macOS(Darwin)、BSD 系统(FreeBSD、OpenBSD)、Solaris 和 AIX。最新规范是 POSIX.1-2024(IEEE Std 1003.1-2024)。
POSIX 终端接口
POSIX 定义的标准终端 I/O 控制 API。termios 结构体及其相关函数(tcgetattr()、tcsetattr())用于配置终端模式、定义特殊字符和设置波特率。这种标准化意味着同样的代码可以在 POSIX 兼容的操作系统上运行。
Unix 终端接口
类 Unix 操作系统中传统的终端控制机制。TTY 驱动程序在内核中管理终端设备,线路规程(line discipline)处理输入/输出处理。POSIX 是对这个 Unix 终端接口的标准化。
TTY 线路规程
线路规程是内核中位于 TTY 驱动程序和用户进程之间的软件层,处理终端输入/输出处理。
┌────────────────────
│ 终端模拟器
└────────┬───────────
│
┌────────▼───────────
│ PTY(主端)
└────────┬───────────
│
┌────────▼──────────────────────────────────────────
│ TTY 线路规程
│
│ 1. 行编辑
│ Backspace = 删除字符
│ Ctrl+U = 删除整行
│ Ctrl+W = 删除单词
│
│ 2. 回显
│ 输入字符自动发回屏幕
│ 让用户能看到自己在打什么
│
│ 3. 特殊字符处理
│ Ctrl+C --> SIGINT(终止进程)
│ Ctrl+Z --> SIGTSTP(挂起进程)
│ Ctrl+D --> EOF(输入结束)
│
│ 4. 字符转换
│ 换行符转换(CR <--> LF)
│
│ 5. 输入缓冲
│ Canonical 模式:缓冲到按 Enter
│ 非 Canonical:立即传递
│
└────────┬──────────────────────────────────────────
│
┌────────▼───────────
│ PTY(从端)
└────────┬───────────
│
┌────────▼───────────
│ Shell / TUI 应用
└────────────────────
这很重要,因为 TUI 应用的工作原理就是禁用这些线路规程的大部分功能。当你运行 vim 时,它告诉内核:"停止行编辑,停止回显,停止把 Ctrl+C 解释为信号。直接把原始字节给我。"
termios
termios 是 POSIX 标准的终端控制结构体。它是你跟线路规程对话的方式。管理的内容包括:
termios 结构体
┌────────────────────────────────────────────────────
│
│ c_iflag(输入标志)
│ 换行转换、流控制等
│
│ c_oflag(输出标志)
│ 输出处理设置
│
│ c_cflag(控制标志)
│ 波特率、字符大小等
│
│ c_lflag(本地标志)
│ 回显、canonical 模式、信号生成等
│
│ c_cc(特殊字符)
│ Ctrl+C、Ctrl+Z、EOF 等的定义
│
│ VMIN / VTIME(超时)
│ 非 canonical 模式下的读取控制
│
└────────────────────────────────────────────────────
在 TUI 开发中,termios 用于实现 Raw Mode。
ioctl(输入/输出控制)
ioctl 是一个通用的设备控制系统调用。通过文件描述符,它指示设备驱动程序执行不能用普通 read/write 完成的特殊操作。主要操作包括获取和设置 termios,以及获取窗口大小。
tcgetattr / tcsetattr
POSIX 标准定义的高级终端控制 API 函数。它们让你写出比直接使用 ioctl 更可移植的代码。
在 Node.js 中,你不需要直接调用这些。高级等价物已经内置了:
// 高级:Node.js 在内部处理 termios
process.stdin.setRawMode(true); // 等同于 MakeRaw / tcsetattr
process.stdin.setRawMode(false); // 等同于 Restore / tcsetattr
要从 JavaScript 底层访问 termios,你需要用原生插件或 FFI。
完整的 I/O 流程
以下是所有东西的连接方式,从你的手指到应用再回来:
┌──────┐ ┌───────────────────────
│ 用户 │────>│ 终端模拟器
│ │ │ (iTerm2, xterm 等)
└──────┘ └──────────┬────────────
│
┌──────────▼────────────
│ PTY
│ (主端 <-> 从端)
└──────────┬────────────
│
┌──────────▼────────────
│ TTY + 线路规程
│ (ICANON/ECHO/ISIG 标志)
└──────────┬────────────
│
┌──────────▼────────────
│ TUI 应用
│ (vim, htop)
└──────────┬────────────
│
┌───────────────┼───────────────
│
▼ ▼ ▼
termios 配置 输出(ANSI 渲染
变更 转义序列) 到屏幕上
(Raw Mode 等) 回到终端模拟器
第三部分:TUI 开发的终端行为
TUI 应用不像 Shell 那样解释命令。它们直接处理终端 I/O 控制。它们使用 termios API 改变终端模式设置,使用 ANSI 转义序列渲染和更新屏幕。
在正常的 Shell 环境中,终端设置为 canonical 模式(ICANON),输入逐行缓冲,按 Enter 后才传给程序。
$ stty -a | grep icanon
# icanon isig iexten echo echoe echok echoke -echonl echoctl
# ^ Canonical 模式开启
你输入的字符自动回显到屏幕,按 Enter 时发给 Shell:
$ ls
example.txt
但 vim 或 less 这样的 TUI 应用会切换到非 canonical 模式。按键输入立即逐字符传给应用,应用自己处理渲染。
$ vim
# 在另一个终端检查 vim 的终端模式:
$ ps aux | grep vim # 找到 vim 的终端
$ stty -a < /dev/ttys049 | grep icanon
# -icanon -isig -iexten -echo -echoe echok echoke -echonl echoctl
# ^ Canonical 模式关闭。回显关闭。信号关闭。
TUI 开发的五大要素
构建 TUI 应用需要理解和控制五件事:
┌─────────────────────────────────────────────
│
│ 1. 终端模式设置
│ Canonical --> Raw Mode
│
│ 2. 输入处理
│ 解析按键、转义序列、Ctrl+
│
│ 3. 屏幕控制
│ 用于渲染的 ANSI 转义序列
│
│ 4. 终端大小管理
│ 检测并响应窗口大小变化
│
│ 5. 缓冲
│ 批量写入防止闪烁
│
└─────────────────────────────────────────────
逐一来看。
1. 终端模式设置
线路规程有三种工作模式。通过设置 termios 结构体中的标志来切换。
Canonical 模式(Cooked 模式)
默认模式。Shell 用的就是这个。输入逐行缓冲,行编辑可用(Backspace、Ctrl+U、Ctrl+W),回显开启,Ctrl+C 等特殊字符会生成信号。
非 Canonical 模式
关闭 Canonical(ICANON 关闭)。输入逐字符到达而不是逐行。行编辑关闭。但回显和信号处理可以选择性保留。
Raw 模式
几乎全部禁用。不回显,不处理信号,不做换行转换。输入作为原始字节流传给应用。TUI 应用用的就是这个。
| 方面 | Canonical (Cooked) | 非 Canonical | Raw |
|---|---|---|---|
| 输入缓冲 | 基于行 | 基于字符 | 基于字符 |
| 行编辑 | 启用 | 禁用 | 禁用 |
| 回显 | 启用 | 可配置 | 禁用 |
| 信号 (Ctrl+C) | 启用 | 可配置 | 禁用 |
| 换行转换 | 启用 | 可配置 | 禁用 |
| 主要用途 | Shell、交互式 | 自定义 CLI | TUI、游戏 |
stty 的 -cooked 等同于 raw。Raw Mode 是 Cooked Mode 的反面。
2. 输入处理
输入处理就是搞清楚"按了什么键"。普通字符很简单,但方向键、功能键和鼠标事件以多字节转义序列发送。TUI 应用需要解析这些。
特殊键转义序列
┌──────────────┬──────────────┬──────────────────
│ 键 │ 序列 │ 字节序列
├──────────────┼──────────────┼──────────────────
│ Up (↑) │ ESC[A │ \x1b[A
│ Down (↓) │ ESC[B │ \x1b[B
│ Right (→) │ ESC[C │ \x1b[C
│ Left (←) │ ESC[D │ \x1b[D
│ Home │ ESC[H │ \x1b[H
│ End │ ESC[F │ \x1b[F
│ Page Up │ ESC[5~ │ \x1b[5~
│ Page Down │ ESC[6~ │ \x1b[6~
│ F1-F4 │ ESC[OP-OS │ \x1b[OP 等
└──────────────┴──────────────┴──────────────────
控制字符
┌──────────────┬──────────┬─────────────────
│ 字符 │ ASCII │ 描述
├──────────────┼──────────┼─────────────────
│ Ctrl+C │ 3 │ SIGINT
│ Ctrl+D │ 4 │ EOF
│ Ctrl+Z │ 26 │ SIGTSTP
│ Enter │ 13 / 10 │ CR / LF
│ Tab │ 9 │ Tab
│ Backspace │ 127 / 8 │ DEL / BS
│ ESC │ 27 │ Escape
└──────────────┴──────────┴─────────────────
3. 屏幕控制(ANSI 转义序列)
屏幕控制就是告诉终端"在哪里显示什么"。ANSI 转义序列是以 ESC 字符(\x1b 或 \033)开头的特殊字符串命令。它们被标准化为 ANSI X3.64 并在 VT100 终端中实现,这就是为什么它们无处不在。
光标控制
┌──────────────────────┬───────────────────────────────
│ 序列 │ 描述
├──────────────────────┼───────────────────────────────
│ ESC[H │ 移到起始位置 (1,1)
│ ESC[{row};{col}H │ 移到指定位置(从 1 开始)
│ ESC[{n}A │ 上移 n 行
│ ESC[{n}B │ 下移 n 行
│ ESC[{n}C │ 右移 n 列
│ ESC[{n}D │ 左移 n 列
│ ESC[s │ 保存光标位置
│ ESC[u │ 恢复光标位置
│ ESC[?25l │ 隐藏光标
│ ESC[?25h │ 显示光标
└──────────────────────┴───────────────────────────────
清屏
┌──────────────────────┬───────────────────────────────────
│ 序列 │ 描述
├──────────────────────┼───────────────────────────────────
│ ESC[2J │ 清除整个屏幕
│ ESC[H │ 光标移到起始位置
│ ESC[K │ 从光标清除到行尾
│ ESC[1K │ 从行首清除到光标
│ ESC[2K │ 清除整行
└──────────────────────┴───────────────────────────────────
基本样式
┌──────────────────────┬──────────────
│ 序列 │ 描述
├──────────────────────┼──────────────
│ ESC[0m │ 重置所有
│ ESC[1m │ 粗体
│ ESC[4m │ 下划线
│ ESC[7m │ 反转
└──────────────────────┴──────────────
前景色(文本)
┌──────────────────────┬──────────────
│ 序列 │ 颜色
├──────────────────────┼──────────────
│ ESC[30m │ 黑色
│ ESC[31m │ 红色
│ ESC[32m │ 绿色
│ ESC[33m │ 黄色
│ ESC[34m │ 蓝色
│ ESC[35m │ 品红
│ ESC[36m │ 青色
│ ESC[37m │ 白色
└──────────────────────┴──────────────
背景色
┌──────────────────────┬──────────────
│ 序列 │ 颜色
├──────────────────────┼──────────────
│ ESC[40m │ 黑色
│ ESC[41m │ 红色
│ ESC[42m │ 绿色
│ ESC[43m │ 黄色
│ ESC[44m │ 蓝色
│ ESC[45m │ 品红
│ ESC[46m │ 青色
│ ESC[47m │ 白色
└──────────────────────┴──────────────
扩展颜色模式
┌──────────────────────────────┬────────────────────────────────
│ 序列 │ 描述
├──────────────────────────────┼────────────────────────────────
│ ESC[38;5;{n}m │ 前景色 (n: 0-255)
│ ESC[48;5;{n}m │ 背景色 (n: 0-255)
│ ESC[38;2;{r};{g};{b}m │ 前景色 (RGB 真彩色)
│ ESC[48;2;{r};{g};{b}m │ 背景色 (RGB 真彩色)
└──────────────────────────────┴────────────────────────────────
备用屏幕缓冲区
┌──────────────────────┬─────────────────────────────────────────
│ 序列 │ 描述
├──────────────────────┼─────────────────────────────────────────
│ ESC[?1049h │ 切换到备用屏幕缓冲区
│ │ (vim、less 等使用)
│ ESC[?1049l │ 返回正常屏幕缓冲区
└──────────────────────┴─────────────────────────────────────────
这就是为什么 vim 能接管你的整个屏幕,然后退出后终端看起来又正常了。它在启动时切换到备用缓冲区,退出时切回来。
4. 终端大小管理
TUI 应用需要根据终端大小渲染,还需要在用户调整窗口大小时重绘。工作原理如下:
用户调整窗口大小
│
▼
终端模拟器
│
│ ioctl(TIOCSWINSZ) -- 通知新的行/列数
▼
PTY(主端 --> 从端)
│
│ 更新内核中的 winsize
▼
内核 TTY 层
│
│ 发送 SIGWINCH 信号
▼
进程(bash、vim、less)
│
│ 接收信号
│ 调用 ioctl(TIOCGWINSZ) 获取新大小
│ 重绘
▼
更新后的屏幕
终端大小由内核 TTY 结构体中的 winsize 结构管理。当大小改变时,终端模拟器通过 ioctl(TIOCSWINSZ) 通知,内核向连接的进程发送 SIGWINCH。
5. 缓冲
当你发送大量控制序列时,逐个发送很慢且会导致明显的闪烁。通过缓冲所有写入并一次性刷新,你可以获得流畅、无闪烁的渲染。每个正经的 TUI 应用都这样做。
第四部分:用 JavaScript/TypeScript 构建 TUI
现在来实际构建一个。我们用 Node.js 实现所有五个要素。
高级实现
使用 Node.js 内置的 process.stdin.setRawMode(),它抽象了 termios 的细节。
// 终端状态管理结构
interface Terminal {
width: number;
height: number;
buffer: string;
}
// ─────────────────────────────────────────────
// 1. 终端模式设置
// ─────────────────────────────────────────────
function initTerminal(): Terminal {
// 设置为 Raw Mode(等同于 Go 中的 term.MakeRaw)
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.setEncoding("utf8");
const { columns, rows } = process.stdout;
return {
width: columns || 80,
height: rows || 24,
buffer: "",
};
}
function restoreTerminal(): void {
write("\x1b[?25h"); // 显示光标
write("\x1b[0m"); // 重置颜色
flush();
process.stdin.setRawMode(false);
}
// ─────────────────────────────────────────────
// 4. 终端大小管理
// ─────────────────────────────────────────────
function getTerminalSize(): { width: number; height: number } {
return {
width: process.stdout.columns || 80,
height: process.stdout.rows || 24,
};
}
// ─────────────────────────────────────────────
// 2. 输入处理
// ─────────────────────────────────────────────
interface KeyEvent {
char: string | null;
key: string | null;
}
function parseKey(data: string): KeyEvent {
if (data === "\x03") return { char: null, key: "CTRL_C" };
if (data === "\x1b[A") return { char: null, key: "UP" };
if (data === "\x1b[B") return { char: null, key: "DOWN" };
if (data === "\x1b[C") return { char: null, key: "RIGHT" };
if (data === "\x1b[D") return { char: null, key: "LEFT" };
if (data === "\x1b[H") return { char: null, key: "HOME" };
if (data === "\x1b[F") return { char: null, key: "END" };
if (data === "\x1b[5~") return { char: null, key: "PAGE_UP" };
if (data === "\x1b[6~") return { char: null, key: "PAGE_DOWN" };
if (data === "\x1b") return { char: null, key: "ESC" };
return { char: data, key: null };
}
// ─────────────────────────────────────────────
// 3. 屏幕控制(ANSI 转义序列)
// ─────────────────────────────────────────────
let outputBuffer = "";
function bufferWrite(s: string): void { outputBuffer += s; }
function clear(): void { bufferWrite("\x1b[2J\x1b[H"); }
function moveTo(row: number, col: number): void { bufferWrite(`\x1b[${row};${col}H`); }
function setColor(fg: number): void { bufferWrite(`\x1b[${fg}m`); }
function writeText(s: string): void { bufferWrite(s); }
// ─────────────────────────────────────────────
// 5. 缓冲
// ─────────────────────────────────────────────
function flush(): void {
process.stdout.write(outputBuffer);
outputBuffer = "";
}
function write(s: string): void { process.stdout.write(s); }
保存为 program.ts 然后运行:
npx tsx program.ts
操作:方向键移动光标标记(●),q 或 Ctrl+C 退出。
底层实现:直接操作 termios
高级版本使用 process.stdin.setRawMode(true),它在内部处理 termios。为了看清底层发生了什么,这里有一个通过 Node.js FFI 直接操作 termios 标志的底层版本。
import { execSync } from "child_process";
import process from "process";
// ─────────────────────────────────────────────
// 通过 stty 进行底层 termios 操作
// ─────────────────────────────────────────────
// termios 标志参考(每个标志控制什么):
//
// 输入标志 (c_iflag):
// IGNBRK - 忽略 break 条件
// BRKINT - break 时发出信号中断
// PARMRK - 标记奇偶校验错误
// ISTRIP - 去掉第 8 位
// INLCR - 输入时将 NL 转换为 CR
// IGNCR - 忽略输入中的 CR
// ICRNL - 输入时将 CR 转换为 NL
// IXON - 启用 XON/XOFF 流控制
//
// 输出标志 (c_oflag):
// OPOST - 后处理输出
//
// 本地标志 (c_lflag):
// ECHO - 回显输入字符
// ECHONL - 即使 ECHO 关闭也回显 NL
// ICANON - Canonical 模式(行缓冲)
// ISIG - 启用信号(Ctrl+C、Ctrl+Z)
// IEXTEN - 扩展输入处理
//
// 控制标志 (c_cflag):
// CSIZE - 字符大小掩码
// PARENB - 启用奇偶校验
// CS8 - 每字符 8 位
function saveTerminalState(): string {
return execSync("stty -g", { stdio: ["inherit", "pipe", "pipe"], encoding: "utf8" }).trim();
}
function restoreTerminalState(savedState: string): void {
execSync(`stty ${savedState}`, { stdio: "inherit" });
}
function enableRawMode(): void {
execSync("stty raw -echo -isig -icanon -iexten -opost min 1 time 0", {
stdio: "inherit",
});
}
function getTerminalSize(): { width: number; height: number } {
try {
const output = execSync("stty size", { stdio: ["inherit", "pipe", "pipe"], encoding: "utf8" }).trim();
const [rows, cols] = output.split(" ").map(Number);
return { width: cols || 80, height: rows || 24 };
} catch {
return { width: 80, height: 24 };
}
}
通过这个底层实现,你可以看到每个 termios 标志具体控制什么,POSIX 的 tcgetattr/tcsetattr 如何映射到 stty 命令,以及 process.stdin.setRawMode(true) 在幕后为你做了什么。
全貌
┌─────────────────────────────────────────────────────
│ 你的屏幕
│
│ ┌─ 终端 (iTerm2, Alacritty 等) ──────────
│ │
│ │ $ grep -r "TODO" ./src
│ │ src/app.js: // TODO fix this
│ │ src/util.js: // TODO refactor
│ │ $ _
│ │
│ └──────────────────┬────────────────────────────
│
└─────────────────────┼───────────────────────────────
│
┌─────────┴─────────
│ TTY 设备 内核层
│ /dev/pts/1 (管道)
│
│ 线路规程
│ termios 标志
└─────────┬─────────
│
┌─────────┴─────────
│ Shell 解释命令
│ (zsh, bash) 运行程序
└─────────┬─────────
│
┌─────────┴─────────
│ grep 进程 Shell 启动的
│ 实际程序
└───────────────────
如果这一切都在直接插到机器上的
显示器 + 键盘上?
那终端同时也是控制台。
快速参考:出问题时看哪里
问题 哪一层?
────────────────────────────── ─────────────
颜色看起来不对 终端
Tab 补全坏了 Shell
SSH 会话行为异常 TTY
GUI 挂了需要登录 控制台
脚本在 bash 能用但在 zsh 不行 Shell
字体渲染丑 终端
Ctrl+C 杀不掉进程 TTY / 线路规程
TUI 应用收不到按键 termios (Raw Mode)
屏幕重绘时闪烁 缓冲
窗口调整后布局乱了 SIGWINCH 处理
总结
这是一次终端知识全栈的概览,从术语到实现:
术语和概念:Terminal、Shell、TTY、Console、Line Discipline、termios、PTY、POSIX 之间的关系,以及它们如何组合在一起。
TUI 开发五大要素:终端模式设置(Canonical/非 Canonical/Raw)、输入处理(解析转义序列和控制字符)、屏幕控制(用于光标、颜色、清屏、备用屏幕缓冲区的 ANSI 转义序列)、终端大小管理(SIGWINCH)、以及缓冲(防止闪烁)。
实现:一个使用 process.stdin.setRawMode() 的高级 Node.js TUI,以及一个通过 stty 直接操作 termios 标志的底层版本,展示高级 API 在幕后做了什么。
理解这些层意味着你知道出了问题该看哪里。而知道该看哪里,就是调试的一半。
原文: The Full Stack of Terminals Explained
作者: Ahmad Awais | Google Developers Advisory Board 创始成员,Langbase 创始人
译注:本文为翻译转载,仅作技术分享用途。所有内容版权归原作者所有。