首页 / 资讯中心 / 文章详情

MFC TCP通信实战:选型、拆包重连与避坑指南

MFC TCP通信实战:选型、拆包重连与避坑指南 ★ FEATURED ARTICLE
简介基于MFC的TCP通信项目源码包面向Windows平台下希望掌握网络编程的C开发者以客户端/服务器模型为蓝本聚焦于使用CSocket类完成可靠连接、文件分块发送与接收并引入多线程处理并发通信。压缩包共含36个文件以C源文件6个cpp、10个h为核心配合工程文件2个sln、2个vcxproj、界面资源2个rc、2个ico及调试配置文件整体压缩后约53.22MB目录中Client与Server两个工程清晰分离易于按模块查看。目前已有758人学习是MFC网络编程入门的常见参考。从中可完整看到基于CSocket的通信流程先通过Connect完成三次握手再按数据块调用Send/Receive传输文件同时结合CWinThread将通信逻辑放入Run函数避免阻塞界面代码还涉及文件I/O、同步对象和异常处理遇到网络连接失败或数据错乱时可结合完整源码排查粘包、断线重连等问题对理解TCP可靠传输机制和Windows多线程开发都很有帮助。1. 基于MFC的TCP通信做了七年后我仍然推荐它基于MFC的TCP通信这个话题放到今天看似乎有些老气但在工业上位机、设备调试工具和内部测试平台里它依旧是最常见、也最稳定的落地方案。CAsyncSocket和CSocket把WinSock的细节包了一层让你不用手动管理句柄和线程同步但很多人第一次上手就翻车在回调机制上界面卡死、粘包抖动、关闭时崩溃问题一个接一个。这篇笔记会从选型开始把客户端和服务器端的实现骨架、连接超时、按帧拆包、重连心跳这些具体问题讲清楚最后给出我个人的排错习惯和进阶写法。适合正在做上位机通信、设备对接、或者被MFC遗留代码困住的开发者新手可以直接照抄代码熟手可以重点看第三、五、六章里踩过的坎。2. 选型先行CAsyncSocket与CSocket的边界以及连接超时的落地方式2.1 两个封装类到底差在哪CAsyncSocket和CSocket都是MFC对WinSock的封装区别不是性能而是使用模型。CAsyncSocket是异步非阻塞的网络事件到了以后MFC会回调你重写的OnConnect、OnReceive、OnClose这些虚函数CSocket则把收发做成阻塞模式配合CFile派生类使用适合每个连接单独开线程、在循环里直接Receive的写法。我用一个对比表说明它们的关键差异对比项CAsyncSocketCSocket工作模式非阻塞异步回调阻塞同步收发方式OnReceive回调里调Receive线程里直接Receive直到缓冲区空必须注意回调里不能做耗时操作线程管理要自行处理适用场景单连接为主、UI线程收发多连接阻塞线程读在实际选型上我很少用CSocket。原因倒不是它不能用而是CSocket的阻塞Receive一旦没有数据线程就会挂在那里程序退出时如果顺序不对很容易卡死或崩溃。CAsyncSocket虽然回调机制绕一下但每个事件都落在消息循环里配合窗口句柄和PostMessage反而更容易写出可控的程序。2.2 我的选型结论客户端走异步回调服务器端走阻塞线程如果只做设备端的上位机通信比如PLC、仪表、视觉相机我一般直接用CAsyncSocket把连接、收发都挂在主窗口上回调里只做数据搬运不解析、不处理UI。原因很简单设备数量少连接数通常在1~16之间异步回调不会阻塞界面。但如果是服务器端要接收几十个连接CAsyncSocket的Accept回调和管理逻辑就会开始变得繁琐。那种场景下我倾向用CSocket或者原生socket放在独立线程里阻塞收发每个连接一个工作线程。注意这会带来线程同步的问题所以连接的句柄、关闭标志、重连状态都要包到一个连接对象里而不是散落在各个全局变量里。这两种场景的代码量差别很大。下面三个小节里我会先给客户端最小可用代码再把服务器端的骨架铺开最后统一说超时和心跳的做法。2.3 工程初始化AfxSocketInit与最小连接代码MFC程序里第一步不是Create而是初始化Socket环境。一般在InitInstance里调用AfxSocketInit它会帮你把WinSock启动、版本协商都做掉。注意在控制台程序里用MFC类时这一步最容易遗漏。// App.cpp 的 InitInstance 中初始化 if (!AfxSocketInit()) { AfxMessageBox(_T(Socket 初始化失败)); return FALSE; }这一步失败的概率很低但一旦失败后面的Create和Connect都会返回错误而且错误码会非常迷惑。所以我习惯在初始化后就立刻检查一次。接下来是客户端连接的最小代码。这里用异步方式Connect是立即返回的真正的连接结果在OnConnect里回传。// ClientSocket.h 中导出类 class CClientSocket : public CAsyncSocket { public: virtual void OnConnect(int nErrorCode) override; virtual void OnReceive(int nErrorCode) override; virtual void OnClose(int nErrorCode) override; };// 发起连接nTimeoutMs 用定时器检查超时 BOOL StartConnect(const CString strHost, UINT nPort, UINT nTimeoutMs) { if (!Create()) { return FALSE; // Create 失败错误码 GetLastError() } // 异步连接立即返回结果在 OnConnect 回调中通知 if (!Connect(strHost, nPort)) { int nErr GetLastError(); if (nErr ! WSAEWOULDBLOCK) { Close(); // 非正在连接的错误直接关闭 return FALSE; } } // 设置定时器用于实现连接超时 SetTimer(WM_CONNECT_TIMER_ID, nTimeoutMs, nullptr); return TRUE; }这里要说明两个关键点。一是Connect返回值异步非阻塞模式下Connect几乎总会返回FALSEGetLastError返回WSAEWOULDBLOCK意思不是失败而是连接正在后台进行中。很多初学者在这里误判为连接失败直接Close导致OnConnect永远不来。二是超时机制CAsyncSocket没有自带的连接超时接口我用SetTimer配合定时器ID在OnTimer里检查连接状态如果超时仍未完成就Close并通知界面层。这段代码是整篇的基础后面拆包、重连都是在这个类上扩展。3. 客户端实现异步连接、OnReceive收包与按帧拆包的完整写法3.1 连接状态机与重连设计复杂一点的设备通信必须有状态机。状态至少包含未连接、连接中、已连接、已断开。异步回调模型的麻烦在于你无法用简单的if判断知道当前连接是哪个阶段因为回调是跳着来的。我维护一个枚举成员变量enum class ConnState { Idle, Connecting, Connected, Closed };每次调用Connect前置为ConnectingOnConnect回调里nErrorCode为0时置为ConnectedOnClose回调里置为Closed。所有外部操作都先查状态防止重复Connect或对未连接状态发送数据。这个设计的价值在重连场景里体现得最明显。设备掉线后OnClose触发不能马上重连——要等几秒防止设备还在重启。我在OnClose里设置一个重连定时器超时后检查当前状态是否是Closed是才重新Connect。void CClientSocket::OnClose(int nErrorCode) { m_eState ConnState::Closed; // 通知界面层连接断开 GetParentWnd()-PostMessage(WM_DEVICE_OFFLINE, 0, 0); // 5秒后尝试重连 SetTimer(WM_RECONNECT_TIMER_ID, 5000, nullptr); }这里有几个细节。一是PostMessage而不是SendMessageSendMessage会同步等待界面处理完才返回网络回调里这么做容易互相卡死。二是重连定时器要一次性触发处理完就KillTimer不然定时器会反复触发。三是OnClose里必须判断nErrorCode有些情况下是主动Close触发的回调这时候不应该重连。3.2 OnReceive回调与粘包拆包很多MFC初学者的第一版代码是OnReceive里直接Receive然后立刻处理数据。这个写法看起来能跑但只要设备端一次发送了半包或者两次数据合并成一个TCP段解析就错乱了。TCP是字节流协议它不保证一次Receive就拿到一个完整的业务帧。所以标准做法是OnReceive里只负责把数据读进缓冲区然后从缓冲区里按帧协议拆包。我常用的帧协议是帧头(2字节) 帧长(2字节, 含帧头帧尾) 数据 校验(1字节异或)。拆包函数核心逻辑如下// 接收缓冲区和拆包函数 std::vectorBYTE m_recvBuf; BOOL CClientSocket::ProcessReceive() { BYTE temp[4096]; int nRet Receive(temp, sizeof(temp)); if (nRet 0) { // 返回0表示对端关闭0表示错误 return FALSE; } // 先把数据追加到缓冲区 m_recvBuf.insert(m_recvBuf.end(), temp, temp nRet); // 循环拆包因为一次Receive里可能有多个完整帧 while (m_recvBuf.size() 4) // 至少能读到帧长字段 { // 查找帧头 if (m_recvBuf[0] ! 0xAA || m_recvBuf[1] ! 0x55) { m_recvBuf.erase(m_recvBuf.begin()); continue; } // 读取帧长小端 UINT nFrameLen m_recvBuf[2] | (m_recvBuf[3] 8); if (nFrameLen 4 || nFrameLen 8192) { m_recvBuf.erase(m_recvBuf.begin()); continue; } // 数据不足一整帧等下一次OnReceive if (m_recvBuf.size() nFrameLen) { return TRUE; } // 取出一整帧送解析 std::vectorBYTE frame(m_recvBuf.begin(), m_recvBuf.begin() nFrameLen); m_recvBuf.erase(m_recvBuf.begin(), m_recvBuf.begin() nFrameLen); // 校验通过后交给业务解析 if (VerifyChecksum(frame)) { ParseFrame(frame); } } return TRUE; }这段代码需要注意三点。第一用std::vector作为接收缓冲区很省事但性能上会有频繁的内存拷贝。设备通信频率不高时完全够用如果每秒几百帧建议改用环形缓冲区。第二帧头搜索是逐字节擦除的寻找效率不高但逻辑清晰。工程中也可以记录上次查找位置来优化。第三校验失败不能直接丢弃整个流——正确做法是把帧头之后的字节当作新流的起点这样才能在串口干扰或者TCP丢段后快速恢复。3.3 主动发送与发送失败处理发送和接收不对称CAsyncSocket的Send在缓冲区满时也可能返回部分字节需要记录剩余部分。但MFC回调里没有OnSend的强制触发所以我的习惯是短数据直接Send一次返回小于待发送长度时就记录到待发队列等下一次OnReceive里顺带补发——这个偷懒的做法在低速率设备通信里够用高频发送时必须要开独立的发送线程。另外Send的返回值判断要分情况返回0未必是失败返回SOCKET_ERROR要看GetLastError如果WSAEWOULDBLOCK说明缓冲区满需要重试如果是其他错误连接基本没救了直接Close。int CClientSocket::SendPacket(const std::vectorBYTE data) { if (m_eState ! ConnState::Connected) { return -1; } int nRet Send(data.data(), (int)data.size()); if (nRet SOCKET_ERROR) { int nErr GetLastError(); if (nErr WSAEWOULDBLOCK) { // 加入待发队列稍后补发 m_pendingSend.insert(m_pendingSend.end(), data.begin(), data.end()); return 0; } // 其他错误连接不可用 Close(); return -1; } if (nRet (int)data.size()) { // 部分发送剩余并入待发队列 m_pendingSend.insert(m_pendingSend.end(), data.begin() nRet, data.end()); } return nRet; }部分发送的补发逻辑不建议在OnReceive里做那个回调的时机不可控。我后来都改成查一次定时器驱动补发比如间隔10ms检查待发队列这样发送时序稳定得多。4. 服务器骨架OnAccept监听、连接对象管理与收发线程划分4.1 用CAsyncSocket实现监听服务端服务器端在MFC里的写法其实比客户端更简单一个监听Socket对象重写OnAccept回调在回调里Accept新连接。但有个细节坑OnAccept触发表示至少有一个连接待接受如果你只Accept一次可能还有连接挂在队列里。class CListenSocket : public CAsyncSocket { public: virtual void OnAccept(int nErrorCode) override; }; void CListenSocket::OnAccept(int nErrorCode) { if (nErrorCode ! 0) { return; } // 循环Accept直到WSAEWOULDBLOCK兜住多个连接 CClientSocket* pClient new CClientSocket(); while (Accept(*pClient)) { // 把客户端对象登记到管理器并通知界面 g_connMgr.AddClient(pClient); pClient new CClientSocket(); } int nErr GetLastError(); if (nErr ! WSAEWOULDBLOCK) { // 非资源不足错误需要记录日志 TRACE(_T(Accept失败: %d\n), nErr); } delete pClient; }这段代码里我坚持new一个客户端对象Accept成功后就交给管理器管理不deleteAccept失败的时候这个临时对象要delete。很多人漏掉最后一步导致内存泄漏而且这种泄漏特别隐蔽托管环境下不容易发现。服务器端还需要注意监听Socket本身要Create和Bind、Listen三步。很多示例只写Create和Listen漏了Bind结果监听端口永远不对。BOOL StartListen(UINT nPort) { if (!m_listenSocket.Create(nPort, SOCK_STREAM, FD_ACCEPT)) { return FALSE; } if (!m_listenSocket.Listen(10)) { return FALSE; } return TRUE; }Create的第二参数是type第三个参数是事件掩码。服务器端必须指定FD_ACCEPT否则OnAccept不会被回调。客户端Create时不传事件掩码默认全部事件但服务器端这里很关键。4.2 连接对象管理与多客户端广播连接多了之后最大的痛点不是收发而是管理。断开的连接如果不及时清理管理器里堆积大量无效socket后面广播数据时发送到已关闭的socket就会触发错误。我维护一个CList或者std::vector里面存连接对象的指针同时给每个连接分配一个自增ID。客户端断开时不是直接delete而是先标记再统一清理防止回调里delete对象导致别的回调悬垂。void CConnManager::OnClientClose(CClientSocket* pClient, int nErr) { // 先从列表移除避免遍历时重复回调 m_connList.Remove(pClient); // 通知UI线程 ::PostMessage(m_hMainWnd, WM_CLIENT_OFFLINE, (WPARAM)pClient-GetId(), 0); // 延迟删除等所有网络事件处理完 pClient-Close(); pClient-Delete(); // CAsyncSocket 的内部引用计数清零 }这里一定要分清Close和Delete的关系。CAsyncSocket被引用时不能直接delete对象必须等它的内部引用计数归零。我在早期踩过这个坑在OnClose回调里直接delete了this结果MFC内部还在访问这个socket对象程序直接崩溃。正确做法是只做Close然后用PostMessage把删除动作交到界面线程消息循环里执行。广播时的常见错误是发送给已关闭的连接。所以发送前不仅检查状态还要捕获异常void CConnManager::Broadcast(const std::vectorBYTE data) { auto it m_connList.begin(); while (it ! m_connList.end()) { CClientSocket* p *it; if (p-GetState() ConnState::Connected) { if (p-SendPacket(data) 0) { // 发送失败标记为待删除 it m_connList.erase(it); p-Close(); p-Delete(); continue; } } it; } }这个写法是安全的因为删除后立刻continue没有继续访问已释放对象。4.3 服务器端线程模型的边界情况如果服务器端要承载几十个连接事件回调模式就不太合适了。连接多了OnReceive回调里处理不过来而且所有回调都在UI线程的消息循环里执行界面会明显卡顿。我的做法是每个客户端对象内部持有自己的接收线程用CSocket阻塞Receive收不到数据时线程挂起等待不占用CPU。数据到达后通过PostMessage上抛给主窗口。这种模型的优点是把网络I/O和UI彻底隔离缺点是每个线程的栈空间占用以及线程退出时不能强制TerminateThread。我一般用WSAEventSelect和WaitForMultipleObjects来优雅退出具体做法是在线程里等两个事件一个是socket事件一个是退出事件。临界资源的保护在这个模型里非常重要。连接的接收缓冲区不能从两个线程同时访问我会加一个临界区对象class CClientSocket { private: CCriticalSection m_lock; public: void AddData(const BYTE* pBuf, int nLen) { CSingleLock lock(m_lock, TRUE); m_recvBuf.insert(m_recvBuf.end(), pBuf, pBuf nLen); } };很多人觉得加锁影响性能但TCP拆包场景下锁的粒度也就是一次插入和一次取出开销微乎其微。真正影响性能的是在锁里做业务解析那才是把并行活活做成串行的元凶。5. 避坑指南MFC TCP通信里最容易翻车的5个细节5.1 界面卡死在OnReceive里做了耗时解析现象程序收发数据一段时间后主窗口转圈设备一多发数据就白屏卡住。 原因OnReceive回调本身在UI线程里你在里面做了文件写入、数据库操作或者复杂的业务解析网络事件一多消息循环被占满界面就无法重绘。 解决把解析逻辑挪出回调通过PostMessage把原始数据送进界面线程后再解析。如果是高频数据攒一批再上抛例如收到完整帧后只PostMessage一个帧头指针解析放到OnIdle或专门线程里做。5.2 粘包拆包漏包把Receive和业务帧画等号现象程序刚启动时正常运行几分钟后数据开始错位CRC校验频繁失败。 原因TCP是字节流不是报文流一次Receive可能只收到半个业务帧也可能一次收到三个帧。按接收次数解析必然错位。 解决按前面写的拆包方案先缓冲再按帧头、帧长、校验逐帧扫描。特别注意帧头出现的位置不确定要用查找而不是固定偏移。5.3 Close后回调还在触发的悬垂崩溃现象点击断开按钮后程序偶发性崩溃崩溃位置在OnReceive或OnClose内。 原因Close是异步的调用后MFC可能还有已经排队的消息回调仍会触发。你在回调里操作了这个对象但对象可能已经被其他逻辑释放了。 解决不要直接delete CAsyncSocket派生类对象。维护连接状态回调里先判断状态再用PostMessage回到UI线程统一清理配合引用计数或延迟删除。5.4 重连风暴掉线后反复拉起连接现象设备断网瞬间程序日志刷屏一秒内发起了大量Connect调用。 原因OnClose里立即重连而对方还没恢复Connect失败后再次触发OnClose形成死循环。 解决重连必须带最小间隔比如5秒一次。而且每次Connect前检查当前状态只有Closed状态才能发起新的连接请求。重连计数超过N次后就停止自动重连改为手动按钮或等待用户操作。5.5 关闭顺序颠倒退出时线程还在收发现象程序退出时偶发报错或者退出后进程还在后台驻留。 原因关闭主窗口后子线程还阻塞在Receive里没有被唤醒就结束了进程或者窗口销毁时socket句柄被回收但回调仍引用窗口。 解决退出时必须先关闭socket、再等待线程结束、最后销毁窗口。我用事件通知线程退出线程收到通知后不阻塞在Receive上而是走完清理代码再退出。顺序一旦写反就会随机翻车。6. 进阶技巧用PostMessage把网络数据送进界面告别卡顿与过期数据前面的避坑章节里反复提PostMessage最后我把这个习惯展开成一个完整的可落地方案。很多人写MFC上位机时会在OnReceive里直接调SetDlgItemText去刷新显示。这在网络数据低频时没问题但数据一旦频繁起来界面刷新本身又会触发重绘导致回调阻塞恶性循环。我的做法是定义一组窗口消息把数据指针和长度打包到自定义结构里用PostMessage上抛。// 自定义消息结构注意不要直接用指针跨线程 struct NetDataMsg { CClientSocket* pSocket; std::vectorBYTE* pFrame; // 指向堆上数据接收方负责释放 }; // 在OnReceive解析出完整帧后 void CClientSocket::NotifyUI(const std::vectorBYTE frame) { NetDataMsg* pMsg new NetDataMsg(); pMsg-pSocket this; pMsg-pFrame new std::vectorBYTE(frame); ::PostMessage(m_hMainWnd, WM_NET_DATA_READY, (WPARAM)pMsg, 0); }窗口消息处理函数里再解包、更新界面LRESULT CMainDlg::OnNetDataReady(WPARAM wParam, LPARAM lParam) { NetDataMsg* pMsg (NetDataMsg*)wParam; if (pMsg nullptr) { return 0; } // 解析帧更新控件 ParseAndDisplay(pMsg-pSocket, *(pMsg-pFrame)); delete pMsg-pFrame; delete pMsg; return 0; }这里有一个必须注意的细节永远不要PostMessage一个指针指向栈上的局部变量。原因是PostMessage只是把消息放进队列就返回了而局部变量在函数结束时就析构了接收方拿到的指针已经悬垂。我在早期用CString做参数时踩过一次显示的文本偶尔对偶尔乱码排查了一下午才发现是栈生命周期问题。另外一个习惯是接收方对消息里的指针负有释放责任谁分配谁释放、谁释放谁置空两条规则写清楚后基本不会内存泄漏。这套写法的好处不只是消除卡顿。因为PostMessage是非阻塞的网络线程和UI线程完全解耦界面刷新慢一点只会导致消息堆积而不会反过来阻塞socket接收。消息堆积如果严重可以在UI线程判断队列长度超过一定阈值就丢弃中间帧只保留最新帧——这正好解决了工控显示场景里数据太新反而没用的问题。做MFC TCP通信这几年我最深的经验是大部分问题都不是网络问题而是线程模型和对象生命周期的问题。如果你也打算在项目里上这套方案先把回调里的职责写清楚再去纠结协议和性能会比边写边试靠谱得多。希望这些踩坑经验帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站