简介这是一份基于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协议的客户端。RFBRemote 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 Hextile16x16 像素块的游程变种万金油 // 16 Zlibzlib 压缩传输量和 CPU 消耗中等 // 22 TightJPEG 有损压缩适合照片类桌面背景 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消息但画面始终是黑屏。用调试器查看像素数组发现全是为零的字节。原因初次连接后发送的是增量请求incrementaltrue。服务端认为没有画面变化就不会主动推送整帧数据。协议规定服务端永远有权忽略增量请求所以客户端收不到任何数据但并不报错。解决把发送的第一条请求改为非增量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实例不要共享一个流对象否则高频连接下会出现随机断开。希望这些细节能帮你少走一些弯路希望帮到你。本文还有配套的精品资源点击获取