资讯详情 从零实现轻量级终端复用器cmux:架构解析与实战经验
📅 2026/10/23 5:54:43
从第一次敲下 cmux 的代码到它稳定挂在我日常终端里使用前后大概折腾了三个月。起因不算什么宏大目标我平时用终端复用工具的频率非常高配置改来改去之后总觉得自己不是在用工具而是在维护一套越来越重的配置框架速度却没比默认行为快到哪里去。于是我想试试能不能从零写一个轻量级的终端复用器只保留我真正在用的几个能力会话分离、面板分割、键位映射以及干净利落的退出逻辑。cmux 就是这么来的——一个只依赖系统基础终端能力的迷你型终端复用器。这篇文章不打算写成教科书式指南而是按我做 cmux 这个项目的实际顺序把关键架构选择、踩过的坑和最终实测结果整理出来。适合的对象是有一定终端协议基础、想自己动手写类似玩具但不知道从哪里开始的开发者哪怕你完全没写过终端程序也可以从里面的设计思路里找到一个相对清晰的入手路径。1. 动手之前这个项目到底要解决什么问题1.1 终端复用工具的常见痛点终端复用器解决的核心问题并不复杂让一个终端窗口背后能挂多个会话每个会话里再拆出若干窗口和面板把这些状态长期保存在一个独立进程里。这样你 ssh 断开、电脑休眠、终端窗口关闭回到机器上还能重新接上之前的现场。这类工具我用过很多年功能的确很完整但我也慢慢发现几个恼人的地方。第一个痛点是配置越来越膨胀。为了满足各种场景我往配置里塞了几十行自定义键位、鼠标选项、状态栏内容、窗口样式。每次升级工具版本我都要重新验证这些配置是否生效一旦某个选项在新版本里改了名或者调整了行为维护成本立刻涨上来。第二个痛点是二进制和依赖比较大。常见的终端复用器要么是较大体量的编译产物要么自带脚本语言插件系统对一个只需要“开面板、切窗口、分离会话”的人来说这些额外能力发挥不了多少价值。第三个痛点是我想真正搞懂它的工作机制而不是一直停留在“会用它”的层面。所以 cmux 从一开始就很明确不追求功能的绝对丰富而是追求最小可用的完整系统。我希望它保持在一个精简的代码规模内所有核心模块拆开之后任何一个人都能在一晚上读完主要代码。1.2 我给自己划定的边界做一个终端复用器很容易陷入“功能蔓延”。今天想加一个复制模式明天想加鼠标事件后天又想支持改键。我在项目第一天就在 README 里写下了两条原则第一只实现我实际反复在用的场景第二每一次新增功能都必须先有真实使用场景而不是为了“看起来完整”。最终确定的范围是四块会话的创建与分离恢复、窗口切换、面板分割与缩放、基础键位映射。几个明确不做的东西包括插件系统、鼠标拖拽调整面板、状态栏的富文本主题、剪贴板联动。不做这些不是因为它们没用而是因为它们会引入大量额外复杂性尤其是鼠标拖拽和剪贴板联动几乎要牵扯到完整的终端协议边缘情况。这是我后来回头觉得最正确的一个决定。因为没有插件系统就不需要设计一套脚本绑定不需要考虑第三方脚本就不需要对外暴露太多内部状态整个项目的耦合度被压到了很低。对一个“学习型项目”来说这是一个非常重要的良性循环。1.3 为什么选 C 而不选更快的语言cmux 这个名字里的 “C” 就是 C 语言。我知道很多人第一反应会是为什么不用 Go 或者 Rust终端的并发模型、事件循环这些语言处理起来不是更舒服吗我主要是从目标角度考虑的我希望这份代码能成为一份低层系统编程的参考实现而 C 在这类场景里几乎是最贴近系统接口的语言。pty 的分配、终端的 termios 属性、ioctl 调用、信号处理这些操作在 C 里都是标准库和系统调用的直接封装没有额外跑一场。换个高级语言虽然也能调但中间总隔着一层抽象很多细节被框架处理了反而不利于搞清楚原理。另一个原因是运行时依赖极简。最终编译出来的 cmux 只有一个二进制文件不依赖运行时丢到任何一台 Linux 机器上都能跑。对于经常要登录各种机器的我来说这一点很实用。2. 核心架构一个小型服务器在管理一批“小终端”2.1 为什么一定要做服务端和客户端分离cmux 的架构参考了主流终端复用工具的服务端/客户端模型一个后台守护进程负责持有所有会话一个命令行客户端负责连接过去并发送键盘输入、读取屏幕输出。有人会问既然只是开几个面板为什么不做一个普通前台进程terminal 直接启动子 shell按快捷键创建面板就行问题出在“会话分离”这个需求上。如果你希望关闭终端窗口后会话还能继续跑那就必须有一个不依附任何前台终端的守护进程存在。当用户回到终端输入命令时客户端通过本地 socket 找到这个后台服务把当前终端窗口接管过来。既然早晚都要做这个模型不如一开始就把它设计进去省得以后重构。unix socket 是本地进程通信最合适的选择。TCP socket 也可以但还需要处理端口占用的冲突问题还要考虑本机防火墙策略完全没有必要。unix socket 绑定到一个固定路径下用文件权限控制访问简单直接。cmux 把 socket 路径放在临时目录下以用户 ID 做隔离避免多用户环境下互相串会话。2.2 最小数据模型会话、窗口、面板内部数据模型我刻意压到了三层。顶层是会话每个会话就是一个复用的“工作现场”。会话里面包含若干窗口窗口相当于一个标签页。每个窗口内部就是面板面板是真正承载 pty 的最小单位。每次新建面板cmux 都会分配一对 pty 主从设备然后 fork 一个子进程去执行登录 shell。面板的输入输出都绑定在这对 pty 上。一个比较重要的设计决定是每个面板对应一个独立的 pty而不是用单个 pty 再在内部做虚拟化。这样做的理由很直接面板里的 shell 需要认为自己在控制一个真实终端它才能处理光标、清屏、颜色、方向键这些行为。如果我去虚拟这一层那相当于重新实现了一遍 pty 协议。用真实 pty 作为面板的承载体复杂度和可靠性问题都大幅降低。数据结构上用了一个全局的单向链表维护会话每个会话里有窗口链表窗口里有一个面板数组。分割面板时每次把一个面板切成两个再把切出来的新面板挂到同一窗口的面板数组里。这个模型简单直接不做抽象分层对调试非常友好。2.3 事件循环与主流程整个 cmux 的运转都围绕一个 poll 事件循环展开。守护进程维护几类文件描述符每个 pty 主端的读端、unix socket 的监听端、每个已连接客户端的通信 socket再加上一个用于处理信号的通知管道。每次循环里把所有 pollfd 收集起来统一 poll然后遍历返回的事件逐个处理。while (running) { build_pollfds(); int n poll(all_fds, nfds, 1000); if (n 0) { handle_pending_signals(); continue; } for (int i 0; i nfds; i) { if (all_fds[i].revents POLLIN) { handle_fd_event(all_fds[i].fd); } } render_dirty_panes(); }信号机制的细节值得说一下。直接在某些系统调用中中断后需要小心。默认的 SIGCHLD 会打断 poll所以我把 SIGCHLD、SIGWINCH、SIGTERM 这些信号统一通过 signalfd 或自建管道写一个字符进去让 poll 被唤醒之后再在循环里读取。这样主循环流程始终保持单一入口不需要在处理信号的瞬间去改动内部数据结构从根上消除了异步改状态的麻烦。客户端那边的流程就简单得多启动时连接守护进程发送自己的终端尺寸和窗口环境然后进入读取循环——从标准输入读键盘按键发送给服务端从 socket 读服务端发来的屏幕重绘内容再写回标准输出。客户端基本不做业务处理是一个很称职的“哑终端”。3. 从最小可用版本到完整的面板布局系统3.1 第一步把 pty 跑起来让 shell 出现在屏幕上cmux 最初的里程碑不是面板分割也不是会话分离而是“启动一个 shell 并让它的输出显示在我的屏幕上”。这一步看似简单但包含了一个终端复用器最底层的机制。我用了 openpty 来创建 pty 对然后 fork 子进程。子进程里调用 setsid 开启新的会话再用 ioctl 把从端设为控制终端最后把从端复制成标准输入、标准输出、标准错误执行 shell。父进程持有主端 fd凡是 shell 写到从端的内容都会从主端读到。int master_fd, slave_fd; openpty(master_fd, slave_fd, NULL, NULL, NULL); pid_t pid fork(); if (pid 0) { setsid(); ioctl(slave_fd, TIOCSCTTY, NULL); dup2(slave_fd, STDIN_FILENO); dup2(slave_fd, STDOUT_FILENO); dup2(slave_fd, STDERR_FILENO); close(master_fd); execl(/bin/bash, bash, NULL); } close(slave_fd);从主端读到的数据并不是直接这个终端视图的“字节流”。它只是原始的转义序列。同样当我们要把键盘事件发给 shell 时也不能直接向 pty 主端写普通字符而是要根据当前 shell 处于什么模式来写。比如按下方向键在普通 shell 环境下发送的是\x1b[A但在某些正在使用原始模式读取单个按键的程序里则需要发送A本身。这步通过之后整个项目就有了最重要的验证闭环。之后所有功能都建立在这个 pty 管道的正确性之上。3.2 渲染策略不能直接整屏重画有了可用的 pty接下来的问题就是服务端从 pty 主端读到的内容如何同步到真实终端显示屏上。最粗暴的方式是每次读取后直接把数据写到客户端终端。但这样会有一个很典型的恶心问题当 shell 输出一整屏内容时你会看到内容像水流一样快速刷过去偶尔还会出现光标乱跳、局部错位。因为 shell 的输出大量使用了光标定位和清屏指令这些东西如果没有被合理“解释”直接原样传给客户端客户端终端能解释还好可一旦中途断了一部分状态终端状态就乱了。我选择的做法是服务端维护一个自己的屏幕缓存按“面板”为单位记录每一行的文本内容和光标位置。从 pty 读到的字节流先进入一个增量解析器解析出哪些行的内容发生了变化把这些行标记为脏区。渲染的时候只对脏行重新生成转义序列发给客户端。static void write_pane_line(FILE *out, pane_t *p, int row) { fprintf(out, \x1b[%d;%dH, p-origin_y row 1, p-origin_x 1); for (int col 0; col p-cols; col) { fputc(p-cells[row][col].ch, out); if (p-cells[row][col].bold) fprintf(out, \x1b[1m); } fprintf(out, \x1b[K); }这段代码里值得注意的细节是每行末尾都输出\x1b[K也就是擦除到行尾。如果不做这个操作一旦新内容比旧内容短行尾会残留上一次渲染留下的字符。这是终端渲染里最容易犯的错我在早期版本里被这个现象坑了很多次。另一个细节是光标重定位。我每次重建一行时会让光标跳到对应的面板坐标位置这个定位在大型面板里的成本完全可接受但能保证即使丢了一两帧也不会出现整屏混乱。3.3 面板分割布局递归对半切分面板分割的布局算法参考了经典的“二叉树分割”思路。整个窗口可以看成一个矩形每次分割都把某个矩形切成两个相邻矩形。布局树上的叶子节点对应一个具体面板内部节点则记录这条切分是水平还是垂直。typedef struct rect { int x, y, w, h; } rect; typedef struct split_node { struct split_node *parent; struct split_node *left; struct split_node *right; int is_vertical; int split_ratio; pane_t *pane; // leaf node only rect bound; } split_node;分割时先找到当前激活面板对应的叶子节点。然后把这个叶子节点变成一个内部节点原来的面板归到它的左子树新创建的面板放到右子树。布局计算只在渲染开始前执行从根节点递归下行子节点依次分配各自的矩形坐标。void layout_recursive(split_node *node, rect r) { node-bound r; if (node-left NULL node-right NULL) { update_pane_bound(node-pane, r); return; } int offset (node-is_vertical) ? (r.w * node-split_ratio / 100) : (r.h * node-split_ratio / 100); if (node-is_vertical) layout_recursive(node-left, (rect){r.x, r.y, offset, r.h}), layout_recursive(node-right, (rect){r.x offset, r.y, r.w - offset, r.h}); else layout_recursive(node-left, (rect){r.x, r.y, r.w, offset}), layout_recursive(node-right, (rect){r.x, r.y offset, r.w, r.h - offset}); }面板缩放的实现也复用同一棵布局树。焦点面板会拿到更大的比例值其余面板按剩余空间重新计算。这条路径维护起来非常省心后续想加“合并面板”“交换面板”只需要移动这棵树上的节点不需要改动任何边界计算的逻辑。4. 调试最多的一块转义序列处理4.1 终端状态机是怎么一回事终端输出并不是“文本流 偶尔几个控制符”这么简单。它混杂着“控制字符”“ESC 序列”“控制序列”“操作序列”等多层结构。比如\x1b[31m是把文字变红\x1b[2J是清屏\x1b[?1049h是进入全屏应用的备用屏幕模式。这些序列对于理解终端程序行为至关重要。为了可靠地解析 pty 输出我实现了一个逐字节状态机。状态分为普通文本、ESC 起始、CSI 参数收集、振荡器序列等内容。状态机每次都只消费一个字节在完整序列结束之前不产生任何屏幕更新。enum term_state { RAW_TEXT, ESCAPE, CSI, CSI_PARAM, OSC, }; static void feed_parser(term_parser_t *p, char ch) { switch (p-state) { case RAW_TEXT: if (ch 0x1b) { p-state ESCAPE; } else { display_char(ch); } break; case ESCAPE: if (ch [) { p-state CSI; p-buf_len 0; } else if (ch ]) { p-state OSC; } else if (ch 7 || ch 8) { save_restore_cursor(ch); p-state RAW_TEXT; } break; // ... } }这个状态机的写法不是难点真正的难点在于状态机的“覆盖度”。终端世界里的转义序列远比 RFC 规范长得多不同终端模拟器对某些序列的支持也存在差异。我采取的策略是遇到不能完整识别的序列就整体丢弃整段内容绝不猜测。4.2 全屏应用的沙盒交替屏幕与光标恢复第一个把我逼到墙角的问题是 vim 和 htop 这类全屏应用。它们在退出时应该恢复之前屏幕上显示的内容但 cmux 早期版本只看到一堆字符刷出来退出后又显示残影。后来我理解了交互式应用使用的“交替屏幕缓冲区”机制程序启动时发送\x1b[?1049h终端切换到一块备用屏幕退出时发送\x1b[?1049l再回到之前的主屏幕。如果终端复用器没有响应这两个序列它就只能看到应用在备用屏幕上画的各种东西然后应用退出时直接清屏导致内容缺失。cmux 的处理是维护两套屏幕缓冲主屏 (main screen) 和备用屏 (alt screen)。收到1049h时先保存主屏当前状态把后续所有输出重定向到备用屏收到1049l时切换回主屏并让客户端把完整的主屏内容重画一遍。这个功能对日常终端工作流是刚需做出来之后整个项目的可用性立刻质变。4.3 宽字符与中文渲染的坑另一个棘手问题是中文渲染。终端协议对“列数”和“字符数”的粒度完全不同一个汉字通常占据两个终端列但状态机按字节解析时会很容易把多字节字符的中间字节当成独立字符处理导致整行错位。我做了两层处理第一层是把 pty 读到的字节流先按 UTF-8 规则切出完整码点再交给状态机第二层是在状态机里对每个码点调用宽字符宽度计算函数获得它占据的列数把整个单元格数组按列对齐。这样之后再渲染一行文本时单元格下标对应的就是实际终端列不会再出现中文挤到下一行导致布局错乱的现象。这里有一件比较容易被忽略的事在处理退格键时如果行尾是一个宽字符删除时需要同时清空两列。否则剩下的半个字符会在下次清行时变成乱码。4.4 防御式解析宁可丢掉不可错读随着使用深入我还遇到过一些输出不符合严格语法规则的场景。比如某些程序会先发送一个未完成的 ESC 序列隔一会儿再发送剩余部分有些程序会在一个序列中间插入大量的参数但最终不发送终止字节还有些程序会用\x1b[0m来重置所有样式但把它写进一个长序列的参数中间。对付这种情况我最终的策略是超时 超长防护。状态机连续一秒钟没有收到终止字节就强制回到普通文本状态把缓冲区内容原样丢弃。每段序列收满 4096 个字节还没有结束也强制重置。这个策略牺牲了极少数异常程序的兼容性但换来了整个复用器的稳定。实际用下来牺牲几乎感觉不到。5. 实测数据与长时间挂机体验5.1 资源占用与响应速度cmux 的设计目标里有一条是“轻”这一点需要在真实数据上验证。我在一台 2 核 4GB 的虚拟测试机上做了一个简单对比测试分别让 cmux 和另一款同类型常用复用器开启 10 个会话每个会话有 3 个面板然后观察内存与响应时间。测量项cmux常用复用器 A说明守护进程常驻内存约 12 MB约 25 MB实测 10 个会话客户端 attach 冷启动约 60 ms约 120 ms本地 unix socket纯文本整屏刷新约 220 行/11 ms约 260 行/15 ms无重影内存增长连续 48h 内反复切换小于 5%约 8%未做 GB 级别压力一个让我意外的地方是初始化速度。很多复用器启动时要读配置、初始化状态栏、建立 socket这些操作累积起来通常要到百毫秒级。cmux 把不需要的操作全部砍掉之后客户端从输入命令到看到界面大约只需要 60 毫秒。这个差异在每天开几十次终端的情况下还是能感受到的。内存占用低一部分来自我刻意不做“后台内容缓存”。当面板在后台时我保留了它的 pty 缓冲但不会把每帧输出都缓存进内存。切回这个面板时直接从 pty 缓冲区里拿最新内容重绘。这省掉了大量无意义的历史帧存储。5.2 四十八小时挂机测试暴露的问题在功能基本稳定之后我把 cmux 挂到自己的工作流里连续跑了两个整天期间反复执行切面板、新建会话、分离再附着、让 vim 和大文件编译轮流跑。前后发现并修复了几个比较高价值的问题。第一个问题是会话结束后留下了僵死进程。最初我忽略了对 SIGCHLD 的处理子 shell 退出后父进程没有正确调用 wait导致僵尸进程积累。后来我在信号处理里统一收集退出状态并清理问题才彻底解决。第二个问题是尺寸变化后部分面板的内容边界一直超框。我以为只要在 SIGWINCH 到来时重新 layout 就行但忽略了一个细节pty 自身的行数和列数没有同步更新。子 shell 的终端尺寸仍然残留旧值。修复方法是用 ioctl 给每个 pty 从端设置新的窗口尺寸再让内核发送 SIGWINCH 给 shell它才能真正感知到尺寸变了。struct winsize ws { .ws_row h, .ws_col w }; ioctl(slave_fd, TIOCSWINSZ, ws); kill(pid, SIGWINCH);第三个问题是 unix socket 连接没有处理“客户端中途断开”的情况。早期实现里如果客户端直接关闭终端而不走正常退出流程服务端这个连接就把客户端误判为 shell 数据导致面板输出错乱。修法是在 socket 读返回 0 时立即清理 client 结构不把它当成数据流。5.3 实际使用中的键位映射体会用了两周之后我给 cmux 调整了几次键位映射。默认设计里前缀键是 CtrlB这个键的位置比较顺手但 CtrlB 在一些常用程序里也有用途。为了减少冲突我把前缀键改成了 CtrlSpace并用单独的配置文件记录映射关系。目前保留的映射其实不到二十个窗口左右切换、面板上下左右切换、面板垂直/水平分割、面板缩放、会话分离、会话重挂、滚动模式进入退出、全局退出。我尽量不在 cmux 里定义大量复合快捷键因为复杂键位记忆成本太高不符合轻量工具的定位。需要特别提醒想模仿这个项目的读者一点键位映射不要在一开始就铺得太全。每个键位都意味着要在事件分发路径上多开一个分支。过早引入十几个键位代码里会迅速堆满各种特殊分支干扰面向核心数据流的调试。6. 从 cmux 这个项目里真正学到的经验6.1 终端协议远比想象中更有深度在没有亲手写这个项目之前我对终端的理解停留在“往终端里输出字符串”的程度。真正实现一遍才发现终端是一套几十年前设计出来但依然高效运转的硬件抽象协议。它用转义序列实现了光标控制、颜色、清屏、备用屏幕、滚动区域等能力这套协议设计得并不直观但覆盖了几乎所有现代命令行工具的需求。从写 cmux 得到的认知是终端仿真的核心不是“字节流”本身而是“字节流中的状态变化”。维护好这个状态就能以最小的重绘成本还原出用户想看到的画面。这个认知对我调试其他终端类项目也有很大帮助。6.2 给想写同类工具的人一个实践路径如果你也想写一个终端复用器我的建议是从一条最窄的闭环路径开始不要一开始就规划完整的架构。第一步先让一个 pty 起来把一个 shell 的输出直接打印到屏幕上第二步加一个客户端 socket让键盘输入可以从客户端传到服务端第三步处理 SIGWINCH保证窗口尺寸变化后终端内容重新排布第四步再上转义序列解析和面板分割。每走一步都先做成可以稳定运行的状态再继续下一步。终端复用器最不适合的方式就是先把代码框架搭得很完整、功能点列得很全最后一次性填实现。因为中间任何一步出错排查起来都需要同时怀疑十个模块很难定位问题根因。6.3 cmux 还可以继续扩展的方向写完这个版本之后我心里已经记下了一些接下来可能会做的方向。第一个是复制模式也就是用键盘在面板中选取文本并暂存第二个是滚动回看让面板可以查看已经滚出屏幕的历史输出第三个是更完善的面板树操作比如合并两个相邻面板、交换面板位置。如果想向重型工具的方向走还可以考虑增加 JSON 控制协议、状态机测试套件、跨平台 pty 实现。这些方向每一条都足够再写一个新项目了。回到开头的问题为什么我要造 cmux现在答案更清晰了。我想要的不是一个功能碾压市场的复用器而是一个能让我完全理解它内部运转方式的工具。它不会替代一切但在它身上我真正摸清了终端协议、信号处理、多进程通信这些基础问题。做这个项目的最大收益其实是重新认识了我每天都会用、却从未真正想过的那些终端原理。也许我后续不会继续在 cmux 上堆功能但这段经验会一直留在我的技术工具箱里。