VC++ VNC远程控制源码解析:屏幕采集、差分编码与网络传输实战
简介这份VC源码资源面向希望深入理解远程桌面控制原理的C开发者与网络编程学习者以VNCVirtual Network Computing为实例完整呈现基于RFB协议的控制端与被控端实现。源码将客户端与服务器分开编写便于对照理解屏幕捕获、输入转发与画面更新的完整链路。压缩包共631个文件约2.27MB以c、h、cpp源码为主体辅以vcproj、sln等工程文件及dsp、dsw旧版工程另含doc、txt、readme等说明文档与ico、bmp、cur等界面资源并附带jpeg编解码相关工具文件目录结构清晰。已有852人学习下载。内容覆盖套接字网络通信、RGB像素编码解码、Windows API图形渲染、键盘鼠标事件处理、多线程同步以及TLS/SSL加密与身份验证等关键知识点并预留定制扩展接口。读者可据此掌握C在网络编程、图形处理与并发控制方面的实战应用适合作为远程控制方向的学习与二次开发参考。1. 拆开一份 VC 写的 VNC 远程控制源码它到底能跑通什么手上这份 VNC 远程控制程序 VC 源码第一次翻目录的时候我以为是某个老项目的残骸结果编译跑起来才发现屏幕采集、编码、网络传输、远端注入这几条链路是完整闭环的。它解决的不是「远程桌面怎么用」这种问题而是「我想自己掌控一条远程屏幕控制链路从抓屏到回传再到反向操作每一环都能改」的问题。适合谁一类是想在 Windows 上做远程协助、教学演示、内网设备巡检的开发者另一类是学生或刚转 Windows 网络编程的从业者想找一个比 MFC 聊天室复杂、又比完整商用远控轻量的练手项目。它不依赖第三方远控框架核心就是 Win32 API 加 Winsock配合一套自研的屏幕分块与差分编码逻辑。换句话说你拿到的是「可拆、可改、可嵌」的底层实现而不是一个装完就用、改不动的黑匣子。2. 屏幕采集与差分编码VNC 源码里最容易被低估的一环2.1 为什么不用整屏 BitBlt 直接发很多人第一次写远程屏幕思路很直接定时器里 BitBlt 抓全屏压缩一下通过 socket 发出去。这份源码没有这么干它把屏幕切成固定大小的 tile常见做法是 32×32 或 64×64然后对每个 tile 做哈希比对只有变化的 tile 才进入编码队列。原因很现实1920×1080 的 32 位色全屏位图是 8MB 左右哪怕压到十分之一一秒发五帧也是 4MB/s 的带宽内网勉强跨网段直接翻车。分块差分之后静态桌面下每秒实际传输可能只有几 KB这就是 VNC 类协议能跑在低带宽上的根本原因。源码里屏幕采集部分主要围绕几个 Win32 调用展开GetDC拿屏幕 DCCreateCompatibleDC和CreateCompatibleBitmap建内存位图BitBlt把屏幕内容拷进内存再用GetDIBits把位图数据转成可以直接处理的像素缓冲区。这里有个细节值得注意GetDIBits拿到的行是自下而上的也就是 bottom-up处理 tile 坐标时如果不做翻转画面会上下颠倒。源码里在初始化阶段就把 biHeight 设成了负值让 DIB 变成 top-down省掉了后续逐行翻转的开销。// 初始化屏幕采集用的 DIB 缓冲区top-down 模式 BITMAPINFO bmi { 0 }; bmi.bmiHeader.biSize sizeof(BITMAPINFOHEADER); bmi.bmiHeader.biWidth screenW; bmi.bmiHeader.biHeight -screenH; // 负值 top-down避免行翻转 bmi.bmiHeader.biPlanes 1; bmi.bmiHeader.biBitCount 32; // 32 位色含 Alpha 通道占位 bmi.bmiHeader.biCompression BI_RGB; // 抓屏到内存 DC再取像素到 pBits HDC hScreenDC GetDC(NULL); HDC hMemDC CreateCompatibleDC(hScreenDC); HBITMAP hBmp CreateCompatibleBitmap(hScreenDC, screenW, screenH); SelectObject(hMemDC, hBmp); BitBlt(hMemDC, 0, 0, screenW, screenH, hScreenDC, 0, 0, SRCCOPY); GetDIBits(hMemDC, hBmp, 0, screenH, pBits, bmi, DIB_RGB_COLORS);参数上biBitCount选 32 而不是 24是因为 32 位在内存里按 4 字节对齐tile 哈希和 SIMD 比对都更省事代价是内存占用多四分之一。biHeight取负值是这份源码里一个不起眼但很关键的处理新手自己改的时候如果把它改回正值画面翻转的锅别甩给网络层。2.2 tile 哈希与变化检测的实现分块之后每个 tile 需要快速判断「这一帧和上一帧是否一样」。源码用的是简单的 FNV-1a 哈希对 tile 内每个像素的 4 字节做异或乘法。为什么不用 CRC32FNV-1a 实现只有几行速度在 tile 这种小数据块上足够而且不需要查表缓存友好。哈希值存进一个和 tile 网格同尺寸的数组每帧比对变化的 tile 打标记进入编码队列。// FNV-1a 哈希用于 tile 变化检测 inline uint32_t Fnv1a(const uint8_t* data, size_t len) { uint32_t hash 2166136261u; for (size_t i 0; i len; i) { hash ^ data[i]; hash * 16777619u; // FNV 质数 } return hash; } // 遍历 tile 网格标记变化块 for (int ty 0; ty tilesY; ty) { for (int tx 0; tx tilesX; tx) { const uint8_t* tilePtr pBits (ty * tileH * screenW tx * tileW) * 4; uint32_t h Fnv1a(tilePtr, tileW * tileH * 4); if (h ! lastHash[ty * tilesX tx]) { dirtyTiles.push_back({tx, ty}); lastHash[ty * tilesX tx] h; } } }这里有个容易踩的坑tile 宽度如果不是 4 的倍数指针偏移计算会错位。源码里 tileW 固定取 32正好是 4 的倍数所以* 4的字节偏移是安全的。如果你改成 30 或 50就得按行单独算偏移不能再用tileW * tileH * 4这种连续内存假设。另一个坑是屏幕分辨率变化时tile 网格和 lastHash 数组必须重建否则越界访问直接崩。源码里用WM_DISPLAYCHANGE消息触发重建这个处理在远程控制场景里是必须的因为被控端改分辨率是很常见的操作。2.3 编码格式选型Raw 与 Hextile 的取舍这份源码实现了两种编码Raw 和 Hextile。Raw 就是直接把 tile 像素按行发出去实现最简单适合变化面积大、网络好的场景。Hextile 是 VNC 里经典的 tile 内子编码把一个 tile 再切成 16×16 的子块对每个子块判断是否纯色、是否与上一子块相同用不同的标志位压缩。源码里 Hextile 的实现大概两百多行核心是子块标志位的组合判断。选型上我一般会这样建议内网千兆环境直接用 Raw省 CPU跨网段或者无线环境切 Hextile带宽能降一半以上代价是编码 CPU 占用上升。源码里编码格式是通过客户端握手阶段协商的服务端根据客户端支持的编码列表选一个双方都有的。如果你想加 Tight 或 ZRLE编码层是插件式设计的新增一个编码类注册进去就行但要注意客户端也得同步支持否则握手会失败。3. 网络传输与握手协议把屏幕数据可靠送到对端3.1 RFB 握手流程的简化实现这份源码的协议层参考了 RFB 的基本流程但做了简化。完整 RFB 握手包括版本协商、安全类型协商、客户端初始化、服务端初始化几个阶段。源码里保留了版本协商和客户端初始化安全类型直接走 None也就是不加密。这一点必须说清楚它适合内网或可信网络下的学习与调试放到公网环境是不合适的任何远程控制链路只要涉及真实设备加密和认证都不能省。握手阶段的关键交互是版本号交换。服务端先发RFB 003.008\n客户端回自己的版本然后服务端发安全类型列表客户端选一个。源码里服务端只提供一种安全类型所以客户端的选择逻辑很简单。接下来客户端发共享标志服务端回屏幕宽高和像素格式然后进入消息循环。// 服务端发送版本号与安全类型 const char* ver RFB 003.008\n; send(clientSock, ver, 12, 0); // 读取客户端版本12 字节 char clientVer[13] { 0 }; recv(clientSock, clientVer, 12, MSG_WAITALL); // 发送安全类型数量与类型值1 种None uint8_t secCount 1; uint8_t secType 1; // 1 None send(clientSock, (char*)secCount, 1, 0); send(clientSock, (char*)secType, 1, 0);参数上MSG_WAITALL在阻塞 socket 下能保证收满指定字节数但如果你把 socket 设成非阻塞这个标志就失效了得自己循环收。源码默认用阻塞模式加单独线程处理每个客户端简单直接代价是并发连接数受线程数限制。想改成 IOCP 或 select 模型网络层需要重写但协议解析部分可以原样复用。3.2 消息循环与帧缓冲更新握手完成后进入消息循环。客户端会发不同类型的消息SetPixelFormat、SetEncodings、FramebufferUpdateRequest、KeyEvent、PointerEvent、ClientCutText。服务端主要处理 FramebufferUpdateRequest收到后把 dirty tile 编码发回去。源码里消息循环是一个while循环加switch每个消息类型一个处理分支。// 简化的消息循环 while (running) { uint8_t msgType; if (recv(clientSock, (char*)msgType, 1, MSG_WAITALL) 0) break; switch (msgType) { case 0: // SetPixelFormat handleSetPixelFormat(clientSock); break; case 2: // SetEncodings handleSetEncodings(clientSock); break; case 3: // FramebufferUpdateRequest handleFbUpdateRequest(clientSock); break; case 4: // KeyEvent handleKeyEvent(clientSock); break; case 5: // PointerEvent handlePointerEvent(clientSock); break; default: skipUnknownMessage(clientSock, msgType); break; } }FramebufferUpdateRequest 里带 incremental 标志。如果 incremental 为 0客户端要的是全量刷新服务端把所有 tile 标记为 dirty如果为 1只发变化的 tile。这个标志的处理直接影响首屏体验客户端刚连上时发一次全量请求之后都发增量请求。源码里对全量请求的处理是把 lastHash 数组清零强制所有 tile 进入编码队列。3.3 反向控制键鼠事件的注入远程控制不只是看还要能操作。源码里键鼠事件的处理分两步解析客户端发来的 KeyEvent 和 PointerEvent然后通过SendInput注入到本机。KeyEvent 里带 downFlag按下和抬起要分别处理否则会出现按键卡住的情况。PointerEvent 带坐标和按键掩码坐标需要根据客户端显示区域和服务端屏幕尺寸做缩放。// 处理 PointerEvent 并注入鼠标 void handlePointerEvent(SOCKET s) { uint8_t btnMask; uint16_t x, y; recv(s, (char*)btnMask, 1, MSG_WAITALL); recv(s, (char*)x, 2, MSG_WAITALL); recv(s, (char*)y, 2, MSG_WAITALL); // 网络字节序转主机字节序 x ntohs(x); y ntohs(y); INPUT input { 0 }; input.type INPUT_MOUSE; input.mi.dx x * 65535 / screenW; // 归一化到 0-65535 input.mi.dy y * 65535 / screenH; input.mi.dwFlags MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE; if (btnMask 0x01) input.mi.dwFlags | MOUSEEVENTF_LEFTDOWN; if (btnMask 0x02) input.mi.dwFlags | MOUSEEVENTF_LEFTUP; // ... 右键、中键类似 SendInput(1, input, sizeof(INPUT)); }坐标归一化是这里的关键。SendInput的绝对坐标是 0 到 65535 的虚拟坐标不是像素坐标所以必须做缩放。如果客户端和服务端分辨率不一致缩放系数要按各自屏幕尺寸算不能直接用像素值。源码里 screenW 和 screenH 是服务端屏幕尺寸客户端的坐标在发送前已经按客户端显示区域换算过这个换算逻辑在客户端代码里服务端只管注入。4. 编译、调试与部署从源码到能跑的程序4.1 工程结构与依赖源码包解压后一般能看到几个目录服务端、客户端、公共协议层。服务端和客户端都是独立的 VC 工程公共协议层以静态库或直接源码包含的方式复用。依赖上除了 Windows SDK 和 Winsock没有第三方库。这意味着你不需要配 vcpkg 或手动下 zlib、openssl 之类的东西编译门槛很低。工程配置里要注意几点字符集用 Unicode 还是多字节要统一源码里字符串处理混用了TCHAR和char如果字符集不匹配会出现编译错误或运行时乱码。链接器需要加ws2_32.lib否则 Winsock 函数全部报未解析外部符号。运行库建议用多线程调试 DLL 或多线程 DLL静态链接在跨机器部署时容易出问题。# 用 MSBuild 命令行编译在 VS 开发者命令提示符下 msbuild VNC_Server.vcxproj /p:ConfigurationRelease /p:Platformx64 msbuild VNC_Client.vcxproj /p:ConfigurationRelease /p:Platformx64编译产物是两个 exe服务端跑在被控机器上客户端跑在控制端。首次运行前要确认防火墙放行监听端口源码默认端口是 5900和标准 VNC 端口一致。如果端口被占用改源码里的常量重新编译或者加一个命令行参数解析。4.2 调试时怎么看画面不刷新调试阶段最常见的问题是客户端连上了但画面不动。排查顺序我一般是这样先确认服务端有没有收到 FramebufferUpdateRequest在消息循环里加日志打印消息类型再确认 dirty tile 有没有被标记如果 lastHash 初始化有问题第一帧可能全是 dirty但后续帧永远不 dirty最后确认编码后的数据有没有真正 send 出去send 返回值有没有检查。另一个高频问题是画面花屏或错位。花屏通常是像素格式协商不一致客户端声明的像素格式和服务端实际发送的不匹配。源码里像素格式是客户端在 SetPixelFormat 消息里指定的服务端要按客户端指定的格式转换。如果服务端忽略了这个消息直接按自己的格式发客户端解析就会错位。错位还可能是 tile 坐标计算错误尤其是屏幕宽度不是 tileW 整数倍时最后一列 tile 的宽度要单独处理。提示调试远程控制程序时最好用两台机器或虚拟机对跑本机同时跑服务端和客户端容易出现输入事件回环鼠标自己动起来排查起来很费劲。4.3 部署时的权限与多屏问题服务端如果要控制需要管理员权限的窗口比如任务管理器或 UAC 弹窗自身必须以管理员权限运行否则SendInput注入的事件会被系统拦截。这一点在源码里没有自动提权逻辑需要手动右键以管理员身份运行或者在 manifest 里加 requestedExecutionLevel。多屏环境下源码默认只抓主屏。如果要支持多屏GetSystemMetrics(SM_XVIRTUALSCREEN)和SM_CXVIRTUALSCREEN可以拿到虚拟桌面的整体尺寸抓屏时用虚拟桌面坐标。但多屏的坐标映射和客户端显示布局要额外处理源码里没有现成实现属于需要自己扩展的部分。5. 避坑与排查这份 VNC 源码里最容易翻车的几个点5.1 连接成功但画面全黑现象是客户端握手通过窗口也出来了但内容全黑。原因通常是GetDIBits调用时传入的 BITMAPINFO 结构和实际位图不匹配或者biBitCount和biCompression组合不被支持。解决方法是先用GetDeviceCaps确认屏幕色深确保biBitCount设为 32 且biCompression为 BI_RGB。另一个可能是抓屏线程还没跑第一帧客户端就发了增量请求服务端没有 dirty tile 可发。可以在服务端启动时强制做一次全量标记。5.2 鼠标坐标偏移现象是远程操作时鼠标点不准越往屏幕边缘偏得越厉害。原因是坐标归一化时用了错误的屏幕尺寸比如用了客户端窗口尺寸而不是服务端屏幕尺寸或者没有考虑 DPI 缩放。解决方法是统一用服务端屏幕的物理像素尺寸做归一化并且在客户端发送坐标前就完成显示区域到服务端坐标的映射。高 DPI 环境下还要注意进程的 DPI 感知设置否则GetSystemMetrics返回的是缩放后的逻辑尺寸。5.3 高频操作下按键卡住现象是快速输入时某个键一直处于按下状态。原因是 KeyEvent 的 down 和 up 消息在网络拥塞时可能乱序或丢失服务端收到 down 后没收到对应的 up。解决方法是在服务端维护一个按键状态表收到 down 时记录收到 up 时清除并且在连接断开时把所有按键状态重置为抬起。源码里没有这个状态表属于需要自己补的健壮性处理。5.4 编译报错找不到 winsock2.h现象是编译时提示winsock2.h找不到或和windows.h冲突。原因是包含顺序不对winsock2.h必须在windows.h之前包含否则会引入旧版winsock.h导致重定义。解决方法是在所有源文件里统一先#include winsock2.h再#include windows.h并且在项目属性里把ws2_32.lib加到链接器依赖。5.5 多客户端同时连接时崩溃现象是第二个客户端连上后服务端崩溃或第一个客户端断线。原因是源码里屏幕采集和编码状态是全局的多个客户端共享同一份 lastHash 和 dirtyTiles一个客户端的全量请求会清空另一个客户端的增量状态。解决方法是把每个客户端的状态独立出来每个连接一份 lastHash 和编码上下文。这个改动涉及结构体拆分是这份源码从「能跑」到「能用」的关键一步。6. 进阶改造把这份源码变成你自己的远程控制底座如果你已经跑通了基本流程接下来可以做的改造方向有几个。第一个是加认证源码里安全类型是 None实际用的时候至少加一个密码校验简单做法是在握手阶段加一个挑战响应客户端发哈希后的密码服务端比对。第二个是换编码Hextile 在文本界面下压缩率一般可以加一个简单的 RLE 或者对接 zlib 做 zlib 编码VNC 标准里有 ZRLE实现复杂度中等但带宽收益明显。第三个是改网络模型。源码用阻塞 socket 加每客户端一线程连接数一多线程切换开销就上来了。改成 select 或 IOCP 之后单进程能扛的连接数提升一个量级。改造时协议解析部分不用动只需要把 recv/send 的调用点替换成事件驱动下的缓冲区读写。第四个是加文件传输和剪贴板同步这两个功能在远程协助场景里很实用协议层可以复用现有的消息类型扩展客户端和服务端各加一个消息分支。验证改造是否成功我一般会用一个笨办法在服务端开一个高帧率动画窗口比如一个不断旋转的方块然后观察客户端画面是否流畅、CPU 占用是否合理、带宽是否在预期范围。静态桌面下看不出编码和传输的问题动态画面才是真正的试金石。另外断线重连和异常断开后的资源回收也要测closesocket之后线程有没有正常退出、GDI 对象有没有泄漏这些在长时间运行下才会暴露。从那以后我每次拿到这类远程控制源码都强制先跑一遍动态画面加断线重连再去看代码结构。静态能跑不代表链路健壮断线重连和资源回收才是区分玩具和工具的分界线。希望帮到你。本文还有配套的精品资源点击获取