ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

纯协议编写:用RFB协议实现低延迟VNC远程控制

纯协议编写:用RFB协议实现低延迟VNC远程控制 1. 项目背景与核心思路1.1 为什么非要自己写协议远程控制这个领域市面上现成的方案太多了。TightVNC、RealVNC、TigerVNC、向日葵、TeamViewer随便挑一个装上就能用何必自己从零写协议我当时的场景比较特殊。手里有一批嵌入式设备硬件资源非常紧张内存只有64MBCPU主频低得可怜跑一个完整的VNC服务端都有点吃力。更麻烦的是这批设备跑的是裁剪过的Linux系统没有X11环境也没有现成的远程控制软件可用。常规思路是装个VNC服务端但在这个环境下完全不现实。另一个痛点是延迟。通用VNC软件为了兼容各种网络环境默认配置非常保守走的是TCP协议栈加上各种缓冲和批处理策略在局域网里操作倒还行一旦跨网络或者网络抖动鼠标就跟泡在水里一样拖都拖不动。我需要的是一种纯协议层面的实现能把每一帧的传输延迟压到最低。所谓纯协议编写就是不走任何现成的VNC库完全按照RFB协议Remote Frame Buffer ProtocolVNC底层的远程帧缓冲协议的标准文档自己实现客户端和服务端之间的所有交互。从握手、编码协商到帧缓冲传输、输入事件回传每一步都是自己写代码解析和构造数据包。这个项目的核心目标其实就一个在不依赖任何重型框架的前提下用最少的资源开销实现一个足够轻量、足够快的远程控制模块。听起来像是在造轮子但有些场景下轮子真的得自己造才够用。1.2 零延迟交互到底意味着什么先说实话“零延迟”是个营销词物理上不可能有真正的零延迟。网络传输、协议解析、图像编码、渲染显示每个环节都需要时间。这里说的零延迟准确理解应该是用户感知不到延迟或者说延迟被压缩到人眼和操作习惯可以忽略的程度。人机交互中对延迟的感知阈值大约在100毫秒。超过这个数值操作者会明显感觉到鼠标移动不跟手低于50毫秒大多数人感觉不到明显卡顿如果能压到30毫秒以内基本就是“指哪打哪”的效果了。影响延迟的因素主要有三个维度网络传输时间数据从A点到B点的时间受物理距离和中间设备转发影响这部分只能通过降低传输数据量来优化无法改变物理规律协议处理时间数据包的封装、解析、编码、解码这个是纯计算开销可以通过优化算法和减少协议层数来压缩渲染显示时间客户端收到帧数据后更新界面显示的过程如果用硬件加速可以大幅缩短这部分时间纯协议编写能优化的主要是第二和第三个维度。跳过中间层不走X11的抓屏接口不走显卡驱动的转换流程直接把帧缓冲里的原始数据用最紧凑的方式打包发送客户端收到后直接写入显存省掉编码转换的步骤整个链路就短了很多。用生活里的例子类比现成VNC软件相当于你叫了个外卖中间经过接单、备餐、配送多个环节纯协议实现相当于你直接去后厨窗口自己端菜路径短了时间自然就下来了。2. 协议层面如何拆解VNC远程控制的底层逻辑2.1 RFB协议的核心机制与数据流转RFB协议是VNC家族所有成员共同遵守的底层协议从1998年由剑桥大学的ATT实验室提出到现在经过多次迭代依然保持着最初的基本架构。理解RFB协议的设计哲学也就理解了整个VNC体系的工作原理。RFB协议说来并不复杂核心思想是“服务端持有画面客户端显示画面”。服务端维护着一块帧缓冲里面存放的是屏幕的像素数据。客户端通过协议协商连接到服务端后服务端会把帧缓冲的变化内容推送给客户端客户端负责解析并渲染。反过来客户端捕捉用户的键盘鼠标操作通过协议发送给服务端服务端在本地模拟执行这些操作。整个数据流转可以用下面的流程描述客户端发起TCP连接请求到服务端的5900端口默认VNC端口双方进行协议版本协商确定使用哪个版本的RFB协议客户端发送安全类型协商请求双方确定认证方式如VNC密码认证认证通过后客户端发送初始化消息告知服务端自己的显示尺寸和像素格式偏好服务端回传初始帧缓冲的全量数据进入正常交互阶段服务端持续推送变化区域增量更新客户端持续发送输入事件这个流程看起来很简单但每条消息的格式定义、字段顺序、字节对齐方式协议文档里都有极其详细的规定。差一个字节连接直接断掉或者解析出乱码。纯协议编写最耗精力的就是这部分必须对着协议规范一字节一字节地抠。RFB协议有一个设计上的聪明之处它设计成了服务端主动推送的模式而不是客户端轮询的模式。客户端不需要反复询问“画面变了吗”服务端一旦检测到屏幕有变化立即把变化区域推过来。这种推模式天然适合远程控制的场景减少了无效的往返请求延迟自然更低。但这个设计也带来一个问题网络拥塞时服务端推送的数据如果超过带宽上限客户端处理不过来就会出现画面撕裂或者卡顿。后面会讲到这就是为什么编码协商机制如此重要。2.2 编码方式选型Raw、Tight与性能权衡编码协商是RFB协议中决定性能的关键环节。说白了就是客户端和服务端商量好帧缓冲里的像素数据用什么样的格式来编码传输。打个比方你要寄一箱瓷器给朋友直接塞满报纸寄过去是最省事的但快递费贵。你花心思把每件瓷器用泡沫塑封好再定制一个刚好大小的箱子快递费就便宜了但打包的时候累。编码就是干这个事儿的在CPU开销和传输数据量之间找平衡。RFC 6143规范里定义了几种核心编码方式Raw编码是最原始的方式不做任何压缩直接把像素数据的原始字节流发出去。这种方式CPU开销最低几乎不需要编码计算但数据量最大。假设分辨率是1920x108032位色深一帧全屏画面的裸数据量是1920x1080x4字节大约8.29MB。就算是在千兆局域网里传一帧也需要约66毫秒完全没法用。Tight编码是目前服务端支持最广泛的压缩编码。它在发送之前先对图像数据进行压缩然后封装发送。从实际效果看对于文字、图形界面这类颜色变化较少的区域Tight编码能把数据量压缩到原来的百分之几。如果是纯文字的终端窗口一帧变化区域可能只有几百字节。代价是编码需要消耗CPU压缩级别越高CPU占用越大。Hextile编码是另一种常见的编码格式把屏幕分成若干个16x16的小方块对每个方块单独判断是否有变化、是否和相邻方块颜色相同然后仅传有变化的方块。这种编码在CPU效率和压缩率之间取得了不错的平衡适合嵌入式场景。在实际的纯协议实现中我采取的策略是混合编码如果变化区域很大超过屏幕面积的30%用Tight编码追求极致压缩如果变化区域很小比如鼠标移动、键盘输入触发的小范围刷新直接用Raw编码省掉压缩耗时因为小区域的Raw数据量本来就不大。这里有一个容易被忽略的细节编码协商在会话开始时进行但并不是一次定终身。RFB协议支持在会话过程中动态切换编码方式客户端可以随时发送SetEncodings消息更新偏好。这意味着可以根据当前网络状况和画面变化特征动态调整编码策略。我实现中就加了这么一层自适应逻辑统计最近几帧的数据量和编码耗时如果网络开始拥塞自动升高压缩级别如果CPU吃紧自动降低压缩级别。2.3 延时的构成分析到底哪些环节在偷走你的时间既然目标是压延迟就得先搞清楚延迟都花在哪儿了。我在实现过程中专门做了性能剖析用tcpdump抓包配合时间戳统计了每一帧从产生到显示的全链路耗时。一次典型的帧数据传输时间消耗分布大概是这样的服务端抓取屏幕变化区域5-15毫秒。这部分取决于系统调用效率和帧缓冲的读取速度编码计算5-50毫秒不等。取决于编码方式和画面复杂程度。Raw编码几乎不耗时Tight编码在低端CPU上可能超过50毫秒网络传输局域网1-5毫秒。千兆网环境下几百字节的数据几乎可以忽略不计客户端解码渲染5-20毫秒。如果走软件渲染这部分开销不小GPU加速能让渲染时间压缩到1-2毫秒客户端输入事件回传1-3毫秒。TCP的Nagle算法如果没禁用这个数据会高很多从这张时间表能清晰地看出编码和渲染是最大的两个可优化项。我之前试过用最保守的方案什么优化都不做端到端延迟在200毫秒以上操作起来像在泥里拖船。把编码策略调优、渲染路径精简之后局域网环境下端到端延迟能稳定压在30毫秒以内。禁用Nagle算法是一个很重要的细节。TCP协议默认开启Nagle算法它会把小的数据包攒起来合并发送目的是提高带宽利用率。但在远程交互场景下Nagle算法会害死人。鼠标的移动事件可能就几十个字节Nagle算法会把它攒着等后面的数据一起来这就凭空增加了40毫秒的延迟Nagle的等待超时。解决方法是设置TCP_NODELAY套接字选项禁用Nagle算法让小的数据包立即发送。另一个容易被忽视的是TCP的延迟确认机制。Linux内核默认开了TCP delayed ACK接收方收到数据后会延迟最多40毫秒才发ACK确认。在纯交互场景下这个延迟会与Nagle算法产生严重的交互问题虽然禁用了Nagle后问题小很多但在高吞吐传输时依然可能引入额外延迟。稳妥的做法是同时优化套接字的TCP_QUICKACK选项让确认包立即发送。3. 实操环节纯协议编写的具体实现3.1 环境准备与协议握手流程实现动手写代码之前先梳理一下需要准备的东西。我的开发环境是Ubuntu 20.04语言选的是C因为要追求极致性能Python或者Java的运行时开销在这种场景下不太合适。用到的核心库只有两个POSIX socket用于网络通信libjpeg-turbo用于Tight编码的JPEG压缩后续扩展用纯Raw编码其实连这个都不需要。实现的第一步是协议握手。这部分代码虽然不长但每一段都要严格对照协议规范。VNC的握手流程分四个阶段第一步版本协商。客户端连接后服务端会发送一条12字节的消息格式类似RFB 003.008\n。客户端收到后必须回复一个自己支持的版本。如果双方版本不一致以客户端回复的版本为准但要求服务端必须兼容。实操中一般直接回复RFB 003.008\n因为3.8版本是当前最通用的版本兼容性最好。第二步安全类型协商。服务端发送一条消息前半部分是支持的安全类型数量后半部分列出各安全类型的编号。编码上要注意安全类型数量占1字节后面紧跟的是安全类型编号列表列表长度等于前面声明的数量。客户端需要从列表里挑一个自己支持的类型回复给服务端。在底层实现里我选择的安全类型是VNC密码认证编号为2这是兼容性最好的方式几乎所有的VNC服务端都支持。第三步密码认证。VNC密码认证的流程比较有意思它用DES算法加密但密钥是密码颠倒后只取前8个字节。服务端发来16字节的随机挑战数据客户端用密钥加密后发回去服务端再解密比对。这里有个细节经常会坑到人VNC密码最长只取8个字符超过部分会被截断。所以password123和password456在VNC认证里是等效的因为有效密码部分都是password。第四步初始化消息。客户端发送初始化消息内容包括是否共享连接、帧缓冲的宽度和高度可以用0表示服务端保持原始分辨率、像素格式。像素格式的定义比较繁琐包括色深、色彩通道的比特分配、端序等字段。简化的做法是选择32位色深、RGB888排列、大端模式这样客户端解析起来最简单。下面是我实现的握手核心代码的骨架// VNC握手第一阶段的简化实现 bool handshake_version(int sockfd) { char buf[12] {0}; ssize_t n recv(sockfd, buf, sizeof(buf), 0); if (n ! 12) return false; buf[n] \0; // 服务端发来的形式是 RFB 003.008\n // 我们需要回复一个支持的版本通常直接回 003.008 const char* response RFB 003.008\n; if (send(sockfd, response, 12, 0) ! 12) { return false; } return true; }注意这段代码里我没做版本号的解析实际实现需要检查收到的版本号如果服务端版本比3.3还老某些字段的行为会不同。不过2025年了遇到的VNC服务端基本都支持3.8。3.2 帧缓冲传输与增量更新机制的完整解析协议握手之后客户端会收到服务端发来的全量帧缓冲数据这一帧包含了整个屏幕的初始状态。之后进入增量更新阶段服务端只发送变化的区域。帧缓冲传输的消息格式是这样定义的服务端发送一条FramebufferUpdate消息消息头是一个共享标志位加一个区域数量计数紧接着是若干个区域描述块。每个区域描述块包含区域坐标x、y、宽、高和编码类型以及编码后的像素数据。在实现FramebufferUpdate解析器时有几个坑需要格外注意第一个坑是消息读取必须严格按块处理不能用简单的recv循环读取到固定字节数就结束。因为区域数量是服务端告诉你的但这个数量在一开始并不知道必须读到头字节后判断类型再根据区域数量逐个去读区域描述块。如果网络包被拆分了TCP的粘包与半包问题一个不当心的循环就可能读错边界导致后面所有数据解析全部乱掉。第二个坑是编码类型的定义要时刻关注。RFB协议中编码类型编号1是Raw5是Hextile7是Tight。但要注意编号0是保留的表示“无数据”有些服务端会用0编码来刷新区域比如光标隐藏时客户端需要正确处理这种“空区域”不能当成数据损坏报错。第三个坑是增量更新的触发机制。服务端不会主动持续推送数据它只在收到客户端的FramebufferRequest消息后才检查帧缓冲是否有变化有变化就回传变化区域。如果客户端一直不发请求服务端就不会推任何数据。这里有个交互节奏的问题发请求的间隔太短CPU空转浪费资源间隔太长画面更新不及时。我的实现采取的是固定50毫秒的定时器触发请求这个频率既保证了流畅性又不会太频繁消耗CPU。增量更新的性能关键在于变化检测的有效率。VNC服务端的帧缓冲变化检测通常是把屏幕切成16x16的块逐一比较当前帧和上一帧的像素差异。这个方案实现简单但在画面频繁局部更新的场景下比如视频播放、动画特效会有较多的无效检测。更先进的做法是用贴墙算法计算脏矩形的最小集合但综合考虑代码复杂度和实际收益我没在最初版本里做这个优化。下面是增量更新解析的核心逻辑// 处理服务端推送的FramebufferUpdate消息 bool process_fb_update(int sockfd, FrameBuffer* fb) { uint8_t msg_type; if (recv_exact(sockfd, msg_type, 1) ! 1) return false; if (msg_type ! 0) return false; // 消息类型0 FramebufferUpdate uint8_t padding; uint16_t num_rects; recv_exact(sockfd, padding, 1); recv_exact(sockfd, num_rects, 2); // 网络字节序需要转换 for (int i 0; i num_rects; i) { uint16_t x, y, w, h; int32_t enc_type; recv_exact(sockfd, x, 2); recv_exact(sockfd, y, 2); recv_exact(sockfd, w, 2); recv_exact(sockfd, h, 2); recv_exact(sockfd, enc_type, 4); // 根据编码类型解析数据 if (enc_type 0) { // RAW编码直接按 w*h*bpp 读像素数据 size_t data_size (size_t)w * h * fb-bytes_per_pixel; std::vectoruint8_t raw(data_size); recv_exact(sockfd, raw.data(), data_size); fb-blit(x, y, w, h, raw.data()); } else if (enc_type 5) { // Hextile编码16x16块解析逻辑 parse_hextile(sockfd, fb, x, y, w, h); } } return true; }3.3 键盘鼠标事件回传通道的细节处理远程控制是双向的服务端把画面推给客户端客户端得把用户的输入回传给服务端。这个回传通道的消息格式相对简单但细节要求很严格。键盘事件的消息格式是8字节结构为消息类型1字节固定为4、按下标志1字节、填充2字节、键码4字节。这里的键码使用X11键码而非ASCII码映射关系需要参考协议文档。鼠标事件的消息格式也是8字节消息类型1字节固定为5、按钮掩码1字节、X坐标2字节、Y坐标2字节、滚轮数据2字节。按钮掩码的设计很有意思每位代表一个鼠标按钮的状态。bit 0代表左键bit 1代表中键bit 2代表右键bit 3和bit 4代表滚轮往一个方向滚动。这种位掩码的设计在键盘鼠标合并上报时非常高效客户端只需要维护一个当前按下的按钮状态位图每次状态变化时直接发送新的位图即可不需要逐个按钮单独发送消息。滚轮事件是用一次虚拟的“按下释放”序列表示的。客户端发送一条按钮掩码为bit 3或bit 4的按下消息紧接着再发送一条按钮掩码为0的释放消息服务端就能识别是一次滚轮滚动。因为在协议层没有专门的滚轮消息类型这个设计在初次实现时容易踩坑我花了不少时间才理清。输入事件回传中有一个关键的交互精度问题鼠标坐标的变化速度比画面更新的速度更快。用户快速移动鼠标时鼠标事件会以极高的频率产生USB鼠标回报率通常是125Hz到1000Hz而画面刷新率被我的定时器限制在20fps。这意味着很多中间状态的鼠标坐标根本没机会被显示客户端看到的会是鼠标跳跃式移动。解决这个问题有两种思路一种是提高画面刷新率来跟上鼠标移动但消耗CPU另一种是在客户端做鼠标光标渲染叠加鼠标的移动位置直接在客户端本地渲染出来不依赖服务端的画面推送。第二种方案更高效也符合实际体验鼠标是实时跟手的画面的内容可以稍慢一步更新。这个逻辑想通后我对客户端做了改造收到服务端推送的帧缓冲后将鼠标位置标记为待重新渲染区域鼠标移动事件不通过画面刷新来体现而是在本地的帧缓冲上直接绘制一个新位置的光标并清除旧位置的光标。实测效果非常好鼠标操作感和本地几乎没有区别。3.4 服务端实现抓屏、帧差检测与区域推送上面聊的大多是客户端视角现在把镜头转到服务端。一个完整的远程控制模块必然包含服务端能力否则只能连别人的机器不能让别人连自己的。VNC服务端的核心组件有三个抓屏模块、帧差检测模块、网络发送模块。抓屏模块负责读取屏幕当前内容到帧缓冲帧差检测模块对比当前帧和上一帧找出变化区域网络发送模块把变化区域按协议格式编码发送给客户端。抓屏在Linux系统上有多种方式X11环境通过XGetImage接口获取屏幕内容Wayland环境走wlroots的截图协议纯命令行环境可以通过fbdev设备节点读取/dev/fb0的内容。我这个项目跑在嵌入式Linux上没有图形环境所以用的是fbdev方案直接mmap映射/dev/fb0设备节点读取原始的帧缓冲数据。fbdev方案的妙处在于读取速度极快几乎没有系统调用开销因为映射后的内存直接可以访问。数据格式是设备原生的通常就是RGB565或者RGB888省去了格式转换的环节。帧差检测我用的是分块比较策略把屏幕分成16x16的块遍历每一块如果该块的任何像素发生了变化就把这个块标记为脏块然后把所有相邻的脏块合并成更大的脏矩形区域。这样做的结果是如果一个文字编辑区域有变化服务端只会推送那一个区域而不是整屏的数据。帧差检测有一个权衡点块大小怎么选块太大会把没有变化的内容一并打包发送浪费带宽块太小检测的开销增加且碎块太多每个碎块的消息头开销反而大于数据本身。16x16是一个在桌面VNC体系中经过验证的平衡点我在嵌入式场景也没发现需要调整的理由。服务端的网络发送模块最关键的是一个调度逻辑同一时间只能有一个帧缓冲更新在发送中新的更新必须等待前一个完成。如果前一个还没发完新的变化被合并进下一个更新周期。这个“延迟合并”的机制天然减少了网络包数量提高了吞吐效率。// 服务端主循环的简化框架抓帧比较推送 void server_main_loop(int client_fd) { FrameBuffer prev capture_screen(); while (running) { FrameBuffer curr capture_screen(); std::vectorRect dirty diff_frames(prev, curr); if (!dirty.empty()) { send_framebuffer_update(client_fd, curr, dirty); } prev curr; std::this_thread::sleep_for(std::chrono::milliseconds(50)); } }在实际代码中capture_screen()和diff_frames()的性能决定了整个服务端的吞吐上限。我这个简化版里直接把整帧拷贝进Prev和Curr内存带宽占用较大优化版用双缓冲加直接像素比较大约能减少40%的无效读取。4. 常见问题与排查技巧实录4.1 连接断断续续过一会儿自动退出这个问题在最初版本的实现里反复出现现象是远程连接建立成功后操作几分钟到十几分钟不等连接会突然断掉有时没有任何报错。排查思路从抓包开始。用tcpdump -i any port 5900持续抓包在连接断开的时间点观察TCP报文。发现断开之前有大量TCP重传报文然后连接被复位。这说明是发送方收不到接收方的ACK达到重传上限后主动断开。进一步分析数据包的特征发现问题出在我提到的Nagle算法和TCP delayed ACK的交互上。我用C写的服务端没设置TCP_NODELAY而我的客户端又没设置TCP_QUICKACK导致一种经典的“延迟确认超时重传超时”死锁客户端攒着数据不发服务端知道有数据但延迟确认积累的未确认数据超过缓冲区后双方开始拥塞控制最终触发重传超时断开。解决方法是两端都做调整// 服务端套接字选项设置 int flag 1; setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY, flag, sizeof(flag)); // 客户端套接字选项设置 setsockopt(sockfd, IPPROTO_TCP, TCP_QUICKACK, flag, sizeof(flag));这里TCP_QUICKACK在Linux上需要每次收到数据后重新设置因为内核会在发送ACK后自动清除这个标志。更彻底的做法是在应用层做心跳保活每隔30秒互发一次心跳消息既能及时检测连接状态又能打断TCP的静默期防止NAT超时把连接干掉。4.2 复制粘贴文件功能失效用VNC远程连接后想从本地复制一段文字或拖一个文件到远程机器这在标准的VNC协议里是做不到的。因为RFB协议本身只定义了键盘鼠标事件和帧缓冲数据没有定义剪贴板共享协议。VNC的剪贴板功能是各实现私有的扩展不同实现之间互不兼容。我一开始以为协议里应该有剪贴板相关的消息类型翻了半天RFC 6143才发现确实没有。这就解释了为什么网上会有一大堆人问“VNC远程怎么复制粘贴文件”因为答案是原版VNC不支持只有装了扩展或者用了特定商业软件才行。我的解决思路是在标准协议上增加一条自定义扩展消息。RFB协议留了一个自定义消息的扩展空间消息类型编号在246到255之间可以用于自定义用途。我选用了类型250作为剪贴板同步消息格式是消息类型1字节、消息长度2字节、UTF-8编码的文本数据。服务端收到类型250消息后把文本写入本地的剪贴板管理器中本地程序需要用剪贴板时直接从管理器读取。反方向同理本地复制内容后向客户端推送类型250消息。这个方案只对使用我这个客户端的场景有效如果用标准VNC Viewer连接则无法理解这个自定义消息类型可能会报错。所以在实现上我做了一个开关只有客户端显式声明支持扩展消息时服务端才启用自定义类型。文件传输比剪贴板文本更复杂涉及到文件的分块传输、断点续传、路径安全校验等。我的做法是用另一个自定义消息类型来传递文件元数据和分块数据但仍然走TCP通道。不过说实话文件传输这块我应该单独写一篇内容量不比远程控制核心功能少。4.3 连接登录界面时光标无法停留实际使用中遇到了一个很诡异的问题连接远程机器的图形登录界面比如GDM或LightDM显示管理器鼠标光标能动但无法停留在密码输入框中点击输入框时焦点总是跳走。这不是我的协议实现的问题而是VNC方案的固有缺陷。原因是图形登录界面运行在X服务器启动前的早期阶段此时帧缓冲的内容还没经过常规的窗口管理器的聚焦处理。VNC服务端在这种环境下发送的光标位置和X11窗口系统感知到的设备位置对不上。解决思路是要么提前启动VNC服务端并保持窗口管理器常驻不推荐安全风险高要么使用X11的XTEST扩展在服务端模拟输入事件让服务端的X服务器认为真的有物理设备在操作。XTEST扩展可以生成合成输入事件不需要真实的输入设备。我在服务端代码里加了XTEST支持收到客户端的鼠标事件后通过XQueryPointer获取当前指针位置再用XWarpPointer
返回列表