资讯动态

MFC远程桌面控制:TCP Socket协议设计与Select多路复用

发布时间:2026/9/12 22:22:35 来源:尧图企业网站定制
简介一份基于 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 而不是 UDPMFC 控制端的技术取舍第一次编译 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 的启动与清理写进 RAIIMFC 里有现成的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 下报错就是 WSAEADDRINUSE10048表现形式类似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。更稳的接收函数要先收满头部再循环收 bodyint 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/Yint32CMD_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::vectorSOCKET 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、SYNACK、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 从内存流加载 JPEGvoid 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 能直接从内存读 JPEGGetClientRect 拿到客户区宽高。尤其注意 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 是每帧间隔毫秒数targetFps15 时约 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成本几乎为零比在图像解析层做缩放抗锯齿容易得多。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价