简介:这份VC++源码资源面向希望深入理解远程桌面控制原理的C++开发者与网络编程学习者,基于RFB协议完整实现了VNC控制端与被控端,可用于研究屏幕捕获、输入事件转发与网络传输的协作机制。压缩包共631个文件,约2.27MB,以c、h、cpp源码为主,配合vcproj、sln等工程文件便于在Visual Studio中直接编译调试,另含doc、txt说明文档及bmp、ico等资源文件。源码覆盖套接字通信、RGB像素编解码、Windows API图形渲染、消息循环事件处理与多线程同步等关键环节,并预留了加密验证与功能定制接口。已有852人学习下载,适合作为网络编程、图形处理与多线程实战的参考案例,帮助读者从代码层面掌握VNC远程控制的完整实现思路。
1. VNC远程控制程序VC++源码:从消息循环到屏幕位图的一条完整链路
很多人第一次接触远程控制,是从「装个客户端就能连」开始的,直到某天需要把控制端嵌进自己的 MFC 程序里,才发现真正难的不是连接,而是把屏幕采集、差分编码、网络收发、输入注入这四件事塞进同一个 Windows 消息循环里还不卡界面。VNC远程控制程序VC++源码这个方向,解决的正是这个问题:它给你一套可编译、可裁剪、可二次开发的 C++ 实现,让你在自己的工程里长出远程桌面能力,而不是被某个成品软件框死。适合谁?适合已经会写 Win32/MFC 窗口程序、懂 socket 基本收发、但没亲手做过屏幕抓取和输入模拟的开发者。下面我按「先跑通、再拆解、后避坑」的顺序,把这条链路讲清楚。
2. 先搞懂 VNC 的 RFB 协议:为什么它能在低带宽下动起来
2.1 RFB 协议的三段式握手与编码协商
VNC 的核心不是某个库,而是 RFB(Remote Framebuffer)协议。它把「服务端屏幕」抽象成一块 framebuffer,客户端只负责请求更新和绘制。握手分三段:协议版本交换、安全类型协商、初始化消息。版本交换阶段双方各发 12 字节,形如RFB 003.008\n,服务端据此决定后续用哪种安全类型。初始化阶段客户端发 ClientInit(1 字节共享标志),服务端回 ServerInit,里面带屏幕宽高、像素格式和桌面名。
真正决定性能的是编码协商。客户端在 SetEncodings 消息里列出自己支持的编码,服务端从中挑一个。常见编码有 Raw、CopyRect、Hextile、ZRLE、Tight。Raw 就是原样传像素,最费带宽;Hextile 把屏幕切成 16x16 块,只传变化的块;ZRLE 在 Hextile 基础上做 zlib 压缩。低带宽场景下,选对编码比优化代码更管用。
// 客户端发送 SetEncodings,按优先级排列 // 消息类型 2,后跟 1 字节填充 + 2 字节编码数量 void SendSetEncodings(SOCKET s) { const int32_t encodings[] = { 7, 6, 5, 0 }; // Tight, ZRLE, Hextile, Raw uint8_t header[4] = { 2, 0, 0, 0 }; header[2] = (uint8_t)((4 >> 8) & 0xFF); header[3] = (uint8_t)(4 & 0xFF); send(s, (char*)header, 4, 0); for (int i = 0; i < 4; ++i) { int32_t e = htonl(encodings[i]); send(s, (char*)&e, 4, 0); } }这段代码里,消息类型 2 是 SetEncodings 的固定值,填充字节必须为 0,编码数量用大端 16 位表示。编码值 7 是 Tight,6 是 ZRLE,5 是 Hextile,0 是 Raw。顺序即优先级,服务端会选它支持且排在最前的那个。参数说明:htonl保证跨平台字节序一致,Windows 下虽然是小端,但协议规定网络字节序为大端,漏掉这步在部分服务端上会直接断连。
2.2 屏幕采集:BitBlt 与 GetDIBits 的配合
服务端要做的第一件事是把屏幕变成内存里的位图。常见做法是先用GetDC(NULL)拿整个屏幕的设备上下文,再CreateCompatibleDC和CreateCompatibleBitmap建一个内存 DC,用BitBlt把屏幕拷进去。但 BitBlt 出来的是设备相关位图,网络传输需要设备无关的像素数据,所以还要GetDIBits转成 DIB。
// 抓取全屏并转为 32 位 BGRA 缓冲区 bool CaptureScreen(std::vector<uint8_t>& buffer, int& w, int& h) { HDC hScreen = GetDC(NULL); w = GetSystemMetrics(SM_CXSCREEN); h = GetSystemMetrics(SM_CYSCREEN); HDC hMem = CreateCompatibleDC(hScreen); HBITMAP hBmp = CreateCompatibleBitmap(hScreen, w, h); HGDIOBJ hOld = SelectObject(hMem, hBmp); BitBlt(hMem, 0, 0, w, h, hScreen, 0, 0, SRCCOPY); BITMAPINFO bi = {}; bi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); bi.bmiHeader.biWidth = w; bi.bmiHeader.biHeight = -h; // 负值表示自上而下 bi.bmiHeader.biPlanes = 1; bi.bmiHeader.biBitCount = 32; bi.bmiHeader.biCompression = BI_RGB; buffer.resize(w * h * 4); GetDIBits(hMem, hBmp, 0, h, buffer.data(), &bi, DIB_RGB_COLORS); SelectObject(hMem, hOld); DeleteObject(hBmp); DeleteDC(hMem); ReleaseDC(NULL, hScreen); return true; }逻辑说明:biHeight设为负值,让 GetDIBits 返回自上而下的行序,省去后续翻转。参数上,biBitCount = 32对应 BGRA,和 RFB 的 32 位像素格式能直接对接。注意GetDC(NULL)拿的是整个虚拟屏幕,多显示器环境下宽高要用SM_CXVIRTUALSCREEN和SM_CYVIRTUALSCREEN,否则副屏内容会丢。
2.3 差分编码:只传变化区域才是省流量的关键
全屏 1920x1080 的 32 位位图约 8MB,每秒传 10 帧就是 80MB,局域网都扛不住。所以必须做差分。最朴素的做法是逐块比较前后两帧,只把变化的矩形区域编码发送。RFB 的 FramebufferUpdate 消息支持多个矩形,每个矩形带 x、y、宽高和编码类型。
// 按 32x32 分块比较,收集变化矩形 struct Rect { int x, y, w, h; }; std::vector<Rect> DiffBlocks(const std::vector<uint8_t>& prev, const std::vector<uint8_t>& curr, int w, int h, int block = 32) { std::vector<Rect> rects; for (int y = 0; y < h; y += block) { for (int x = 0; x < w; x += block) { int bw = min(block, w - x); int bh = min(block, h - y); bool changed = false; for (int row = 0; row < bh && !changed; ++row) { size_t off = ((size_t)(y + row) * w + x) * 4; if (memcmp(&prev[off], &curr[off], bw * 4) != 0) changed = true; } if (changed) rects.push_back({ x, y, bw, bh }); } } return rects; }这段代码用memcmp逐行比较,块大小 32 是经验值:太小则矩形数量爆炸,协议头开销超过像素本身;太大则每次传输冗余像素多。参数block可按网络状况调,局域网可到 64,公网建议 16 到 32。注意这里只做了矩形收集,实际发送时还要把相邻矩形合并,否则一屏可能产生上千个矩形,客户端解析都来不及。
3. 用 VC++ 把服务端和客户端跑起来:最小可编译工程
3.1 工程结构与依赖:只需要 Winsock2 和 GDI
一个能跑的最小 VNC 工程不需要第三方库。服务端依赖 Winsock2 做监听和收发,依赖 GDI 做屏幕采集和输入模拟。客户端同样用 Winsock2,加一个窗口做绘制。工程结构建议分三块:rfb_protocol.h放消息类型常量和结构体,vnc_server.cpp放采集与编码,vnc_client.cpp放解码与绘制。链接时加ws2_32.lib、gdi32.lib、user32.lib。
// rfb_protocol.h 关键常量 #pragma once #include <cstdint> constexpr uint8_t MSG_SET_PIXEL_FORMAT = 0; constexpr uint8_t MSG_SET_ENCODINGS = 2; constexpr uint8_t MSG_FB_UPDATE_REQUEST = 3; constexpr uint8_t MSG_KEY_EVENT = 4; constexpr uint8_t MSG_POINTER_EVENT = 5; constexpr uint8_t MSG_CLIENT_CUT_TEXT = 6; constexpr uint8_t ENC_RAW = 0; constexpr uint8_t ENC_COPYRECT= 1; constexpr uint8_t ENC_HEXTILE = 5; constexpr uint8_t ENC_ZRLE = 6; constexpr uint8_t ENC_TIGHT = 7; #pragma pack(push, 1) struct PixelFormat { uint8_t bitsPerPixel; uint8_t depth; uint8_t bigEndian; uint8_t trueColor; uint16_t redMax, greenMax, blueMax; uint8_t redShift, greenShift, blueShift; uint8_t padding[3]; }; #pragma pack(pop)逻辑说明:#pragma pack(1)保证结构体按协议字节对齐,不加的话编译器会插入填充字节,导致收发错位。参数上,bitsPerPixel常用 32,depth常用 24,trueColor为 1 表示真彩色。这些值在 SetPixelFormat 消息里由客户端发给服务端,服务端必须按客户端指定的格式转换像素,否则颜色会偏。
3.2 服务端主循环:accept 之后的消息分发
服务端启动后先WSAStartup,再socket、bind、listen。accept 到客户端后进入消息循环,先做版本和安全握手,再等客户端发 SetPixelFormat 和 SetEncodings,之后按客户端请求发 FramebufferUpdate。
// 服务端消息分发骨架 void ServerLoop(SOCKET client) { // 1. 版本交换 SendAll(client, "RFB 003.008\n", 12); char ver[13] = {}; RecvAll(client, ver, 12); // 2. 安全类型:发 1 种 None uint8_t sec[2] = { 1, 1 }; SendAll(client, (char*)sec, 2); uint8_t chosen = 0; RecvAll(client, (char*)&chosen, 1); // 3. 发 SecurityResult = 0 uint32_t ok = 0; SendAll(client, (char*)&ok, 4); // 4. 收 ClientInit uint8_t shared = 0; RecvAll(client, (char*)&shared, 1); // 5. 发 ServerInit SendServerInit(client); // 6. 消息循环 while (true) { uint8_t msgType = 0; if (!RecvAll(client, (char*)&msgType, 1)) break; switch (msgType) { case MSG_SET_PIXEL_FORMAT: HandleSetPixelFormat(client); break; case MSG_SET_ENCODINGS: HandleSetEncodings(client); break; case MSG_FB_UPDATE_REQUEST: HandleFbUpdate(client); break; case MSG_KEY_EVENT: HandleKeyEvent(client); break; case MSG_POINTER_EVENT: HandlePointerEvent(client); break; default: break; } } }逻辑说明:SendAll和RecvAll是自己封装的循环收发函数,因为 socket 的 send/recv 不保证一次发完或收完,直接调用会在大数据量时丢包。参数上,安全类型发{1, 1}表示只支持一种类型且为 None,生产环境应换成 VncAuth 并做挑战应答。注意SecurityResult在 None 类型下也必须发,漏掉客户端会一直等。
3.3 客户端绘制:把 FramebufferUpdate 画到窗口
客户端收到 FramebufferUpdate 后,先读 1 字节填充和 2 字节矩形数量,然后逐个矩形读 x、y、宽高、编码类型,按编码解码后写入本地缓冲区,最后StretchDIBits或SetDIBitsToDevice画到窗口。
// 客户端处理一个 Raw 编码矩形 void HandleRawRect(SOCKET s, std::vector<uint8_t>& fb, int fbW, int fbH) { uint16_t x, y, w, h; uint32_t enc; RecvAll(s, (char*)&x, 2); x = ntohs(x); RecvAll(s, (char*)&y, 2); y = ntohs(y); RecvAll(s, (char*)&w, 2); w = ntohs(w); RecvAll(s, (char*)&h, 2); h = ntohs(h); RecvAll(s, (char*)&enc, 4); enc = ntohl(enc); if (enc != ENC_RAW) return; std::vector<uint8_t> pixels((size_t)w * h * 4); RecvAll(s, (char*)pixels.data(), (int)pixels.size()); for (int row = 0; row < h; ++row) { size_t dst = ((size_t)(y + row) * fbW + x) * 4; memcpy(&fb[dst], &pixels[(size_t)row * w * 4], (size_t)w * 4); } }逻辑说明:RFB 里所有多字节整数都是大端,所以ntohs/ntohl不能省。参数上,fb是本地 framebuffer,尺寸和服务端一致,fbW用于计算行偏移。注意 Raw 编码下像素是逐行连续排列,没有行填充,直接 memcpy 即可。如果是 Hextile 或 ZRLE,解码逻辑要复杂得多,建议先跑通 Raw 再逐步加编码。
4. 避坑与排查:那些让程序「连上了但不动」的细节
4.1 现象:客户端连上后黑屏,日志显示收到更新但没画面
原因通常是像素格式不匹配。服务端按自己的默认格式发像素,客户端按另一套格式解析,颜色通道错位后整屏接近黑色。解决:在 SetPixelFormat 处理里严格按客户端发来的 PixelFormat 转换,尤其是 redShift、greenShift、blueShift 三个字段,BGRA 和 RGBA 就差在这。排查时可以在服务端把转换后的前 16 字节打印出来,和客户端收到的对比。
4.2 现象:鼠标能动但点击位置偏移
原因是坐标缩放。客户端窗口如果做了拉伸显示,鼠标事件里的坐标是窗口坐标,直接发给服务端就会偏。解决:在客户端把窗口坐标按fbW / clientW的比例换算回 framebuffer 坐标再发 PointerEvent。注意多显示器下服务端 framebuffer 是虚拟屏幕,负坐标要加上虚拟屏幕原点偏移。
4.3 现象:长时间运行后内存持续上涨
原因是每帧都新建 HBITMAP 和缓冲区,没释放干净。CreateCompatibleBitmap和CreateCompatibleDC必须成对释放,SelectObject换回旧对象后再DeleteObject。解决:把屏幕采集的 DC 和位图做成成员变量,初始化时建一次,循环里复用,只在分辨率变化时重建。缓冲区用std::vector并resize而非每次重新分配。
4.4 现象:局域网流畅,公网卡顿严重
原因是编码选错。Raw 编码在公网延迟下体验极差。解决:客户端 SetEncodings 里把 Tight 或 ZRLE 放最前,服务端实现对应编码。如果暂时只实现了 Raw,至少加上分块差分,把每帧传输量从全屏降到变化区域。另外把 FramebufferUpdateRequest 的 incremental 标志设为 1,只请求变化区域。
4.5 现象:键盘输入大写字母和符号错乱
原因是 KeyEvent 发的是 keysym 而非虚拟键码。RFB 的 KeyEvent 用 X11 keysym,比如字母 a 是 0x0061,回车是 0xFF0D。解决:在客户端做虚拟键码到 keysym 的映射表,Shift 状态要单独发 Shift 的按下和抬起事件。注意 keysym 和扫描码不同,不能直接拿MapVirtualKey的结果当 keysym 用。
5. 进阶技巧:把差分和压缩做到能用的程度
跑通 Raw 之后,真正决定这套 VNC远程控制程序VC++源码能不能投入使用的,是差分粒度和压缩策略。我一般会做三件事。第一,把分块比较从 32x32 降到 16x16,同时加一个「脏矩形合并」步骤:相邻的变化块如果边界相接,合并成一个大矩形再发,减少协议头。第二,对合并后的矩形做简单的 RLE 压缩,连续相同像素用「计数 + 像素值」表示,屏幕大面积纯色时压缩比能到几十比一。第三,给 FramebufferUpdateRequest 加节流,客户端上一帧没画完就不发下一帧请求,避免网络和绘制互相拖累。
// 脏矩形合并:把相邻块合成大矩形 std::vector<Rect> MergeRects(std::vector<Rect>& in) { std::vector<Rect> out; std::sort(in.begin(), in.end(), [](const Rect& a, const Rect& b) { return a.y != b.y ? a.y < b.y : a.x < b.x; }); for (auto& r : in) { bool merged = false; for (auto& o : out) { // 水平或垂直相邻则合并 if (r.y == o.y && r.x == o.x + o.w) { o.w += r.w; merged = true; break; } if (r.x == o.x && r.y == o.y + o.h) { o.h += r.h; merged = true; break; } } if (!merged) out.push_back(r); } return out; }这段合并逻辑只处理了最简单的右邻和下邻,实际工程里还要处理包含关系和重叠。参数上,合并阈值可以设成「合并后面积不超过原面积 1.5 倍」,超过就不合并,避免一个大矩形里包含大量未变化像素反而浪费带宽。验证方法很直接:在服务端统计每帧发送的字节数,对比合并前后的数值,再在客户端用GetTickCount测从收到更新到绘制完成的耗时,两个指标一起看才能判断优化是否有效。
我自己的习惯是,每加一个编码或优化,都先用一个固定场景压测:打开一个文本编辑器,快速滚动一屏文字,看帧率和带宽。文字滚动是差分编码的噩梦,因为几乎每个块都在变,能扛住这个场景,日常使用就没问题。另一个场景是播放视频,这时候差分基本失效,要靠 Tight 的 JPEG 压缩或直接降帧率,别指望无损编码能救。希望帮到你。
本文还有配套的精品资源,点击获取