在终端中按下按键时会发生什么?

在终端中按下按键时会发生什么?

长期以来,我一直对终端的工作原理感到困惑。

但就在上周,我用 xterm.js 在浏览器中显示交互式终端时,终于想到了一个很基础的问题:在终端中按下键盘上的键(比如删除键、Esc 键或字母 a)时,会发送哪些字节?

和往常一样,我们会通过做实验、看结果来回答这个问题 :)

远程终端是非常古老的技术

首先我想说,用 xterm.js 在浏览器中显示终端看似是个新鲜事,但其实不然。70 年代时,计算机很昂贵。所以机构里的很多员工会共用一台计算机,每个人都能拥有一个连接到这台计算机的 “终端”。

比如,这是一张 70 或 80 年代 VT100 终端的照片。它看起来像台电脑(还挺大的!),但其实不是 —— 它只显示真正的计算机发送给它的信息。

512px-DEC_VT100_terminal.jpg

当然,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+bCtrl+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+aCtrl+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 点了,写得有点长,而且我脑子今天实在装不下更多关于终端的新知识了。