简介:一款基于Qt开发的Windows远程控制软件,同时提供服务器端(被控端)与客户端(主控端)的完整实现。服务器端以进程形式在后台运行,客户端为图形界面程序,连接时需指定服务器IP及显示区域的宽高(不能超过服务器端屏幕分辨率),适合学习Qt网络编程、TCP通信及远程桌面控制的开发者参考。资源包共40个文件,压缩后6.08MB,包含14个头文件与14个C++源文件、2个Qt工程文件、2个可执行程序以及6个动态链接库(DLL)等,源码与可运行程序齐备。可直接运行体验,也可在Qt环境中打开工程阅读源码,理解远程控制命令交互、屏幕数据传输与界面刷新等核心机制。已有5327人学习下载,适合具备一定C++/Qt基础、希望动手实现远程协助类应用的开发者。
1. 从一次跨办公室救援说起:这个 Qt 远程控制项目能干什么
某天,隔壁办公室一位同事被一台 Windows 机器卡在登录界面,人还在楼下。系统自带的远程桌面需要账号密码,跨网段配置又繁琐;最后我们用这套 Qt 远程控制项目救场:被控端(服务器端)随手启动,控制端(客户端)填上 IP 和连接码,桌面画面按帧传过来,鼠标键盘操作反向模拟回去,几分钟就把问题处理完。
这个项目用 Qt 原生能力把「抓屏—编码—传输—输入回放」这条远程控制链路完整跑通:两端都是 Qt Widgets 界面,网络层用 QTcpSocket,屏幕帧做 JPEG 压缩,输入事件走 Windows 驱动层模拟,启动简单,没有额外运行时依赖。
适合想研究远程控制原理的 Qt 学习者,也适合团队自建局域网远控小工具的开发者。一句话,这是一个能编译、能跑、能继续改的 Windows 远程控制最小实现。
2. 架构与技术选型:双端通信骨架是如何搭起来的
2.1 为什么选 Qt:一只箱子装下 GUI、网络与编解码
远程控制涉及四件事:画界面、收网络包、压缩图像、回放输入。如果拆成多门语言来做,光是环境就够折腾。Qt 的价值在于把信号槽、TCP Socket、QImage 和 UI 模板放在同一套框架里,一个 Qt Widgets 程序就能覆盖被控端和控制端的全部需求,不用再引第三方网络库。Windows 是当前目标平台,但代码里大量使用 Qt 的跨平台抽象,比如 QImage 代替 HBITMAP、QAbstractSocket 代替 Winsock,将来移植 Linux 时只需要重新编译并调整抓屏和输入模拟这两个局部模块。
C# 或 Electron 也能做,但对比下来 Qt 有个实际好处:部署简单。被控端拿到的是独立 exe,不需要目标机器装 .NET 运行时库,也不需要 Node 环境;用 windeployqt 打完包体积可控。对「临时拷到别人电脑上用一下」的场景,单目录运行体验很重要。
还有一种常见误用是直接用裸 TCP 加自定义二进制流,不在应用层设计协议。这样做往往第一个版本能跑通,等加入文件传输、心跳、加密之后就会发现消息边界根本分不清。这个项目在一开始就定义了明确的包头结构,后面的每一种业务都挂在同一个分帧框架下扩展,避免了返工。
2.2 双端职责与线程模型:谁在抓屏,谁在回放
被控端(服务器端)的职责非常聚焦:捕获本机屏幕、监听网络连接、执行控制端发来的输入指令。控制端(客户端)则是镜像职责:解码并显示画面、采集本地鼠标键盘动作、把动作封装成协议包发出去。两端通过自定义协议通信,职责互不重叠,这也是远程控制项目里最不容易做乱的结构。
线程模型上,被控端是典型的多线程结构。主线程管窗口和连接生命周期,一个独立抓屏线程定时截屏并编码,一个网络线程负责收发数据。控制端相对简单,网络收发在 Qt 事件循环里完成,画面解码放在独立线程避免 UI 卡顿。这里容易踩的坑是 QImage 跨线程传递时默认是隐式共享,图像数据要在发给网络线程之前显式 detach(),否则新线程读到的是半张空图或旧数据的引用。
连接建立后的时序也值得注意:被控端 QTcpserver 的 newConnection 信号触发后,紧接着完成认证、发送本机屏幕分辨率、进入周期抓屏循环;控制端则先等待认证结果,再启动帧率统计和输入采集。两端的事件顺序由协议层保证,业务层只管处理消息,不用关心网络细节。
2.3 协议设计:8 字节包头撑起全部分帧逻辑
网络通信最怕两端各自定义消息格式。这个项目使用了一个很简练的自定义协议:每一条消息由 8 字节包头加一段载荷组成。包头结构如下:
#pragma pack(push, 1) struct PacketHead { quint16 magic; // 固定 0x5A5A,用来快速丢弃杂散包 quint16 type; // 1=认证 2=屏幕帧 3=鼠标事件 4=键盘事件 5=心跳 6=文件块 quint32 length; // 载荷字节数,最大帧数据也由这个字段决定 }; #pragma pack(pop)用 pragma pack(1) 是为了避免结构体对齐在包头里塞进填充字节,否则不同编译选项下收包方读出来的 length 可能是错的。magic 像哨兵一样,能提前把网络杂音过滤掉;type 决定后续载荷怎么解释;length 配合 QByteArray::read(),收包方只要循环读,就能把一条完整消息捞出来。
粘包和半包是远程控制里最容易翻车的地方。QTcpSocket 是流式协议,一次 read() 可能只读到半条消息,也可能一次读到好几条。项目里用「peek 先看,够了再读」的套路:
void TcpPeer::onReadyRead() { while (m_socket->bytesAvailable() >= int(sizeof(PacketHead))) { PacketHead head; m_socket->peek((char*)&head, sizeof(head)); // magic 不对或长度异常时,跳过 1 字节重新对齐 if (head.magic != 0x5A5A || head.length > 10 * 1024 * 1024) { m_socket->read(1); continue; } // 载荷没到齐,挂起等待下一次 onReadyRead if (m_socket->bytesAvailable() < int(sizeof(head) + head.length)) return; m_socket->read((char*)&head, sizeof(head)); QByteArray payload = m_socket->read(static_cast<qint64>(head.length)); dispatch(head.type, payload); } }参数说明:bytesAvailable() 返回当前接收缓冲区的字节数;peek 读数据但不消费,所以包头检查失败时还能用 read(1) 丢弃垃圾字节继续对齐。length 上限 10MB 是为了防止异常包把内存撑爆,这个值必须大于最大屏幕帧的压缩体积。
还有一个细节:字节序。局域网场景下两端都是 x86 小端机,直接读结构体没问题;如果将来要加 Linux 或国产系统端,应该用 qToBigEndian 转换后再发送。这个项目因为目标平台是 Windows,才用了「裸结构体」这种偷懒写法,二次开发时一定要补上字节序转换。
3. 核心模块拆解:一条屏幕帧从捕获到回放的完整链路
3.1 屏幕捕获:GDI 抓图与帧差判断
被控端抓屏用的是 QScreen::grabWindow(),它在 Windows 底层走 GDI 的 BitBlt,实现简单、兼容性好,适合办公场景这类不剧烈变化的桌面。核心代码只有几行:
QImage ScreenCatcher::capture() { QScreen *screen = QGuiApplication::primaryScreen(); QPixmap pm = screen->grabWindow(0); QImage img = pm.toImage().convertToFormat(QImage::Format_RGB32); img.detach(); // 解除隐式共享,保证跨线程数据独立 return img; }grabWindow(0) 表示抓整个主屏;convertToFormat 统一成 RGB32,让后面的 JPEG 编码函数有固定输入格式,省去每个像素判断格式分支的开销。detach() 是关键,QImage 默认写时复制,不调用它,发送线程里修改图像数据会导致画面闪烁或内容错位。
裸抓全屏的话,1920x1080 的 RGB32 单帧原始数据是 8MB,即使局域网也扛不住高频推送。因此项目在抓屏循环里加了帧差判断:把当前帧缩小成灰度缩略图,与上一帧逐点比较,变化低于阈值就跳过这一帧,只发一个「保持上一帧」的通知;有变化才走编码流程。这个优化能把空闲桌面的带宽占用从几 MB/s 压到几十 KB/s,代价只是每帧多一次缩放和比较。
帧差判断的实现可以很轻量:
bool FrameDiffer::hasChanged(const QImage &curr, const QImage &prev) { if (curr.size() != prev.size()) return true; QImage c = curr.scaled(320, 200).convertToFormat(QImage::Format_Grayscale8); QImage p = prev.scaled(320, 200).convertToFormat(QImage::Format_Grayscale8); int changed = 0; const uchar *cp = c.constBits(); const uchar *pp = p.constBits(); for (int i = 0; i < c.byteCount(); ++i) { if (abs(int(cp[i]) - int(pp[i])) > 16) changed++; } // 超过 1% 像素变化才触发完整编码 return changed > (c.byteCount() / 100); }参数说明:缩略图固定 320x200,灰度图逐字节比较,阈值 16 能过滤掉轻微的反色和字体渲染毛边;1% 像素变化意味着整个屏幕有 640 个字节块发生变动,低于这个阈值时画面人眼几乎无感知,直接跳过编码可以省大量 CPU。
3.2 帧编码与自适应质量:把带宽压到可控范围
屏幕帧的编码方案直接决定延迟和画质。这个项目选用的是 QImage 保存为 JPEG 数据,也就是把整帧压缩后放进协议包。JPEG 对自然图像效果好,对文字会有点糊,但胜在编码快、库是 Qt 自带,不需要引入额外的 H.264 编码器。
QByteArray encodeFrame(const QImage &img, int quality) { QByteArray ba; QBuffer buffer(&ba); buffer.open(QIODevice::WriteOnly); img.save(&buffer, "JPG", quality); return ba; }quality 在 30 到 85 之间动态调整。局域网畅通时维持高值保证清晰度,网络延迟升高时降到 40,牺牲文字锐度换取流畅度。调整逻辑放在抓屏线程的帧统计里,每 200 帧算一次平均编码耗时:
if (++frameCount == 200) { double average = totalEncodeMs / 200.0; if (average > 30) quality = qMax(30, quality - 5); // 编码太慢,降压 else if (average < 15) quality = qMin(85, quality + 3); // 编码快,提画质 frameCount = 0; totalEncodeMs = 0; }这里用的是简单反馈控制,虽然不是严格的 PID,但在固定分辨率场景下足够收敛:片刻之后 quality 会稳定在一个让编码耗时处于 15~30ms 之间的值。控制端收到 JPEG 数据后用 QImage::fromData 解码即可,解码耗时通常只有编码的一半,不会成为瓶颈。
显示层负责把解码后的帧交给界面刷新。项目里因为追求简单,用的是 QLabel 加 setPixmap 整图替换。在 1080p 下帧率能达到 15fps,对远程操作够用。后续要是追求 60fps,显示层要换 QGraphicsView 加局部更新,这一步留到二次开发优化。
3.3 输入模拟:从控制端坐标到被控端鼠标动作
远程控制的核心是把控制端用户的鼠标点击映射到被控端屏幕上。这里有两个难点:坐标换算和硬件模拟。协议里鼠标坐标用 0~10000 的整数表示百分比位置,被控端收到后先换算成逻辑坐标,再乘上 DPI 缩放系数转成物理像素:
void InputSimulator::mouseEvent(quint16 xRate, quint16 yRate, quint8 action) { QScreen *screen = QGuiApplication::primaryScreen(); const QRect geo = screen->geometry(); const qreal dpr = screen->devicePixelRatio(); int logicX = int(geo.width() * xRate / 10000.0); int logicY = int(geo.height() * yRate / 10000.0); // Qt 逻辑坐标是设备无关像素,驱动层需要物理像素 POINT pt; pt.x = int(logicX * dpr); pt.y = int(logicY * dpr); INPUT input = {}; input.type = INPUT_MOUSE; input.mi.dx = int(pt.x * 65535.0 / (geo.width() * dpr - 1)); input.mi.dy = int(pt.y * 65535.0 / (geo.height() * dpr - 1)); input.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE; if (action & 0x01) input.mi.dwFlags |= MOUSEEVENTF_LEFTDOWN; if (action & 0x02) input.mi.dwFlags |= MOUSEEVENTF_LEFTUP; SendInput(1, &input, sizeof(INPUT)); }为什么用 10000 而不是 100?协议把坐标放大到 0~10000 的整数域,保留两位小数精度,避免浮点数在两端的舍入误差。SendInput 要求传入 0~65535 的绝对坐标,所以再做一次线性映射。devicePixelRatio 处理的是 Windows 150% 缩放,不乘它的话,从高 DPI 控制端发来的坐标会在被控端上明显偏左上。
键盘事件走同样的封装思路:接收端拿到虚拟键值和 action,构造 KEYBDINPUT 结构,处理扩展键标志后调用 SendInput。唯一的坑是进程必须运行在当前用户的交互式会话里,否则 SendInput 只作用于自己的后台会话,用户屏幕上没有任何反应。这也是很多自建远程工具改成 Windows 服务方式安装后「动不了」的根源。
4. 编译与运行:从源码到两台 Windows 机器联调
4.1 环境准备:Qt 版本与编译套件的选择
项目是标准 Qt Widgets 工程,建议用 Qt 5.15 LTS,配 MinGW 64-bit 套件就能编译。如果机器上装了 Qt 6 也能编,但有两个变化要处理:Qt 6 默认启用高 DPI 缩放,需要在 main() 里设置 Qt::AA_EnableHighDpiScaling 来保持与旧行为一致;另外 QBuffer 等相关头文件的路径变了,.pro 文件要核对。
套件方面,MinGW 和 MSVC 都能过。建议用 MinGW,因为被控端要打包成免安装绿色目录,MinGW 系不依赖 VC++ 运行库。如果用 MSVC,部署时必须把 vcruntime140.dll 一起拷过去,否则到别人机器上直接报「缺少 DLL」。构建前记得确认 PATH 里能同时找到 qmake 和 mingw32-make,一个常见的环境问题是 Qt 装好了但 PATH 没配,终端里敲 qmake 用的是旧版本的。
源码包里的目录划分很清楚,Server 和 Client 是两个独立工程,中间共享的 protocol.h 单独放在 Protocol 目录下,两端都引入这个路径。工程结构如以下目录树:
Server/ ├─ Server.pro ├─ main.cpp ├─ MainWindow.h/.cpp // 端口配置与连接状态界面 ├─ ScreenCatcher.h/.cpp // 抓屏与帧差判断 ├─ Encoder.h/.cpp // JPEG 编码与质量自适应 ├─ InputSimulator.h/.cpp // SendInput 封装 ├─ TcpServerPeer.h/.cpp // 协议拆包与分发 └─ protocol.h // PacketHead 定义与消息类型枚举 Client/ ├─ Client.pro ├─ main.cpp ├─ MainWindow.h/.cpp // 连接入口与被控端 IP 配置 ├─ TcpClientPeer.h/.cpp // 协议组装与心跳 ├─ FrameViewer.h/.cpp // 画面显示与刷新 ├─ InputCollector.h/.cpp // 鼠标键盘事件采集 └─ protocol.h // 与 Server 共用同一份协议定义其中 protocol.h 一共只有四十多行,定义了枚举、PacketHead 和几个内联的打包辅助函数,控制端和被控端共用这一份,避免协议字段改了一边忘另一边。
4.2 构建与部署:qmake 流程与 windeployqt 打包
源码解压后打开终端,执行常规的 qmake 构建流程:
mkdir build-server && cd build-server qmake ../Server/Server.pro mingw32-make -j8Server.pro 里注意两个配置项:QT += network widgets用来引入网络和界面模块,CONFIG += c++17启用现代 C++ 语法。Client 目录结构对称,用同样方式构建 Client.pro。构建结束后每个目录下会产出可执行文件,先别急着拷走,还要用 windeployqt 收集 Qt 运行库:
windeployqt --release --no-translations build-server/Server.exe windeployqt --release --no-translations build-client/Client.exewindeployqt 会读取 exe 的导入表,自动把 Qt5Core.dll、Qt5Gui.dll、Qt5Network.dll 和 platform 插件拷到同目录。--no-translations 用来省掉语言翻译文件,减小体积。这一步做完后,把 Server 整个目录拷到被控机器,Client 目录留在控制端,部署完成。
提示:被控端目录里如果少了 platforms/qwindows.dll,程序双击会直接闪退。windeployqt 正常执行会带上,就怕手动精简目录时误删。
4.3 联调验证:从命令行启动到画面回传
被控端启动后默认监听 8899 端口,并在状态栏打印本机局域网 IP。控制端打开后,界面中央是 IP 输入框和一个连接按钮,连接成功后先发送认证包,认证通过后才开始请求画面帧。联调时先确认四件事:两端能互相 ping 通、防火墙放行 8899、被控端没有锁屏、认证密码一致。
最常见的情况是 ping 通但连接超时,被控端的系统防火墙拦住了入站。解决方法是打开防火墙高级设置,新建一条入站规则,放行 TCP 8899 端口,作用域限制在局域网。
跑起来后可以用任务管理器观察:被控端 CPU 占用通常在 5%~20% 之间,取决于桌面变化剧烈程度,控制端内存占用稳定在 100MB 左右。如果被控端 CPU 直接飙到 80%,多半是帧差判断没生效,每帧都在全量编码,这时回头查 3.1 节缩略图比较逻辑里的阈值设置。
5. 常见问题与排查:远程控制里的玄学时刻
5.1 鼠标坐标在显示器边缘越偏越厉害
现象:控制端画面里点击右下角的文件夹,被控端鼠标落在了左下角,离边缘越近偏移越明显。
原因:两端 DPI 不一致时,坐标换算漏掉了 devicePixelRatio。控制端发送的坐标基于逻辑分辨率,被控端按物理像素计算,整体缩放失配。
解决:协议里直接传百分比坐标,被控端接收后再乘自身屏幕的 DPR,这个项目已经采用这种做法。如果你拿到的是旧版本,检查协议是否传了整数像素,如果是,控制端发送时乘以控制端 DPR,被控端除以被控端 DPR,把两端差异抹平。
5.2 被控端锁屏或黑屏后控制端只有一片黑
现象:远程连接正常,但对方按 Win+L 锁屏后,控制端画面马上变黑,解锁后才恢复。
原因:Windows 会话隔离机制。锁屏界面运行在独立桌面,普通用户进程的 GDI 抓屏拿不到这个桌面的内容,QScreen::grabWindow 返回的就是全黑图。
解决:远程协助场景不要锁屏;需要绕过锁屏的运维场景,必须把远控进程以服务方式运行并切换交互式桌面,这是另一个量级的工作。这个项目当前版本明确定位在「当前用户会话内远控」,锁屏黑屏属于特性限制,不是 bug。
5.3 画面间歇性花屏,像被人撕成一条条
现象:控制端画面每隔十几秒出现一次横向撕裂,随后自动恢复,持续操作时更明显。
原因:典型的粘包处理不完整。抓屏线程在编码的同时,网络线程读到了半帧数据就开始解码,QImage::fromData 解出残缺 JPEG 就渲染。如果接收端每次只 read 一次、读多少算多少,这个问题一定会出现。
解决:严格按 2.3 节的拆包循环,先攒够 8 字节包头,再判断载荷长度是否到齐,不到就 return,等下一次 onReadyRead 触发。同时在分发函数里对屏幕帧做长度二次校验,与包头里的 length 不一致直接丢弃,不渲染。
5.4 连接一段时间后自动掉线,重连又能用
现象:控制端挂着不动,约 20 分钟后提示连接已断开,重新连接正常,如此反复。
原因:网络空闲时,路由器或中间设备的 NAT 映射会回收空闲会话。项目默认心跳间隔偏长,某些设备在更短时间内就把空闲连接清掉了,心跳跟不上,连接就活不过去。
解决:我一般会把心跳间隔改成 10 秒,连续 3 次心跳无响应才判定超时。心跳包走 type=5 的分支,载荷只是一个时间戳,带宽开销可忽略。改完参数后一晚上挂机也不掉线。
5.5 局域网内连接正常,跨网段就卡成幻灯片
现象:同网段操作流畅,跨一个路由器后延迟 20ms,画面帧率立刻掉到 3fps。
原因:TCP 拥塞控制和 Nagle 算法叠加,小包被延迟发送。屏幕帧数据很大,而鼠标事件和心跳只有几十字节,Nagle 会把小包攒起来等合并,延迟自然上去。
解决:给 QTcpSocket 设置 setTcpNoDelay(true),禁用 Nagle。同时把鼠标键盘事件和帧数据放在同一条连接里发送时,让控制包走独立分支,直接 write 后 flush(),不进入帧队列排队。
6. 二次开发:把远程控制写成自己的运维工具
6.1 协议扩展:从纯远控到带文件传输的运维台
当前协议只有屏幕和输入两类核心消息,日常运维最需要补的是文件传输。给协议增加 type=7 的 FileBlock 分支:控制端发起路径请求,被控端把文件按 64KB 分块,每块携带 offset、文件大小和 CRC32 校验值,接收端收齐后校验拼接。分块大小值得注意,64KB 在局域网内丢包率很低,过大容易被路由器缓冲导致延迟抖动,过小会让 TCP 包头开销占比升高,64KB 是兼顾速度与稳定性的起点队列。
发送端还应该加限速。我一般用令牌桶实现,每 100ms 允许发送 5MB,避免远控工具一传文件就把被控端带宽打满,反而导致画面编码卡回去。文件传输期间帧差判断会认为桌面变化很小,自动降帧率,整体体验能保持可用。
6.2 加密与鉴权:从密码认证升级到 TLS 传输
项目默认的密码认证是明文拼在包头后面,局域网信任环境够用,拿到公网就裸奔了。改造路径很直接:把 QTcpSocket 换成 QSslSocket,在 connectToHostEncrypted 之后校验证书。被控端作为服务端持有自签名证书,控制端本地安装这个证书的 CA 副本。改动集中在 TcpPeer 类,业务层的 PacketHead 完全不用动,因为 TLS 只在 socket 层加了一层加密。
如果不想引入证书体系,也可以用 AES 对协议载荷做对称加密,密钥在认证阶段用 RSA 交换。但不推荐自己实现密钥交换,很容易留漏洞,直接用 QSslSocket 是更稳妥的路线。
6.3 验证方法:用冒烟链路把改动跑出数据
改完协议后别急着上机联调,先在两台虚拟机上跑冒烟链路。我的习惯是:被控端启动后输出 FRAME_FPS 和 ENC_MS 指标,控制端显示 RTT,连续跑 10 分钟记录。帧率掉到 15 以下说明编码或带宽出问题;RTT 波动超过 50ms 说明 Nagle 没关或网络线程被阻塞。
加文件传输以后,用系统自带命令生成一个 1GB 的随机文件传一遍,记录耗时和 CPU 占用。理想情况下千兆局域网 1GB 应该在 20 秒左右完成,CPU 占用不超过 30%。如果传输很快但 CPU 打满,说明编码开销太高,高性能场景就该考虑把 JPEG 换成硬件编码,或使用未压缩帧加轻量压缩库。从那以后,我每次提交改动都强制走一遍「连接—认证—抓帧—动鼠标—传文件—断开」的全流程脚本,协议新增字段必须同步更新两端,避免出现控制端发了 type=7 被控端还不认识的情况。希望帮到你。
本文还有配套的精品资源,点击获取