Y

YunlongZ的日常分享

A text-focused Halo theme

  • 首页
  • Hello Halo
  • Halo
  • 关于
主页 终端全栈详解:Terminal、Shell、TTY、Console、POSIX、ANSI 转义序列、PTY、Raw/Canonical 模式
文章

终端全栈详解:Terminal、Shell、TTY、Console、POSIX、ANSI 转义序列、PTY、Raw/Canonical 模式

发表于 最近 更新于 最近
作者 Administrator
51~66 分钟 阅读

原文: 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 ModeRaw Mode
示例bash, zsh, fish, Python REPLvim, 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)非 CanonicalRaw
输入缓冲基于行基于字符基于字符
行编辑启用禁用禁用
回显启用可配置禁用
信号 (Ctrl+C)启用可配置禁用
换行转换启用可配置禁用
主要用途Shell、交互式自定义 CLITUI、游戏

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 创始人
译注:本文为翻译转载,仅作技术分享用途。所有内容版权归原作者所有。

开发日志
许可协议:  CC BY 4.0
分享

相关文章

7月 6, 2026

终端全栈详解:Terminal、Shell、TTY、Console、POSIX、ANSI 转义序列、PTY、Raw/Canonical 模式

原文: The Full Stack of Terminals Explained 作者: Ahmad Awais 发布日期: 2026 年 2 月 17 日 译注: 本文为翻译转载,仅作技术分享用途。所有内容版权归原作者所有。 大多数开发者用了好几年终端,却从未真正搞清楚"终端"到底是什么。他们会

下一篇

上一篇

iOS 27 新功能全解析:哪些在中国能用?

最近更新

  • 终端全栈详解:Terminal、Shell、TTY、Console、POSIX、ANSI 转义序列、PTY、Raw/Canonical 模式
  • iOS 27 新功能全解析:哪些在中国能用?
  • iOS 27 启动速度提升 30%:深入 dyld4 的 Pre-main 优化
  • Codex 也犯低级错误:TRACE 日志进生产,用户 SSD 21 天写入 37TB
  • Ford AI 质检翻车 — 社区讨论汇总

热门标签

AI Halo Codex GPT Hermes 独立开发 OpenClaw Vibe Coding

目录

©2026 YunlongZ的日常分享. 保留部分权利。

使用 Halo 主题 Chirpy