很长一段时间以来,我一直对终端发生的事情感到困惑。
但上周我使用xterm.js在浏览器中显示交互式终端,我终于想问一个非常基本的问题:当您在终端中按下键盘上的一个键时(例如Delete
或Escape
或a
) , 哪些字节被发送?
像往常一样,我们将通过做一些实验并看看会发生什么来回答这个问题:)
远程终端是非常古老的技术
首先,我想说的是,使用xterm.js
在浏览器中显示终端可能看起来像是一个新事物,但实际上并非如此。在 70 年代,计算机价格昂贵。一个机构的这么多员工将共享一台计算机,每个人都可以拥有自己的“终端”到那台计算机。
例如,这是一张 70 或 80 年代 VT100 终端的照片。这看起来可能是一台计算机(它有点大!),但它不是——它只是显示实际计算机发送给它的任何信息。
当然,在 70 年代他们并没有为此使用 websockets,但是来回发送的信息与当时差不多。
(那张照片中的终端来自西雅图的生活计算机博物馆,我曾经参观过一次,并在一个非常古老的 Unix 系统上编写了ed
zz,所以我可能真的使用过那台机器或它的兄弟姐妹之一!我真希望活电脑博物馆能重新开张,能玩旧电脑真是太爽了。)
什么信息被发送?
很明显,如果您想连接到远程计算机(使用ssh
或使用xterm.js
和 websocket 或其他任何东西),则需要在客户端和服务器之间发送一些信息。
具体来说:
- 客户端需要发送用户输入的击键(如
ls -l
) - 服务器需要告诉客户端在屏幕上显示什么
让我们看一个在浏览器中运行远程终端的真实程序,看看来回发送什么信息!
我们将使用goterm
进行实验
我在 GitHub 上发现了一个名为goterm的小程序,它运行一个 Go 服务器,让您可以使用xterm.js
与浏览器中的终端进行交互。这个程序非常不安全,但它很简单,非常适合学习。
我对它进行了 fork 以使其与最新的 xterm.js 一起使用,因为它上次更新是 6 年前。然后,我添加了一些日志语句以在每次通过 websocket 发送/接收字节时打印出来。
让我们看看在几个不同的终端交互过程中发送和接收!
示例: ls
首先,让我们运行ls
。这是我在xterm.js
终端上看到的内容:
bork@kiwi:/play$ ls file bork@kiwi:/play$
这是发送和接收的内容:(在我的代码中,我记录sent: [bytes]
每次客户端发送字节时的recv: [bytes]
每次从服务器接收字节时的 [bytes])
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$ "
我在这个输出中注意到 3 件事:
- 回显:客户端发送
l
,然后立即收到一个回传的l
。我想这里的想法是客户端真的很笨——它不知道当我输入l
时,我希望l
回显到屏幕上。服务器进程必须明确告知它才能显示它。 - 换行符:当我按下回车键时,它发送一个
\r
(回车)符号而不是一个\n
(换行符) - 转义序列:
\x1b
是 ASCII 转义字符,因此\x1b[?2004h
告诉终端显示某些内容或其他内容。我认为这是一个颜色序列,但我不确定。稍后我们将更多地讨论转义序列。
好的,现在让我们做一些稍微复杂一点的事情。
示例: Ctrl+C
接下来,让我们看看当我们使用Ctrl+C
中断进程时会发生什么。这是我在终端中看到的:
bork@kiwi:/play$ cat ^C bork@kiwi:/play$
这是客户端发送和接收的内容。
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
字符。
我相信当我们按下Ctrl+C
时cat
被打断的原因是服务器端的 Linux 内核接收到这个\x03
字符,识别它意味着“中断”,然后向拥有伪终端进程组的进程发送一个SIGINT
.所以它是在内核中处理的,而不是在用户空间中处理的。
示例: Ctrl+D
让我们尝试完全相同的事情,除了Ctrl+D
。这是我在终端中看到的:
bork@kiwi:/play$ cat bork@kiwi:/play$
以下是发送和接收的内容:
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 个字母)
- 转义 ->
\x1b
- 退格 ->
\x7f
- 主页 ->
\x1b[H
- 结束:
\x1b[F
- 打印屏幕:
\x1b\x5b\x31\x3b\x35\x41
- 插入:
\x1b\x5b\x32\x7e
- 删除 ->
\x1b\x5b\x33\x7e
- 我的
Meta
键什么都不做
阿尔特呢?从我的实验(和一些谷歌搜索)来看, Alt
似乎与“Escape”字面意思相同,只是单独按Alt
不会向终端发送任何字符,而单独按Escape
则可以。所以:
- alt + d =>
\x1bd
(其他每个字母都一样) - alt + shift + d =>
\x1bD
(其他字母相同) - 等等
让我们再看一个例子!
示例: nano
这是我运行文本编辑器nano
时发送和接收的内容:
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 转义序列
vim
向客户端发送的这些\x1b[
内容称为“转义序列”或“转义码”。这是因为它们都以“转义”字符\x1b
。 .他们改变光标的位置,使文本加粗或加下划线,改变颜色等。如果你感兴趣的话,维基百科有一些历史。
作为一个简单的例子:如果你运行
echo -e '\e[0;31mhi\e[0m there'
在您的终端中,它会打印出“hi there”,其中“hi”为红色,“there”为黑色。这个页面有一些很好的颜色和格式转义码的例子。
我认为转义码有几种不同的标准,但我的理解是人们在 Unix 上使用的最常见的转义码集来自 VT100(博文顶部图片中的那个旧终端),并且在过去的 40 年里并没有真正改变太多。
转义码就是为什么如果你将cat
二进制文件放到屏幕上,你的终端会变得混乱——通常你最终会不小心打印出一堆随机的转义码,这会弄乱你的终端——里面肯定有一个0x1b
字节如果您cat
足够的二进制文件添加到终端,则在某个地方。
你可以手动输入转义序列吗?
前几节,我们讨论了Home
键如何映射到\x1b[H
。这 3 个字节是Escape + [ + H
(因为 Escape 是\x1b
)。
如果我在xterm.js
终端中手动键入 Escape,然后是 [,然后是 H,我会在行首结束,就像我按Home
一样。
我注意到这在我的计算机上的fish
中不起作用——如果我输入Escape
然后[
,它只是打印出[
而不是让我继续转义序列。我问了我的朋友 Jesse,他写了一堆 Rust 终端代码,Jesse 告诉我很多程序都实现了转义码的超时——如果你在最短的时间后不按另一个键,它会确定它实际上不再是转义码。
显然这可以在 fish 中使用fish_escape_delay_ms
进行配置,所以我运行了set fish_escape_delay_ms 1000
然后我可以手动输入转义码。凉爽的!
终端编码有点奇怪
我想在这里暂停一分钟,并说您按下的键映射到字节的方式非常奇怪。就像,如果我们今天从头开始设计密钥的编码方式,我们可能不会这样设置:
-
Ctrl + a
作用与Ctrl + Shift + a
完全相同 Alt
与Escape
相同- 控制序列(如颜色/移动光标)使用与
Escape
键相同的字节,因此您需要依靠时间来确定它是否是用户的控制序列,只是打算按Escape
但是所有这些都是在 70 年代或 80 年代或其他什么时候设计的,然后为了向后兼容需要永远保持不变,所以这就是我们得到的 🙂
改变窗口大小
并非您在终端中可以做的所有事情都是通过来回发送字节来实现的。例如,当终端调整大小时,我们必须告诉 Linux 窗口大小以不同的方式发生了变化。
下面是goterm中的 Go 代码的样子:
syscall.Syscall( syscall.SYS_IOCTL, tty.Fd(), syscall.TIOCSWINSZ, uintptr(unsafe.Pointer(&resizeMessage)), )
这是使用ioctl
系统调用。我对ioctl
的理解是,它是对一堆其他系统调用未涵盖的随机内容的系统调用,我猜通常与 IO 相关。
syscall.TIOCSWINSZ
是一个整数常量,它告诉ioctl
在这种情况下我们希望它做什么(更改终端的窗口大小)。
这也是 xterm 的工作原理
在这篇文章中,我们一直在讨论远程终端,其中客户端和服务器位于不同的计算机上。但实际上,如果您使用像xterm
这样的终端仿真器,所有这些都以完全相同的方式工作,只是更难注意到,因为字节不是通过网络连接发送的。
目前为止就这样了!
关于终端肯定还有很多要了解的(我们可以讨论更多关于颜色、原始模式与熟模式、unicode 支持或 Linux 伪终端接口),但我会在这里停下来,因为现在是晚上 10 点,这有点长,而且我认为我的大脑今天无法处理更多关于终端的新信息。
感谢Jesse Luehrs回答了我关于终端的十亿个问题,所有的错误都是我的 🙂