☰
C# VNC客户端源码解析:RFB协议与帧缓冲实现
2026/10/15 15:26:31 网站建设 项目流程

简介:这是一份基于VncSharp的C# VNC客户端源码示例,面向需要在.NET环境中集成远程桌面控制功能的开发者,尤其适合具备一定WinForms基础、希望学习VNC/RFB协议调用方式的初中级程序员。资源采用zip压缩,共15个文件,其中包含7个C#源码文件、3个resx资源文件及VncSharp.dll,此外还有解决方案、工程文件与README说明;源码中的连接对话框、主窗体和事件处理模块共同构成一个完整演示程序,可直接编译运行,也可按需改造。压缩包仅73KB,轻量易用,已有2074人学习下载。通过这份资源,读者既能掌握VncSharp库的初始化与会话连接流程,又能参考远程画面显示、认证交互等具体实现,对开发远程运维、屏幕共享类工具具有直接参考价值,也是VNC客户端入门学习的典型范例。

1. C# VNC客户端源码:能看懂的协议实现,比能跑通的更重要

如果你搜索过“C# VNC客户端源码”,大概率经历过这种场景:从代码托管站下载了一个看起来五脏俱全的项目,编译时缺包、运行时连不上、画面花屏,最后只能放弃。这不是你技术不行,而是大部分流传的源码都只解决了“能跑”,没解决“能改”。你拿回来的是一堆类文件,却没有一条清晰的链路告诉你握手、编码协商、帧缓冲更新、输入回传是怎么串起来的。本文要讲的就是把这条链路读透、跑通、改到自己的项目里。适合正在做远程桌面、跨平台运维工具、内网巡检系统的开发者,也适合想用C#重写一套远程协助模块的团队。读完你能判断一份源码值不值得用,也能自己动手补齐它缺的那块。

2. 读源码前先立框架:RFB协议里的三次握手与帧缓冲模型

2.1 RFB握手流程:从协议版本到安全协商的逐字节含义

VNC客户端本质上是一个RFB协议的客户端。RFB(Remote Frame Buffer)协议把远程屏幕看成一块可以随时局部刷新的帧缓冲,客户端要做的只有三件事:连接、请求画面、回传输入。读源码如果不先抓住这个模型,很容易迷失在大量位运算和字节流转里。

先看握手阶段。一份合格的C#源码里,握手通常由三个连续步骤组成:

// 第一步:发送协议版本号 // RFB 3.8 是最主流的兼容版本,3.3 太老,3.7 和 3.8 差异不大 byte[] version = Encoding.ASCII.GetBytes("RFB 003.008\n"); await stream.WriteAsync(version, 0, version.Length); // 第二步:读取服务端返回的版本号并判断是否兼容 byte[] response = new byte[12]; await stream.ReadAsync(response, 0, 12); string serverVersion = Encoding.ASCII.GetString(response); if (!serverVersion.Contains("003.008") && !serverVersion.Contains("003.007")) { throw new NotSupportedException("服务端协议版本不兼容:" + serverVersion); } // 第三步:读取服务端支持的安全类型列表 // 第一个字节是列表长度,后面跟着具体的安全类型编号 // 0 表示无效,1 表示无认证,2 表示 VNC 密码认证 byte[] secTypeHeader = new byte[1]; await stream.ReadAsync(secTypeHeader, 0, 1); byte[] secTypes = new byte[secTypeHeader[0]]; await stream.ReadAsync(secTypes, 0, secTypes.Length);

这里最容易被忽略的是版本号的换行符。RFB协议要求版本号必须以\n结尾,而且整个报文固定12字节。很多源码改自其他语言移植版,容易漏掉这个换行符,导致服务端一直等待不回应。调试时表现为“连接建立后卡住”,抓包看是客户端只发了11个字节。

安全协商之后的细节更值得关注。选定了安全类型后,客户端要发送一个字节表示“我接受这个类型”,然后进入认证流程。如果用的密码认证,接下来是DES加密的密码挑战应答,这个环节在C#里最容易出问题的是DES算法的填充模式,后面避坑章节会专门讲。

2.2 帧缓冲更新:客户端真正要啃的硬骨头

完成握手和认证后,客户端会发送SetPixelFormat和SetEncodings请求,然后进入持续的事件循环等待服务端发来FrameBufferUpdate消息。这条循环就是整个VNC客户端的主干,读源码时你应该先找到它,再看其他类。

// 主循环:持续读取服务端消息类型 // 消息类型共 4 种常见值: // 0 = FrameBufferUpdate(帧缓冲更新) // 2 = Bell(响铃) // 3 = ServerCutText(剪贴板文本) // 4 = TextChat(聊天,3.8 以后很少用) while (!cancellationToken.IsCancellationRequested) { int messageType = await stream.ReadByteAsync(); switch (messageType) { case 0: await ProcessFramebufferUpdateAsync(cancellationToken); break; case 2: Console.Beep(); break; case 3: await ProcessServerCutTextAsync(cancellationToken); break; default: // 未知消息类型直接跳过,不要抛异常 // 某些服务端在断开前会发送奇怪的消息类型 break; } }

FrameBufferUpdate消息的结构是:1字节类型 + 1字节填充 + 2字节矩形数量,然后每个矩形单独描述。矩形是VNC协议里最核心的概念——每次屏幕变化都被切分成若干不重叠的矩形区域,客户端只需要更新这些区域即可,不需要刷新整个屏幕。

private async Task ProcessFramebufferUpdateAsync(CancellationToken ct) { // 读掉 1 字节填充位 await stream.ReadByteAsync(); // 读取矩形数量,高字节在前 byte[] rectCountBytes = new byte[2]; await stream.ReadAsync(rectCountBytes, 0, 2); int rectCount = (rectCountBytes[0] << 8) | rectCountBytes[1]; for (int i = 0; i < rectCount; i++) { // 每个矩形包含:x(2) + y(2) + width(2) + height(2) + encodingType(4) byte[] rectHeader = new byte[12]; await stream.ReadAsync(rectHeader, 0, 12); int x = (rectHeader[0] << 8) | rectHeader[1]; int y = (rectHeader[2] << 8) | rectHeader[3]; int w = (rectHeader[4] << 8) | rectHeader[5]; int h = (rectHeader[6] << 8) | rectHeader[7]; int encoding = (rectHeader[8] << 24) | (rectHeader[9] << 16) | (rectHeader[10] << 8) | rectHeader[11]; // 根据编码类型读取像素数据 await DecodeRectangleAsync(x, y, w, h, encoding, ct); } }

矩形头部的12字节结构在几乎所有实现里都是一样的,所以你把任何一份源码的这段逻辑挑出来对比,差异只在DecodeRectangle里的编码解码实现。理解了这段,你就摸清了一份源码的“骨架”。接下来需要搞清楚的,是它把解码后的像素数据画到哪里,是直接写内存位图,还是交给渲染控件。

3. 把源码跑起来:最小可运行的C#客户端搭建过程

3.1 项目结构和依赖选择:从源码里挑出真正的核心文件

很多下载到的源码包动辄几十个文件,其实核心部分很少。一个典型的C# VNC客户端最小集只有五块:协议层负责收发RFB报文,加密层负责DES认证,解码层负责把编码后的像素还原成位图,事件层负责处理鼠标键盘输入,UI层负责显示。其他比如自动重连、会话管理、多显示器支持,都是锦上添花。我通常在拿到源码后先看有没有依赖项文件,C#项目里一般是.csproj或packages.config。如果项目引了一堆NuGet包但协议层代码很少,说明主要是靠第三方库封装的,这类源码改起来价值不大。

确定核心文件后,建议新建一个独立的控制台项目,只把协议相关的.cs文件拖进去编译一次。这一步能快速排掉编译错误,也能看清源码真正的依赖边界。

# 以最简方式建立测试项目(.NET 6 及以上版本) dotnet new console -n VncClientTest cd VncClientTest # 拷贝源码里的协议层核心文件 cp /path/to/source/VncProtocol.cs . cp /path/to/source/DesEncryption.cs . cp /path/to/source/RemoteClient.cs . # 编译验证 dotnet build

如果编译报错,优先看错误是否集中在某个类里。常见的原因是源码用了WinForms或WPF的UI类型,而你建的是控制台项目。这种情况下不建议硬编译,直接把UI相关类从编译范围里排除掉,或者把整体的TargetFramework改成net6.0-windows并加上UseWindowsForms属性。

参数说明:net6.0-windows是带Windows桌面框架的目标框架标识,只有它才能引用WinForms类型;UseWindowsForms是项目文件里的开关,设为true才能使用System.Windows.Forms命名空间。引用的文件路径按你实际拷的位置写,不要照抄。

3.2 连接与认证的最小示例:从握手走向像素流

协议层编译通过后,下一步是写一个真正的连接示例。这个示例的目标很单一:连上一个VNC服务端,完成握手和认证,收到一帧画面并保存成PNG文件。这一步走通,整个客户端的技术链路就算通了。

using System.Net.Sockets; using System.Net; // 目标服务端地址 // 假设你自建了一个监听在 5900 端口的 VNC 服务端 // host 和 password 按实际运行环境填写 string host = "127.0.0.1"; int port = 5900; string password = "test1234"; using var client = new TcpClient(); await client.ConnectAsync(host, port); await using var stream = client.GetStream(); // 创建协议实例并完成握手 var vnc = new VncProtocol(stream); await vnc.HandshakeAsync(); await vnc.AuthenticateAsync(password); // 请求原始 32 位颜色格式,避免服务端做像素格式转换 vnc.SetPixelFormat(32, 24, 0, 255, 255, 255); // 请求最基础的 Raw 编码,确保兼容性 vnc.SetEncodings(new int[] { 0 }); // 0 = Raw // 让服务端发送全屏更新 vnc.SendFramebufferUpdateRequest(0, 0, 1920, 1080, false); // 循环读取直到拿到第一帧 var frame = await vnc.ReceiveFramebufferUpdateAsync(); // 把像素数组写到本地 BMP 文件 // 注意 BMP 字节序是 BGR,而 RFB 默认也是 BGR,直接用就行 BitConverter.ToString(frame.Pixels).SaveAsBitmap(frame.Width, frame.Height, "first-frame.bmp"); Console.WriteLine($"收到画面: {frame.Width} x {frame.Height}");

这块代码里的VncProtocol是我们上一节梳理出来的协议类的实例化封装,不同源码类名可能不一样,但它对应的一定是“持有流并处理握手”的那个类。SetPixelFormat的六个参数依次是:每个像素位数、颜色深度、是否大端字节序和红绿蓝三色的最大分量值。这里填32位和255的意思是让服务端直接发32位真彩色,省掉解码时的颜色换算。

注意SendFramebufferUpdateRequest的最后一个布尔值是incremental。第一次请求必须传false,表示“把整个屏幕都发给我”。后续的实时刷新才传true,表示“只发变化区域”。把初次请求写成true是很多初改源码的人会踩的坑,表现是画面只有局部刷新,或者干脆黑屏。

3.3 显示与刷新:把一帧像素流变成能看的桌面

控制台项目把画面存成文件只能验证链路,真正让用户体验“远程桌面”的连续性,需要把像素流接到一个可以持续刷新的画面上。

// 使用 WinForms 的 PictureBox 展示画面 // 核心处理是每次收到 FrameBufferUpdate 后把对应矩形区域拷贝到全屏位图上 // 而不是重建整个位图——后者会导致内存抖动和明显的闪烁 private Bitmap _screen; // 整屏位图 private Graphics _graphics; // 整屏位图的画布 void InitScreen(int width, int height) { _screen = new Bitmap(width, height); _graphics = Graphics.FromImage(_screen); } void UpdateRectangle(int x, int y, int width, int height, byte[] pixels) { // 从像素数组构建临时位图 using var tempBitmap = CreateBitmapFromBgr(pixels, width, height); // 画到对应位置 _graphics.DrawImage(tempBitmap, x, y, width, height); // 通知界面刷新该区域 pictureBox.Invalidate(new Rectangle(x, y, width, height)); }

这段代码逻辑很简单但最实用:_screen缓存了一帧完整画面,后面所有矩形更新都是在它上面打补丁。Invalidate只让UI控件重绘指定矩形,实现局部刷新的视觉效果。如果你把Invalidate写成整个控件刷新,在高分辨率下帧率会立刻掉下来,CPU占用直接打满。

到这里,一个可交互的C# VNC客户端技术主干已经全通了。剩下的鼠标键盘事件回传属于锦上添花但必备的功能,下一章把参数调优和输入事件一起讲。

4. 必调参数与输入回传:编码方式、像素格式和鼠标键盘消息

4.1 编码方式选型:Raw/Hextile/Tight 的取舍与切换条件

编码方式决定了服务端怎么压缩屏幕差异数据,也直接决定了带宽和CPU占用。一份成熟的C# VNC客户端源码,通常在连接后通过SetEncodings一次性声明自己支持哪些编码,服务端会自动从列表里挑选它认为最合适的。

// 声明支持的编码方式列表 // 数值含义: // 0 = Raw(无压缩,最稳定但带宽占用大) // 1 = CopyRect(复制屏幕已有区域,适合窗口拖动) // 2 = RRE(游程编码,旧式低压缩比) // 5 = Hextile(16x16 像素块的游程变种,万金油) // 16 = Zlib(zlib 压缩,传输量和 CPU 消耗中等) // 22 = Tight(JPEG 有损压缩,适合照片类桌面背景) int[] encodings = new int[] { 22, 16, 5, 1, 0 }; vnc.SetEncodings(encodings);

选择策略通常取决于你的网速和桌面变化频繁度。局域网内我一般推荐Hextile,它的编码解码速度快,CPU占用低,画面质量无损。跨公网运维场景才用Tight或Zlib,因为它们能把带宽压到十分之一,代价是画质损失和CPU占用。

这里有个关键细节:声明编码列表时顺序就是服务端的优先级顺序,服务端会从第一个开始尝试,不支持才往后退。所以把Tight放在第一位不是一个好习惯,很多服务端虽然实现了Tight但部分区域会退回Raw,反而造成不稳定。我一般把Hextile放第一,让服务端默认用它。

4.2 输入回传:鼠标事件消息格式和本地坐标系换算

VNC的输入回传是一个长消息,结构包括:1字节消息类型(5表示指针事件)、1字节按钮掩码、2字节X坐标、2字节Y坐标。鼠标事件里最烦的问题是本地坐标到远端坐标的换算,因为窗口可能被缩放,而远端是原始分辨率。

// 鼠标移动事件 // x/y 必须使用远端屏幕坐标,不是本地控件坐标 private void OnMouseMove(object sender, MouseEventArgs e) { // 将本地控件坐标换算成远端坐标 int remoteX = (int)(e.X * (double)_remoteWidth / pictureBox.Width); int remoteY = (int)(e.Y * (double)_remoteHeight / pictureBox.Height); // 当前没有按键按下,按钮掩码为 0 SendPointerEvent(remoteX, remoteY, 0); } // 鼠标按下事件,按钮掩码的含义: // 1 = 左键,2 = 中键,4 = 右键,8 = 滚轮向上,16 = 滚轮向下 private void OnMouseDown(object sender, MouseEventArgs e) { int mask = GetButtonMask(e.Button); int remoteX = (int)(e.X * (double)_remoteWidth / pictureBox.Width); int remoteY = (int)(e.Y * (double)_remoteHeight / pictureBox.Height); SendPointerEvent(remoteX, remoteY, mask); } private void SendPointerEvent(int x, int y, int buttonMask) { byte[] message = new byte[6]; message[0] = 5; // PointerEvent 类型号 message[1] = (byte)buttonMask; message[2] = (byte)(x >> 8); message[3] = (byte)(x & 0xFF); message[4] = (byte)(y >> 8); message[5] = (byte)(y & 0xFF); stream.Write(message, 0, message.Length); }

这段代码里的坐标换算必须用double计算而不是整数除法,否则缩放后坐标误差会累积,在分辨率差大的场景里点击位置会明显偏移。按钮掩码的设计也值得注意:它支持多个按钮同时按下的组合,所以在OnMouseDown和OnMouseUp里计算掩码时要做位或运算,而不是直接赋值。

键盘事件相对简单但有一个隐藏坑:键码必须转换成X11键码,Windows的KeyEventArgs.KeyCode不能直接发给远端。转换表很长,通常在源码里会有一个静态字典。如果源码里没有这张表,只能说明这份源码的键盘功能是不完整的,需要自己补。

4.3 请求更新的节奏控制:增量请求与流量平衡

很多源码把主循环写成“收到一帧→立即请求下一帧”的毫无间隙模式。这在局域网没问题,但在公网上会因为数据积压造成延迟越来越大。控制方法是在请求消息里设置incremental标志,让服务端只在画面变化时才推送。

// 实时刷新阶段,增量请求整个屏幕区域 // incremental = true 表示服务端只在画面变化时推送 // 如果画面完全没变,服务端可以不发送任何数据 vnc.SendFramebufferUpdateRequest(0, 0, _remoteWidth, _remoteHeight, true);

真正要调的是屏幕区域大小和请求频率的组合。我一般习惯把整屏增量请求放到一个定时器里,每100毫秒发一次。如果服务端画面变化频率低,这个间隔根本不会产生额外流量;如果频率高,它起到了限流作用,避免了持续请求导致的缓冲区堆积。源码里如果直接用了死循环请求,我会建议改成这个节奏。

5. VNC客户端常见问题排查:五个高频翻车点及修复方法

5.1 连接成功但画面一直黑屏:问题出在初次全屏请求

现象:和服务端握手、认证都通过了,日志也显示收到了FrameBufferUpdate消息,但画面始终是黑屏。用调试器查看像素数组,发现全是为零的字节。

原因:初次连接后发送的是增量请求(incremental=true)。服务端认为没有画面变化,就不会主动推送整帧数据。协议规定服务端永远有权忽略增量请求,所以客户端收不到任何数据但并不报错。

解决:把发送的第一条请求改为非增量(false),并明确指定完整屏幕范围。如果范围写小了,比如只请求了左上角一小块,画面也会大部分是黑的。RFB协议里初次请求必须是完整区域,这个规则要在主循环里做成状态位,只在第一次执行。

5.2 认证阶段直接崩掉或报DES密钥错误:算法填充的锅

现象:使用VNC密码认证时,有些服务端能连上,有些报“Authentication failed”,还有些在收到挑战数据后直接断开。对比测试发现和密码长度无关。

原因:RFB的DES加密不是标准DES做一整个分块,而是把16字节的挑战分成两个8字节块分别加密。密码不足8字节时要用0补齐。如果源码里直接用.NET的DES.Create()默认填充模式(PKCS7),会导致填充字节参与加密,服务端解出来对不上。

解决:把密码补齐为8字节后,手动指定PaddingMode.None和CipherMode.ECB,然后分块加密。

private byte[] EncryptChallenge(byte[] challenge, byte[] passwordBytes) { using var des = DES.Create(); des.Mode = CipherMode.ECB; // RFB 要求 ECB 模式 des.Padding = PaddingMode.None; // 不要自动填充 des.Key = passwordBytes; using var encryptor = des.CreateEncryptor(); // 16 字节挑战分成两段各 8 字节分别加密 byte[] encrypted = new byte[16]; encryptor.TransformBlock(challenge, 0, 8, encrypted, 0); encryptor.TransformBlock(challenge, 8, 8, encrypted, 8); return encrypted; }

这个坑在移植Java或C版本源码时极其常见,因为那些语言里没有默认填充机制,而C#默认会加。判断源码是否有这个问题很简单:查看DES相关代码里有没有设置PaddingMode.None,没有就大概率踩坑。

5.3 画面花掉或颜色偏绿偏紫:像素格式没对齐

现象:画面能出来,但颜色明显不对,红色变成蓝色,或者有些区域有彩色噪点。Windows下尤其常见。

原因:RFB支持的像素格式非常多,很多服务端默认发16位颜色(RGB565)而不是24位或32位。如果客户端没协商好SetPixelFormat,或者解码逻辑只支持一种格式,就会解析错每一位。

解决:确认源码的像素格式类里是否同时支持16位、24位和32位。不支持的格式要么在SetPixelFormat里强制指定一种所有服务端都会接受的格式(通常是32位),要么补齐解码逻辑。注意有些服务端不支持客户端强制的格式,此时只能让它按自己的格式发,客户端适配解码。

5.4 鼠标位置比实际偏左上:滚轮缩放时的坐标取整问题

现象:窗口放大显示时,在右下角点击总是点不准,越靠右下偏移越严重。

原因:坐标换算用了整数除法。比如控件宽500像素、远端分辨率1920,计算500 * 1920 / 500确实没问题,但(int)(e.X * (1920 / 500))中1920 / 500在整数运算里等于3,而不是3.84,偏差就出来了。

解决:按4.2节的方式先乘double再取整,并且确保分子分母都是double类型。建议写一个专门的静态方法处理换算,方便所有鼠标事件统一调用。类似的坑还出现在滚轮消息上,有些服务端滚轮方向是反的,需要在事件里反转符号位。

5.5 程序退出时服务端连接不释放:半开连接累积

现象:客户端异常退出后,服务端会把这个连接保持一段时间,之后再也连不上去,服务端日志显示“connection refused”。

原因:TCP的TIME_WAIT状态加上VNC服务端的连接数限制。客户端没有在退出前发送关闭握手(如果协议版本支持),或没有主动调用Disconnect方法。

解决:在主窗口的FormClosing事件里显式关闭流和连接,并且发送一个RFB消息告知服务端。标准做法是把stream.Close()和client.Close()都执行,并把Socket的LingerState设为0秒,避免进入TIME_WAIT状态。如果源码里已经封装了断开方法,确认它在异常路径上也会被调用。

6. 进阶变体:把客户端改成无界面的截屏采集器

当客户端能稳定显示画面后,一个很实用的变体是去掉UI层,把这套源码改成一个批量截图工具。需求常常来自自动化测试团队:同时采集多台目标机器的屏幕,保存成连续帧用于断言分析。我第一次做这个改造时,发现核心工作量不是删UI,而是处理两个问题:多实例的资源隔离,以及帧保存的格式选择。

无界面模式下去掉WinForms依赖后,很大一片编译引用都可以删除,包括PictureBox、Form和所有事件处理。保留协议类、解码类和核心事件循环即可。连接池可以用简单的Task数组管理,每个Task运行一个独立的TcpClient实例。关键参数是PixelFormat一定要选32位,因为后续保存成图片时不需要再做格式转换。帧更新循环里把ProcessFramebufferUpdateAsync收到的矩形直接写入整帧位图,每收到一帧完整区域后保存为JPEG或PNG文件,保存频率用时间戳控制,比如至少间隔两秒存一次。

// 无界面截屏模式的简化主循环 // 核心逻辑:收到整帧矩形更新后,把矩形像素写入全屏位图 // 当所有待更新矩形写完且时间间隔超过两秒时,保存一次 while (!ct.IsCancellationRequested) { await vnc.ReceiveFramebufferUpdateAsync(); if (DateTime.UtcNow - _lastSave >= TimeSpan.FromSeconds(2)) { SaveFrameToFile(); _lastSave = DateTime.UtcNow; } }

这个模式我自己跑过一整晚的稳定性测试,暴露出的一个教训是:不能依赖Timer控件做定时保存,因为主循环被打断后会漏读协议流,导致下一次解析错位。正确做法是在同一循环里同步做“读一帧→检查时间→决定是否保存”,保证协议流读取的连续性。另外,多实例场景下每台目标机器要单独持有自己的VncProtocol实例,不要共享一个流对象,否则高频连接下会出现随机断开。希望这些细节能帮你少走一些弯路,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询