MFC远程桌面控制:TCP Socket协议设计与Select多路复用
2026/9/12 22:22:29 网站建设 项目流程

简介:一份基于 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_SCREEN0x01JPEG 编码后的屏幕图像
CMD_MOVE0x02远端坐标 X/Y(int32)
CMD_CLICK0x03鼠标键位与按下/释放标志
CMD_KEY0x04键盘虚拟键码

这个表里的数值不要求完全一致,关键是理解:网络层只认识 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

三行日志可以扩展成一张排查表,不只在某个工程里用:

阶段系统调用日志含义失败时常见原因
创建 socketsocket()socket created: 句柄句柄不足,或 Winsock 未初始化
绑定地址bind()bind port: 666610048 端口占用 / 地址被占用
开始监听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_MOVEint32 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,成本几乎为零,比在图像解析层做缩放抗锯齿容易得多。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询