简介:VNC远程控制程序VC++源码是一份完整的远程桌面控制项目,面向需要学习RFB协议、网络编程及C++实战的开发者。资源基于Visual C++实现控制端与被控端,涵盖TCP/IP通信、屏幕图像捕获与编码、键盘鼠标事件转发、多线程同步及安全机制等关键环节,适用于远程运维、教学实验和二次开发。压缩包共631个文件,约2.27MB,以c/h/cpp源文件为主,配合vcproj、dsp、dsw等工程文件,另含doc、txt说明文档及ico、bmp等界面资源,目录结构完整,便于对照源码逐模块研读。目前已有851人浏览学习。通过该源码可深入理解RFB协议栈的编码解码流程,掌握Windows GDI图形操作、Socket编程、消息循环与线程同步的工程实现,同时可参考其中多平台构建脚本与配置文件,快速移植或定制自己的远程控制工具。对于希望从事远程桌面、屏幕共享或内网管控方向开发的程序员,这是一份难得的实战学习资料。 干了这么多年远程运维,公司内部一直用的那套VNC工具,算是“开源改改”的老古董。之前维护某一版远程控制源码时,我真是头大——业务逻辑和杂七杂八的钩子函数全揉在一起,想加一个“指定区域监控”功能,得在十几个文件里翻全局变量。今年终于下决心,直接用 VC++ 把 VNC 重写了一遍。从 RFB 协议握手、像素格式协商、增量更新,到键盘鼠标事件注入和剪贴板同步,全部理顺。这篇文章把整套 VNC 远程控制程序的源码设计思路、关键代码和踩坑过程整理出来,给想研究远程控制原理,或者打算在 C++ 项目里集成屏幕共享能力的朋友做参考。
1. 为什么我要在 VC++ 里重写一套 VNC 源码
1.1 老代码的痛点,远比你想的多
市面上流行的 VNC 实现,底层协议大同小异,但代码质量差异非常大。老一批开源代码绝大多数是纯 C 写的,全局变量满天飞,socket 句柄和 GDI 对象散落在各个回调里,连基本的状态机都靠几个 switch 硬撑。这样的代码,编译能过,跑起来也稳定,但一旦要加功能,就非常痛苦。比如我当时接到的需求:用户只看某个特定屏幕区域的画面,顺带控制这台机器的鼠标键盘。改动涉及截屏范围计算、帧缓冲更新请求、像素坐标换算、编码器裁剪四个模块,而原来的代码根本没有把这些拆开。
老代码还喜欢直接在 Windows 上依赖老式 GDI 调用,加上一堆#ifdef _WIN32分支。你看着是跨平台,实际上 Windows 分支全是能用就行的状态。想接入新的压缩算法,比如 ZLib/Tight,得在编码器列表里加编号、加分支、加内存管理,改完还要担心内存泄漏。这种情况下,不如直接重写。
1.2 C++ 封装真正解决了什么问题
用 VC++ 重写,不只是“把 struct 换成 class”这么简单。我体会到几个实打实的好处,这也是我建议你用 C++ 而不是继续写 C 的原因。
第一,RAII 管理资源。socket、GDI 位图、内存 DC、z_stream 这些资源,在 C 时代要记得每个 return 前释放,漏一个就泄漏。C++ 里写一个SocketGuard、DCGuard、BitmapHolder,析构函数里统一清理,异常路径也不会漏。第二,协议状态机可以用类建模。CRfbConnection持有握手阶段、初始化阶段、运行阶段的状态,每个阶段有对应的方法,比一个巨型函数清晰得多。第三,编码器可以抽象成接口。CEncoder基类定义EncodeRect纯虚函数,Raw、ZLib、Tight 各自实现,运行时按客户端请求的编码类型来切换,加新算法只要新增一个派生类,不用动主循环。
所以这套源码的架构我定成:一个CVncServer管理监听和连接列表,每个客户端连接对应一个CRfbConnection独立线程,CGdiCapturer负责截屏并计算变化矩形,CEncoder负责把矩形编码成协议规定的数据格式,CInputInjector负责把远程按键和鼠标事件注入到本机。模块边界清晰以后,后面每一步都好改。
2. RFB 协议握手与初始化:远程连接的“见面礼”
VNC 用的协议是 RFB(Remote Framebuffer)。所有远程控制的交互,都建立在 RFB 消息之上。在动手写截屏和编解码之前,必须先把协议握手摸透。
2.1 版本协商:一上来先互报家门
TCP 连接建立后,服务器先发送一个 12 字节的版本字符串,比如RFB 003.008\n,客户端收到后也必须回一个它支持的版本字符串,格式一样。如果双方版本不一致,以较低的版本作为共同标准。这里有个细节:版本协商时服务器先发还是客户端先发,各个实现不完全一致,但主流约定是服务器等待客户端先发出版本,然后服务器选择一个双方都支持的版本返回。
我当时为了兼容市面上常见的 VNC Viewer,干脆直接固定支持 3.8,因为 3.8 是目前最通用的。代码也很简单:
char buf[13] = {0}; // 客户端先发支持的版本 const char* protocol = "RFB 003.008\n"; send(sockfd, protocol, 12, 0); // 等待服务器返回协商后的版本 if (recv_exact(sockfd, buf, 12) != 12) { // 连接失败 }2.2 安全类型协商:门禁卡和暗号
协议版本确定以后,双方开始协商安全类型。3.8 协议规定:服务器先发送一个字节的“安全类型数量”,然后跟着安全类型列表。客户端从列表里选一个它支持的类型,回复单个字节。主流的安全类型有这几个:
- 0:Invalid(无效)
- 1:None(无认证)
- 2:VNC Authentication(标准的 DES challenge-response 认证)
- 16:Tight 安全类型扩展
- 18:TLS 等加密传输
我建议初版实现只保留 VNC Authentication(2)。它的流程是:服务器生成一个 16 字节的随机 challenge 发给客户端,客户端用密码的 DES 加密结果加密这个 challenge 后返回,服务器解出来比对。用生活类比就是:门卫递给你一张写有暗语的字条,你用手里的钥匙卡片(密码)按特定算法把字条加密后递回去,门卫能解出原样才放你进去。密码本身不通过网络传输,所以比直接发明文密码好一点。
代码上要注意 challenge-response 的细节:VNC 的 DES 密钥不是直接用密码,而是把密码按字节倒序再填充到 8 字节,中间补 0。很多初学实现这里会写错,导致认证永远失败。
2.3 像素格式是理解帧缓冲的基础
安全类型协商通过后,客户端先发一条初始化消息:一个字节的shared-flag(一般填 1,表示允许多个客户端连接共享同一桌面)。然后服务器回复初始化消息,包括桌面宽度、高度,一个PixelFormat结构体,以及桌面名称。像素格式非常重要,它决定了之后每一帧图像的像素是怎么排列和解释的。
struct PixelFormat { BYTE bitsPerPixel; // 每像素位数,通常是 32 BYTE depth; // 颜色深度,通常是 24 BYTE bigEndian; // 是否大端字节序 BYTE trueColour; // 是否真彩色 UINT16 redMax; // 红色分量最大值,255 UINT16 greenMax; // 255 UINT16 blueMax; // 255 BYTE redShift; // 红色分量移位,16 BYTE greenShift; // 8 BYTE blueShift; // 0 BYTE padding[3]; };桌面宽、高是两个 UINT16,桌面名是一个 UINT32 长度加字符串。我实际测试发现,高版本 Viewer 发送的SetPixelFormat消息不一定和这个初始格式完全一致,所以后续收到客户端的格式转换请求时,服务端要按客户端要求的像素格式重新编码帧数据,而不是直接拿自己屏幕的 DIB 原样发送。
3. 核心循环:截屏、编码、发送这三件事
握手完成,接下来进入运行阶段。这里的核心循环就三件事:收到客户端的FramebufferUpdateRequest,截屏得到变化的矩形,编码后通过FramebufferUpdate消息发回去。听起来简单,但每个环节都有让人头疼的细节。
3.1 FramebufferUpdateRequest 与增量更新
客户端发送FramebufferUpdateRequest的格式是:1 字节消息类型(3),1 字节incremental标志,然后 4 字节 x、4 字节 y、4 字节宽、4 字节高。服务端拿到这个请求后,往客户端发送FramebufferUpdate消息。
incremental标志是性能的关键。如果不是增量更新(incremental=0),表示客户端要求全屏全量刷新,比如刚连接时或画面严重损坏时。如果是增量更新(incremental=1),服务端只需要发送自上次更新以来发生变化的那部分矩形。这个机制特别像拍照:全量是拍一张完整的大图,增量是只把移动的人、变化的窗口区域拍下来,节省的带宽是数量级的差别。
我实现的时候,CGdiCapturer维护一个脏矩形集合。每次截屏拿到当前桌面图像后,和上次保存的参考帧做差异比较。最简单的做法是全屏逐像素对比,但高分辨率下非常慢,所以我后来改成按固定大小块(比如 16×16 像素块)比较,只把发生变化的块加入矩形集合。这个方案在 CPU 占用率和压缩率之间平衡得还可以。真要追求极致性能,可以用分块哈希或 GPU 加速,但初版完全没必要。
3.2 GDI 截屏:先有图才有帧
Windows 下截屏最直接的方式是 GDI。先创建屏幕 DC,再创建一个兼容 DC 和兼容位图,用BitBlt把屏幕像素拷贝到位图里,然后用GetDIBits转成原始 DIB 数据。
HDC screenDC = CreateDC(_T("DISPLAY"), NULL, NULL, NULL); HDC memDC = CreateCompatibleDC(screenDC); HBITMAP bmp = CreateCompatibleBitmap(screenDC, screenW, screenH); HGDIOBJ oldBmp = SelectObject(memDC, bmp); BitBlt(memDC, 0, 0, screenW, screenH, screenDC, 0, 0, SRCCOPY); // 这里拿到 bmp 的 DIB 数据,进入编码环节 SelectObject(memDC, oldBmp); DeleteObject(bmp); DeleteDC(memDC); DeleteDC(screenDC);注意一个性能坑:屏幕 DC 和内存 DC 这种 GDI 对象不要每帧都创建销毁,开销不小。我建议成员变量里提前创建好,程序退出时统一释放,或者用 RAII 封装,会话期间始终复用。会话过程中如果分辨率变了,再重新创建。
3.3 编码方式:先跑通 Raw,再考虑 ZLib
编码器是 VNC 源码里最值得花时间研究的部分。RFB 协议定义了很多编码,下面是常见的几种:
| 编码类型 | 编号 | 特点 |
|---|---|---|
| Raw | 0 | 不压缩,直接原始像素,适合局域网 |
| CopyRect | 1 | 只发送矩形移动偏移,适合滚屏场景 |
| RRE | 2 | 矩形填充编码,适合大面积纯色 |
| Hextile | 5 | 分块 + RLE,比 Raw 好很多 |
| ZLib | 6 | 压缩整块像素数据 |
| Tight | 7 | 多种压缩组合,适用性最好 |
| ZRLE | 16 | ZLib + RLE 混合,压缩比高 |
初版我建议只实现 Raw 编码。原因是它逻辑最简单,也不涉及压缩状态的管理,能先把协议跑通。但你要有心理准备:Raw 编码下,1920×1080 的屏幕,32 位色深,一帧就是 8MB 左右,局域网内没问题,走公网就卡到没法用。所以裸功能跑通后,我立刻实现了 ZLib 编码。
ZLib 编码的格式是:1 字节编码类型(6),然后 4 字节数据长度,后面跟着 zlib 压缩后的像素数据。压缩级别我建议设在 6,继续调高会明显拖慢 CPU,压缩率提升却不明显。
4. 输入事件与剪贴板:远程操作的手和嘴
远程控制不只是看画面,还要能操作。键盘、鼠标、剪贴板这三块,是体验好坏的分水岭。很多简化版源码根本不实现键盘映射,导致远程敲代码时完全没法用。
4.1 键盘事件:Linux 键码换 Windows 虚拟键码
RFB 协议里,键盘事件用 X11 的 KeySym 来表示键。但是在 Windows 上注入按键,用的却是VK_*虚拟键码。所以这边必须建立一张映射表,把协议键码转成 Windows 虚拟键码。
| KeySym | Windows 虚拟键码 | 含义 |
|---|---|---|
| 0xffe1 | VK_LSHIFT | 左 Shift |
| 0xffe9 | VK_LCONTROL | 左 Ctrl |
| 0xff09 | VK_TAB | Tab |
| 0xff0d | VK_RETURN | 回车 |
| 0xffc8 | VK_UP | 方向键上 |
| 0xffc9 | VK_PRIOR | Page Up |
按键消息的格式是:1 字节消息类型(4),1 字节 down-flag(1 按下,0 松开),2 字节 padding,4 字节 KeySym。我实测下来,普通字符键(字母、数字、符号)转换容易,难的是那些特殊键,比如大键盘数字键和小键盘数字键的区分别是常见的坑。如果映射不正确,远程输密码时会经常打错。
4.2 鼠标事件:坐标与按键的换算
鼠标事件的格式是:1 字节消息类型(5),1 字节 button-mask,4 字节 x 坐标,4 字节 y 坐标。button-mask 的低 8 位表示按键状态:bit0 左键、bit2 右键、bit1 中键、bit3 滚轮上、bit4 滚轮下。收到消息后,我调用SetCursorPos把鼠标移到目标坐标,再用mouse_event发送按下和释放动作。
这里最容易出问题的是坐标换算。客户端发来的坐标是它在自己的屏幕上相对 RFB 桌面区域的坐标,如果你的程序支持多显示器,或者客户端的显示比例和服务器不一致,就必须按比例换算。我第一版没做换算,远程协助时鼠标指针永远点不到目标按钮,后来改成:收到坐标后先判断 x、y 是否在可显示区域内,再根据服务器屏幕尺寸做 clamp,调整缩放比例后注入。
滚轮的处理也要小心。RFB 协议里的滚轮是通过 button-mask 变化模拟的,收到滚轮事件(bit3 或 bit4 置位)后用mouse_event发送MOUSEEVENTF_WHEEL事件,但要注意一次滚动的步进值,Windows 的默认值是 120,如果你转成其他值会导致滑动过快或过慢。
4.3 剪贴板同步:双向传输的防循环
剪贴板同步是远程协助里非常实用的功能,也是很多源码默认不做的一块。RFB 协议定义了ClientCutText消息,客户端可以把本地剪贴板文本发给服务端,服务端也可以主动把服务器的剪贴板文本发给客户端。
实现思路是:服务端捕获WM_CLIPBOARDUPDATE窗口消息,当剪贴板内容变化时,判断是不是本机用户自己复制的还是远程客户端传过来的(用一个标志位区分),然后决定是否把新内容发给客户端。收到客户端的文本后,再写入 Windows 剪贴板,同时置位标志位防止再次触发上传。
这块我踩过一个经典的坑:UTF-8 和本地编码的转换。RFB 协议规定剪贴板文本是 UTF-8,但 Windows 剪贴板的CF_UNICODETEXT是 UTF-16,老的CF_TEXT又是本地代码页。不做转换的话,复制中文内容过来全是乱码。正确做法是收到 UTF-8 后先转成 UTF-16 再写入剪贴板,发送时反过来。
5. 源码工程组织:别让网络和界面缠在一起
写远程控制程序,最大的危险是代码越写越乱。网络收发、截屏、编码、输入注入全部在同一个回调或线程里堆,后面调试会让人崩溃。这套 VC++ 源码在工程组织上,我用了如下结构。
5.1 核心类:每个类只干一件事
| 类名 | 职责 | 关键设计点 |
|---|---|---|
| CVncServer | 监听端口、接收连接、管理会话列表 | accept 后创建新线程 |
| CRfbConnection | 维护单个连接的状态机和消息收发 | 持有 socket 和协议状态 |
| CGdiCapturer | 截屏、脏矩形计算 | 只做采集,不做编码 |
| CEncoder | 编码器的抽象基类 | 派生 RawEncoder、ZLibEncoder |
| CInputInjector | 键盘、鼠标事件注入 | 依赖映射表 |
| CClipboardSync | 剪贴板双向同步 | 处理 UTF-8/UTF-16 转换 |
这样拆完以后,每个类都能单独测试。CGdiCapturer不依赖网络,CEncoder不依赖 GDI,你要加新编码器或新采集方式,都不用碰其他模块。
5.2 线程模型:一个连接一个线程,主线程管 UI
我用的线程模型比较朴素:主线程负责 Windows 消息循环,一个后台线程管 accept,每个连接进来就创建一个新线程跑CRfbConnection::Run()。这个模型的好处是代码简单,每个连接的状态天然隔离,不需要加锁。缺点是连接多的时候线程也多,但对于远程运维这种场景,一台服务器同时连接几十个客户端的情况很少,新线程创建的开销完全可以接受。
截屏和发送放在同一个线程里顺序执行,这样不需要额外的锁。不过要注意:如果BitBlt在大分辨率下耗时较长,可能导致客户端画面的刷新率下降。后面我优化时引入了“采集线程 + 发送线程”的双缓冲流水线,采集线程把一帧放到std::shared_ptr<Frame>中,发送线程取走当前帧发送,中间用条件变量同步。但这是进阶优化,初版不建议这么搞,先把顺序模型跑稳再说。
5.3 数据收发:半包粘包必须解决的底层问题
网络编程绕不开半包和粘包。RFB 协议的每条消息基本都有固定头,比如FramebufferUpdate头是 1 字节类型 + 1 字节 padding + 2 字节矩形数量。收发时必须严格按照“先收指定字节数,再解析内容”的方式处理,不能用recv一次返回多少就认为是多少。
我封装了两个基础函数:
bool recv_exact(SOCKET fd, char* buf, int len); // 循环接收直到收满 len 字节 bool send_all(SOCKET fd, const char* buf, int len); // 循环发送直到发完 len 字节所有协议解析都必须基于这两个函数。这样做之后,粘包半包问题就消失了,不会出现“解析一条消息发现长度不够然后用错数据”的情况。
6. 编译联调踩坑记:五个容易被绕进去的问题
代码功能写全之后,真正让我掉头发的不是协议,而是各种 Windows 环境下的细节。下面这五个问题,几乎每个人都会遇到。
6.1 Winsock 初始化被忽略
第一件事就是WSAStartup必须在使用 socket 前调用,而且是每个进程初始化一次。我用WSAStartup(MAKEWORD(2, 2), &wsaData)来请求 Winsock 2.2。如果忘了,connect 会返回SOCKET_ERROR,错误码还不是WSAECONNREFUSED,而是WSANOTINITIALISED。更坑的是,老代码里的#include <winsock.h>和WinSock2.h一起包含会导致结构体重定义,所以我所有头文件统一只包含WinSock2.h,而且把它放在 Windows.h 之前,或者加WIN32_LEAN_AND_MEAN宏。
6.2 64 位编译的指针告警
刚开始编译,一开 64 位模式,一大堆 C4244/C4267。问题核心是 socket 句柄、GDI 对象不能像 32 位时那样随手往 int 里塞。微软的句柄类型是 64 位指针大小,强转成 32 位int会截断。解决办法是用HANDLE、SOCKET本身做类型,不要用int存。遇到打印或哈希的情况,用reinterpret_cast<uintptr_t>,不要用 C 风格强转。
6.3 高 DPI 导致鼠标坐标错位
Windows 在高分屏下默认会做 DPI 缩放,如果你的程序没有声明 DPI 感知,GetCursorPos返回的是逻辑坐标,但BitBlt截屏取的是物理像素坐标。两边一换算,远程鼠标就会“指哪打不中”。解决方案是在程序入口调用:
SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);然后在截屏和注入坐标时都按物理像素处理。注意这个 API 需要 Windows 10 1703 以上,编译时最好加运行时判断,老系统降级到系统 DPI 模式。
6.4 GDI 对象泄漏:会话内存稳定上涨
用 GDI 截屏最容易犯的问题是每帧创建位图、内存 DC,忘记释放。一开始我在FramebufferUpdateRequest处理里创建兼容位图,发送完就丢一边不管。跑了几个小时后,目标进程的句柄数飙到几万,GDI 对象耗尽后整个桌面绘制直接黑屏。后面我把这些资源都放进 RAII 封装里,或者直接在CGdiCapturer::Init里创建一次,整个会话复用,不再反复创建。这个教训也让我确信:C++ 的资源管理必须靠 RAII 而不是靠记性。
6.5 消息解析的边界处理
RFB 消息长度字段有的地方是 4 字节无符号,有的是 2 字节。一个不小心的符号扩展,会把负数长度传进去,导致recv_exact死等。所以在任何解析逻辑里,我要把长度字段全部转成uint32_t或size_t,并且加一层“长度合理性”检查(比如矩形数量不能大于一个上限)。见过太多版本在消息解析时因为长度异常直接崩溃,尤其是客户端先断开连接,服务端还在循环接收,必须对recv返回 0 或SOCKET_ERROR的情况做连接退出处理,不然线程会一直挂在那里。
7. 安全加固:把源码从“能跑”带到“敢用”
很多 VNC 程序只实现了基础功能,安全这块完全裸奔。以前网上大量“VNC 未授权访问”的问题,本质上是把 VNC 暴露在公网且没有做任何防护。源码写完了,要真正落地到生产环境,安全加固一步都不能省。
7.1 VNC 认证的局限,要心里有数
标准 VNC Authentication 使用的是 DES 挑战响应机制,也就是前面说的 16 字节 challenge 加解密流程。但它的密钥最终是密码派生出来的 56 位 DES 密钥。现代 GPU 暴力破解这种密钥已经是家常便饭,所以这套老的认证机制只能算“防君子不防小人”。如果有人直接在公网对你开放 5900 端口,几分钟就能扫出来,然后开始尝试爆破。因此,VNC 认证不能是唯一防线。
7.2 外网发布必须叠加加密通道
如果在公网使用,我强烈建议在 VNC 外层叠加 TLS 加密传输,让挑战响应、帧数据、键盘事件全部走加密通道。Windows 上可以直接用 SChannel API 做 TLS,不需要引入第三方库,当然用 OpenSSL 也可以。实现思路是在现有的send_all/recv_exact之上包一层 SSL 读写函数,协议逻辑完全不用改,只是把底层的 socket 收发换成加解密的收发。这一步做完,别人在网络上抓包只能看到密文,看不到你远程桌面上的内容和密码原文。
7.3 访问控制与运维防护,比修漏洞更有效
VNC 层面的认证只是第一道门,实际部署还要叠加访问控制。我列出几个我实际用到的策略:
- 不要用默认端口 5900,改成随机高位端口,减少被端口扫描直接命中的概率。
- 密码要设置强口令,而且必须支持锁定机制:连续错误一定次数后拒绝该 IP 连接一段时间。
- 通过防火墙或安全组限制来源 IP,只允许公司内网网段访问。
- 服务端记录所有连接的 IP、时间、断开原因,并定期检查日志,发现问题 IP 直接拉黑。
这些措施组合起来,能极大降低被爆破和未授权访问的风险。安全没有银弹,但把基础门槛抬高,已经能过滤掉绝大多数试图混进来的扫描流量。
最后再分享一个体会。这套 VNC 源码做出来后,我先是在公司内网机房给它试运行,后来给子公司的同事做远程桌面协助,整体稳定程度远超预期。最深的感受是:协议栈本身并不复杂,真正难的是在 Windows 生态里把 GDI、DPI、剪贴板、消息循环这些零零碎碎的细节都拧成一股绳。另一个忠告是不要在初版就过度设计,先把 Raw 编码跑通,再一步步加压缩、加密、优化,这条路走起来最顺。如果你也在动手写类似的远程控制程序,照着这个顺序走,能省不少白头发。
本文还有配套的精品资源,点击获取