ARTICLE DETAIL

资讯详情

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

C++远程桌面控制源码解析:屏幕采集、图像编码与Socket通信实战

C++远程桌面控制源码解析:屏幕采集、图像编码与Socket通信实战 简介基于C实现的远程桌面控制源码工程完整包含服务器端与客户端面向网络编程、Windows桌面应用及远程控制方向的学习者可用于理解远程屏幕采集、画面传输、控制指令下发等核心机制。资源包为RAR压缩格式共168个文件压缩后仅219KB包含45个cpp源文件、48个头文件、56个ico图标、5个bmp位图以及dsw/dsp工程配置、dll/lib动态库等目录结构清晰适合按模块阅读和二次开发。项目以TCP/IP与套接字编程为基础通过多线程处理多个客户端并发连接服务器端负责监听、接受连接并推送屏幕数据客户端负责连接、接收画面并将用户操作编码回传形成完整的远程控制交互流程。同时涉及JPEG/PNG图像压缩与帧率控制以降低带宽占用也包含SSL/TLS加密通信和身份验证等安全机制对理解远程桌面原理和实际工程实现很有价值也可作为进一步扩展截屏、图像处理等功能的参考起点。已有3295人学习/下载适合作为课程设计、毕业设计或自学进阶的参考资料。 这段时间折腾了一套基于C的远程桌面控制源码编译出来就是两个exe一个装在被控电脑上当服务端一个装在控制端当客户端跑起来就是自己搭的轻量版远程控制程序。这个项目不依赖任何第三方远程软件从屏幕采集、图像编码、Socket通信到鼠标键盘模拟全部是原生C代码非常适合想深入理解远程控制原理、或者想在自己的项目里内嵌远程协助能力的同学。我这次把整套代码的架构思路、核心模块实现、编译打包过程、以及实测调优踩过的坑都完整梳理了一遍可以直接照着思路复现。这套源码解决的是最核心的远程桌面控制需求怎么把一台电脑的屏幕画面实时搬运到另一台电脑上同时把另一台电脑的鼠标键盘操作回传到这台电脑上。市面上的商用远控软件虽然好用但闭源、体积大而且你根本不知道它在后台干了什么。自己写一套至少代码是透明的能按需裁剪还能学到不少Windows底层编程的东西。下面直接进入正题。1. 先搞清楚这套C远程桌面源码是什么形态1.1 核心需求解析远程桌面控制听起来高大上拆开看无非四件事抓屏、传图、收指令、模拟输入。被控端持续抓取屏幕画面通过TCP连接发送给控制端控制端把画面渲染出来同时把你本地的鼠标键盘操作打包成指令回传给被控端被控端收到后调用系统API模拟执行。这一来一回就构成了远程控制的完整闭环。目标平台是Windows这也是绝大多数远程控制软件的主战场。Windows提供了GDI、DXGI等底层图形接口抓屏和模拟输入都相对成熟。这套源码选择了经典的TCP传输方案相比UDP来说虽然稍慢一点但稳定可靠适合教学和中小规模远程协助场景。整个项目不需要额外的第三方库只要你机器上有Visual Studio或MinGW环境就能直接编译。1.2 为什么用C而不是Python或者Electron很多人会问Python写个远程控制不香吗Python做原型确实快但一旦涉及到高频屏幕截图、像素级图像处理、底层Socket收发Python的解释器开销和GIL锁会成为明显的瓶颈。Electron那种方案更是重量级一个远程控制客户端打包完少说一两百MB启动还慢。C在这里的优势是系统级能力。直接调用Win32 API操作HDC、HBITMAP、SOCKET这些底层资源内存布局完全可控性能可以压到极限。做屏幕传输时一帧1080P的原始位图就有大约8MB数据如果用C做差分编码、脏矩形裁剪能把单帧数据压到几十KB。换成Python光是把这8MB数据从底层拷贝到Python对象里就够喝一壶的。所以远程控制这种对性能和实时性有刚需的软件C是最合适的语言没有之一。1.3 项目整体架构与模块划分源码采用经典的两端架构服务端被控端和控制端主控端是完全独立的程序但它们共享一套协议定义和图像编解码模块。服务端的职责包括屏幕画面采集、图像压缩编码、Socket服务监听、指令接收与执行、多客户端连接管理。控制端的职责包括Socket连接管理与重连、图像解压渲染、鼠标键盘事件捕获与发送、画面缩放显示。两端共用的是一个图像块切割协议也就是把每帧画面切成多个小矩形块只有发生变化的块才被编码发送。这个局部刷新策略是整个程序性能的核心后面我会详细展开。整个源码目录规划大概是这样的RemoteDesktop/ ├── Server/ # 被控端程序源码 │ ├── Capture.cpp # 屏幕采集模块 │ ├── Encoder.cpp # 图像编码模块 │ ├── Server.cpp # 网络服务主逻辑 │ └── Input.cpp # 鼠标键盘指令执行 ├── Client/ # 控制端程序源码 │ ├── Decoder.cpp # 图像解码模块 │ ├── Client.cpp # 网络客户端主逻辑 │ └── UI.cpp # 画面显示与事件捕获 └── Common/ # 公共协议定义 ├── Protocol.h # 协议头、命令字定义 └── Utils.cpp # 工具函数这种分层的设计思路同样适用于任何C/S项目。把协议、编解码、采集逻辑剥离开后续想扩展文件传输、语音通话、多显示器支持都能在不影响主流程的情况下加模块。2. 核心原理拆解屏幕搬运的三步走2.1 第一步把屏幕像素搬出来Windows下抓屏幕最传统也最通用的办法是GDI核心就三行APIGetDC拿到屏幕设备上下文CreateCompatibleDC创建兼容的内存画布BitBlt把屏幕像素复制到位图里。这套方案从Win95时代就有了兼容性极好所有Windows版本都能跑。#include Windows.h // 简单屏幕抓取获取主屏幕的原始位图数据 BYTE* CaptureScreen(int width, int height) { HDC hScreen GetDC(NULL); width GetSystemMetrics(SM_CXSCREEN); height GetSystemMetrics(SM_CYSCREEN); HDC hMemDC CreateCompatibleDC(hScreen); HBITMAP hBmp CreateCompatibleBitmap(hScreen, width, height); HGDIOBJ hOld SelectObject(hMemDC, hBmp); BitBlt(hMemDC, 0, 0, width, height, hScreen, 0, 0, SRCCOPY); BITMAPINFO bmpInfo {0}; bmpInfo.bmiHeader.biSize sizeof(BITMAPINFOHEADER); bmpInfo.bmiHeader.biWidth width; bmpInfo.bmiHeader.biHeight -height; // 负值表示自上而下排列 bmpInfo.bmiHeader.biPlanes 1; bmpInfo.bmiHeader.biBitCount 32; // 32位色BGRA格式 bmpInfo.bmiHeader.biCompression BI_RGB; BYTE* pixelData new BYTE[width * height * 4]; GetDIBits(hMemDC, hBmp, 0, height, pixelData, bmpInfo, DIB_RGB_COLORS); SelectObject(hMemDC, hOld); DeleteObject(hBmp); DeleteDC(hMemDC); ReleaseDC(NULL, hScreen); return pixelData; }这里有两个细节值得注意。一是GetDIBits是CPU拷贝速度尚可但会把整帧数据从显存拉回内存性能敏感场合可以考虑用DXGI Desktop Duplication走GPU拷贝效率高很多但Windows 8以上才支持。二是位图格式要固定为32位BGRA这是后续所有图像处理的统一输入格式。如果混用24位或32位RGB后续编码时颜色通道错乱会让你调试到怀疑人生。2.2 第二步压缩、分块、只传变化区域原始位图太大直接发送不现实。1080P显示器一帧画面约8.3MB就算千兆局域网每秒也只能传一百来帧但CPU处理不过来带宽也扛不住更别提公网传输了。解决方案有两个方向对每帧做JPEG压缩以及只传输变化的区域。这两个方案通常是组合使用的。源码把屏幕划分为64x64像素的网格块每次采集完当前帧后与上一帧做逐块像素差分对比只有差异超过阈值的块才进入编码流程。JPEG压缩之后一个静态桌面的增量数据通常只有几KB就算是播放视频这种剧烈变化的场景也只需要传少数运动区域数据量能降到原始画面的十分之一以下。// 判断某一块是否发生变化 bool IsBlockDirty(BYTE* prevFrame, BYTE* currFrame, int width, int height, int blockX, int blockY) { int blockSize 64; int startX blockX * blockSize; int startY blockY * blockSize; for (int y startY; y startY blockSize y height; y) { for (int x startX; x startX blockSize x width; x) { BYTE* pPrev prevFrame (y * width x) * 4; BYTE* pCurr currFrame (y * width x) * 4; if (abs(pPrev[0] - pCurr[0]) 10 || abs(pPrev[1] - pCurr[1]) 10 || abs(pPrev[2] - pCurr[2]) 10) { return true; } } } return false; }阈值设为10的用意是滤掉轻微的噪点波动避免因为一小块像素抖动就触发整块重传。这个阈值不能设得太大否则画面会出现明显的色块残留也不能太小否则背景轻微抖动就会导致传输量飙升。10到15这个区间是实践下来比较合理的值。压缩环节选用了JPEG编码。JPEG对照片、视频这类连续色调画面的压缩率很高质量调成75时肉眼几乎看不出损失体积却只有BMP的几十分之一。源码中集成了一个轻量JPEG编码器不依赖外部库核心原理就是色彩空间转换加离散余弦变换代码量不大但很值得读一读。2.3 第三步TCP长连接与远程输入模拟网络传输采用TCP长连接因为远程控制不允许丢包。图像数据丢一包画面就花一块TCP的可靠重传虽然会增加延迟但保证了画面完整。协议格式设计也尽量精简每个数据包由包头和负载组成包头8字节前2字节是命令字后4字节是负载长度最后2字节是保留字段。这种紧凑的二进制协议比JSON、XML这些文本协议高效得多解析也简单。被控端收到控制端的鼠标键盘指令后调用SendInput函数模拟输入。这里有个关键点需要把控制端窗口上的坐标换算成被控端屏幕的绝对坐标。控制端窗口可能只显示了被控端屏幕的一部分缩放比例也不一定是1:1所以换算公式要同时考虑偏移量、缩放比例和屏幕分辨率。void SimulateMouse(int screenX, int screenY, bool leftDown) { int width GetSystemMetrics(SM_CXSCREEN); int height GetSystemMetrics(SM_CYSCREEN); INPUT input {0}; input.type INPUT_MOUSE; input.mi.dx (screenX * 65535) / (width - 1); input.mi.dy (screenY * 65535) / (height - 1); input.mi.dwFlags MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE; if (leftDown) input.mi.dwFlags | MOUSEEVENTF_LEFTDOWN; SendInput(1, input, sizeof(INPUT)); }SendInput的坐标范围是0到65535所以需要将实际的屏幕坐标映射到这个区间使用MOUSEEVENTF_ABSOLUTE标志告诉系统这是绝对坐标而不是相对偏移。模拟完鼠标之后如果还需要模拟点击、拖拽、滚动、键盘输入原理都是一样的只是INPUT的type和dwFlags不同。3. 源码解析关键模块与核心代码落点3.1 网络传输模块协议与Socket实现Socket通信是整套代码的血管。服务端启动后用listen监听一个端口控制端用connect连上来之后就是双向数据交换。服务端使用select模型管理多个socket虽然不如IOCP这类高级模型性能好但胜在实现简单、逻辑清晰适合作为理解网络编程的起点。SOCKET ConnectToServer(const char* ip, int port) { WSADATA wsaData; WSAStartup(MAKEWORD(2, 2), wsaData); SOCKET sock socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (sock INVALID_SOCKET) return INVALID_SOCKET; sockaddr_in addr {0}; addr.sin_family AF_INET; addr.sin_port htons(port); inet_pton(AF_INET, ip, addr.sin_addr); if (connect(sock, (sockaddr*)addr, sizeof(addr)) SOCKET_ERROR) { closesocket(sock); return INVALID_SOCKET; } return sock; }代码里用了inet_pton而不是旧的inet_addr是因为后者不支持IPv6而且返回值的错误处理不够清晰。WSAStartup记得在程序初始化时调用一次退出时调用WSACleanup释放资源忘了这个会莫名其妙地报10093错误。发送图像数据时要特别注意TCP粘包半包问题。发送端可能一次性send了10KB的数据接收端recv却可能只收到2KB。所以接收方必须维护一个环形缓冲区把收到的数据先存进去再按包头声明的长度解析出完整的帧数据。源码里这块逻辑放在了Decoder模块专门有个组装缓存来合并半包。3.2 屏幕采集模块多线程与帧率控制屏幕采集是一个独立的线程与控制端的指令接收线程并行运行。采集线程每完成一帧抓取、编码、发送后根据实际耗时动态调整帧间隔。比如静态桌面时画面几乎无变化可以降到每秒2到3帧给CPU省点事播放视频时变化区域大则提升到每秒20帧以上保证流畅度。采集线程和编码线程之间的数据交接用双缓冲实现。一个缓冲区用于当前帧采集一个缓冲区用于上一帧编码通过简单的双缓冲指针交换避免加锁带来的性能损失。如果再加锁每次屏幕内容变化时通知采集线程重新抓帧可以进一步降低无效采集但源码里没有做这一步属于可选的优化方向。3.3 指令模拟模块从网络到系统的最后一公里被控端收到控制端发来的指令包之后先解析包头里的命令字再分发给对应的处理函数。命令字大致分为几类鼠标移动、鼠标点击、键盘按键、滚轮滚动。每类指令包的负载部分都打包了具体的参数比如坐标值、按键码、按下还是释放。键盘模拟用的是keybd_event的升级版SendInput它支持更多的扫描码标志位。比如模拟Windows键组合、CtrlAltDel这类特殊快捷键时SendInput配合KEYEVENTF_EXTENDEDKEY标志才能正确触发。这里有个容易踩的坑以管理员权限运行的被控端控制端也必须有管理员权限否则SendInput无法模拟UI特权级别的输入比如UAC弹窗上的操作这个权限问题我在后面的排查章节会细说。4. 从源码到程序编译、打包、部署全流程4.1 编译环境配置我在Windows 10上用Visual Studio 2019编译这套源码工程是标准的Win32控制台程序。创建工程的时候选桌面应用程序然后配置附加依赖项。网络部分需要链接ws2_32.lib图形成分需要链接gdi32.lib。在VS里加依赖有两个地方项目属性-链接器-输入-附加依赖项或者在代码开头加一句#pragma comment(lib, ws2_32.lib)后者更省事。如果你用的是VSCode配MinGW编译命令差不多是这样g -O2 -stdc17 Server/*.cpp Common/*.cpp -o rdp_server -lws2_32 -lgdi32 g -O2 -stdc17 Client/*.cpp Common/*.cpp -o rdp_client -lws2_32 -lgdi32MinGW环境下记得在链接参数里加-lws2_32和-lgdi32不然后面会报一堆莫名其妙的undefined reference错误。优化选项建议开-O2不开压缩性能差距明显尤其是像素差分比较这种CPU密集操作。4.2 两个编译容易踩的坑字符集与链接库第一个坑是字符集混乱。VS默认的Unicode字符集会让你在字符串字面量上踩坑——char和wchar_t类型不匹配报错一大片。建议直接在代码里统一用TEXT宏或者明确使用char并在项目属性里把字符集设为使用多字节字符集简单粗暴。当然更规范的做法是全项目统一Unicode但这个涉及字符串处理的地方都要改初学阶段不必为难自己。第二个坑是32位与64位编译目标的选择。远程控制程序本质上没有特别大的内存需求但如果你用的是宽字符或大位图缓冲区建议直接编译成x64版本。x86版本在64位系统上通过WoW64兼容层运行性能和内存空间都受限制。4.3 打包分发与运行时环境编译出来的exe是不能直接拷给别人用的因为VC运行时库vcruntime140.dll等不一定存在。最简单的办法是使用VS的Release 静态链接配置项目属性-代码生成-运行库-多线程(/MT)这样CRT就静态链接进exe里了程序体积会大几百KB但换来的是拷到任何Windows机器上都能直接运行不用装Visual C Redistributable。有个细节容易被忽视服务端exe需要管理员权限。因为屏幕采集在很多场景下需要UIAccess权限模拟输入更是必须。可以在工程里加一个应用程序清单文件指定requestedExecutionLevel levelrequireAdministrator。或者你在源码里调用ShellExecuteA以runas方式重新启动自身也可以但不如清单文件干净。5. 实测调优与性能笔记5.1 局域网内实测数据我在两台局域网机器上做了测试一台是i5-8400、16GB内存的台式机另一台是i7-1165G7的笔记本中间走千兆交换机。静态桌面环境下服务端CPU占用只有3%到5%传输带宽约100KB/s到200KB/s画面更新完全无感知延迟。看视频场景时CPU占用跳到12%到18%带宽涨到3MB/s左右。这个数字的可接受程度取决于你的用途如果是纯桌面协助这个表现完全够用如果追求视频流级别的流畅度编码效率和脏矩形精度还有很大优化空间。5.2 延迟优化三板斧脏矩形、动态帧率、独立发送线程远程控制的流畅度核心也就在这三个手段上。脏矩形决定了每一帧要传多少数据动态帧率决定了频次独立发送线程决定了网络阻塞时其他模块不受影响。优化之前我的第一版是整帧JPEG编码后直接发送看视频时带宽占满、CPU 100%、延迟飙到2秒以上。改成脏矩形后带宽降到原来的三分之一延迟降到500ms左右。再加上动态帧率控制静态画面时CPU直接降为零头。最后把send调用从采集线程里拆出来放到独立线程用队列按顺序发送延迟进一步降到200ms以内。这几个优化思路对其他实时传输类项目同样适用。5.3 内存与线程资源管理的小心得远控服务端通常要跑很久内存泄露会慢慢拖垮系统。这类项目常见的泄漏点有三个GetDIBits分配了BYTE*数组却忘了delete[]、创建了HDC和HBITMAP没有配对释放、SelectObject选入的对象没有恢复直接删除了DC。我在代码里给每个new都配了对应的delete并把DC的创建与释放封装在RAII风格的类里这样即使中途抛异常也不会漏掉清理。线程方面服务端有采集线程、网络接收线程和发送线程三个常驻线程退出时必须用事件通知停止循环不能直接TerminateThread强杀否则会留下悬挂的互斥锁或者未释放的句柄。我在源码里用了一个atomic 停止标志加WaitForSingleObject等待线程退出路径清晰也不会在关程序的时候崩掉。6. 高频问题排查与避坑指南6.1 黑屏、花屏、卡死怎么定位遇到这些画面问题先检查的是传输链路而不是采集链路。花屏多半是图像分块的重组顺序出错了比如控制端拼接脏矩形时坐标错乱或者JPEG解压后的颜色通道顺序不一致。黑屏则要检查是否有权限抓屏很多远程控制程序在UAC提升的桌面和普通桌面之间是无法互相抓屏的这是Windows会话隔离机制造成的不是你代码的问题。如果画面卡死不刷新优先怀疑发送线程阻塞了。send函数在TCP发送缓冲区满时会阻塞如果不加超时控制网络一卡整条链路就停摆。我用的是非阻塞send加select超时的方案发送失败最多等500ms就返回错误然后跳过当前帧重试而不是无限阻塞。6.2 控制端连不上被控端最常见的三个原因是防火墙拦截、端口未监听、网络不通。被控端第一次启动时Windows会弹防火墙授权窗口没点允许就永远连不上。如果确认授权了还是连不上先在被控端本机执行netstat -ano | findstr 端口号看端口是否真的在监听再在控制端执行ping、telnet来分层排查。源码里默认端口是8888你在用的时候记得改掉固定端口容易和局域网里其他软件冲突。另外需要注意被控端的账号是否处于锁屏状态。Windows出于安全考虑某些系统配置下锁屏后SendInput无法正常模拟输入用户需要在组策略里开启交互式登录无需按CtrlAltDel或者做一些额外的服务配置这个也是市面上免费远控工具经常被吐槽的一个点。6.3 给新手的扩展建议如果你想把这套源码继续往深做我建议按几个方向走。第一个是加文件传输功能这与画面传输走的是完全独立的流程在协议里增加一个文件发送命令字即可。第二个是改为DXGI Desktop Duplication抓屏性能大概还能再翻一倍但只支持Win8以上系统。第三个是做强校验和断线重连机制公网场景下TCP断连是常态控制端要能自动重连而不是直接退出。还想加多显示器支持的话核心变化在于把屏幕从单个改为显示器数组坐标映射也要在多个显示器之间做偏移计算工作量不算小。建议先把单屏做到稳定流畅再考虑扩展。实际动手时优先保证基础闭环稳定跑起来再逐项添加功能。我个人在调试这套程序时反复验证了一个结论远程控制这种实时性要求高的软件瓶颈往往不在单项技术上而在各个环节的协作方式。抓屏、编码、发送、接收、解码、渲染这一整个链条中任何一环拖慢都会变成可感知的卡顿。在这套源码里把每一环都单独做好然后通过协议设计把它们高效串起来这才是核心所在。你拿着这套源码跑通之后再去做任何实时音视频、远程协作类的项目都会有底气很多。本文还有配套的精品资源点击获取
返回列表