在linux终端里执行echo $TERM,可以得到一个类似于xterm或者vt100的环境变量值。这个值指示了当前终端所支持的协议。从VT100终端到XTERM,从伴随着计算机走入寻常百姓,到由于图形界面的发展而式微,终端以及字符界面推动着人机交互在一条岔路上不断演进。

物理终端

故事要从 tty 开始说起。

最早的时候,人机交互的界面——终端就是电传打字机teletypewriter, teletype or TTY),通过打字机的键盘键入命令,发送到小型机上。命令执行的结果则打印在打字机的纸筒上。字符回显的速度大概是10个字符每秒。操作计算机彼时是一个非常需要耐心的活。某些情况下,现在也是,不过情况已经好很多了。

后来,计算机虽然还未微型化,但电子技术的进步使得基于阴极射线管显示器(CRT显示器)的电子终端出现了。经典的 VT100 终端就是这个时候诞生的。

VT100

由于这些古老的终端还没用上后来才出现的标准键盘布局,并且屏幕也只支持单色显示,因此 TERM=VT100 等值往往意味着这台终端只能使用非常有限的快捷键,以及不支持颜色。

从物理终端上引申出来一个很重要的概念就是“终端控制对(pair)”。通过终端来控制计算机,实际上是两个独立的输入输出设备之间交互。终端的输入是用户的鼠标、键盘,而输出字符流则被传送至计算机的输入接口。计算机的输出字符流则传送至终端的输入接口,并显示到屏幕上。两个设备之间的连接可以是串口通信,可以是网络,也可以像现在的系统里一样,只是虚拟的内存流。虽然物理终端的时代结束了,但操作系统里仍然沿用着主从设备的概念。

终端控制对

模拟终端

伴随着计算机的微型化,显示器、键盘等设备可以直接接入计算机进行输入、输出,因而物理终端也变成了博物馆的藏品。但操作系统内仍然保留着终端的概念,毕竟图形化界面还在遥远的未来,打开计算机,彼时的UI还是字符界面的天下。

控制串与 Linux 伪终端

除了打印字符之外,终端还支持通过接收特定编码的字符串来响应对光标、颜色等的控制。Shell 就是通过这些控制串来实现不同颜色的指定、移动光标位置、局部字符的刷新。

  • CSI 控制串: ESC[ + 一个或多个参数 + 字符组成。由最后的字符确定终端将执行何种操作,通过中间的参数指定操作对应的内容,例如光标移动 n 个位置、颜色设置为 RGB 值等等。
  • OSC 控制串:由 Xterm 终端定义的控制串,以 ESC ] + 指令的格式,执行 bell、设置窗口标题等操作。

另一方面,从物理终端开始,不同的终端所支持的控制串标准也不一样。有的终端使用^C作为中断信号,而有的则使用Delete,因此对于运行在 shell 内的应用程序而言,就需要弄明白自己所面对的是何种终端。不论是物理终端还是虚拟终端,都会设置一个环境变量$TERM来表明自己的属性。

TERM 仅仅是一个字符串,类似于vt100或者xterm这样。真正让其发挥作用的,是系统中内置的终端数据库文件,以 terminfo 格式预置在系统内(当然也可以人为增添)。可以使用infocmp命令来查询不同类型终端所对应的标准,例如 xterm:

$ infocmp xterm
#       Reconstructed via infocmp from file: /usr/share/terminfo/78/xterm
xterm|xterm terminal emulator (X Window System),
        am, bce, km, mc5i, mir, msgr, npc, xenl,
        colors#8, cols#80, it#8, lines#24, pairs#64,
        acsc=``aaffggiijjkkllmmnnooppqqrrssttuuvvwwxxyyzz{{||}}~~,
        bel=^G, blink=\E[5m, bold=\E[1m, cbt=\E[Z, civis=\E[?25l,
        clear=\E[H\E[2J, cnorm=\E[?12l\E[?25h, cr=^M,
        csr=\E[%i%p1%d;%p2%dr, cub=\E[%p1%dD, cub1=^H,
        cud=\E[%p1%dB, cud1=^J, cuf=\E[%p1%dC, cuf1=\E[C,
        cup=\E[%i%p1%d;%p2%dH, cuu=\E[%p1%dA, cuu1=\E[A,
        cvvis=\E[?12;25h, dch=\E[%p1%dP, dch1=\E[P, dl=\E[%p1%dM,
        dl1=\E[M, ech=\E[%p1%dX, ed=\E[J, el=\E[K, el1=\E[1K,
        flash=\E[?5h$<100/>\E[?5l, home=\E[H, hpa=\E[%i%p1%dG,
        ht=^I, hts=\EH, ich=\E[%p1%d@, il=\E[%p1%dL, il1=\E[L,
        ind=^J, indn=\E[%p1%dS, invis=\E[8m,
        is2=\E[!p\E[?3;4l\E[4l\E>, kDC=\E[3;2~, kEND=\E[1;2F,
        kHOM=\E[1;2H, kIC=\E[2;2~, kLFT=\E[1;2D, kNXT=\E[6;2~,
        kPRV=\E[5;2~, kRIT=\E[1;2C, kb2=\EOE, kbs=^H, kcbt=\E[Z,
        kcub1=\EOD, kcud1=\EOB, kcuf1=\EOC, kcuu1=\EOA,
        kdch1=\E[3~, kend=\EOF, kent=\EOM, kf1=\EOP, kf10=\E[21~,
        kf11=\E[23~, kf12=\E[24~, kf13=\E[1;2P, kf14=\E[1;2Q,
        kf15=\E[1;2R, kf16=\E[1;2S, kf17=\E[15;2~, kf18=\E[17;2~,
        kf19=\E[18;2~, kf2=\EOQ, kf20=\E[19;2~, kf21=\E[20;2~,
        kf22=\E[21;2~, kf23=\E[23;2~, kf24=\E[24;2~,
        kf25=\E[1;5P, kf26=\E[1;5Q, kf27=\E[1;5R, kf28=\E[1;5S,
        kf29=\E[15;5~, kf3=\EOR, kf30=\E[17;5~, kf31=\E[18;5~,
        kf32=\E[19;5~, kf33=\E[20;5~, kf34=\E[21;5~,
        kf35=\E[23;5~, kf36=\E[24;5~, kf37=\E[1;6P, kf38=\E[1;6Q,
        kf39=\E[1;6R, kf4=\EOS, kf40=\E[1;6S, kf41=\E[15;6~,
        kf42=\E[17;6~, kf43=\E[18;6~, kf44=\E[19;6~,
        kf45=\E[20;6~, kf46=\E[21;6~, kf47=\E[23;6~,
        kf48=\E[24;6~, kf49=\E[1;3P, kf5=\E[15~, kf50=\E[1;3Q,
        kf51=\E[1;3R, kf52=\E[1;3S, kf53=\E[15;3~, kf54=\E[17;3~,
        kf55=\E[18;3~, kf56=\E[19;3~, kf57=\E[20;3~,
        kf58=\E[21;3~, kf59=\E[23;3~, kf6=\E[17~, kf60=\E[24;3~,
        kf61=\E[1;4P, kf62=\E[1;4Q, kf63=\E[1;4R, kf7=\E[18~,
        kf8=\E[19~, kf9=\E[20~, khome=\EOH, kich1=\E[2~,
        kind=\E[1;2B, kmous=\E[M, knp=\E[6~, kpp=\E[5~,
        kri=\E[1;2A, mc0=\E[i, mc4=\E[4i, mc5=\E[5i, meml=\El,
        memu=\Em, op=\E[39;49m, rc=\E8, rev=\E[7m, ri=\EM,
        rin=\E[%p1%dT, rmacs=\E(B, rmam=\E[?7l, rmcup=\E[?1049l,
        rmir=\E[4l, rmkx=\E[?1l\E>, rmm=\E[?1034l, rmso=\E[27m,
        rmul=\E[24m, rs1=\Ec, rs2=\E[!p\E[?3;4l\E[4l\E>, sc=\E7,
        setab=\E[4%p1%dm, setaf=\E[3%p1%dm,
        setb=\E[4%?%p1%{1}%=%t4%e%p1%{3}%=%t6%e%p1%{4}%=%t1%e%p1%{6}%=%t3%e%p1%d%;m,
        setf=\E[3%?%p1%{1}%=%t4%e%p1%{3}%=%t6%e%p1%{4}%=%t1%e%p1%{6}%=%t3%e%p1%d%;m,
        sgr=%?%p9%t\E(0%e\E(B%;\E[0%?%p6%t;1%;%?%p2%t;4%;%?%p1%p3%|%t;7%;%?%p4%t;5%;%?%p7%t;8%;m,
        sgr0=\E(B\E[m, smacs=\E(0, smam=\E[?7h, smcup=\E[?1049h,
        smir=\E[4h, smkx=\E[?1h\E=, smm=\E[?1034h, smso=\E[7m,
        smul=\E[4m, tbc=\E[3g, u6=\E[%i%d;%dR, u7=\E[6n,
        u8=\E[?1;2c, u9=\E[c, vpa=\E[%i%p1%dd,

(放心,正常人都看不懂)

时下流行的终端模拟器应用都会致力于实现某种终端标准(往往是 xterm 或者 xterm-256color),以获得最佳的兼容性。但也有某些奇葩决定使用自己的标准,比如xterm-kitty

在 Linux 下执行程序,将由 shell 调起对应的可执行文件(使用系统调用) ,而应用的输入输出被定向到(伪)终端的 I/O 上。

Windows 伪终端

终端到了 Windows 上,又变成另外一副模样。

在 Windows 上,应用程序使用 ConsoleAPI 来进行输入输出、控制光标移动以及颜色等等,而不是使用纯字符流的控制串。在 Windows 10之前,字符界面的程序调起流程是这样的:首先是系统调起字符交互模式的程序运行,然后由系统自动创建一个终端进程 ConHost,并 attach 到字符程序上,作为字符程序的输入输出终端。字符程序通过 Windows Console API 与终端进程进行交互,获取用户输入并输出内容。这个终端 GUI 程序就是万恶之源 WindowsConsole

经典 Windows 终端

(很多人以为小黑窗就是 CMD.EXE,但实际上 CMD 也只是一个普通的字符应用程序,而小黑窗就是系统为其运行时默认创建的终端)

然而这一与 Linux 阵营格格不入的架构也使得跨平台应用的研发变得非常困难,众多在 Linux 上所普遍使用的字符应用无法在 Windows 上运行,开发者也无法在 Windows 上使用熟悉的其他平台的命令行程序。更致命的是,随着『子系统 WSL』的提出,Windows 上拥有了直接运行 Linux 系统的能力,而 Linux 系统的输入输出也必须经由 Windows 上的终端来呈现。这也意味着,与 WSL 配套地,Windows 终端也必须支持控制串协议

为此,微软成立了专门负责终端的项目组,在不破坏原有 Console API 兼容性的基础上,使系统里的终端子系统同时支持字符流控制串的输入、输出与正确渲染。总结一下主要是两个事:

  • WindowsTerminal:新的终端模拟器,支持正确的渲染 VT 字符流。
  • conhost/ConPTY:新的终端子系统,支持字符应用程序输出 VT 流,并正确渲染到终端上。

随着新终端架构的开发,现在的终端模拟器开发者可以使用ConPTY接口。首先由终端程序创建一个伪终端对象,同时得到一个进程信息,指明后续如何在伪终端模式下创建子进程。之后在创建字符程序作为子进程时,使用传统的 CreateProcess API,并传入前面获得的控制信息,则系统会将这一字符程序的输出重定向到ConPTY输入输出流上,从而父进程经由ConPTY接口,使用传统的 VT 字符流与子进程交互。

然而 ConPTY 不仅仅是一个“管道”,其内部还包括了“渲染”的逻辑。这个中间层会试图解析原始的输入字符流,并仅把关键的渲染操作提交给上层的调用者,因此上层应用得到的字符流经过这层过滤后,可能也会出现意料之外的情况,比如某些特殊的、应用自定义的控制串。例如最近在给 wezterm 提交一个适用于 tmux control mode 的改动[1],试图在这一跨平台的终端上实现 iTerm 那个惠及不少开发者的功能。然而在 Windows 上偏偏无法成功进入 tmux -CC 模式,就是因为 tmux 指定的起始控制串被 ConPTY 过滤了。相关的 Passthrough Mode 也在讨论中[2],相信在一两年内这一情况将得到改善。

[1] [WIP] Tmux control mode

[2] ConPTY Passthrough Mode