Skip to content
Lyx 的博客

终端键盘输入原理:TTY、Readline 与 Keymap 全解析

当你在 Ghostty 的 Claude Code 中将 Ctrl+M 绑定到一个自定义操作,它正常工作。换到 GNOME Terminal 打开同一个 Claude Code,这个绑定就失效了 ——Ctrl+M 变成了 Enter。问题的根源既不在 Claude Code,也不在你的配置,而在于终端模拟器传输的底层字节。Ghostty 通过 Kitty Keyboard Protocol 为 Ctrl+M 发送 \x1b[109;5u,而 GNOME Terminal 发送 0x0D—— 和 Enter 完全相同的字节。你的快捷键在一个终端里生效,在另一个里消失,因为底层的字节序列根本不同。

终端键盘输入在到达应用程序之前会流经三个层级:底层的 ASCII 控制字符编码、中间的 TTY 驱动(通过 termios 配置)以及顶层的应用程序处理(GNU Readline, Fish, Claude Code)。理解这个堆栈可以解释为什么 Ctrl+M 在 ASCII 层面等同于 Enter,为什么大多数工具中无法重新绑定 Ctrl+C,以及为什么 Ghostty 和 Kitty 等现代终端可以突破 GNOME Terminal 仍然遵循的 1960 年代约束。本文追踪单次按键从键盘流经全部三层的过程 —— 解释每一层做了什么、没做什么,以及标准与现实之间的鸿沟在哪里。

终端输入流水线:从键盘到应用程序

当你在 Ghostty、Kitty 或 Alacritty 等终端模拟器中按下按键时,字节会经过一个定义明确的流水线:

终端模拟器 (Ghostty)     →  捕获键盘事件,将字节写入 PTY Master

TTY 驱动 (内核)           →  PTY Slave 端,根据 termios 设置拦截或传递字节

应用程序 (Fish 等)        →  在 Raw Mode 下,自行处理所有按键

终端模拟器是现代版的 TTY。PTY (Pseudo-TTY) 对是现代版的串行电缆。TTY 驱动则是 1970 年代处理 TTY 串行数据的同一个内核模块。

1. TTY 与 Terminal:名称的历史

TTY 代表 TeleTYpewriter(电传打字机)—— 一种带有键盘和打印头的物理机电设备,通过串行电缆连接到计算机。Terminal(终端)是其后续概念。早期的终端也是硬件(如 DEC VT100),用 CRT 屏幕取代了纸张。

硬件演进

时代设备接口显示
1960sTTY(物理电传打字机)串行端口纸张
1970s视频终端 (VT100)串行端口CRT 屏幕
1980s终端模拟器 (xterm)PTY (Pseudo-TTY)X11 窗口
2020sGhostty / Kitty / AlacrittyPTYGPU 加速窗口

内核的 TTY 驱动和 PTY 接口是为 TTY 设计的,并一直沿用至今。如今,Ghostty 充当了 “模拟电传机” 的角色,PTY 是 “串行电缆”,而 TTY 驱动则是那个 “处理串行数据的内核模块”。

术语渊源

名称原始含义现代含义
TTY电传打字机 (Teletypewriter)内核终端驱动模块
PTY伪电传打字机 (Pseudo-TTY)软件模拟的 TTY(Master/Slave 对)
terminal物理终端设备终端模拟器软件 (Ghostty)
console系统控制台TUI 或系统主终端
sttySet teletype配置 TTY 驱动参数
/dev/ttyTTY 设备文件当前终端的设备文件

2. ASCII 控制字符编码

Ctrl 组合键的编码规则定义在 1960 年代的 ASCII 标准中:

Ctrl + key = key & 0x1F  (清除第 5 和第 6 位,即与 0b00011111 进行 AND 运算)

该规则适用于 ASCII 范围 0x400x5F 的字符(大写字母 AZ 以及符号 @, [, \, ], ^, _)。它不适用于小写字母或该范围之外的字符。等价减法简写 ASCII − 64 仅在该范围内成立。

按键计算方式字节ASCII 名称含义
Ctrl+M0x4D & 0x1F = 0x0D0x0DCR等同于 Enter
Ctrl+J0x4A & 0x1F = 0x0A0x0ALF换行 (Line Feed)
Ctrl+I0x49 & 0x1F = 0x090x09HT等同于 Tab
Ctrl+[0x5B & 0x1F = 0x1B0x1BESCEscape
Ctrl+C0x43 & 0x1F = 0x030x03ETX文本结束 (End of text)
Ctrl+D0x44 & 0x1F = 0x040x04EOT传输结束 (End of transmission)

Ctrl+M = EnterCtrl+I = Tab 不是工具配置。在字节层面,它们是同一个值。这是由 ASCII 标准 (ANSI X3.4-1963) 定义的,任何应用程序都无法更改。

3. TTY 驱动 (termios)

TTY 驱动是位于终端和进程之间的内核级软件层。POSIX (IEEE 1003.1 / ISO/IEC 9945) 通过 termios 结构定义了其行为:

struct termios {
    tcflag_t c_iflag;    // 输入模式标志
    tcflag_t c_oflag;    // 输出模式标志
    tcflag_t c_cflag;    // 控制模式标志
    tcflag_t c_lflag;    // 本地模式标志 (echo, icanon, ...)
    cc_t     c_cc[NCCS]; // 特殊字符数组
};

c_cc 数组 (Linux, 19 个条目)

索引stty 名称默认值含义
VINTRintr^C发送 SIGINT
VQUITquit^\发送 SIGQUIT
VERASEerase^?删除前一个字符
VKILLkill^U删除整行
VEOFeof^DEOF 标记 / 刷新缓冲区
VSTARTstart^Q恢复输出 (XON)
VSTOPstop^S暂停输出 (XOFF)
VSUSPsusp^Z发送 SIGTSTP
VEOLeol<undef>行结束字符
VMINmin1read() 最小字符数 (仅限非标准模式)
VTIMEtime0read() 超时时间 (仅限非标准模式)

在 POSIX termios 结构中,Cooked Mode (Canonical Mode) 和非标准模式是互斥的,因此驱动程序会复用 c_cc 数组中的内存槽位。VMIN 通常与 VEOF (^D) 共享同一个索引,而 VTIMEVEOL 共享。这就是为什么系统程序员在切换模式时必须格外小心。

stty 命令

stty (Set Teletype) 是用于配置 termios 结构的底层工具。它在后台调用 tcgetattr()tcsetattr()

$ stty -a
intr = ^C     →  SIGINT (中断)
quit = ^\     →  SIGQUIT (退出 + Core Dump)
susp = ^Z     →  SIGTSTP (挂起)
erase = ^?    →  删除前一个字符
kill = ^U     →  删除整行
eof = ^D      →  刷新缓冲区 / EOF 标记

注意,Ctrl+D (VEOF) 并不是一个信号。在 Cooked Mode 下,它指示 TTY 驱动立即将当前缓冲区内的所有字节返回给等待的 read() 系统调用。如果缓冲区为空(即在全新的一行直接按下 Ctrl+D),read() 会返回 0 字节。在 POSIX 系统中,read() 读到 0 字节被应用程序约定俗成地解释为 End-of-File (EOF)。

4. Cooked Mode:为 TTY 设计的行编辑

Cooked Mode (也称为 Canonical Mode) 是 TTY 驱动的默认行编辑模式。TTY 驱动会为用户维护一个内部输入缓冲区 —— 直到按下 Enter 键,驱动程序才会将整行内容交付给应用程序。

这种设计是为了 TTY 而构建的,因为纸面上的内容无法被物理擦除。所有的编辑操作都局限在 TTY 驱动的内部缓冲区内;而纸面上留下的,则是一份独立且不可更改的物理记录。

在 1960 年代早期的 TTY 上,擦除字符是 ^H (BS, 0x08),纸上标记删除的惯例是打印 #^? (DEL, 0x7F) 后来成为了标准的擦除字符,由 1970 年代的 DEC VT 系列终端确立。

^? (DEL) 的起源是物理层面的:在 打孔纸带 (Punched paper tape) 时代,字符是通过在纸带上打孔来记录的。一旦孔打好了,就无法 “取消打孔”。0x7F 的二进制表示是 1111111(7 个孔全部打满)。如果操作员打错了字符,他们只需倒退纸带并打满所有孔(即 “擦除 (Rubout)” 字符),机器读取时会直接忽略这个全孔字符。这种来自物理介质的局限性,正是 DEL 字符成为 “删除” 标准的历史原由。

现代 Linux 系统默认使用 ^? 作为 VERASE,而一些较旧的 BSD 衍生系统仍默认使用 ^H

按键TTY 缓冲区操作纸上的效果 (1960s TTY 时代)
^H (早期擦除)删除最后一个字符退回一个位置,打印 # 标记删除
^W (werase)删除最后一个单词逐个覆盖字符
^U (kill)清除整行打印 @ 标记该行作废,换行重打
^R (reprint)重新输出缓冲区内容重新打印整洁的当前缓冲区

考虑这个输入序列:fix the bu + ^W + ^W + the bug

纸上显示的内容(带有划掉标记):

fix the bu####the bug
         ^^^^
         两个 ^W 划掉了 "bu" 和 "the"

TTY 缓冲区(操作员不可见,逐步演变):

fix the bu          ← 输入到这里
fix the             ← ^W 删除了 "bu"
fix                 ← ^W 删除了 "the"
fix the bug         ← 输入 "the bug"

纸张和缓冲区是不同步的。纸张显示了带有划掉标记的完整历史。缓冲区只持有整洁的当前状态。这就是为什么存在 ^R (reprint) —— 操作员忘记了缓冲区里到底是什么,需要重新打印一份整洁的内容。

当支持光标移动和屏幕擦除功能的视频终端 (VT100) 出现时,TTY 驱动可以发送 BS SPACE BS 序列来实际擦除屏幕上的字符。纸张和缓冲区终于在视觉上同步了。现代终端模拟器 (Ghostty) 继承了这一机制。

5. TTY 拦截 vs 应用程序处理

TTY 驱动拦截它识别的字节并自行处理(发送信号、编辑行)。它不识别的字节则传递给应用程序:

键盘 Ctrl+C → 0x03 → TTY 拦截 → 发送 SIGINT → 进程收到信号
键盘 Ctrl+A → 0x01 → TTY 传递 → 到达 Readline/Fish → 应用程序处理

这意味着:

6. Readline 与 POSIX:什么是标准,什么不是

Readline 并不是一个标准。它是 GNU Readline —— 最初由 Brian Fox 为 GNU 项目编写的行编辑库(约 1988-1989 年随 Bash 发布)。其键绑定源自 Emacs 惯例,而非任何正式规范。

历史时间线

年份里程碑编辑能力
1970sUnix sh仅限 termios Cooked Mode (^U 删行,^W 删词,^H/^? 退格)。无光标移动。
1978BSD cshBill Joy 引入历史替换 (!!, !$),但仍无交互式行编辑
~1981tcshKen Greer 为 csh 添加 Emacs 风格交互编辑 (Ctrl+A Home, Ctrl+E End)
~1983ksh (AT&T)David Korn 独立添加 Emacs 模式和 Vi 模式行编辑
1988–89GNU ReadlineBrian Fox 为 Bash 创建可复用的库。采纳了 tcsh/ksh 建立的 Emacs 惯例。Chet Ramey 约于 1990 年成为主要维护者。
1990sPOSIX标准化了 termios 接口和 sh 命令语言语法。未标准化行编辑键绑定。

标准化鸿沟

POSIX termios           — TTY 驱动行为 (标准化)
    ↑ 没有任何标准涵盖 ↑
BSD tcsh / GNU Readline — 行编辑键绑定 (事实标准,非正式规范)
    ↑ 每个工具自行实现 ↑
Fish / Claude Code      — 不使用 GNU Readline;独立实现行编辑

关键点:

你在终端工具中看到的键绑定是 40 年惯例传播的结果:Emacs → tcsh → GNU Readline。它们并非来自任何 ISO 或 POSIX 规范。

7. Raw Mode:当应用程序接管一切

Cooked Mode vs Raw Mode

Raw Mode 是一种 termios 配置,它禁用所有 TTY 驱动处理,让应用程序直接接收原始字节。

行为Cooked Mode (默认)Raw Mode
行缓冲按下 Enter 后交付输入每个字节立即交付
^C → SIGINTTTY 拦截,发送信号字节 0x03 直接传给应用程序
^U / ^WTTY 执行行 / 词删除字节直接传给应用程序
^D EOFTTY 触发 EOF 状态字节 0x04 直接传给应用程序
^S / ^QTTY 暂停 / 恢复输出字节直接传给应用程序
回显 (Echo)TTY 自动显示输入应用程序决定是否回显
^VTTY 转义下一个字符字节直接传给应用程序

Linux 和 BSD 系统提供了 cfmakeraw() 便捷函数,一次调用即可切换至 Raw Mode。在底层,它禁用了 termios 结构中的 icanonechoISIGIXON 等标志位。注意,cfmakeraw() 并非 POSIX 标准的一部分 —— 它源自 BSD 扩展,后来被 Linux glibc 采纳。在严格遵循 POSIX 的环境中,你必须通过 tcgetattr()tcsetattr() 手动设置标志位。

三个角色

终端模拟器 (Ghostty)           —  捕获按键,转换为字节,写入 PTY Master

TTY 驱动 (内核)                —  PTY Slave 端,根据 termios 设置拦截或传递字节

应用程序 (Fish, Claude Code)   —  调用 tcsetattr() 切换至 Raw Mode,接管所有按键处理

终端模拟器不参与 Raw Mode 的切换。它只将键盘事件转换为字节并通过 PTY 传输。真正调用 tcsetattr() 修改 termios 标志并接管按键处理的是应用程序 ——Fish、Claude Code。

c_cc 定义的重叠

TTY c_ccTTY 行为应用程序行为冲突?
^CSIGINTFish: 取消,Claude Code: 取消并无冲突 —— 工具保留了 TTY 处理
^DVEOF (退出)Fish: 退出并无冲突 —— 语义相同
^UVKILL (删除整行)删除至行首并无冲突 —— 这属于对原有语义的高级抽象或优化
^WVWERASE (删除前一个词)删除前一个词并无冲突 —— 语义相同,应用程序在 Raw Mode 下处理
^SVSTOP (暂停输出)Claude Code: stash并无冲突 ——Raw ModeIXON 已禁用
^RVREPRINTClaude Code: 搜索历史并无冲突 —— 应用程序在 Raw Mode 下处理

没有实际冲突。在 Raw Mode 下,应用程序接管所有按键处理。c_cc 定义不再适用。键绑定与 TTY 默认值在语义上保持一致,这是经过刻意设计的。

8. Ctrl+J vs Ctrl+M:深度解析

这两个键的区别揭示了 termios 标志如何影响应用程序看到的内容:

按键字节终端默认行为能否独立绑定?
Ctrl+M0x0D (CR)等同于 EnterCooked Mode 下不行;在 Raw Mode 下配合现代协议才行
Ctrl+J0x0A (LF)与 Enter 不同可以 —— 字节不同

Cooked Mode

TTY 驱动默认启用 icrnl (Input CR to NL) 标志。这会将输入的 0x0D (CR) 转换为 0x0A (LF)。对于应用程序,Ctrl+MCtrl+J 都产生字节 0x0A —— 它们是无法区分的。

为什么需要这个转换?在物理电传打字机上,CR(0x0D,回车)和 LF(0x0A,换行)是两个独立的机械动作:CR 将打印头回到左边,LF 将纸向上卷一行。开始新的一行需要两个操作。Unix 体系采用 \n(LF)作为换行符,而 Windows 则沿用了 \r\n(CR+LF)的传统。TTY 驱动的 icrnl 标志连接了这两者 —— 它将 Enter 键产生的 0x0D (CR) 转换为 0x0A (LF),让 Unix 应用程序只看到 \n

Raw Mode

当应用程序切换到 Raw Mode 时,icrnl 被禁用。这允许应用程序区分 Ctrl+J (0x0A) 和 Enter/Ctrl+M (0x0D)。

然而,一个常见的误区是认为仅靠 Raw Mode 就能区分 Ctrl+MEnter。在标准终端配置中,这两个按键发送的字节完全相同 (0x0D)。如果字节完全一样,即使在 Raw Mode 下,应用程序也无法区分它们。要真正区分 EnterCtrl+M,必须依赖终端模拟器发送不同的转义序列。

9. 现代方案:Kitty Keyboard Protocol 与 CSI u

我们难以实现 Shift+Enter 或区分 TabCtrl+I 的根本原因是僵化的 1963 年 ASCII 编码。为了在不破坏向后兼容性的情况下解决这个问题,现代终端模拟器(如 Ghostty, Kitty, Alacritty)支持增强的键盘协议(如 Kitty Keyboard Protocol 或 CSI u 标准)。

这些协议不再为 Enter 发送单一的遗留字节 0x0D (CR),而是可以发送丰富的转义序列:

应用程序(如果支持这些协议)可以解析这些转义序列,从而为每一个可能的按键组合提供完全独立的键绑定,最终摆脱 TTY 时代硬件的约束。

10. 调试终端输入:亲眼查看字节

你可以使用标准的 Unix 工具观察各层的终端输入处理。

查看当前 TTY 设置

stty -a

这会显示所有 termios 标志和 c_cc 字符映射。留意 icanon(标准模式开 / 关)、echo(回显开 / 关)以及特殊字符分配。

观察原始按键字节

# 按下按键,然后 Ctrl+C 退出
cat | xxd

你会看到每个按键的原始十六进制值。尝试比较 Ctrl+M (显示 0d) 和 Ctrl+J (显示 0a)。在标准模式下,由于 icrnl 的存在,两者可能都显示为 0a

切换至 Raw Mode 观察

import sys, tty, termios

fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
    tty.setraw(fd)
    while True:
        ch = sys.stdin.read(1)
        print('byte: %02x (%r)' % (ord(ch), ch), file=sys.stderr)
finally:
    termios.tcsetattr(fd, termios.TCSADRAIN, old)

Raw Mode 下,你会看到 Ctrl+M 显示为 0dCtrl+J 显示为 0a—— 确认当 icrnl 关闭时它们是不同的字节。

回答引言的问题

在 Ghostty 里运行上面的脚本,按下 Ctrl+M,你不会看到 0x0D—— 你会看到:

byte: 1b ('\x1b')  byte: 5b ('[')  byte: 31 ('1')  byte: 30 ('0')  byte: 39 ('9')  byte: 3b (';')  byte: 35 ('5')  byte: 75 ('u')

这是 \x1b[109;5u——Kitty Keyboard Protocol 对” 按键 m + Ctrl 修饰符” 的编码。解码这个序列:

\x1b  [  1  0  9  ;  5  u
ESC  CSI ├─────┘  ├──┘  └─ CSI u 格式结束符
│        │        │
│        │        └─ 修饰符 5 = Ctrl
│        └─ Unicode codepoint 109 = 'm'
└─ ASCII 编码 0x1b,等同于 Ctrl+[

Ghostty 没有把 Ctrl+M 压缩成遗留的 0x0D。所以 Claude Code 能区分 Ctrl+MEnter

再按 EnterCtrl+J 对比:

按键遗留字节Ghostty 实际发送
Enter0x0D0x0D — 遗留字节
Ctrl+M0x0D(相同)\x1b[109;5u — 完全不同
Ctrl+J0x0A0x0A — 遗留字节

哪些终端能区分两者,取决于协议支持:

终端EnterCtrl+M可区分?
Ghostty0x0D\x1b[109;5u可以
Kitty0x0D\x1b[109;5u可以(协议的创建者)
WezTerm0x0D\x1b[109;5u可以
Alacritty部分支持取决于版本部分
GNOME Terminal (VTE)0x0D0x0D不可以
xterm0x0D0x0D不可以

这就是为什么你的 Ctrl+M 绑定在 Ghostty 里生效,在 GNOME Terminal 里却静默失效。三层架构 ——ASCII、TTY 驱动、应用程序 —— 没有变。但终端模拟器在中间插入了一层:键盘协议。这一层决定了 Ctrl+MEnter 是同一个字节,还是两个不同的字节。

常见问题

为什么我不能独立于 Enter 重新绑定 Ctrl+M?

Cooked Mode 下,不行:Ctrl+M 产生 0x0D (CR),TTY 驱动的 icrnl 标志将其转换为 0x0A (LF) —— 与 Ctrl+J 相同。

Raw Mode 下,icrnl 已禁用,但 Ctrl+MEnter 通常仍发送相同的字节 (0x0D)。要区分它们,你需要终端模拟器和应用程序都支持 Kitty Keyboard Protocol 或 CSI u 等现代协议,这些协议会为不同的组合键发送唯一的转义序列。

Ctrl+J 和 Ctrl+M 有什么区别?

Ctrl+J 产生 0x0A (换行符),而 Ctrl+M 产生 0x0D (回车符) —— 它们是不同的字节。但在 Cooked Mode 下,TTY 驱动会把 0x0D 转换为 0x0A。在 Raw Mode 下,字节会原样到达,允许独立绑定。

stty raw 到底做了什么?

它禁用了所有 TTY 驱动处理:关闭 icanon (行编辑)、echo (自动显示)、ISIG (信号生成)、IXON (流控制) 和 icrnl (CR 转 NL)。应用程序随后会收到输入的每一个原始字节。

为什么 Ctrl+U 是删至行首而非整行?

在 POSIX termios 中,VKILL (Ctrl+U) 定义为” 删除整行”。但当 Bash(通过 GNU Readline)或 Fish 切换到 Raw Mode 时,TTYc_cc 处理被绕过。应用程序将 Ctrl+U 重新定义为” 删除至行首”(保留光标后的字符),这在实践中更实用。这是语义细化,而非冲突。

Fish 和 Claude Code 使用 GNU Readline 吗?

不。它们各自实现了自己的行编辑和输入处理。但两者都遵循相同的 Emacs 键绑定惯例(Ctrl+A 跳到行首、Ctrl+E 跳到行尾等),因为这些是 40 年的肌肉记忆标准,而非 Readline 特有的功能。

结论

终端键盘输入处理是一个三层系统:ASCII 定义了字节级编码(自 1963 年未变),TTY 驱动提供了源自 TTY 设计的行编辑和信号处理,而应用程序层处理其余一切。

现代终端工具中的键绑定 —— Ctrl+A 行首、Ctrl+E 行尾、Ctrl+K 向后删除 —— 源自 40 年前的 Emacs 惯例,而非 POSIX 或任何正式标准。Raw Mode 下的应用程序绕过了 TTY 驱动,但出于设计考虑保留了其语义。

理解这个堆栈可以揭开一些谜团:为什么有些快捷键无法重新绑定(它们在 ASCII 层面是同一个字节),为什么 Shift+Enter 需要 Raw Mode 才能工作,以及为什么每个终端工具都独立实现了相同的 Emacs 键绑定。

延伸阅读


Share this post on:

下一篇
macOS 的影子工具链:为什么 AI 代理总是生成错误的命令