☰
VC++ VNC远程控制源码解析:屏幕采集、差分编码与网络传输实战
2026/10/11 11:21:34 网站建设 项目流程

简介:这份VC++源码资源面向希望深入理解远程桌面控制原理的C++开发者与网络编程学习者,以VNC(Virtual 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拿屏幕 DC,CreateCompatibleDC和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 字节做异或乘法。为什么不用 CRC32?FNV-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:Configuration=Release /p:Platform=x64 msbuild VNC_Client.vcxproj /p:Configuration=Release /p:Platform=x64

编译产物是两个 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 对象有没有泄漏,这些在长时间运行下才会暴露。

从那以后我每次拿到这类远程控制源码,都强制先跑一遍动态画面加断线重连,再去看代码结构。静态能跑不代表链路健壮,断线重连和资源回收才是区分玩具和工具的分界线。希望帮到你。

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

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

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

立即咨询