最近突然对终端里的"控制码"产生了好奇,比如 Ctrl-A、Ctrl-C、Ctrl-W 等等。这些控制码到底是怎么回事?
一张 ASCII 控制字符表
下面是全部 33 个 ASCII 控制字符的表格,以及它们在我机器上(Mac OS)大致的行为。这里面有很多需要注意的细节问题,后面我会逐个解释这张图的含义,以及我所知道的它的各种问题。

不同类型的控制码混杂在一起
这张图让我感到意外的第一点是:控制码总共有 33 个,大致可以分成(非常粗略地说)这几类:
由操作系统的终端驱动程序处理的控制码。比如当操作系统看到
3(即Ctrl-C)时,它会向当前程序发送SIGINT信号其余的控制码会原样传递给应用程序,应用程序可以随意处置。这些又可以细分为几类:
- 对应键盘上某个实体按键的控制码(
Enter、Tab、Backspace)。比如你按下Enter时,终端会收到13 readline使用的控制码:"应用程序可以随意处置"在实际中往往意味着"它的行为大概率和readline库差不多——不管应用程序是否真的用了 readline",所以我标注了一堆 readline 会用到的控制码- 其他控制码。比如
Ctrl-X,我认为它在终端里 generally 没有标准含义,但 emacs 对它的使用非常频繁
- 对应键盘上某个实体按键的控制码(
哪些控制码属于哪个类别并没有什么真正的规律可言,它们的分布就是杂乱无章的,因为这个体系是有机演化出来的。
控制码只有 33 个
另一个让我有点意外的是:控制码总共只有 33 个——即 Ctrl-A 到 Ctrl-Z,外加 7 个(@, [, \, ], ^, _, ?)。这意味着,如果你想在一个终端应用程序里用 Ctrl-1 之类的组合键作为快捷键,其实是行不通的——至少在我的机器上,Ctrl-1 和单独按 1 完全是一回事,Ctrl-3 则等同于 Ctrl-[,依此类推。
另外,Ctrl+Shift+C 并不是一个控制码——它的行为取决于你使用的终端模拟器。在 Linux 上,Ctrl-Shift-X 这类组合通常被终端模拟器本身用来复制、粘贴或打开新标签页,根本不会被发送到 TTY。
还有经常使用的 Ctrl+Left Arrow,它也不是控制码,而是发送一个 ANSI 转义序列 ctrl-[[1;5D。这是另一个话题,本文暂未展开讨论。
"只有 33 个控制码" —— 这一点和 GUI 中快捷键的工作方式截然不同:在 GUI 里,你可以给任意按键配上 Ctrl+KEY。
官方的 ASCII 名称对我来说没什么意义
这 33 个控制码每一个在 ASCII 标准里都有一个名字(比如 3 叫 ETX)。这些控制码最初被定义时,压根不是给计算机或终端用的,而是给电报机用的。电报机和 UNIX 终端不是一回事,所以很多控制码后来被重新赋予了别的含义。
就我个人而言,我觉得这些 ASCII 名称没什么用,因为一半的情况下,ASCII 里的名称与这个控制码如今在 UNIX 系统上的实际行为毫无关系。与其去甄别哪些名称还保留着原始含义,不如干脆把 ASCII 名称整个忽略掉。
把 Ctrl-M 用作快捷键很麻烦
还有一件有点诡异的事:Ctrl-M 其实就等同于 Enter,Ctrl-I 等同于 Tab,这就导致这两个组合很难被用作快捷键。
根据我快速查到的资料,确实有些人仍在把 Ctrl-I 和 Ctrl-M 当快捷键用,但要做到这一点,你需要把终端模拟器配置成对它们做与默认行为不同的处理。
对我来说,主要的启示是:如果哪天我要写一个终端应用程序,应该避免把 Ctrl-I 和 Ctrl-M 用作快捷键。
如何查看按下的键会发送哪些控制码
写这篇文章的过程中,我需要做大量实验来弄清各种按键组合的实际行为,所以我写了这个 Python 脚本 echo-key.py,它可以把这些控制码打印出来。
或许有更"官方"的办法,但我更喜欢有一个可以自己定制的脚本。
注意事项:经典模式与非经典模式
表格中有两个控制码(Ctrl-W 和 Ctrl-U)被标注为"由操作系统处理",但实际上它们并不总是由操作系统处理,这取决于终端处于"经典模式"(canonical mode)还是"非经典模式"(noncanonical mode)。
在经典模式下,只有当你按下 Enter 时程序才会收到输入(并且由操作系统负责在你按 Backspace 或 Ctrl-W 时删除字符)。而在非经典模式下,程序在你按键的瞬间就会立即收到输入,此时 Ctrl-W 和 Ctrl-U 会被直接透传给程序,由它自行处理。
通常情况下,非经典模式下的程序处理 Ctrl-W 和 Ctrl-U 的方式会和操作系统类似,但也存在一些细微差别。
一些使用经典模式的程序:
- 基本上所有非交互式程序,比如
grep或cat - 还有
git,我记得是这样
一些使用非经典模式的程序:
python3、irb以及其他 REPL- 你的 shell
- 任何全屏 TUI 程序,比如
less或vim
注意事项:所有"OS 终端驱动"的控制码都可以用 stty 重新配置
前面我说 Ctrl-C 会发送 SIGINT,但严格来说这未必成立。如果你真的想,你可以用一个叫 stty 的工具重新映射所有标注为"OS 终端驱动"的控制码(外加 Backspace),并且可以用 stty -a 查看当前的映射。
以下是我机器上目前的映射:
$ stty -a
cchars: discard = ^O; dsusp = ^Y; eof = ^D; eol = <undef>;
eol2 = <undef>; erase = ^?; intr = ^C; kill = ^U; lnext = ^V;
min = 1; quit = ^\; reprint = ^R; start = ^Q; status = ^T;
stop = ^S; susp = ^Z; time = 0; werase = ^W;我个人从来没重新映射过这些,也想不出什么理由要这么做(我觉得对我来说这多半会带来混乱和灾难),不过人们使用 stty 最常见的原因有:
- 用
stty sane修复状态错乱的终端 - 用
stty erase ^H改变 Backspace 的行为 - 设置
stty ixoff - 甚至有人把
SIGINT映射到别的键,比如他们的DELETE键
关于信号
关于信号有两条注意事项:
- 如果
ISIG终端模式被关闭,操作系统就不会发送信号。比如vim会关闭ISIG - 据说在 BSD 系统上,还有一个额外的控制码(
Ctrl-T),它会发送SIGINFO
你可以用 strace 这样查看一个程序设置了哪些终端模式,终端模式是通过 ioctl 系统调用设置的:
$ strace -tt -o out vim
$ grep ioctl out | grep SET
下面是 vim 启动时设置的模式(注意 ISIG 和 ICANON 都不见了!):
17:43:36.670636 ioctl(0, TCSETS, {c_iflag=IXANY|IMAXBEL|IUTF8,
c_oflag=NL0|CR0|TAB0|BS0|VT0|FF0|OPOST, c_cflag=B38400|CS8|CREAD,
c_lflag=ECHOK|ECHOCTL|ECHOKE|PENDIN, ...}) = 0退出时它会把模式恢复原样:
17:43:38.027284 ioctl(0, TCSETS, {c_iflag=ICRNL|IXANY|IMAXBEL|IUTF8,
c_oflag=NL0|CR0|TAB0|BS0|VT0|FF0|OPOST|ONLCR, c_cflag=B38400|CS8|CREAD,
c_lflag=ISIG|ICANON|ECHO|ECHOE|ECHOK|IEXTEN|ECHOCTL|ECHOKE|PENDIN, ...}) = 0我认为 vim 在这里使用的这组特定模式的组合可能就是所谓的"原始模式"(raw mode),man cfmakeraw 中有相关说明。
冲突非常多
与"只有 33 个控制码"相关的一个问题是:系统中不同的组件常常想用同一个控制码来做不同的事情,由此产生了大量冲突。比如默认情况下 Ctrl-S 会冻结你的屏幕输出,但如果你把这个功能关掉,readline 就会把 Ctrl-S 用作前向搜索(forward search)。
另一个例子是在我的机器上,Ctrl-T 有时会发送 SIGINFO,有时会交换(transpose)两个字符的位置,有时又会做完全不同的事情,具体取决于:
- 该程序是否设置了
ISIG - 该程序是否使用
readline,或模仿了 readline 的行为
注意事项:关于 "backspace" 和 "另一个 backspace"
在这张图里,我把控制码 127 标注为"backspace",把 8 标注为"另一个 backspace"。呃,这是什么鬼?
我想这大概是 Mastodon 回复中讨论最热烈的一个话题——显然这里面有相当多的历史渊源,而我此前对此一无所知。
先说说它在我机器上的工作方式:
- 我按下
Backspace键 - TTY 收到字节
127,它在 ASCII 中叫DEL - 操作系统的终端驱动程序和 readline 都把
127映射为"backspace"(所以在经典模式和非经典模式下它都能正常工作) - 前一个字符被删除
如果我按 Ctrl+H,在我使用 readline 的场合,效果和 Backspace 一样;但在一个不支持 readline 的程序里(比如 cat),它只会打印出 ^H。
显然,上面第 2 步在某些人的机器上是不同的——他们的 Backspace 键发送的是字节 8 而不是 127,所以如果想让 Backspace 正常工作,他们需要(用 stty)配置操作系统,设置 erase = ^H。
在 Debian Policy Manual 关于键盘配置的章节中,它规定了按照 Debian 的政策,Delete 和 Backspace 应该如何工作,这与我如今 Mac 上的行为非常接近。我的理解是:这项政策制定于 90 年代,因为当时关于 Backspace 应该做什么存在大量混乱,需要有一个标准才能让一切正常运转。
关于终端的历史还有很多可讲的,但今天就到此为止。
实际情况的多样性远不止于此
我大概还遗漏了不少"我机器上的行为"与他人机器上可能不同的地方,而且关于我自己机器上的行为,我可能也有些说错的地方。不过今天要说的就是这些了。
还有一些我知道但没写进来的东西:根据 stty -a 的输出,Ctrl-O 是"discard",Ctrl-R 是"reprint",Ctrl-Y 是"dsusp"。我完全不知道怎么让它们真的起作用(按下去没有任何明显反应,有些人告诉了我它们历史上是干什么的,但我不确定它们在 2024 年还有什么用处),而且实际上很多时候它们似乎就是被直接透传给了应用程序,所以我干脆把 Ctrl-R 和 Ctrl-Y 标注为 readline。
这些知识未必都有用
我还想说一句:我觉得这篇文章的内容挺有意思,但未必有那么实用。过去 20 年里,我在对这些东西几乎一无所知的情况下,每天都把终端用得很好——我只是知道 Ctrl-C、Ctrl-D、Ctrl-Z、Ctrl-R、Ctrl-L 实际上能干什么(可能再加上 Ctrl-A、Ctrl-E 和 Ctrl-W),绝大部分时候根本不去操心底层细节,而这样也几乎从来没出过问题。
全文完!