ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++实现远程桌面控制:从屏幕采集到指令传输的完整方案

C++实现远程桌面控制:从屏幕采集到指令传输的完整方案 简介这套C远程桌面控制源码工程以模拟Windows远程桌面为目标适合想深入理解网络编程、多线程及屏幕传输原理的开发者。资源同时提供服务器端与客户端实现覆盖TCP/IP套接字通信、多线程并发处理、JPEG/PNG图像压缩传输、SSL/TLS加密与身份验证等关键环节通过阅读源码可理清从连接监听、画面采集、压缩发送到控制指令回传的完整链路代码结构清晰便于二次扩展。压缩包共168个文件以45个cpp源程序、48个h头文件为核心附有56个ico图标、5个bmp位图资源以及工程配置和数据库文件整体仅219KB轻量易用。目前已有3295人学习下载既适合C初学者对照代码理解远程控制全流程也给进阶开发者提供了可复用的通信框架和界面基础。 最近在折腾远程桌面控制用C从零写了一套源码加程序。起因很直接市面上的远程工具要么收费要么协议不透明想在里面加自定义功能非常困难。干脆自己动手把采集、编码、传输、控制这一整条链路都攥在自己手里。这篇文章就把项目的完整实现思路和关键代码细节分享出来包括屏幕捕获怎么做、图像压缩怎么选型、控制指令如何可靠送达还有我在开发过程中踩过的几个坑给想自己实现远程控制或做类似桌面自动化项目的朋友做参考。1. 为什么用C重写一个远程控制工具需求与选型逻辑先说清楚动机不然你很难理解后面那些代码设计。我当时的场景是需要在内网环境下对几台没有公网IP的机器做远程维护同时要能把控制能力嵌入到自己的工具链里而不是打开一个独立的TeamViewer窗口去操作。市面上的方案我在选型阶段基本都过了一遍方案优点缺点开源协议RDP/VNC成熟稳定协议公开二次开发成本高封装层次深想改底层很难商业软件TeamViewer/AnyDesk开箱即用体验好收费无法二次集成内网部署受限自己实现完全可控体积小可嵌入需要自己处理网络、编码、并发等一堆问题对比下来我的结论是如果只是临时远程一下直接用现成工具就好但如果要做功能定制、协议集成、自动化控制自研是唯一能满足需求的路。C在这个场景里的优势在于底层API直接可用Windows GDI/DXGI、Socket、消息循环没有托管语言那层抽象带来的性能损耗而且最终产物是一个很小的exe便于分发。1.1 功能边界的定义做什么不做什么项目立项时我给自己划定了清晰的目标范围避免失控必须做屏幕画面实时采集、图像压缩与传输、鼠标键盘事件回放、TCP可靠连接、多客户端状态管理。不做文件传输、语音通话、剪贴板同步、跨平台先只做Windows到Windows。这个边界很关键。远程控制的复杂度可以无限延伸文件传输涉及目录遍历和断点续传剪贴板同步涉及系统剪贴板格式转换和权限模型这些功能一旦加进来项目周期至少翻倍。先跑通一个“能看、能控”的最小闭环再逐步扩展是这类工具开发最务实的路线。1.2 从整体到细节的技术选型原则整个项目我始终遵循一个原则每一层用最成熟、最省事、够用的方案而不是最新最炫的方案。屏幕采集先用GDIWindows图形设备接口而不是DXGI图像编码先用JPEG而不是H.264网络传输直接用原生Socket而不是引入WebSocket库。原因很简单GDI在大部分Windows机器上都有兼容性最好JPEG用GDI几十行代码就能编码不需要引入第三方库原生Socket零依赖部署时不用带一堆DLL。这些选型可能不是性能最优解但确实是单机开发、代码可控、快速出成果的最优解。2. 屏幕采集与图像压缩远程画质的核心源头远程桌面的体验好坏第一决定因素就是屏幕数据从哪来、以什么形式被压缩。如果这一步做得糙后面传输层再优化也无济于事。2.1 用GDI获取屏幕位图数据GDIGraphics Device InterfaceWindows图形设备接口采集屏幕的思路很直接拿到屏幕DC设备上下文Device Context创建一块兼容内存DC和兼容位图把屏幕内容复制到位图里再锁定位图数据取走像素。核心代码是这样的// 获取主屏幕分辨率 int screenWidth GetSystemMetrics(SM_CXSCREEN); int screenHeight GetSystemMetrics(SM_CYSCREEN); // 创建屏幕DC和兼容DC HDC screenDC GetDC(NULL); HDC memDC CreateCompatibleDC(screenDC); // 创建兼容位图 HBITMAP bitmap CreateCompatibleBitmap(screenDC, screenWidth, screenHeight); HGDIOBJ oldObj SelectObject(memDC, bitmap); // 关键把屏幕内容复制到内存DC BitBlt(memDC, 0, 0, screenWidth, screenHeight, screenDC, 0, 0, SRCCOPY); // 获取位图数据 BITMAPINFO bmpInfo {0}; bmpInfo.bmiHeader.biSize sizeof(BITMAPINFOHEADER); bmpInfo.bmiHeader.biWidth screenWidth; bmpInfo.bmiHeader.biHeight -screenHeight; // 负值表示自顶向下存储 bmpInfo.bmiHeader.biPlanes 1; bmpInfo.bmiHeader.biBitCount 32; bmpInfo.bmiHeader.biCompression BI_RGB; BYTE* pixelData (BYTE*)malloc(screenWidth * screenHeight * 4); GetDIBits(memDC, bitmap, 0, screenHeight, pixelData, bmpInfo, DIB_RGB_COLORS);这里有个特别容易踩的细节biHeight必须设为负值否则取出来的图像是上下颠倒的。我第一次写的时候没注意结果在控制端看到的桌面是倒着的排查了半天才发现是这个参数的问题。另外GetDIBits的第三个参数是起始扫描行第四个参数是扫描行总数这两个参数必须配合正确否则只取到半张图。2.2 为什么选JPEG而不是原始位图假设一个1920x1080的屏幕32位色深一帧原始数据是1920×1080×4 ≈ 8.3MB。如果不压缩直接发送千兆网下每秒最多也就传12帧而且会占满所有带宽。所以图像压缩是必须的。我对比过几条路方案编码速度压缩比实现复杂度原始BMP无1:1零RLE游程编码快低到中等低JPEGGDI实现中等高10:1到20:1低H.264硬编码快极高高需要DirectX或NVIDIA API最终选了JPEG。原因是GDIWindows自带的图形库从Windows XP开始就内置了编码JPEG只需要几行代码不需要任何第三方依赖。在1920x1080分辨率下JPEG压缩后一帧大约80~300KB取决于画面复杂程度这样在网络传输层的压力就小多了。用GDI编码JPEG的代码是// 从像素数据创建GDI位图 Bitmap bitmap(screenWidth, screenHeight, stride, PixelFormat32bppRGB, pixelData); // 把位图保存为JPEG到内存流 CLSID clsid; GetEncoderClsid(Limage/jpeg, clsid); IStream* stream nullptr; CreateStreamOnHGlobal(NULL, TRUE, stream); bitmap.Save(stream, clsid, NULL); // 从流中取数据 STATSTG stats; stream-Stat(stats, STATFLAG_NONAME); ULONG size stats.cbSize.QuadPart; BYTE* jpegData new BYTE[size]; LARGE_INTEGER li {0}; stream-Seek(li, STREAM_SEEK_SET, NULL); ULONG read 0; stream-Read(jpegData, size, read);JPEG的压缩质量可以在EncoderParameters里设置我实测下来质量系数quality在70到80之间最合适——画面还算清晰单帧大小能控制在150KB左右比质量90的版本小一倍但肉眼看不出太明显差别。如果你要远程操作的是文本编辑器、IDE这种对文字清晰度要求高的场景建议调到80以上否则小字号文字边缘会有明显发虚。3. 网络传输与控制通道画面和指令不能挤一条路远程桌面系统里面数据流实际上有两种完全不同的类型屏幕图像数据是单向的、大块的、对延迟敏感但允许偶尔丢帧重传鼠标键盘指令是双向的、小包的、必须保证可靠到达。如果把这两种数据混在一条通道里图像大数据块会阻塞指令小包导致鼠标操作延迟和键盘输入丢字。3.1 双Socket架构数据通道与控制通道分离我的设计是被控端开两个监听端口控制端建立两条TCP连接数据通道端口8001传输JPEG压缩后的屏幕图像帧从被控端流到控制端。这条通道允许短暂的阻塞和排队因为画面掉几帧关系不大。控制通道端口8002双向传输鼠标移动、点击、键盘键值等控制指令。这条通道必须低延迟、高可靠优先级最高。初版我把两条合在一条连接里结果画面数据稍微一堵鼠标就卡成幻灯片。拆成两条连接后体验立刻上了一个台阶。核心原因在于TCP是字节流协议没有消息边界大块图像数据会把小指令包“挤”到缓冲区后面延迟变得不可预测。两条通道分离后即使画面通道拥塞控制通道的延迟也能保持稳定在10ms以内。3.2 自定义帧协议解决TCP粘包问题TCP是流式协议发送端调一次send()接收端可能分几次recv()才收完发送端在循环里连续send()接收端也可能一次recv()就收到了多帧数据。这就是经典的粘包半包问题。我的解决方案是自定义一个极简的帧协议[2字节魔数 0xAA55] [4字节数据长度] [1字节帧类型] [N字节有效载荷]魔数用来在异常情况下快速同步帧边界数据长度让接收端知道这一帧要读多少字节帧类型告诉上层这是图像帧还是控制帧。接收端处理逻辑是一个典型的状态机bool ReadFrame(SOCKET sock, FrameHeader* header, std::vectorBYTE* payload) { // 先读8字节固定头 if (!RecvExactly(sock, (char*)header, sizeof(FrameHeader))) { return false; } // 校验魔数 if (header-magic ! 0xAA55) { // 帧不同步逐字节扫描找魔数 return Resync(sock); } // 根据长度读取载荷 payload-resize(header-length); if (!RecvExactly(sock, (char*)payload-data(), header-length)) { return false; } return true; }重点是这个RecvExactly函数它不是一次recv()就完事而是循环recv()直到收满指定字节数才返回。这是处理TCP半包最稳妥的方式没有之一bool RecvExactly(SOCKET sock, char* buffer, int length) { int received 0; while (received length) { int result recv(sock, buffer received, length - received, 0); if (result 0) return false; received result; } return true; }3.3 鼠标键盘事件的模拟注入控制端把鼠标和键盘操作序列化后通过控制通道发送到被控端被控端再把这些指令注入到系统。Windows下有两个层次的注入方案一是SendInput。这是现代推荐的方式鼠标坐标是绝对的屏幕坐标键盘用虚拟键码。它不被老式游戏的反作弊系统检测但满足远程控制需求完全够了。// 鼠标移动 INPUT input {0}; input.type INPUT_MOUSE; input.mi.dx x * 65535 / (screenWidth - 1); // 注意归一化 input.mi.dy y * 65535 / (screenHeight - 1); input.mi.dwFlags MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE; SendInput(1, input, sizeof(INPUT)); // 鼠标左键按下 input.mi.dwFlags MOUSEEVENTF_LEFTDOWN; SendInput(1, input, sizeof(INPUT)); // 键盘按键按下A键 INPUT kbInput {0}; kbInput.type INPUT_KEYBOARD; kbInput.ki.wVk A; kbInput.ki.dwFlags 0; // 0表示按下 SendInput(1, kbInput, sizeof(INPUT));这里有个大坑SendInput的鼠标坐标要求的是0到65535的归一化值不是像素坐标。如果不做归一化鼠标会飞到屏幕边缘的随机位置。公式是归一化坐标 像素坐标 * 65535 / (屏幕分辨率 - 1)。注意必须减1否则最右边的像素会被映射到比65535稍大的值导致溢出。二是SetCursorPos加mouse_event。这是传统方式很多老教程还在用但mouse_event已经被微软标注为废弃API。如果不做外挂没必要用老API。4. 控制端的图像渲染与交互拿什么看怎么操作被控端把JPEG数据送过来之后控制端需要解码、渲染还要把用户的鼠标键盘操作捕获下来发出去。4.1 控制端实时显示画面控制端拿到JPEG帧后最省事的显示方案是用GDI把JPEG直接画到窗口上核心就两行Graphics graphics(hdc); Image image(jpegData, jpegSize); // 从内存数据构造图片 graphics.DrawImage(image, 0, 0, targetWidth, targetHeight);这里有个性能细节反复从JPEG数据构造Image对象涉及解码运算1920x1080画面解一次JPEG大约要10~20ms。如果你只做每秒10帧的刷新这个开销可以接受但如果要提高帧率就得用Direct2D WICWindows Image ComponentWindows图像组件走硬件加速渲染。我当前的版本用的还是GDI因为实现简单CPU占用可控。后续优化方向是引入WIC的IWICBitmapFrameDecode做流式解码渲染交给Direct2D。4.2 控制端坐标换算与缩放远程控制里坐标换算是最容易出问题的环节。分两种情况等比缩放控制端窗口和远程屏幕分辨率相同。这种情况下鼠标事件里上报的坐标直接发过去就行无需计算。窗口缩放窗口比远程屏幕小画面被缩小显示。这时候控制端鼠标在窗口内的坐标必须换算成远程屏幕的实际像素坐标。换算公式double scaleX (double)remoteWidth / windowWidth; double scaleY (double)remoteHeight / windowHeight; int remoteX (int)(localX * scaleX); int remoteY (int)(localY * scaleY);初次实现时我犯过一个错误把缩放比当成单一的scale即scaleX scaleY导致窗口拖拽后横向纵向比例不一致鼠标点击出了偏差。实际上窗口可能被拖成任意宽高比必须分开计算X和Y的缩放比例。4.3 双缓冲绘制避免画面闪烁控制端窗口如果直接在WM_PAINT里画图画面刷新时会不停闪烁因为解码、绘图、擦除背景这些操作是分开执行的。解决方法是双缓冲先在内存里把整幅画面画好再一次BitBlt到窗口上。case WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hwnd, ps); // 创建内存DC和兼容位图 HDC memDC CreateCompatibleDC(hdc); HBITMAP memBitmap CreateCompatibleBitmap(hdc, width, height); HGDIOBJ oldBitmap SelectObject(memDC, memBitmap); // 在内存DC中绘制JPEG画面 Graphics graphics(memDC); Image image(jpegData, jpegSize); graphics.DrawImage(image, 0, 0, width, height); // 一次性拷贝到窗口 BitBlt(hdc, 0, 0, width, height, memDC, 0, 0, SRCCOPY); SelectObject(memDC, oldBitmap); DeleteObject(memBitmap); DeleteDC(memDC); EndPaint(hwnd, ps); }双缓冲的意义在于用户看到的所有绘制操作都是在内存里完成之后一次性呈现的永远不会看到“擦了一半”的中间状态。5. 帧率控制与性能优化如何让体验更流畅功能跑通只是第一步真正决定远程控制好不好用的是性能。我在性能上做了几轮迭代记录下关键数据。5.1 按固定帧率采集发送别跑满CPU初版代码里被控端用while(true)死循环采集屏幕并发送结果CPU占用飙到80%以上。后来意识到远程控制不需要无限快的帧率人眼对连续画面的感知上限大约在24~30fps。对于远程操作场景15fps已经能提供流畅的操控体验10fps对办公操作也够用。我的做法是用定时器控制帧率默认采集帧率15fps可在配置文件里调节// 控制帧率的简单实现每帧之间休眠 while (running) { CaptureAndSendFrame(); Sleep(1000 / targetFps); // targetFps 15 }更精确的做法是利用QueryPerformanceCounterWindows高精度计时器做自适应的帧间隔校正LARGE_INTEGER freq, last, now; QueryPerformanceFrequency(freq); QueryPerformanceCounter(last); while (running) { CaptureAndSendFrame(); LONGLONG elapsedMs (now.QuadPart - last.QuadPart) * 1000 / freq.QuadPart; int waitMs (1000 / targetFps) - (int)elapsedMs; if (waitMs 0) Sleep(waitMs); QueryPerformanceCounter(last); }5.2 画面变化检测没变化的区域不发送一个非常有效的优化手段是只在屏幕内容发生变化时才重新采集并发送JPEG。远程操作场景中画面大部分时间是静止的。如果静止时也以15fps全量采集只是在浪费CPU和网络带宽。实现思路采集屏幕位图后与上一帧的像素做逐块对比标记发生变化的矩形区域只发送这些区域的JPEG编码。这里有两个复杂度档次简单实现——全帧哈希比较对整帧的像素数据做快速哈希如果哈希值相同就跳过发送。这样静止场景直接把发送频率降到0但画面任何一小块变化都会触发全帧重传。进阶实现——分块脏矩形把屏幕分成16x16的块逐块比较像素差值只发送变化块的最小包围矩形。这是VNC协议的核心思想实现复杂度中等但性能收益非常明显。我的项目先实现了全帧哈希比较后续会扩展脏矩形检测。实测下来代码编辑场景下将实际网络发送从15fps降到了不到2fpsCPU从70%降到了25%左右。5.3 JPEG质量自适应的优化另一个优化是根据网络状况动态调整JPEG质量。在带宽充足时用quality80保证画质清晰带宽紧张时降到quality40保证流畅度不会断。判断带宽是否充足最简单的方式统计上一帧的发送耗时和队列长度。如果TCP发送缓冲区长时间积压说明带宽不够就自动降低质量如果发送很快完成且缓冲区空闲就逐步升回高质量。int currentQuality 80; // 初始质量 // 发送完成后根据耗时调整质量 if (sendMs 100) { currentQuality max(40, currentQuality - 5); // 变粗糙 } else if (sendMs 20) { currentQuality min(90, currentQuality 2); // 变清晰 }6. 编译运行与踩坑记录从源码到能用的程序6.1 编译环境与源码结构我用的是Visual Studio 2022社区版项目类型为Windows桌面应用程序C。工程依赖都很基础Windows SDK系统自带GDI通过#include gdiplus.h和#pragma comment(lib, gdiplus.lib)链接Winsock2用于网络通信源码目录规划RemoteDesktop/ ├── Server/ # 被控端 │ ├── main.cpp # 程序入口、初始化 │ ├── capture.cpp # 屏幕采集、JPEG编码 │ ├── network.cpp # Socket监听、帧协议处理 │ └── input_inject.cpp # 鼠标键盘事件注入 ├── Client/ # 控制端 │ ├── main.cpp # 程序入口、窗口创建 │ ├── dispaly.cpp # 图像接收、解码、渲染 │ ├── network.cpp # Socket连接、帧协议解析 │ └── input_capture.cpp # 鼠标键盘事件捕获 ├── Common/ │ └── protocol.h # 帧结构定义、常量 └── README.md6.2 两个典型坑GDI初始化和DPI缩放坑一GDI使用前必须初始化。这一步被官方文档轻描淡写但忘了会导致Bitmap::Save返回GdiplusNotInitialized错误。初始化代码必须在创建任何GDI对象之前调用GdiplusStartupInput gdiplusStartupInput; ULONG_PTR gdiplusToken; GdiplusStartup(gdiplusToken, gdiplusStartupInput, NULL); // ... 程序运行期间正常使用GDI ... GdiplusShutdown(gdiplusToken); // 程序退出时清理坑二Windows的DPI缩放会导致坐标偏移。如果控制端显示器设置了125%或150%缩放Win32 API返回的鼠标坐标和实际物理像素之间会有偏差表现为远程操作时鼠标总是偏一格。解决办法是在程序启动时调用SetProcessDPIAware()告诉系统这个程序自己处理DPI缩放// 必须在创建窗口之前调用 SetProcessDPIAware();如果不加这一句在高DPI显示器上远程鼠标点击的位置会系统性偏移到左上方而且分辨率越高的屏幕偏移越明显。6.3 网络异常恢复与断开重连远程控制使用过程中网络抖动、被控端休眠、防火墙误判等情况会导致连接断开。这时候如果不处理控制端就会一直卡在“等待数据”的状态。我加了一套心跳机制控制端每5秒发一个心跳包帧类型0x02负载为空被控端收到心跳后回一个心跳响应控制端连续3次没收到响应就判定连接断开弹出提示并重连被控端连续30秒没收到任何数据自动断开并回到监听状态。这个机制让整个系统在真实网络环境里“活”了拔网线、睡眠唤醒、路由器重启都能在十几秒内自动恢复不需要手动重启程序。6.4 安全性考量因为是内网自用工具我用了几层简单的安全措施第一连接前需要校验一个口令口令哈希后放在握手包的第一个消息里第二控制指令和图像数据都跑在内网VLAN里不暴露到公网。如果你要部署到公网环境建议至少再加一层TLS加密或者用SSH隧道把两条TCP端口都包进去否则任何抓到流量的人都能看你的屏幕数据。7. 实测效果与后续扩展方向局域网千兆环境下这个工具实测能达到画面延迟约50~80ms从操作到画面变化帧率15fps稳定运行CPU占用20~35%画质1920x1080quality75单帧150KB左右带宽占用约20Mbps控制响应鼠标点击延迟低于30ms体感基本跟手互联网环境下5G家庭宽带通过端口映射直连时延迟会升高到150~200ms画面有可感知但可接受的卡顿。如果要做互联网环境的产品级体验下一步必须引入WebRTC或自研UDP 前向纠错这是完全不同的技术栈。后续扩展方向上我觉得有四个值得做一是脏矩形检测把画面更新的数据量再降一个量级二是剪贴板同步在控制通道上扩展一个文本同步类型三是H.264硬编码用NVIDIA NVENC或Intel QSV把压缩率提升一个量级四是多屏支持通过EnumDisplayMonitors枚举所有显示器并各自建立采集上下文。这个项目的源码我整理成了可以直接编译的工程注释写到了每个关键函数里。如果你也想自己造一个远程控制轮子建议先照着这个最小闭环跑通再根据你的需求往里面加功能。最后再分享一个小经验远程控制这种工具最花时间的往往不是写代码而是处理各种边界情况——屏幕分辨率变了、DPI变了、网络抖动了、系统锁屏了每一种情况都需要在代码里体面地处理。把边界情况想清楚你的工具才能真正从“能跑”变成“好用”。本文还有配套的精品资源点击获取
返回列表