
简介这份VC源码资源面向希望深入理解远程桌面控制原理的C开发者与网络编程学习者以VNCVirtual Network Computing为实例完整呈现基于RFB协议的控制端与被控端实现。源码将客户端与服务器分开编写便于对照理解屏幕捕获、输入转发与画面更新的协作流程。压缩包共631个文件约2.27MB以c、h、cpp源码为主体配合vcproj、sln等工程文件可直接在Visual Studio中打开编译另有doc、txt、readme等说明文档及ico、bmp、cur等界面资源整体结构清晰。内容覆盖套接字网络通信、RGB像素编解码、Windows API图形渲染、键盘鼠标事件处理、多线程同步以及可扩展的安全验证与自定义配置等关键知识点。目前已有852人学习下载适合作为网络编程、图形处理与多线程实战的参考案例也可在此基础上定制功能或优化性能。1. VNC 远程控制程序 VC 源码从协议到可编译工程的落地路径很多人第一次接触远程桌面是从装一个客户端、输个地址、点连接开始的用起来像黑盒。但当你手里拿到一份 VNC 远程控制程序 VC 源码事情就变了——你面对的不再是黑盒而是 RFB 协议、帧缓冲更新、编码协商、输入事件注入这一整套可拆解、可改、可裁的机制。这份源码能解决的核心问题是让你在 Windows 上用原生 C 把「屏幕采集 → 编码压缩 → 网络传输 → 远端解码渲染 → 反向输入回传」这条链路完整跑通而不是停留在调 API 的层面。它适合两类人一是想搞懂远程桌面底层怎么运转的 C 开发者二是需要在自有系统里嵌入远程控制能力、又不想被第三方 SDK 绑死的工程师。下面按「协议先立住、再动手复现、最后讲坑」的顺序拆开讲。2. RFB 协议与 VC 工程结构先搞懂数据怎么流再谈代码怎么写2.1 RFB 协议的三段式握手决定了你代码的骨架VNC 的底层是 RFBRemote Framebuffer协议它最大的特点是「瘦客户端」——服务端负责几乎所有计算客户端只做解码和渲染。整个连接建立分三段协议版本协商、安全类型协商、初始化消息交换。版本协商阶段双方各发 12 字节的版本字符串比如RFB 003.008\n取较低版本作为后续通信基准。安全类型阶段服务端列出支持的安全类型客户端选一个常见的是 None 和 VNC Authentication。初始化阶段客户端发 ClientInit一个字节的共享标志服务端回 ServerInit里面包含帧缓冲宽高、像素格式、桌面名称。这三段握手在 VC 里的体现就是 socket 上的顺序读写。我一般会把每一段封装成一个独立函数返回值用 bool失败就带错误码退出而不是一股脑塞进一个 Connect() 里。原因是远程控制调试时你经常需要单独验证某一段——比如怀疑是认证失败还是初始化失败拆开就能快速定位。// RFB 版本协商发送本端版本读取服务端版本取较低者 bool NegotiateVersion(SOCKET sock, std::string negotiated) { const char* clientVer RFB 003.008\n; if (send(sock, clientVer, 12, 0) ! 12) return false; char serverVer[13] {0}; if (recv(sock, serverVer, 12, MSG_WAITALL) ! 12) return false; // 比较主版本和次版本取较小值作为后续通信版本 int cMajor 3, cMinor 8; int sMajor (serverVer[4] - 0) * 100 (serverVer[5] - 0) * 10 (serverVer[6] - 0); int sMinor (serverVer[8] - 0) * 100 (serverVer[9] - 0) * 10 (serverVer[10] - 0); // 实际比较逻辑按协议规范处理这里简化为取小 negotiated (sMinor cMinor) ? std::string(serverVer, 12) : std::string(clientVer, 12); return true; }这段代码的关键参数是版本字符串的格式——第 4 到第 6 位是主版本第 8 到第 10 位是次版本固定 12 字节。MSG_WAITALL保证收满 12 字节再返回否则在慢网络下你会收到半截字符串解析直接翻车。协商结果决定后面用哪套消息格式比如 3.8 支持安全类型列表3.3 只支持固定的一种这个分支必须在后续代码里体现。2.2 帧缓冲更新请求客户端主动拉还是服务端主动推RFB 的帧缓冲更新有两种模式客户端发 FramebufferUpdateRequest 主动拉或者服务端在共享模式下主动推。源码里通常实现的是「拉模式」——客户端发一个请求指定增量和感兴趣的区域服务端回一帧 FramebufferUpdate。增量模式下服务端只发变化的矩形区域这是省带宽的关键。在 VC 里这个请求是一个 10 字节的结构消息类型1 字节值为 3、增量标志1 字节、x 位置2 字节、y 位置2 字节、宽2 字节、高2 字节。全部用网络字节序。我一般会写一个SendFramebufferUpdateRequest函数把参数打包成字节流再发。// 发送帧缓冲更新请求incremental1 表示只请求变化区域 bool SendFramebufferUpdateRequest(SOCKET sock, bool incremental, uint16_t x, uint16_t y, uint16_t w, uint16_t h) { uint8_t buf[10]; buf[0] 3; // 消息类型FramebufferUpdateRequest buf[1] incremental ? 1 : 0; // 增量标志 // 网络字节序写入坐标和尺寸 buf[2] (x 8) 0xFF; buf[3] x 0xFF; buf[4] (y 8) 0xFF; buf[5] y 0xFF; buf[6] (w 8) 0xFF; buf[7] w 0xFF; buf[8] (h 8) 0xFF; buf[9] h 0xFF; return send(sock, (char*)buf, 10, 0) 10; }参数里incremental是最容易踩坑的一个。第一次请求必须用全量0否则服务端不知道初始画面是什么你收到的增量矩形可能引用不存在的背景。后续请求用增量1但要注意如果客户端渲染速度跟不上服务端可能积压多帧这时候要么限制请求频率要么在收到一帧后再发下一个请求形成「请求-响应」的节拍而不是无脑循环发。2.3 编码方式选型Raw、CopyRect、Hextile 各自适合什么场景RFB 支持多种编码源码里常见的是 Raw、CopyRect、Hextile有些还会带 Tight 或 ZRLE。Raw 就是原始像素直接传实现最简单但带宽消耗最大适合局域网或小分辨率。CopyRect 用于屏幕滚动场景——服务端告诉客户端「把某块区域复制到另一块」客户端本地做内存拷贝几乎不占带宽。Hextile 把画面切成 16x16 的瓦片每个瓦片单独编码适合有大片纯色区域的桌面。选型逻辑很直接局域网优先 Raw 或 Hextile跨公网优先 Tight/ZRLE。但源码里如果只实现了 Raw你也不用急着加——先把 Raw 跑通确认握手、请求、渲染、输入这条链路没问题再换编码。换编码时只需要改解码分支协议框架不动。// 根据编码类型分发到不同解码器 void DecodeRect(const uint8_t* data, size_t len, int encoding, int x, int y, int w, int h, uint8_t* framebuffer) { switch (encoding) { case 0: // Raw DecodeRaw(data, len, x, y, w, h, framebuffer); break; case 1: // CopyRect DecodeCopyRect(data, len, x, y, w, h, framebuffer); break; case 5: // Hextile DecodeHextile(data, len, x, y, w, h, framebuffer); break; default: // 未知编码记录日志并跳过该矩形 break; } }这段分发的关键是 encoding 编号必须和协议规范一致Raw 是 0CopyRect 是 1Hextile 是 5。如果你自己扩展编码编号要从 0xFFFFFF00 以上取避免和标准冲突。解码后的像素要按 ServerInit 里协商的像素格式写入 framebuffer格式不对会出现颜色错乱——这是新手最常见的翻车点之一。3. 用 VC 把服务端和客户端跑起来最小可运行工程的搭建步骤3.1 工程目录与依赖只留必要的别一上来就堆库一份能跑的 VNC VC 源码目录结构通常分三块core协议编解码、netsocket 封装、ui窗口和渲染。依赖方面Windows 下只需要 Winsock2链接ws2_32.lib即可。不需要 MFC 也能跑用 Win32 API 创建窗口和接收输入就够。我一般会建一个空的 Win32 项目把 core 和 net 作为静态库编进去ui 作为主工程。# 在 VS 开发者命令行下编译 core 静态库 cl /c /EHsc /I include core/*.cpp lib /OUT:core.lib *.obj # 编译主程序并链接 winsock cl /EHsc /I include ui/main.cpp core.lib ws2_32.lib user32.lib gdi32.lib编译参数里/EHsc启用标准 C 异常/I include指定头文件路径。链接时ws2_32.lib是 Winsock 必须的user32.lib和gdi32.lib用于窗口和位图渲染。如果你用 CMake写一个CMakeLists.txt把这三块组织起来跨版本编译会省心很多。3.2 服务端屏幕采集与帧缓冲更新的最小实现服务端要做的事监听端口、接受连接、发 ServerInit、响应 FramebufferUpdateRequest、把屏幕内容编码后发回。屏幕采集用 GDI 的BitBlt把桌面拷到内存 DC再取像素数据。最小实现里用 Raw 编码直接把像素按行发出去。// 采集屏幕并发送 Raw 编码的帧缓冲更新 void SendRawFramebuffer(SOCKET sock, int x, int y, int w, int h) { HDC hScreen GetDC(NULL); HDC hMem CreateCompatibleDC(hScreen); HBITMAP hBmp CreateCompatibleBitmap(hScreen, w, h); SelectObject(hMem, hBmp); BitBlt(hMem, 0, 0, w, h, hScreen, x, y, SRCCOPY); BITMAPINFO bmi {0}; bmi.bmiHeader.biSize sizeof(BITMAPINFOHEADER); bmi.bmiHeader.biWidth w; bmi.bmiHeader.biHeight -h; // 负值表示自上而下 bmi.bmiHeader.biPlanes 1; bmi.bmiHeader.biBitCount 32; bmi.bmiHeader.biCompression BI_RGB; std::vectoruint8_t pixels(w * h * 4); GetDIBits(hMem, hBmp, 0, h, pixels.data(), bmi, DIB_RGB_COLORS); // 发送 FramebufferUpdate 消息头 Raw 像素 // 消息头包含矩形数量、每个矩形的位置尺寸和编码类型 // 此处省略消息头打包重点在像素采集 send(sock, (char*)pixels.data(), (int)pixels.size(), 0); DeleteObject(hBmp); DeleteDC(hMem); ReleaseDC(NULL, hScreen); }biHeight设为负值是为了让像素从上到下排列和 RFB 的坐标原点在左上角一致。如果设正值图像会上下颠倒这是 GDI 采集的经典坑。biBitCount用 32 位对应 RFB 的 32bpp 像素格式但要注意字节序——RFB 默认是大端而 Windows 是小端发之前需要做字节交换或者协商时把像素格式设成小端。3.3 客户端解码渲染与输入事件回传客户端收到 FramebufferUpdate 后解析矩形列表逐个解码把像素写入本地 framebuffer再通过 GDI 的StretchDIBits或SetDIBitsToDevice渲染到窗口。输入回传方面鼠标事件对应 PointerEvent消息类型 5键盘事件对应 KeyEvent消息类型 4。// 处理鼠标事件并回传给服务端 void SendPointerEvent(SOCKET sock, uint8_t buttonMask, uint16_t x, uint16_t y) { uint8_t buf[6]; buf[0] 5; // 消息类型PointerEvent buf[1] buttonMask; // 按键掩码bit0 左键bit1 中键bit2 右键 buf[2] (x 8) 0xFF; buf[3] x 0xFF; buf[4] (y 8) 0xFF; buf[5] y 0xFF; send(sock, (char*)buf, 6, 0); }buttonMask是位掩码左键是 1中键是 2右键是 4同时按下就是按位或。坐标要映射到远端分辨率——如果本地窗口和远端分辨率不一致需要按比例换算否则鼠标位置会偏。键盘事件更复杂需要把 Windows 虚拟键码转成 RFB 的 keysym这个映射表在源码里通常单独一个文件漏掉某个键就会导致远端按不出来。4. 避坑与排查VNC 源码调试中最容易翻车的五个点4.1 连接建立后黑屏日志显示握手成功现象是客户端连上后窗口全黑但抓包看握手和初始化都正常。原因通常是像素格式不匹配——服务端发的像素格式是 32bpp 大端客户端按小端解析颜色全乱或全黑。解决方法是检查 ServerInit 里的 pixelFormat 结构确认 redShift、greenShift、blueShift 和字节序客户端解码时严格按这个格式转换。我一般会在解码前打印一次像素格式确认无误再往下走。4.2 画面卡顿帧率上不去现象是画面能显示但明显卡顿鼠标移动有延迟。原因多半是请求节拍不对——客户端无脑循环发 FramebufferUpdateRequest服务端积压多帧网络缓冲区满了之后延迟越来越大。解决方法是改成「收到一帧再发下一个请求」的同步模式或者限制请求频率到 30fps 以下。另外检查是否用了增量模式全量模式每帧传整屏带宽吃不消。4.3 键盘输入远端无响应现象是鼠标能用但键盘按了没反应。原因通常是 keysym 映射缺失或者 KeyEvent 的 downFlag 没设对。RFB 的 KeyEvent 里有一个 downFlag按下是 1松开是 0两个事件都要发只发按下不发松开远端会认为键一直按着。解决方法是补全映射表并确保按下和松开成对发送。4.4 多显示器环境下只采集到主屏现象是服务端只传了主显示器的画面副屏内容看不到。原因是 GDI 的GetDC(NULL)只拿主屏 DC多屏需要枚举显示器并分别采集或者用虚拟桌面的整体 DC。解决方法是调用EnumDisplayMonitors获取每个显示器的区域分别 BitBlt 后拼接到一个大的 framebuffer 里再按矩形发送。4.5 编译通过但运行时报 ws2_32.lib 找不到现象是链接阶段报错提示无法解析的外部符号。原因是项目没有链接 Winsock 库或者WSAStartup没调用。解决方法是在链接器输入里加上ws2_32.lib并在程序启动时调用WSAStartup(MAKEWORD(2,2), wsaData)退出时调用WSACleanup()。这个坑在第一次建工程时几乎必踩记住就行。5. 进阶技巧把 Raw 换成 Hextile带宽能降多少Raw 编码在 1920x1080 分辨率下每帧要传约 8MB 数据局域网还行跨网络基本不可用。Hextile 把画面切成 16x16 的瓦片每个瓦片根据内容选择编码方式——纯色瓦片只传一个像素值复杂瓦片才传原始数据。实测在典型办公桌面场景下Hextile 能把带宽降到 Raw 的 20% 到 40%具体取决于画面复杂度。实现 Hextile 解码的关键是理解瓦片的子编码标志Raw、BackgroundSpecified、ForegroundSpecified、AnySubrects、SubrectsColoured。每个瓦片先读一个字节的标志位根据标志决定后续读什么。下面是一个简化的解码框架// Hextile 瓦片解码按标志位逐瓦片处理 void DecodeHextileTile(const uint8_t* data, size_t offset, int tileX, int tileY, int tileW, int tileH, uint8_t* framebuffer, int fbWidth) { uint8_t flags data[offset]; uint32_t bg 0, fg 0; if (flags 0x02) { // BackgroundSpecified bg ReadPixel(data, offset); } if (flags 0x04) { // ForegroundSpecified fg ReadPixel(data, offset); } // 先用背景色填充整个瓦片 FillTile(framebuffer, fbWidth, tileX, tileY, tileW, tileH, bg); if (flags 0x08) { // AnySubrects uint8_t count data[offset]; for (int i 0; i count; i) { uint32_t color (flags 0x10) ? ReadPixel(data, offset) : fg; uint8_t xy data[offset]; uint8_t wh data[offset]; int sx (xy 4) 0x0F; int sy xy 0x0F; int sw ((wh 4) 0x0F) 1; int sh (wh 0x0F) 1; FillSubrect(framebuffer, fbWidth, tileX sx, tileY sy, sw, sh, color); } } }标志位的含义0x01 是 Raw表示整个瓦片直接传原始像素0x02 是背景色指定0x04 是前景色指定0x08 是有子矩形0x10 是子矩形带颜色。子矩形的坐标和尺寸各占 4 位所以单个子矩形最大 16x16正好一个瓦片。解码时先填背景再画子矩形顺序不能反否则背景会覆盖子矩形。换编码后要做的验证同一画面分别用 Raw 和 Hextile 传一帧对比像素是否一致。我一般会写一个离屏的 framebuffer 对比函数逐像素比较有差异就打印坐标。另外注意 Hextile 的瓦片边界——画面宽高不是 16 的整数倍时最后一个瓦片是不完整的解码时要按实际宽高裁剪否则会越界写内存。带宽对比可以用一个简单的计数器在 send 调用处累加发送字节数跑 10 秒后打印平均值。办公场景下 Raw 大约 5 到 8 MB/sHextile 大约 1 到 2 MB/s差距很明显。如果还要进一步压缩可以在 Hextile 基础上加 zlib但那就接近 Tight 编码了实现复杂度会上升一个量级。最后说个我自己的习惯每次改编码或改协议分支先用 Wireshark 抓一段包确认字节流和协议规范对得上再去看代码。远程控制这类程序网络层的问题占七成盯着代码猜不如直接看包。希望帮到你。本文还有配套的精品资源点击获取