简介:一份基于 MFC 框架与 Socket TCP 协议实现的远程桌面控制软件源码,采用经典 C/S 架构,服务端与客户端分离,适合学习 C++ 网络编程、MFC 界面开发及远程控制原理的在校生、毕业设计者或初级开发者。项目实现本地回环测试与远程 IP 设置,能完成基本的桌面画面传输与控制流程,代码结构按 ITCP、ControlServer、ControlClient 等模块划分,并提供 CmdDlg、SettingDlg、FileBrowseDlg 等交互界面,便于理解主控端与被控端的通信逻辑。压缩包共 43 个文件,以 h/cpp 源文件为主体,含 15 个头文件与 11 个 C++ 实现文件,另有 vcxproj/sln 工程文件、ico/rc/bmp 界面资源以及 README 说明文档,整体仅 2.52MB,轻量完整。已有 221 人浏览/学习,代码上传前已跑通并保持可运行状态,可直接打开解决方案编译调试;配套文档梳理了启动顺序、本地测试注意事项和远程连接方式,对做课程设计、毕业设计或二次功能扩展都有参考价值。
1. 远程桌面为什么偏爱 TCP 而不是 UDP:MFC 控制端的技术取舍
第一次编译 RemoteControl-main 时,服务端控制台刷出三行等待连接。这不只是一个 socket 示例,而是一整套远程桌面链路。受控端的 ControlServer 负责图像采集和指令接收,操控端的 ControlClient 渲染远端桌面并把鼠标键盘事件回传,两个端都是 C++,核心是 Socket TCP。
选 TCP 是必然,鼠标点击和键盘输入不能丢包,TCP 三次握手慢一点没关系,可靠交付由协议栈保证。项目用 Winsock 2.2,不用额外装第三方运行库,也避开了“mfc 安装不完整”的环境问题。
适合两类人:拿 MFC 交课设的学生,写 Windows 运维小工具的工程师。把服务端看成被控端、客户端看成控制端,这条链路的坑都能迁移到其他 TCP 应用。下面从协议头拆起,粘包和坐标偏移,往往就在这种项目里出现。
2. 通信层拆解:ITCP、CSocketInit 与 Package 的协议设计
远程桌面不是 HTTP 那种一问一答,图像上行和指令下行永远同时存在。如果两端直接裸发字节流,收端根本分不清哪段是图像哪段是鼠标指令。项目把网络契约拆成了三部分:ITCP.h 定义命令字和接口,CSocketInit 负责 Winsock 的启动与清理,Package.h 则把“命令字 + 长度 + 数据体”打包成二进制的帧。这三件事合起来,就是 tcp/ip协议 面向字节流时必须自己解决的边界问题。
我习惯在打开界面工程之前先读 ITCP.h,尽管它只有几个 enum 和纯虚函数,却把链路边界说清了。控制端实现的是“连接对方、发送指令、接收屏幕”,服务端实现的是“等待连接、接收指令、发送屏幕”。接口一旦定下来,后面换界面只是换调用方,网络层不用动。
2.1 为什么把协议头设计成“命令字 + 长度 + 数据体”
先看一个参考实现,它代表这类型项目里最通用的帧结构:
#pragma pack(push, 1) struct RemotePackage { unsigned short cmd; // 命令字 unsigned short len; // 数据体长度 unsigned int crc; // 可选的校验字段 char data[0]; // 柔性数组,不占包头空间 }; #pragma pack(pop)cmd 用来区分 CMD_SCREEN、CMD_MOVE、CMD_CLICK 这类操作,len 告诉接收端后面还有多少字节的 data 体,crc 是可选校验,data[0] 是柔性数组,这样sizeof(RemotePackage)仍然是 6 或者 8,取决于有没有 crc。#pragma pack(push, 1)必须放在结构体前面取消字节对齐,否则在 32 位默认对齐下,结构体会多出填充字节,客户端按固定偏移读数据就会错位。
为什么不直接发一个大的 byte 数组,而是先发固定头再发数据?因为 TCP 是字节流,不保证一次 recv 就返回一个完整逻辑包。同一时刻可能有图像帧和鼠标坐标在传输,连续发送会造成粘包;一条 JPEG 很大,又会在中间被拆成多次 recv。没有长度字段,就只能在收端靠超时去猜边界,远程控制场景根本不敢猜。
2.2 CSocketInit:把 Winsock 的启动与清理写进 RAII
MFC 里有现成的AfxSocketInit,但项目里选择自己调 WSAStartup,这样的好处是网络层不依赖 MFC 的 CWinApp 生命周期。常见做法是写一个 RAII 类:
class CSocketInit { public: CSocketInit() { WSADATA wsa; int rc = WSAStartup(MAKEWORD(2, 2), &wsa); if (rc != 0) { // 打印错误码,通常 0 表示成功 } } ~CSocketInit() { WSACleanup(); } private: CSocketInit(const CSocketInit&); CSocketInit& operator=(const CSocketInit&); };WSAStartup 的第一个参数 MAKEWORD(2,2) 请求 Winsock 2.2 版本,第二个参数返回系统实际支持的实现细节。把清理动作放在析构函数里,无论从哪个 return 路径退出,程序关闭时都会调用 WSACleanup。如果你在调试时反复启动服务端,又漏掉这个清理,会遇到端口被占用,Windows 下报错就是 WSAEADDRINUSE(10048),表现形式类似bind: Only one usage of each socket address,其实不是代码逻辑问题,是上一个进程的 TIME_WAIT 还占着地址。
CSocketInit 里我一般还会再封装一个 IsReady 方法,返回 WSAStartup 的返回码,这样界面层可以弹一个 MessageBox 而不是静默崩溃。
2.3 Package 打包解析:字节序与半包
字节序这块,Windows 默认是小端,直接把 unsigned short 塞进 char 缓冲区发送,在 Windows 到 Windows 的局域网里没问题;一旦移植到 Linux 或嵌入式设备,就是灾难。所以发送端接收端都应该用 htons/ntohs 转换。以发送一包屏幕数据为例:
bool SendPackage(SOCKET s, unsigned short cmd, const char* payload, int nLen) { char header[4]; *(unsigned short*)header = htons(cmd); *(unsigned short*)(header + 2) = htons((unsigned short)nLen); if (send(s, header, 4, 0) == SOCKET_ERROR) return false; if (nLen > 0 && send(s, payload, nLen, 0) == SOCKET_ERROR) return false; return true; }这里我把包头固定成 4 字节,没有放 crc,是为了让你看清主体结构。htons 把主机字节序转成网络字节序,大端网络序;对方收到后用 ntohs 还原成主机字节序。send的两个调用是独立的,TCP 会自己优化合并或拆分,所以不能假设接收端一次 recv 就拿到 4 字节头加整个 payload。更稳的接收函数要先收满头部,再循环收 body:
int RecvFull(SOCKET s, char* buf, int len) { int total = 0; while (total < len) { int r = recv(s, buf + total, len - total, 0); if (r <= 0) return r; // 0 表示对端关闭 total += r; } return total; }RecvFull 的 len 是期望字节数,buf 是应用层缓冲区,返回值必须等于 len 才说明完整收到一帧。如果返回值是 SOCKET_ERROR,调用方再查 WSAGetLastError 决定是重新 connect 还是丢弃。用这段代码处理粘包,比在 MFC 的 OnReceive 里拆分一部分然后保留剩余数据要直观得多。
命令字可以参考这种映射方式:
| 命令字 | 常见取值 | 数据体 |
|---|---|---|
| CMD_SCREEN | 0x01 | JPEG 编码后的屏幕图像 |
| CMD_MOVE | 0x02 | 远端坐标 X/Y(int32) |
| CMD_CLICK | 0x03 | 鼠标键位与按下/释放标志 |
| CMD_KEY | 0x04 | 键盘虚拟键码 |
这个表里的数值不要求完全一致,关键是理解:网络层只认识 cmd 和 len,具体含义由两个端共同维护。改协议时先加枚举,再改两端的 switch,最怕一处按数值硬编码,另一处已经忘了当时为什么这么定。
3. 服务端 Select 模型与 TCP 多路复用:从控制台三行日志说起
服务端工程编译出来,双击运行,控制台会依次打印三条输出。很多人以为它是网络库在刷存在感,其实是三个关键函数的成功标志:socket 创建成功、bind 绑定端口成功、listen 开始监听。只要这三行状态正常,就说明 TCP 服务端已经能接受新连接了。接下来进入真正重要的环节,在 accept 之后的 while 循环里选择什么模型。
3.1 为什么选 Select 而不是阻塞 accept
阻塞 accept 的写法最容易理解,但每个客户端都要一个独立线程去循环 recv,线程数一旦多起来,调度开销和锁竞争会立刻吃掉截图压缩节省下来的 CPU。远程控制场景通常只有一两个控制端,但服务端还可能要响应 CmdDlg 下发命令、FileBrowseDlg 拉取文件列表,这些控制请求本质上是多个逻辑通道。如果都用线程硬扛,调试时头部会大。
Select 模型用一个 fd_set 集合监控所有 socket,交给内核去判断哪些可读。这里我贴一段服务端多路复用的骨架,来自我对 WinSock 服务端的典型写法,不是原项目的逐行代码:
SOCKET listenSock = socket(AF_INET, SOCK_STREAM, 0); u_short port = 6666; sockaddr_in addr = {0}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有网卡 addr.sin_port = htons(port); bind(listenSock, (sockaddr*)&addr, sizeof(addr)); listen(listenSock, 5); fd_set readSet; FD_ZERO(&readSet); FD_SET(listenSock, &readSet); std::vector<SOCKET> clients; while (true) { fd_set tmp = readSet; timeval timeout = { 0, 100000 }; // 100ms int ret = select(0, &tmp, nullptr, nullptr, &timeout); if (ret == SOCKET_ERROR) break; if (FD_ISSET(listenSock, &tmp)) { SOCKET c = accept(listenSock, nullptr, nullptr); clients.push_back(c); FD_SET(c, &readSet); printf("client connected: %d\n", c); } for (auto s : clients) { if (FD_ISSET(s, &tmp)) { if (HandleRecv(s) <= 0) { // 0 表示对端关闭 closesocket(s); FD_CLR(s, &readSet); } } } }这段代码有几个参数容易被新手误解。select第一个参数在 Windows 下是忽略的,传 0 即可,Linux 才要传最大 fd 加 1。timeout是 select 的等待时间,我设成 100ms,既不会让 CPU 空转到 100%,又能让新连接和指令在 100ms 内被感知。真正有数据来的时候 select 会立即返回,timeout 只影响没有事件时的主循环周期。FD_ISSET(listenSock, &tmp)用来判断监听 socket 是否有新连接。
有个细节:select 会修改传入的 fd_set,所以每次循环我用 tmp 做拷贝,不污染 readSet。如果你直接把 readSet 传给 select,再 FD_ISSET,后面的套接字会越来越少,最终连不上客户端。这份项目的 ControlServer 就踩过类似的坑,最终通过每次重建集合解决。
3.2 三行日志、三次握手与 backlog
三行日志可以扩展成一张排查表,不只在某个工程里用:
| 阶段 | 系统调用 | 日志含义 | 失败时常见原因 |
|---|---|---|---|
| 创建 socket | socket() | socket created: 句柄 | 句柄不足,或 Winsock 未初始化 |
| 绑定地址 | bind() | bind port: 6666 | 10048 端口占用 / 地址被占用 |
| 开始监听 | listen() | listening... | socket 无效,或 backlog 设得过大 |
TCP 三次握手的 SYN、SYN+ACK、ACK 发生在 connect 调用之后,服务端的 listen 只是把 socket 标记为被动监听。当客户端发起连接,内核在后台完成握手,把已完成连接放进 accept 队列。select 检测到 listenSock 可读,并不意味着握手过程开始,而是说可以 accept 了。很多人误把“监听状态”当成“已经连上”,才反复去 RecvFull 一个还没建立连接的 socket,最后得到 WSAECONNREFUSED。
bind 地址要特别说:INADDR_ANY表示绑定所有网卡,局域网其他机器都能连。如果图省事写成inet_addr("127.0.0.1"),本机测试没问题,换一台电脑就连不上。项目 README 里强调“测试可以先连接本地”,是因为客户端和服务端跑在同一台机器时,走回环地址最方便观察输出。判断回环可以比较客户端 IP 是否是 127.0.0.1,这个小逻辑之后可以用来屏蔽本地鼠标指令。
3.3 屏幕数据与指令数据的优先级处理
一个服务端正被控制时,网络上行方向是屏幕 JPEG,下行方向是鼠标和键盘指令。上下行不在同一条阻塞路径上,但都共用 socket,如果图像发送把发送缓冲占满,指令 recv 就阻塞住。Select 模型的收发都在主循环里,图像发送是同步调用,一次 send 很大会占用较长时间,这时候指令包可能在接收缓冲区里排了好几个。
常见的优化是拆分发送任务:截图线程只负责压缩 JPEG,不直接 send;发送线程从队列取最新帧,每次 send 一帧;指令则通过单独的低延迟路径调用 send。原项目用的方式比较朴素,我在它基础上一般会加一个原子指针指向最新帧,发送线程发现上一帧还没发完,就直接丢弃旧帧,发最新的。这个策略可以用代码表示:
// 服务端发送线程中 while (running) { if (latestFramePtr != nullptr) { SendPackage(clientSock, CMD_SCREEN, latestFrame, latestSize); // 这里发送完不必清空,等下一次截图线程更新 latestFramePtr } Sleep(20); }latestFramePtr 是指向共享缓冲区的指针,latestSize 是 JPEG 字节数。服务端没有用队列,只保留最新一帧,因此网络慢时自动丢帧,控制指令的发送节奏不会被一堆图像帧拖住。截图线程更新指针时要注意加轻量锁或使用原子变量,否则客户端可能收到撕裂的半帧数据。
4. 客户端渲染与指令注入:从 BitBlt 到 mouse_event
客户端工程名叫 ControlClient,核心视图类在 ControlClientView.cpp。它的角色像一个瘦客户端,网络线程收到图像数据后交给 UI 线程画图,UI 线程捕获鼠标键盘消息后又会回传到网络线程发送。这里最容易犯的错是在网络线程直接操作 DC,把 GDI 对象和 MFC 消息队列搅在一起,最后界面卡死。
4.1 MFC 视图刷新与屏幕图像解包
收到 CMD_SCREEN 包之后,把 JPEG 数据保存到视图类成员变量,并调用Invalidate()。WM_PAINT 会由 UI 线程触发,OnPaint 里才有机会安全地画图。常见的做法是用 CImage 从内存流加载 JPEG:
void CControlClientView::OnPaint() { CPaintDC dc(this); if (m_jpegData.GetSize() == 0) return; CImage img; HGLOBAL hMem = GlobalAlloc(GMEM_MOVEABLE, m_jpegData.GetSize()); CopyMemory(GlobalLock(hMem), m_jpegData.GetData(), m_jpegData.GetSize()); GlobalUnlock(hMem); IStream* pStream = nullptr; CreateStreamOnHGlobal(hMem, TRUE, &pStream); img.Load(pStream); CRect rc; GetClientRect(&rc); img.StretchBlt(dc, 0, 0, rc.Width(), rc.Height(), SRCCOPY); pStream->Release(); GlobalFree(hMem); img.Destroy(); }每行参数不难懂:GlobalAlloc 分配一块内存作为流源,CreateStreamOnHGlobal 让 CImage 能直接从内存读 JPEG,GetClientRect 拿到客户区宽高。尤其注意 img.StretchBlt 的目标矩形是客户区,不是远端屏幕分辨率。如果这里固定成客户区大小,图像会被拉伸变形。所以项目里设置 IP 对话框通常会附带远端分辨率输入,让 StretchBlt 按比例计算目标矩形。
4.2 设置 IP 与控制指令映射
菜单“设置 IP”对应 SettingDlg,输入服务端 IP 和端口。端口在两端必须一致,否则 connect 会返回 10061,也就是目标主机拒绝。连接完成后,视图鼠标消息开始工作。鼠标消息处理函数里不能直接 send,因为 MFC 的消息循环频率和 socket 发送时机不同步,我会先把要发出去的动作包装成一个小结构体,再用 PostMessage 发给网络线程。
| 本地操作 | 命令字 | 数据体 | 说明 |
|---|---|---|---|
| 鼠标移动 | CMD_MOVE | int32 x,y | 坐标要乘缩放比例 |
| 左键按下 | CMD_CLICK | 按下 | 一键按下,不用 Move 再 Click |
| 左键释放 | CMD_CLICK | 释放 | 配合上一行使用 |
| 右键菜单 | CMD_CLICK | 右键 | 对应远端弹出菜单 |
| 键盘按键 | CMD_KEY | 虚拟键码 | 按下和释放都要传 |
坐标映射函数我通常会写成一行:
int remoteX = ::MulDiv(point.x, remoteWidth, clientWidth);MulDiv 是 Windows 提供的有符号整数乘除法,比point.x * remoteWidth / clientWidth的越界风险更小,而且一次性获得四舍五入取整的结果。第一个参数是被乘数,第二个是分子,第三个是分母。客户端窗口大小变化时,OnSize 里要更新 clientWidth 和 clientHeight。如果你在做 MFC 控件自适应屏幕分辨率,通常会在 OnInitDialog 里读控件位置再按缩放比例调整;这里也是一样,只是尺子换成鼠标映射。
4.3 本地回环测试的鼠标坐标偏移问题
README 里专门提了一句:本地测试无法使用鼠标控制,因为进入客户端界面的鼠标会被转换成当前电脑屏幕位置。这句话背后是典型的“回环地狱”。客户端画的是服务端桌面,如果服务端和客户端在同一台机器上,客户端的鼠标坐标换算后发送给服务端,服务端再用 SetCursorPos 设置光标,结果又把光标移到了客户端窗口外的某个位置,于是你发现鼠标根本不受控制,光标满屏跳。
我在做本机联调时,不在视图里直接做坐标系转换,而是加一个全局开关:当对端 IP 是 127.0.0.1 时,忽略 CMD_MOVE 包,只保留点击和键盘事件。这个开关放在服务端接收线程的入口,识别到回环地址就跳过 SetCursorPos,避免无意义的递归操作。
另一个坑是窗口缩放。客户端窗口如果允许用户拉小,OnPaint 的 StretchBlt 会把图像压缩显示,但鼠标坐标如果不按缩放系数映射,远程光标的偏差会非常大。我一般会在 SettingDlg 里把远端分辨率和端口放一起保存,在 ControlClientView 初始化时读出来,作为鼠标映射的基准。远端分辨率变化时要重新触发一次连接,或者让服务端在每次首帧数据里附带屏幕宽高,这个做法比配置更稳。
5. 进阶:给远程桌面加一档可控帧率与断线重连
原版跑通后,在局域网里用已经够顺。但我把客户端切换到外网测试时,画面会卡在最后一帧,鼠标点击也没反应。问题不在协议,在于服务端发送线程用while(true)狂发图像,把带宽吃完;客户端 recv 失败后直接退出,没有重连机制。我给这个项目加了两块:目标帧率控制和断线重连。
5.1 帧率控制与发送窗口
在服务端发送线程里加一个简单的节拍器:
void SendLoop(SOCKET s) { int targetFps = 15; // 可以在设置里改 DWORD interval = 1000 / targetFps; DWORD lastTick = GetTickCount(); while (running) { DWORD now = GetTickCount(); if (now - lastTick < interval) { Sleep(interval - (now - lastTick)); // 让出 CPU continue; } lastTick = now; SendScreen(s); // 抓屏 + 压缩 + SendPackage } }interval 是每帧间隔毫秒数,targetFps=15 时约 66ms。改成 30,间隔 33ms,体感更流畅但 CPU 占用更高。我用它配合外网低带宽场景,效果比调整 JPEG quality 还明显,因为多出来的时间能让 TCP 缓冲区慢慢清掉,不至于每次 send 都等重传。
5.2 断线重连的循环细节
客户端 recv 返回 0 或者 SOCKET_ERROR,常见处理是把 socket 关掉再走一遍连接逻辑。我加了一个重连线程,每次失败等待 3 秒:
void ReconnectThreadProc() { while (!g_connected && g_enableReconnect) { if (ConnectServer(g_ip, g_port) == 0) { g_connected = true; break; } Sleep(3000); } }g_ip、g_port 来自 SettingDlg 保存的值;ConnectServer 内部执行 socket、connect,然后决定阻塞或非阻塞模式。这是最简单但有效的保活方式,WiFi 闪断和服务端重启后都能自己拉回来。最后,如果你在这个项目里看到 StretchBlt 出来画面边缘锯齿,把 SetStretchBltMode 改成 HALFTONE,成本几乎为零,比在图像解析层做缩放抗锯齿容易得多。
本文还有配套的精品资源,点击获取