终端的那些“黑魔法”

作为程序员,终端可能是我们每天都会使用的东西。但这个看起来平平无奇的黑框框,却完全不像它表面上的那么简单。这篇文章我们就一起探索一下终端背后的故事。

一点历史

说到终端,就不得不提到 TTY 这个词。我们现在普遍都用它代指终端或者命令行,但它本身的意思其实是 teletype,也就是电传打印机。这个在二战时期用来传输情报的东西,被延续至今演变成了我们日常使用的东西……

电脑出现了以后,TTY 就变成了人与电脑交互的介质。人们在键盘上输入命令,输入的内容就被实时打印在纸上,回车之后电脑开始执行命令,并把输出结果也打印到纸上。然而,打印出来的字符是不能被擦出的,所以那时退格键也会被打印在纸上,用来表示上一个字符已经被删除。那时候,TTY 并不能被称为终端,它最多就是一个高级一点的打印机。

再后来,得益于显像管技术的成熟,CRT 视频终端出现了,人们终于不需要又费时间又费纸的打印机了。那时候有很多型号的视频终端,而 VT100 几乎成了行业标准,现代终端模拟器也多采用扩展自 VT100 的xterm 标准。在当时,VT100 已经支持了诸如光标控制、文字颜色和屏幕滚动等能力,实现方式我们后面也会提到。

回到现代 —— 伪终端

我并没有经历过那个年代,我所接触的第一台终端应该是 GNOME Terminal,没错,它是一个终端模拟器。终端这个电脑的操作界面虽然一直被延续了下来,但应该没有人会喜欢天天在一个外设上操作电脑。既然电脑现在都自带屏幕,那是不是可以直接在电脑屏幕上模拟一个终端呢?伪终端就是用来做这个的。虽然市面上的终端模拟器众多,功能形态也各具特色,但它们底层都离不开伪终端这个操作系统提供的能力。

大部分类 UNIX 操作系统都会具备 PTY 这个虚拟设备(位于 /dev/ptmx 下)和配套的系统调用,Windows 也有类似的技术叫做 ConPTY,终端模拟器程序可以通过这些能力来创建一台虚拟的终端设备。随便在你的电脑上打开一个终端模拟器,执行 tty 命令,就能看到这个终端模拟器所创建出来的虚拟终端设备了。例如我的输出是:

/dev/ttys004

对于运行在这个终端里的程序(例如 zsh)来说,它与操作一个真实的终端无异。你也可以直接向这个文件里写内容,它就会被输出在你的终端模拟器里。

其实不止终端模拟器会使用到伪终端,很多运行在终端里的程序本身也可以是一个伪终端,例如 tmux、screen。伪终端更多的玩法就看我们的创意了。

这里我们不会深入伪终端在各个平台上的使用方法,有兴趣的朋友可以自行查阅相关资料。

特殊字符与序列

上面我们提到过,VT100 具备了很多高级能力,当时那么多的操作系统该如何使用这些能力呢?

还记得以前高中的时候刚接触 Shell 脚本,就很好奇为什么有些人写的脚本能打印出五颜六色的文字。网上研究了一波发现在输出的文字前加上一串 \033[31m 就可以让后面的文字变成红色。那时候并不懂什么 VT100,只是感觉很神奇。

而这些其实都是终端标准里定义的控制序列,不同的标准所支持的控制序列也不尽相同。由于终端是一个字符设备,因此设备间传输的只有字符数据。ASCII 编码中可打印的字符就会被直接显示在屏幕上。而想要控制终端做出其他的动作就需要一组特殊的字符了,它们就是控制字符和控制序列。

控制字符

先来说说最简单的控制字符,它其实就是一个简单的 ASCII 字符,例如 CR (\r)、LF (\n)。这个单一字符就可以实现一些相对复杂的输出效果了,例如用 \r 把光标移动到行首,这时候继续打印新的字符就可以覆盖掉这行之前的内容,可以实现 npm install 的状态栏效果。

另一个很常见但可能会被你忽略的控制字符是 BS (\b),当你按下退格键时,操作系统为了让终端擦除前面一个字符就会使用到 \b。这个字符在打印机上只能被打印出来,而屏幕就灵活的多,但大部分终端并不会直接清除上一个字符,而是只会将光标向前移动一格。因此对于 VT100 终端机,如果我们对它与电脑的连接抓包,就会看到当你按下退格键时,电脑发出了 \r \r 这样一串文本。它的作用就是将光标向前移动一格,然后打印一个空格(此时空格将会覆盖之前的字符),然后再将光标向前移动一格(不然光标就会留在空格的后面)。所以一个看似朴素的退格操作,背后却是这样一番 tricky 的操作。

还有一些不常见的控制字符,如 BEL (\a) 可以让终端发出提示音,这里就不一一列举了,完整列表可以参考这个 wiki

转义序列

控制字符由于只有一位,能做的事情也比较少,对于一些更复杂的操作就需要借助转义序列了。转义序列也是由一个控制字符 ESC (\x1b\033) 开头,后面跟上一串 ASCII 字符。转义序列通常会用来引出一些控制序列,常见的有 CSI (Control Sequence Introducer), OSC (Operating System Command) 和 DCS (Device Control String)。

CSI 序列

CSI 序列是最常见的控制序列,在 ESC 转义字符后接一个 [ 就会开启一个 CSI 序列了。没错,控制字符颜色使用的正是一个 CSI 序列,它的名字叫 SGR (Select Graphic Rendition)。

除了控制颜色,CSI 序列还可以移动光标、区域擦除以及滚动屏幕等。那些复杂的终端程序会大量使用 CSI 序列来在终端里绘制 UI。

CSI 序列通常都是由参数和终止符组成的,参数只能包含数字字符和分号,而终止符则是其他可打印的 ASCII 字符。终止符表示这个 CSI 序列所要做的操作,前面的数字是这个操作所需的参数。这也意味着,CSI 序列不常用来承载文本信息,因为我们必须将文本编码成数字。

这里就用控制颜色的 CSI 序列举个例子吧,它的终止符是 m,可以接受不定个参数。由于这个序列叫做 Select Graphic Rendition,也就是选择图像渲染方式,所以它的每个参数不只能表示颜色,还可以表示诸如粗体、下划线以及闪烁等显示特性。每个特性包括颜色都被赋予了唯一的数值,比如 1 表示粗体、4 表示下划线、31 表示红色文字,而 41 表示红色背景等等。你可以用分号把一组特性连起来在一个 SGR 指令中应用,例如粗体红色文字就是:

<ESC> '[' 1 ';' 31 'm'

终端接收到这个指令后会将后续接收到的所有普通字符都以粗体红色打印,直到遇到新的 SGR 指令。

OSC 序列

上面说到 CSI 序列只能接受数字作为参数,那么 OSC 序列接受的参数就是字符串了。其实 OSC 的作用跟它的名字并不是十分匹配,大部分普遍在使用的 OSC 序列都是现代终端模拟器定义的,比如 iTerm2 定义的设置窗口标题命令。与 CSI 序列不同的是,它是在 ESC 转义字符后接一个 ] 开启的,然后接上不定个范围在 0x20 到 0xff 的 ASCII 字符,并以 BEL 或 ESC \ 结束。

OSC 主要用于一些与字符串有关的特殊操作,例如给文本添加超链接、修改窗口标题等。

DCS 序列

依然与名字无关,DCS 序列更像是 CSI 序列和 OSC 序列的结合体,它可以同时接受数字和字符串参数。它的组成与前面两个序列类似,是在 ESC 转义字符后接一个 P 开启,然后是 CSI 的参数部分,再加上 OSC 的参数部分结尾。

应用程序可以通过 DCS 序列请求一些终端的信息和状态,但具体操作也是由终端标准所定义的。大家如果有兴趣可以阅读这篇文章

解密 Warp 实现原理

如果你了解 Warp,那么应该对它的 blocks 特性非常熟悉,这也是 Warp 的特色之一。但作为一个终端模拟器,它如何感知 shell 的状态,把不同的命令分割成不同的 blocks 呢?

要实现这个效果,我们就需要知道什么时候一个命令执行结束了。但有了上面介绍的这些内容,相信你也能想到可以通过一个约定的特殊序列来通知 Warp 这个事件。那么如何在命令执行结束的时候做一些自己的事情就是重点了。好在主流的 shells 都有 hooks 的能力,以 zsh 举例,我们可以定义一个 precmd hook 函数来在准备打印提示符的时候执行自定义操作。我们看一下 Warp 给 zsh 设置的 precmd hook:

> echo $precmd_functions
warp_set_title_idle_on_precmd _omz_async_request omz_termsupport_precmd omz_termsupport_cwd _zshz_precmd _zsh_autosuggest_start prompt_starship_precmd warp_precmd warp_update_prompt_vars

可以看到这里面有一个 warp_precmd,我们通过 which warp_precmd 就可以看到它里面做的事情还是不少的。主要是收集了一些上下文信息,组装成一个 JSON 消息,然后作为参数调用 warp_send_json_message 函数。我们可以看一下 warp_send_json_message 函数的实现:

warp_send_json_message () {
	local msg=$(warp_hex_encode_string "$1")
	if [ "$WARP_USING_WINDOWS_CON_PTY" = true ]
	then
		printf $OSC_START$DCS_JSON_MARKER$OSC_PARAM_SEPARATOR$msg$OSC_END
	else
		printf "%b%b%s%b" $DCS_START $DCS_JSON_MARKER $msg $DCS_END
	fi
}

可以看到,它在 UNIX 环境下使用了 DCS 序列作为媒介来传输这个 JSON 消息,上面说到 DCS 序列既可以传输数字也可以传输字符串,因此 Warp 可以定义一个私有的 DCS 序列终止符。这里执行 print $DCS_JSON_MARKER | xxd 可以看到终止符是 d,接下来就是十六进制编码的 JSON 作为字符串参数。这样,Warp 就可以在接收到这个特殊序列时为前面的内容创建一个 block,并且根据携带的各种状态来渲染成不同的颜色。

所以理论上我们可以在任何时候发送这个消息来让 Warp 创建 block,不妨尝试执行一下这行命令看看效果吧:

> echo hello; warp_precmd; echo world

结语

这些,只是终端背后的冰山一角,它复杂且历史悠久的体系很难在短时间内完全梳理清楚。本文仅起到一个抛砖引玉的作用,让大家对终端的基本概念有一个初步了解,那就写到这里吧。