在终端中按下按键时会发生什么?
- 作者: Julia Evans
- 来源: Julia Evans's Blog
- 阅读:18
- 发布: 2025-08-24 12:06
- 最后更新: 2025-08-24 12:07
在终端中按下按键时会发生什么?
长期以来,我一直对终端的工作原理感到困惑。
但就在上周,我用 xterm.js 在浏览器中显示交互式终端时,终于想到了一个很基础的问题:在终端中按下键盘上的键(比如删除键、Esc 键或字母 a)时,会发送哪些字节?
和往常一样,我们会通过做实验、看结果来回答这个问题 :)
远程终端是非常古老的技术
首先我想说,用 xterm.js 在浏览器中显示终端看似是个新鲜事,但其实不然。70 年代时,计算机很昂贵。所以机构里的很多员工会共用一台计算机,每个人都能拥有一个连接到这台计算机的 “终端”。
比如,这是一张 70 或 80 年代 VT100 终端的照片。它看起来像台电脑(还挺大的!),但其实不是 —— 它只显示真正的计算机发送给它的信息。
当然,70 年代的人们不会用 websocket 来传输信息,但当时终端和计算机之间来回传输的信息,和现在大致是一样的。
(照片里的终端来自西雅图的 Living Computer 博物馆,我曾去过那里,在一台很古老的 Unix 系统上用 ed 编辑器写过 FizzBuzz 程序,所以我可能真的用过这台机器或者它的同款!真心希望 Living Computer 博物馆能重新开放,摆弄老电脑太有意思了。)
会传输哪些信息?
显然,如果你想连接远程计算机(通过 ssh、xterm.js 加 websocket 或其他方式),客户端和服务器之间需要传输一些信息。
具体来说:
-
客户端需要发送用户输入的按键(比如 ls -l)
-
服务器需要告诉客户端屏幕上该显示什么
我们来看一个能在浏览器中运行远程终端的真实程序,看看来回传输的是什么信息!
我们用 goterm 做实验
我在 GitHub 上找到一个叫 goterm 的小程序,它运行一个 Go 服务器,能让你通过 xterm.js 在浏览器中与终端交互。这个程序安全性很差,但很简单,非常适合学习。
由于它已经 6 年没更新了,我复刻了一份,让它适配最新的 xterm.js。然后我加了些日志语句,每次通过 websocket 发送 / 接收字节时就打印出来。
我们来看看几次不同的终端交互中,发送和接收的内容!
示例:ls
首先,我们运行 ls。在 xterm.js 终端上,我看到的是:
bash
bork@kiwi:/play$ ls
file
bork@kiwi:/play$
以下是发送和接收的内容(在我的代码里,客户端发送字节时会记录 “发送:[字节]”,从服务器接收字节时会记录 “接收:[字节]”):
bash
sent: "l"
recv: "l"
sent: "s"
recv: "s"
sent: "\r"
recv: "\r\n\x1b[?2004l\r"
recv: "file\r\n"
recv: "\x1b[?2004hbork@kiwi:/play$ "
从这个输出中,我注意到三点:
-
回显:客户端发送 “l” 后,立即收到返回的 “l”。我猜这是因为客户端很 “笨”—— 它不知道我输入 “l” 时,希望屏幕上能回显 “l”。必须由服务器进程明确告诉它要显示这个 “l”。
-
换行:按回车时,发送的是
\r
(回车符)而不是\n
(换行符) -
转义序列:
\x1b
是 ASCII 转义字符,所以\x1b [?2004h
是在告诉终端要显示些什么。我觉得这可能是个颜色序列,但不确定。后面我们再稍微聊聊转义序列。
好,现在来做点稍微复杂点的操作。
示例:Ctrl+C
接下来,看看用 Ctrl+C 中断进程时会发生什么。在终端里我看到的是:
bash
bork@kiwi:/play$ cat
^C
bork@kiwi:/play$
以下是客户端发送和接收的内容:
bash
sent: "c"
recv: "c"
sent: "a"
recv: "a"
sent: "t"
recv: "t"
sent: "\r"
recv: "\r\n\x1b[?2004l\r"
sent: "\x03"
recv: "^C"
recv: "\r\n"
recv: "\x1b[?2004h"
recv: "bork@kiwi:/play$ "
按下 Ctrl+C
时,客户端发送的是 \x03
。查 ASCII 表就会知道,\x03
代表 “文本结束”,这很合理。我一直有点搞不懂 Ctrl+C
是怎么工作的,现在知道它只是发送了一个 \x03
字符,感觉豁然开朗。
我认为 cat
进程会被 Ctrl+C
中断,是因为服务器端的 Linux 内核收到这个 \x03
字符后,识别出它表示 “中断”,然后向控制伪终端的进程组发送 SIGINT
信号。所以这是由内核处理的,而非用户态。
示例:Ctrl+D
我们用 Ctrl+D
做同样的实验。在终端里我看到的是:
bash
bork@kiwi:/play$ cat
bork@kiwi:/play$
以下是发送和接收的内容:
bash
sent: "c"
recv: "c"
sent: "a"
recv: "a"
sent: "t"
recv: "t"
sent: "\r"
recv: "\r\n\x1b[?2004l\r"
sent: "\x04"
recv: "\x1b[?2004h"
recv: "bork@kiwi:/play$ "
和 Ctrl+C
很像,只是发送的是 \x04
而不是 \x03
。很酷吧!\x04
对应 ASCII 中的 “传输结束”。
那 Ctrl + 其他字母呢?
接下来我好奇的是 —— 如果按 Ctrl+e,会发送什么字节?
结果发现,它其实就是该字母在字母表中的序号,比如:
-
Ctrl+a
=> 1 -
Ctrl+b
=> 2 -
Ctrl+c
=> 3 -
Ctrl+d
=> 4 -
……
-
Ctrl+z
=> 26
另外,Ctrl+Shift+b
和 Ctrl+b
的效果完全一样(都会发送 0x2
)。
键盘上其他键对应什么呢?如下:
-
Tab ->
0x9
(和 Ctrl+I 一样,因为 I 是第 9 个字母) -
Esc ->
\x1b
-
退格键 ->
\x7f
-
Home ->
\x1b [H
-
End ->
\x1b [F
-
Print Screen ->
\x1b\x5b\x31\x3b\x35\x41
-
Insert ->
\x1b\x5b\x32\x7e
-
Delete ->
\x1b\x5b\x33\x7e
我的 Meta
键完全没反应
那 Alt
键呢?根据我的实验(和一些谷歌搜索结果),Alt
键其实和 “Esc 键” 作用一样,只是单独按 Alt
键不会向终端发送任何字符,而单独按 Esc
键会。所以:
-
alt + d =>
\x1bd
(其他字母也一样) -
alt + shift + d =>
\x1bD
(其他字母也一样) -
等等
再看一个示例:nano
运行文本编辑器 nano
时,发送和接收的内容如下:
bash
recv: "\r\x1b[Kbork@kiwi:/play$ "
sent: "n" [[]byte{0x6e}]
recv: "n"
sent: "a" [[]byte{0x61}]
recv: "a"
sent: "n" [[]byte{0x6e}]
recv: "n"
sent: "o" [[]byte{0x6f}]
recv: "o"
sent: "\r" [[]byte{0xd}]
recv: "\r\n\x1b[?2004l\r"
recv: "\x1b[?2004h"
recv: "\x1b[?1049h\x1b[22;0;0t\x1b[1;16r\x1b(B\x1b[m\x1b[4l\x1b[?7h\x1b[39;49m\x1b[?1h\x1b=\x1b[?1h\x1b=\x1b[?25l"
recv: "\x1b[39;49m\x1b(B\x1b[m\x1b[H\x1b[2J"
recv: "\x1b(B\x1b[0;7m GNU nano 6.2 \x1b[44bNew Buffer \x1b[53b \x1b[1;123H\x1b(B\x1b[m\x1b[14;38H\x1b(B\x1b[0;7m[ Welcome to nano. For basic help, type Ctrl+G. ]\x1b(B\x1b[m\r\x1b[15d\x1b(B\x1b[0;7m^G\x1b(B\x1b[m Help\x1b[15;16H\x1b(B\x1b[0;7m^O\x1b(B\x1b[m Write Out \x1b(B\x1b[0;7m^W\x1b(B\x1b[m Where Is \x1b(B\x1b[0;7m^K\x1b(B\x1b[m Cut\x1b[15;61H"
可以看到其中有一些 UI 文本,比如 “GNU nano 6.2”,还有这些 \x1b [27m
之类的是转义序列。我们来聊聊转义序列!
ANSI 转义序列
上面 nano
发送给客户端的这些 \x1b
[开头的内容,叫做 “转义序列” 或 “转义码”。因为它们都以 \x1b
(即 “转义” 字符)开头。转义序列可以改变光标位置、让文本加粗或下划线、改变颜色等等。如果你感兴趣,维基百科上有相关历史。
举个简单的例子:在终端中运行
bash
echo -e '\e[0;31mhi\e[0m there'
会打印出 “hi there”,其中 “hi” 是红色,“there” 是黑色。这个网页上有一些关于颜色和格式转义码的不错示例。
我知道转义码有几个不同的标准,但我的理解是,Unix 系统上最常用的那套转义码来自 VT100(就是文章开头那张照片里的老终端),而且过去 40 年基本没怎么变过。
这也是为什么用 cat
命令在屏幕上输出大量二进制内容时,终端会变乱 —— 通常你会不小心打印出一堆随机的转义码,它们会搞乱终端设置。只要输出的二进制内容足够多,里面肯定会有 0x1b
字节。
能手动输入转义序列吗?
前面我们说过,Home
键对应 \x1b [H
。这 3 个字节是转义符 Escape + [ + H
(因为转义符是 \x1b
)。
在 xterm.js 终端中,如果我手动按 Esc,再按 [,再按 H,就会跳到行首,和按 Home 键的效果完全一样。
不过我发现这在我电脑上的 fish shell
里不管用 —— 如果我按 Esc 再按 [,它只会打印出 [,不会让我继续输入转义序列。我问了朋友杰西,他写过很多 Rust 终端代码,他说很多程序会给转义码设置超时 —— 如果在一定时间内没按其他键,程序就会判定这不是转义序列。
显然,在 fish
里可以通过 fish_escape_delay_ms
配置这个超时时间。我运行了 set fish_escape_delay_ms 1000,之后就能手动输入转义序列了。太酷了!
终端编码有点奇怪
这里我想插一句,按键映射到字节的方式其实挺奇怪的。如果现在重新设计按键编码方式,我们大概不会搞成这样:
-
Ctrl+a
和Ctrl+Shift+a
的效果完全一样 -
Alt
键和Esc
键作用相同 -
控制序列(比如颜色 / 光标移动)和
Esc
键用同一个字节,所以得靠时间来判断这是个控制序列还是用户真的按了Esc
键
但这些都是 70、80 年代设计的,为了向后兼容,就得一直保持这样 :)
改变窗口大小
终端里的操作不都是通过来回发送字节实现的。比如终端窗口大小改变时,我们得用另一种方式告诉 Linux 窗口大小变了。
goterm 中实现这个功能的 Go 代码是这样的:
bash
syscall.Syscall(
syscall.SYS_IOCTL,
tty.Fd(),
syscall.TIOCSWINSZ,
uintptr(unsafe.Pointer(&resizeMessage)),
)
这用了 ioctl 系统调用。我对 ioctl
的理解是,它是个处理各种杂项的系统调用,那些其他系统调用覆盖不到的功能(通常和 IO 有关)都靠它。
syscall.TIOCSWINSZ
是个整数常量,告诉 ioctl
这次要做的具体操作(改变终端窗口大小)。
xterm 的工作原理也是这样
这篇文章讲的是远程终端,即客户端和服务器在不同电脑上。但其实像 xterm
这样的终端模拟器,工作原理完全一样,只是不太容易注意到,因为字节不是通过网络传输的。
今天就到这啦!
关于终端还有很多可以说的(比如颜色、原始模式 vs 规范模式、Unicode 支持、Linux 伪终端接口等),但我得停笔了,已经晚上 10 点了,写得有点长,而且我脑子今天实在装不下更多关于终端的新知识了。