终端模拟器原理:从字节流到屏幕上的字符
「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):
- C0 控制符:小于 0x20 的单字节,例如回车
\r、换行\n、退格\b、制表\t、响铃BEL。 - ESC 序列:
ESC(0x1B)后面跟一个或几个字节。 - CSI 序列:
ESC [,加可选参数和中间字节,再以 0x40–0x7E 之间的终止字节结束。ESC[31m把前景色设成红色(SGR),ESC[2J清屏,ESC[10;5H把光标移到第 10 行第 5 列。 - OSC 字符串:以
ESC ]开头,以BEL或字符串终止符ESC \结束,用于窗口标题、超链接等。 - DEC 私有模式:
CSI ? Pm h开启,CSI ? Pm l关闭。模式 1049 切换到备用屏幕,vim 和 less 退出后能把原来的屏幕还回来,靠的就是它。模式 2004 开启 bracketed paste,第 3 篇会专门讲。
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 会中止当前序列。
Rust 生态里这一层是 Alacritty 项目的 vte crate,它的解析器就基于 Williams 状态机。它刻意只做「字节 → 回调」这一件事,并不知道「擦除行」是什么意思。
第二步:网格才是语义所在
真正费工夫的是每个动作的含义。可见屏幕是一个 行 × 列 的单元格数组,每格保存一个字符和它的属性:前景色、背景色、粗体、下划线、反显、宽字符标志等。网格之外还有光标位置及其保存副本、当前开启的模式、滚动区域、制表位、备用屏幕和回滚历史。应用程序最在意的,往往是这些细节规则:
DECSTBM设置滚动区域之后,在区域底边换行只滚动这个区域。- 备用屏幕没有回滚历史。
- 在最后一列写字符后,光标进入「待换行」状态,等下一个可打印字符到来才真正换行。
- 双宽字符占两格,覆盖其中任何一半都要清掉另一半。
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 的文字排版来画。
每个可见行的单元格会被转换成一个 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 };
// 与当前段同格式则追加,否则先提交当前段再开新段
}
这里有两个细节是为中日韩文字准备的:
- 占位格。宽字符在网格里存成字符本身(带
WIDE_CHAR标志)加一个占位格(WIDE_CHAR_SPACER)。占位格不能画任何东西,但每行的格数必须一致,所以 MistTerm 给它输出一个零宽空格。 - 强制字宽。等宽字体里的汉字,宽度并不保证正好是 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 和流控。
小结
- 终端和程序之间只有字节流。控制序列遵循 ECMA-48 和 xterm 的约定,
$TERM告诉程序该说哪种方言。 - 解析器是一台可以从中途恢复的状态机,采用 Williams 模型,由
vte实现。 - 难的语义都在网格里:滚动区域、待换行状态、各种模式、宽字符、备用屏幕。这一层用
alacritty_terminal,没有自己重写。 - 在通用 GUI 里渲染终端要处理好几件事:同样式合并成段,宽字符显式补足间距,背景按单元格自己画。
常见问题
终端模拟器里的解析器和网格有什么区别?
解析器只负责把字节变成“打印字符”“执行 CSI 序列”这类动作;网格负责赋予动作含义,保存字符单元格、光标、滚动边界、模式和备用屏幕。
TERM=xterm-256color 有什么作用?
它告诉服务器上的程序该按哪种终端类型输出。程序用这个名字查 terminfo 数据库,决定发送哪些控制序列。MistTerm 申请 PTY 时使用这个终端类型。
为什么有的终端里中文会越来越对不齐?
等宽字体里中文字形的宽度不一定恰好是 ASCII 字形的两倍,误差会沿着一行累积。MistTerm 强制每个宽字符正好占两个单元格宽。
为什么 vim 退出后屏幕内容会恢复?
vim 用 DEC 私有模式 1049 切换到备用屏幕,退出时再切回来。备用屏幕没有回滚历史。