终端模拟器原理:从字节流到屏幕上的字符

「SSH 终端是怎样做成的」系列第 1 篇。文中代码摘自 MistTerm v1.2.26。MistTerm 是 MistLab 出品的开源 SSH 终端客户端,用 Rust 编写,采用 AGPL 协议。代码只做了删减,没有改写。

要点

终端模拟器把程序输出的字节流变成屏幕上的字符,分三步。解析器要能跨两次 read 接着跑(半截 ESC[ 不能丢),把字节流拆成可打印字符和 ECMA-48/xterm 控制序列;网格负责光标、滚动区域、模式、宽字符和备用屏幕;渲染器再把网格画出来。MistTerm 用 alacritty_terminal 做解析器和网格,用 egui 自己画。

打开终端敲 ls --color,颜色几乎马上出来。中间其实隔着解析器、网格和一层自己画的 egui 文字——MistTerm 没重写前两层。标准怎么约束,以及怎么把 alacritty_terminal 接进自己的界面:适配层很薄。

终端和程序怎么通信:只有字节流

终端和运行在里面的程序之间没有任何 API。bash、vim、top 这些程序只是往标准输出写字节。可打印字符直接显示,其余的都是给终端的指令。这些指令的编码约定可以追溯到 DEC VT100,后来有一部分被标准化为 ECMA-48(ISO 6429):

ECMA-48 规定了语法和很多功能。但现代终端「应该怎么表现」,事实上的参考是 xterm 的 Control Sequences 文档(ctlseqs),因为大多数软件默认对面是「兼容 xterm」的终端。这个约定通过 $TERM 协商:MistTerm 打开会话时,以终端类型 xterm-256color 申请 PTY,服务器上的程序再到 terminfo 数据库里查这个名字,决定该输出哪些序列。

// src/ssh/client.rs
// 请求 PTY(尺寸错误会导致远端按 80 列换行、vim 只开一行等)
channel
    .request_pty(
        "xterm-256color",
        None,
        Some((cols, rows, px_w, px_h)),
    )

第一步:VT 解析器状态机是怎么工作的

字节流没有消息边界。一次 read() 可能在 ESC[3 处结束,下一次从 1m 开始。所以解析器必须跨调用保存状态,逐字节消费输入。

Paul Williams 那套 DEC 兼容状态机现在几乎人人照着做,文档在 vt100.net。它定义了十几个状态,包括 ground、escape、CSI entry/param/intermediate/ignore、OSC 字符串、DCS 系列等,并给每个字节值规定了状态转移。转移会产生动作:打印一个字符,执行一个 C0 控制符,或者分派一条完整的 ESC/CSI/OSC 序列。有些转移在「任意状态」下都成立:ESC 总是开启一条新序列,CAN 和 SUB 会中止当前序列。

VT 解析器状态机(简化)
VT 解析器状态机(简化)

Rust 生态里这一层是 Alacritty 项目的 vte crate,它的解析器就基于 Williams 状态机。它刻意只做「字节 → 回调」这一件事,并不知道「擦除行」是什么意思。

第二步:网格才是语义所在

真正费工夫的是每个动作的含义。可见屏幕是一个 行 × 列 的单元格数组,每格保存一个字符和它的属性:前景色、背景色、粗体、下划线、反显、宽字符标志等。网格之外还有光标位置及其保存副本、当前开启的模式、滚动区域、制表位、备用屏幕和回滚历史。应用程序最在意的,往往是这些细节规则:

tmux、vim、htop 能不能画对,就取决于这些细节。写一个解析器只要一个下午,把网格语义做对,却需要多年对照 xterm 和 vttest 之类的测试集。

所以 MistTerm 没有自己实现这一层。alacritty_terminal 把 Alacritty 终端的解析器、网格和语义打包成了库,MistTerm 的适配层(src/terminal/alacritty.rs)喂数据只需要一行:

pub fn feed(&mut self, data: &[u8]) {
    if !data.is_empty() {
        self.content_epoch = self.content_epoch.wrapping_add(1);
    }
    self.parser.advance(&mut self.term, data);
}

parser 是 alacritty_terminal::vte::ansi::Processor,term 是 Term。advance 消费整块数据,没处理完的半截序列留到下次调用。content_epoch 是 MistTerm 自己加的计数器,UI 在帧与帧之间比较它,没有变化就跳过重新排版。

模式查询直接问 Term。比如粘贴代码要知道远端程序有没有开启 bracketed paste,就是这样查的:

/// 远端程序是否开启了 bracketed paste(DECSET 2004,bash/zsh/vim 等会开)。
pub fn is_bracketed_paste(&self) -> bool {
    self.term
        .mode()
        .contains(alacritty_terminal::term::TermMode::BRACKETED_PASTE)
}

网格尺寸必须和远端 PTY 一致。窗口缩放时,本地 Term 要跟着改尺寸,同时通过 SSH 的 window-change 通道请求(RFC 4254 §6.7,在 ssh2 crate 里是 request_pty_size)通知服务器。否则远端程序还会按旧宽度折行。

第三步:把网格画出来

Alacritty 自己用 OpenGL 绘制字形。MistTerm 则是一个完整的桌面应用,有侧边栏、SFTP 文件浏览器和各种对话框,基于即时模式 GUI 框架 egui,后端是 wgpu。所以终端区域也用 egui 的文字排版来画。

MistTerm 中从字节到像素
MistTerm 中从字节到像素

每个可见行的单元格会被转换成一个 egui LayoutJob。样式相同的相邻单元格合并成一段,排版引擎面对的是几十个文本段,而不是几千个单字符:

let same_run = |a: &TextFormat, b: &TextFormat| {
    a.color == b.color
        && a.extra_letter_spacing == b.extra_letter_spacing
        && a.line_height == b.line_height
        && a.font_id == b.font_id
};
// …
for (ch, color, _bg, flags) in row {
    if flags.intersects(wide_spacer) {
        flush_run(&mut job, &mut run_text, &mut run_fmt);
        job.append("\u{200b}", 0.0, cell_fmt(color, 0.0));
        continue;
    }
    let spacing = if flags.contains(Flags::WIDE_CHAR) { cell_w.max(0.0) } else { 0.0 };
    // 与当前段同格式则追加,否则先提交当前段再开新段
}

这里有两个细节是为中日韩文字准备的:

  1. 占位格。宽字符在网格里存成字符本身(带 WIDE_CHAR 标志)加一个占位格(WIDE_CHAR_SPACER)。占位格不能画任何东西,但每行的格数必须一致,所以 MistTerm 给它输出一个零宽空格。
  2. 强制字宽。等宽字体里的汉字,宽度并不保证正好是 ASCII 字符的两倍。直接排版的话,误差会沿着一行累积,文字和光标越错越远。给宽字符加 extra_letter_spacing,就能让每个宽字符恰好占两个单元格宽度。

背景色单独处理:

// 字形层底色一律透明;ANSI/选区底由 UI 自绘,避免 egui background expand 压行。
let cell_fmt = |color: Color32, extra_spacing: f32| TextFormat {
    font_id: FontId::monospace(font_size),
    color,
    background: Color32::TRANSPARENT,
    line_height: Some(line_height),
    extra_letter_spacing: extra_spacing,
    valign: egui::Align::BOTTOM,
    ..Default::default()
};

文字排版引擎的「背景色」是围绕每个字形的包围盒画的,可能渗到相邻的行。终端需要的是严格对齐单元格网格的实心色块。所以 MistTerm 按网格坐标自己画背景矩形,文字层只负责前景字形。

剩下要做的

终端模拟内核借来了,剩下的是胶水:字节从哪里来,按键发到哪里去,什么时候重绘,以及选区、搜索、回滚这些界面功能。下一篇从「字节从哪里来」讲起:SSH 通道、远端 PTY 和流控。

小结

常见问题

终端模拟器里的解析器和网格有什么区别?

解析器只负责把字节变成“打印字符”“执行 CSI 序列”这类动作;网格负责赋予动作含义,保存字符单元格、光标、滚动边界、模式和备用屏幕。

TERM=xterm-256color 有什么作用?

它告诉服务器上的程序该按哪种终端类型输出。程序用这个名字查 terminfo 数据库,决定发送哪些控制序列。MistTerm 申请 PTY 时使用这个终端类型。

为什么有的终端里中文会越来越对不齐?

等宽字体里中文字形的宽度不一定恰好是 ASCII 字形的两倍,误差会沿着一行累积。MistTerm 强制每个宽字符正好占两个单元格宽。

为什么 vim 退出后屏幕内容会恢复?

vim 用 DEC 私有模式 1049 切换到备用屏幕,退出时再切回来。备用屏幕没有回滚历史。

相关文章

系列目录 · 第 2 篇 →