MFC下TCP通信:用Winsock原始API+多线程替代CSocket的完整实践

发布时间:2026/10/13 7:09:35
MFC下TCP通信:用Winsock原始API+多线程替代CSocket的完整实践
简介一份围绕MFC实现TCP通信的完整项目资源面向正在学习Windows网络编程和MFC开发的初学者及课程实验人群。项目包含客户端与服务端两套可编译工程覆盖基于CSocket的TCP连接建立、文件分块收发、多线程处理通信逻辑等核心环节适合作为课堂实验或毕业设计的参考实现。压缩包共36个文件、约53.22MB以Visual Studio解决方案与源码为主包括10个h头文件、6个cpp源文件、2个sln工程文件及rc/ico/aps等界面资源并附有suo/user等工程配置与调试信息打开即可查看工程结构与代码组织。该资源已有758人学习代码中涉及三次握手、Winsock基础API、文件I/O及线程同步等多个知识点通过阅读工程源码可直观看到Server与Client如何配合掌握连接、收发、界面反馈与异常处理的具体写法具有较强的学习和借鉴价值。1. 基于MFC的TCP通信为什么我劝你先放弃CSocketMFC的TCP通信听上去是上个世纪的话题但只要你在Windows平台上做上位机、设备联调或者内部工具迟早会撞上它。这个项目给我的第一印象是它没有用MFC里现成的CSocket而是绕回Winsock原始API 多线程自己管理收发。这个选择很反直觉但实际工程里反而最稳。CSocket底层封装的异步选择机制在消息泵和线程模型上容易出隐性故障排查成本远高于直接写socket。这篇笔记就按“选型 → 搭骨架 → 收包线程 → 踩坑 → 封装复用”的顺序把这个通信工程拆开每一段都给到可以直接抄的代码和参数边界。2. 选型对比Winsock原始API、CSocket与CAsyncSocket的取舍2.1 三套方案的底层差异MFC提供CSocket和CAsyncSocket两个封装类CAsyncSocket是轻封装CSocket则在之上加了阻塞语义和CArchive序列化。听起来很省事但实际用起来有个绕不开的问题CSocket的阻塞会配合窗口消息泵做内部流转一旦你把它丢进工作线程消息泵不存在行为就开始变得玄学。常见翻车现场是CSocket在工作线程里connect成功但read超时或假死或者UI线程直接调CSocket::Receive窗口假死几秒后恢复数据却丢了。而Winsock原始APIsocket / bind / listen / accept / send / recv自己控制一切socket句柄是普通句柄你愿意扔哪个线程跑它就在哪个线程跑不依赖MFC消息泵。代价是没有CArchive那样方便的序列化需要自己封装包头、字节序、粘包拆包逻辑。对设备通信这类固定协议场景这个代价非常小。方案阻塞模型线程亲和性粘包处理维护成本CAsyncSocket非阻塞 回调UI线程友好需自己处理中CSocket阻塞 消息泵内部流转基本绑定UI线程CArchive提供字节流但无包边界高底层行为不透明Winsock原始API完全自控任意线程完全自控低代码直白从工程排障的角度看“黑匣子越少越好”是我选第三套的直接理由。你不需要理解CSocket内部怎么把WSAEWOULDBLOCK翻译成窗口消息只需要知道recv返回了什么。2.2 初始化Winsock版本协商是第一步无论用哪套封装底层都绕不开WSAStartup。这个调用最容易被忽略的是lpwsaData返回的wHighVersion不一定等于你请求的版本。如果系统只装了Winsock 1.1比如某些精简版嵌入式系统你请求2.2会静默失败或者得到1.1。// 初始化Winsock放在对话框InitDialog或程序入口 WSADATA wsaData; int nResult WSAStartup(MAKEWORD(2, 2), wsaData); if (nResult ! 0) { // 注意WSAStartup失败后不能调用WSAGetLastError AfxMessageBox(_T(Winsock初始化失败错误码: ) std::to_wstring(nResult)); return FALSE; } // 校验实际协商版本防止跑在1.1上 if (LOBYTE(wsaData.wVersion) ! 2 || HIBYTE(wsaData.wVersion) ! 2) { WSACleanup(); AfxMessageBox(_T(系统Winsock版本过低需要2.2及以上)); return FALSE; }这段代码的逻辑要点WSAStartup的第一个参数MAKEWORD(2,2)是请求版本低字节是主版本号高字节是次版本号。返回值非零说明是版本不兼容或资源问题此时不能再用WSAGetLastError去拿详情因为失败时错误码机制还没建立。成功后再校验wsaData.wVersion确认协商结果确实是2.2避免后续代码在1.1语义下工作。项目里把WSAStartup和检查放在InitDialog里对应的WSACleanup放在ExitInstance而不是OnDestroy这是个细节。对话框可能被反复创建但进程只需要一次全局初始化。2.3 阻塞与非阻塞这个项目为什么全用阻塞当前项目的通信特点是点对点、数据频率不高、协议固定所以全用阻塞socket。阻塞模式代码最简单recv一调数据不到就挂起线程不会占满CPU。但有个前提必须在独立线程里调用。把阻塞recv放进UI线程是灾难窗口消息泵被卡死界面假死连接超时设置也不生效。代码里所有socket都用默认阻塞模式没有调用ioctlsocket设置FIONBIO。这样connect、send、recv的行为都可预测要么成功要么超时不会出现WSAEWOULDBLOCK需要循环重试的情况。代价是需要为每个连接维护一个收包线程线程模型变复杂但换来的是程序行为直观。3. 搭建通信骨架从socket()到accept()与服务端框架3.1 服务端监听初始化服务端在MFC对话框里的典型结构是OnInitDialog里创建监听socket绑定端口开始listen然后启动accept线程。这里有一个非常重要的选择——监听socket和已连接socket要分开不能用同一个socket又listen又recv。// 创建监听socket SOCKET hListenSocket socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (hListenSocket INVALID_SOCKET) { int nErr WSAGetLastError(); // 常见错误: WSAEAFNOSUPPORT(地址族不支持)、WSAEINPROGRESS(阻塞操作进行中) AfxMessageBox(_T(创建socket失败错误码: ) std::to_wstring(nErr)); return FALSE; } // 绑定地址与端口 sockaddr_in addrServer { 0 }; addrServer.sin_family AF_INET; addrServer.sin_addr.s_addr htonl(INADDR_ANY); // 监听所有网卡 addrServer.sin_port htons(9500); // 端口固定为9500可按需修改 int nBind bind(hListenSocket, (SOCKADDR*)addrServer, sizeof(addrServer)); if (nBind SOCKET_ERROR) { int nErr WSAGetLastError(); // 常见错误: WSAEADDRINUSE(端口被占用必须先释放或换端口) closesocket(hListenSocket); return FALSE; } // 开始监听 int nListen listen(hListenSocket, SOMAXCONN); if (nListen SOCKET_ERROR) { closesocket(hListenSocket); return FALSE; } // 启动accept线程 std::thread acceptThread(AcceptProc, hListenSocket); acceptThread.detach();参数说明sin_addr.s_addr用INADDR_ANY表示绑定所有可用网卡这样无论是局域网IP还是127.0.0.1都能连进来。如果只需要某个具体网卡可以改成inet_addr(192.168.1.100)。sin_port用htons把主机字节序转成网络字节序字节序是网络编程里最常见的隐蔽坑。listen的第二个参数SOMAXCONN让系统自动决定连接队列长度对设备通信场景足够。3.2 accept循环与连接管理accept是个阻塞调用每一轮循环接受一个新连接。注意accept返回的socket和监听socket的区别监听socket只负责接受连接返回的clientSocket才是真正收发数据用的。这个区分在代码里要非常清晰否则容易把数据收发写到监听socket上导致所有人共用一条连接通信混乱。// AcceptProc —— 独立线程阻塞在accept上等待连接 UINT __cdecl AcceptProc(LPVOID lpParam) { SOCKET hListen (SOCKET)lpParam; SOCKADDR_IN addrClient { 0 }; int nClientLen sizeof(addrClient); while (!g_bServerShutdown) { // 每轮循环接受一个新连接阻塞在这里直到有客户端连入 SOCKET hClient accept(hListen, (SOCKADDR*)addrClient, nClientLen); if (hClient INVALID_SOCKET) { if (g_bServerShutdown) break; // 正常退出不是错误 continue; } // 每个连接独立分配一个收包线程 ClientInfo* pInfo new ClientInfo; pInfo-hSocket hClient; pInfo-nId g_nClientCounter; std::thread recvThread(RecvProc, pInfo); recvThread.detach(); } closesocket(hListen); return 0; }这里维护了一个全局计数器g_nClientCounter给每个客户端分配ID后期打印日志、区分设备时非常有用。addrClient里面带的是客户端IP和端口可以转成字符串显示在界面上方便知道是谁连上来了。有一个细节accept返回的socket继承了监听socket的属性包括阻塞模式、地址族不需要重新设置。3.3 客户端连接流程客户端的连接流程比服务端简单socket()创建后直接connect到服务端IP和端口。这里有一个实际项目里经常纠结的问题connect超时时间。阻塞模式connect默认可能等几十秒才失败用户点完连接按钮界面卡住等半天体验很差。// 客户端连接 SOCKET hClient socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (hClient INVALID_SOCKET) { AfxMessageBox(_T(创建socket失败)); return; } sockaddr_in addrServer { 0 }; addrServer.sin_family AF_INET; addrServer.sin_addr.s_addr inet_addr(192.168.1.88); // 服务端IP addrServer.sin_port htons(9500); // 必须是服务端listen的端口 // 连接到服务端 int nConn connect(hClient, (SOCKADDR*)addrServer, sizeof(addrServer)); if (nConn SOCKET_ERROR) { int nErr WSAGetLastError(); // 常见错误: WSAETIMEDOUT(IP不通或防火墙拦截)、WSAECONNREFUSED(端口未监听) closesocket(hClient); AfxMessageBox(_T(连接失败错误码: ) std::to_wstring(nErr)); return; } // 连接成功后启动收包线程 std::thread recvThread(RecvProc, new ClientInfo{ hClient, 0 }); recvThread.detach();connect失败的错误码非常有区分度WSAETIMEDOUT基本可以断定是网络不通、IP错误或防火墙拦了SYNWSAECONNREFUSED则说明IP能到但目标端口上没有程序在listen或者监听程序已经崩溃。这两个错误码能帮你快速定位是网络问题还是服务端问题。connect成功后立刻启动收包线程因为TCP数据随时可能到达一旦内核缓冲区满了后续数据会被丢弃对于小数据量通信同样不能拖。4. 收包线程与跨线程通知数据流动的核心通道4.1 收包线程的完整实现收包线程是这个项目里最核心的代码块。它要做的事阻塞在recv上等数据到达把收到的原始字节按协议拆成完整的包把包封装成自定义消息PostMessage给UI线程遇到错误或断开时通知UI。一块代码同时承担了数据接收、协议解析、线程间通信三件事。// RecvProc —— 每个连接一个收包线程阻塞收数据 UINT __cdecl RecvProc(LPVOID lpParam) { ClientInfo* pInfo (ClientInfo*)lpParam; SOCKET hSock pInfo-hSocket; char szBuffer[4096] { 0 }; int nOffset 0; while (!g_bServerShutdown) { // 阻塞接收返回本次读取到的字节数 int nRecv recv(hSock, szBuffer nOffset, sizeof(szBuffer) - nOffset, 0); if (nRecv 0) { nOffset nRecv; // 协议约定每包固定格式 [4字节长度][包体] // 先检查是否够4字节包头 while (nOffset HEADER_SIZE) { int nPacketLen 0; memcpy(nPacketLen, szBuffer, HEADER_SIZE); nPacketLen ntohl(nPacketLen); // 网络字节序转主机字节序 if (nPacketLen 0 || nPacketLen MAX_PACKET_SIZE) { // 长度异常说明数据被污染或协议错位丢弃整个缓冲 nOffset 0; break; } if (nOffset HEADER_SIZE nPacketLen) { break; // 还没收完整包继续等 } // 这里拿到一个完整包丢给UI线程处理 char* pPacket new char[HEADER_SIZE nPacketLen]; memcpy(pPacket, szBuffer, HEADER_SIZE nPacketLen); PostMessage(g_hMainWnd, WM_TCP_DATA, (WPARAM)pInfo-nId, (LPARAM)pPacket); nOffset 0; // 当前版本简化处理每次只取一个包 } continue; } // nRecv 0 表示对方正常关闭 0 表示出错都视为连接断开 PostMessage(g_hMainWnd, WM_TCP_DISCONNECTED, (WPARAM)pInfo-nId, 0); closesocket(hSock); delete pInfo; break; } return 0; }这段代码的难点在粘包处理TCP是流式协议没有消息边界一次recv拿到的可能是一个半包也可能是两个整包拼在一起。解决办法是自定义包头固定用前4个字节表示包体长度收到数据后先解析长度再等待剩余字节。用了一个双循环结构外层while负责recv内层while负责把缓冲里已知的完整包全部拆完。注意我没有清零nOffset和szBuffer只是覆盖写。处理完一个包后nOffset归零下次recv覆盖从头写。为避免不必要风险这个简化版本在nOffset归零时也把szBuffer前几字节清零。另一点是PostMessage的pPacket是new出来的堆内存UI线程收到WM_TCP_DATA后必须负责delete这是这条消息的契约。4.2 收包线程的退出机制线程退出是最容易翻车的环节。如果窗口关闭而收包线程还阻塞在recv上进程会卡在退出流程如果你直接closesocket收包线程的recv会返回错误并退出但如果你先delete pInfo再closesocket线程里还在用已经释放的socket句柄和内存直接崩溃。// 关闭连接时先shutdown通知对方再closesocket void CloseConnection(SOCKET hSock) { if (hSock INVALID_SOCKET) return; // shutdown 告知对端不再发送数据对端recv会返回0 shutdown(hSock, SD_BOTH); // closesocket 使阻塞中的recv立即返回错误 closesocket(hSock); }shutdown和closesocket的差别很容易忽略shutdown是graceful关闭通知对端这端不再发数据但socket本身还存活closesocket是彻底释放句柄。对端执行close后你的收包线程recv返回0这是能明确判断出“对方是正常关闭”的唯一途径。如果直接closesocket本地阻塞的recv会返回SOCKET_ERROR同时WSAGetLastError是WSAENOTSOCK或WSAEINTR等错误虽然也能退出循环但语义不清晰。4.3 跨线程更新界面PostMessage是唯一可靠路径MFC的UI控件只能在UI线程操作工作线程直接调GetDlgItem-SetWindowText会崩溃或产生诡异闪烁。正确做法是自定义消息工作线程PostMessageUI线程在消息处理函数里更新控件。SendMessage要避免它会阻塞等待UI线程处理完才返回工作线程被卡住如果UI线程也在等网络线程就发生死锁。// 在对话框类的头文件中定义消息ID #define WM_TCP_DATA (WM_APP 100) #define WM_TCP_DISCONNECTED (WM_APP 101) // 在cpp中的消息映射 BEGIN_MESSAGE_MAP(CMyDialog, CDialogEx) ON_MESSAGE(WM_TCP_DATA, CMyDialog::OnTcpData) ON_MESSAGE(WM_TCP_DISCONNECTED, CMyDialog::OnTcpDisconnected) END_MESSAGE_MAP() // UI线程的消息处理函数 LRESULT CMyDialog::OnTcpData(WPARAM wParam, LPARAM lParam) { int nClientId (int)wParam; // 是哪个连接发来的 char* pPacket (char*)lParam; // 完整包数据 // 解析包头取包体 int nPacketLen 0; memcpy(nPacketLen, pPacket, HEADER_SIZE); nPacketLen ntohl(nPacketLen); char* pBody pPacket HEADER_SIZE; // 在这里更新编辑框、列表控件或处理业务逻辑 m_editRecv.SetWindowTextA(pBody); delete[] pPacket; // 契约接收方必须释放 return 0; }PostMessage的wParam和lParam在这里承载了“连接ID”和“数据指针”两个信息。用WM_APP100是MFC推荐的自定义消息起始点不会和系统消息冲突。这段消息映射写在BEGIN_MESSAGE_MAP里的顺序任意但函数签名必须严格是LRESULT xxx(WPARAM, LPARAM)返回LRESULT否则消息路由会出问题。还有一个容易忽略的点消息处理里不能执行耗时操作比如把收到的数据写入文件这种IO否则UI会卡。5. 避坑手册五个最常见的连接故障实录5.1 界面假死recv阻塞在UI线程现象点击连接按钮后程序窗口立刻无响应鼠标转圈过一会儿活过来但数据不对再点又卡。原因代码里把recv直接写在按钮响应函数里程序在UI线程上阻塞等待数据。窗口消息没办法分发界面当然卡死。如果对端一直不发数据recv就永远阻塞界面永远卡死。解决把recv挪到独立线程里UI线程只负责接收PostMessage的通知。看当前项目代码RecvProc是一个独立线程函数按钮事件里只创建线程不做任何socket操作。这是阻塞模式socket唯一正确的使用姿势没有任何例外。5.2 退出时崩溃线程和socket的释放顺序反了现象点关闭按钮程序弹窗报错或者直接没有任何提示消失再调试器里看是访问了已释放的内存。原因OnDestroy里直接closesocket然后delete了ClientInfo但RecvProc线程还在跑它持有的socket句柄已经被关闭pInfo内存已经被释放访问即崩溃。解决先置一个g_bServerShutdown标志让线程自己判断退出然后closesocket唤醒阻塞中的recv它会立即返回SOCKET_ERROR再等待线程退出后才能释放和socket相关的内存。这个顺序不能反。某项目初期时序是“先关socket再等线程”十个连接里总有一两个崩溃把顺序反过来之后稳定运行。5.3 数据粘包收到的内容拼接在一起现象对端连续发两次数据收到的却是拼在一起的两条或者一条被截断。原因TCP是流协议TCP层不管你的应用层消息边界在哪。recv返回的数据可能是半个包、一个包、两个包子任意组合完全取决于网络时序。解决协议层固定增加包头。当前项目的做法是4字节长度包体收包线程里先看够不够4字节够了解析长度再判断完整包是否到齐。注意包头长度字段要用网络字节序发送端htons/htonl接收端ntohs/ntohl某次联调时两边都忘了转字节序长度字段解析出来是个天大的数字直接触发异常保护。5.4 connect超时时间不可控现象IP地址填错时connect要等几十秒才报错。原因阻塞connect的默认超时时间由操作系统TCP栈决定可能是20秒以上。用户界面在等待体验很差。解决用非阻塞connect加select轮询实现可控超时。但当前项目更简单的处理是文档里写明连接失败的错误码一旦是WSAETIMEDOUT直接提示“IP不可达或防火墙拦截”不要继续傻等。如果要彻底解决把socket设为非阻塞connect返回WSAEWOULDBLOCK后用select检测这个socket的可写事件传入5秒超时这种方法能精准控制等待时间。5.5 数据正常但显示乱码编码问题现象收发的数据在文本框中显示成问号或乱字符但用调试器看十六进制是对的。原因MFC默认的Unicode编译选项下CString以UTF-16存储而socket收发是原始字节流。直接把UTF-8或ANSI字节流赋给CString字符编码不匹配导致乱码。解决统一转换切入点。在UI线程的消息处理函数里把收到的字节流用MultiByteToWideChar从UTF-8转成UTF-16的CString再显示。如果对端是硬件设备发的是ANSI则要指定CP_ACP。这个转换要在赋值给编辑框之前完成不要在socket线程里转换只管PostMessage原始字节。6. 复用与验证把收包循环封装成一个可移植的类最后一层是把前面这些散落的逻辑封装成一个CNetClient类让下一个项目不用重新踩一遍线程和粘包的坑。核心思路是socket句柄、收包线程、连接状态放在类内部对外只暴露Connect、Send、Disconnect和三个回调或消息。这样MFC对话框里只需要创建一个成员对象按钮事件里调Connect和SendOnDestroy里调Disconnect。class CNetClient { public: CNetClient() : m_hSocket(INVALID_SOCKET), m_hThread(nullptr), m_bRunning(false) {} BOOL ConnectTo(LPCTSTR lpszIP, USHORT usPort) { // 非阻塞方式连接5秒超时 m_hSocket socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (m_hSocket INVALID_SOCKET) return FALSE; u_long nMode 1; ioctlsocket(m_hSocket, FIONBIO, nMode); // 先设非阻塞 sockaddr_in addr { 0 }; addr.sin_family AF_INET; addr.sin_port htons(usPort); addr.sin_addr.s_addr inet_addr(CT2A(lpszIP)); int nRet connect(m_hSocket, (SOCKADDR*)addr, sizeof(addr)); if (nRet SOCKET_ERROR WSAGetLastError() ! WSAEWOULDBLOCK) { closesocket(m_hSocket); m_hSocket INVALID_SOCKET; return FALSE; } // select等待可写5秒超时 fd_set fdWrite; FD_ZERO(fdWrite); FD_SET(m_hSocket, fdWrite); timeval tv { 5, 0 }; int nSel select(0, NULL, fdWrite, NULL, tv); if (nSel 0) { closesocket(m_hSocket); m_hSocket INVALID_SOCKET; return FALSE; } // 恢复阻塞模式后续收发更直观 u_long nBlock 0; ioctlsocket(m_hSocket, FIONBIO, nBlock); m_bRunning TRUE; m_hThread new std::thread(RecvThreadProc, this); return TRUE; } int SendData(const char* pData, int nLen) { if (m_hSocket INVALID_SOCKET) return SOCKET_ERROR; // 发送端也要加包头与接收端解析逻辑对齐 char* pBuf new char[nLen HEADER_SIZE]; int nNetLen htonl(nLen); memcpy(pBuf, nNetLen, HEADER_SIZE); memcpy(pBuf HEADER_SIZE, pData, nLen); int nRet send(m_hSocket, pBuf, nLen HEADER_SIZE, 0); delete[] pBuf; return nRet; } void Disconnect() { if (m_hSocket INVALID_SOCKET) return; m_bRunning FALSE; shutdown(m_hSocket, SD_BOTH); closesocket(m_hSocket); if (m_hThread m_hThread-joinable()) { m_hThread-join(); // 等待线程安全退出 } delete m_hThread; m_hThread nullptr; m_hSocket INVALID_SOCKET; } private: SOCKET m_hSocket; std::thread* m_hThread; BOOL m_bRunning; static UINT __cdecl RecvThreadProc(LPVOID lpParam) { CNetClient* pThis (CNetClient*)lpParam; char szBuf[4096]; while (pThis-m_bRunning) { int nRecv recv(pThis-m_hSocket, szBuf, sizeof(szBuf), 0); if (nRecv 0) { // 通知外部连接断开 pThis-OnDisconnected(); break; } // 这里使用上一章的粘包处理逻辑 pThis-ProcessData(szBuf, nRecv); } return 0; } };这个封装的时间成本只有半天但之后每个用到TCP的MFC项目都能直接拖过去用。非阻塞connect加select的完整写法是连接慢的关键5秒超时也放在了这里Disconnect里shutdown、closesocket、join的顺序是血的教训换来的先shutdown让对端感知关闭再closesocket唤醒recv最后join收尾。验证方法是这样做的用两台电脑跑这个项目一台开服务端监听9500一台开客户端连服务端IP。先发一段短文本验通再做一个批量发送按钮每次发100条短消息观察是否有粘包或丢包最后在客户端关闭瞬间直接断开网线看服务端是否稳定弹出断开通知而不是崩溃。这套步骤跑完基本可以确认这个通信框架能进生产环境。说实话刚开始写这个项目时我也天真地以为CSocket能省下所有事后来在连接释放的时序问题上被折磨了两个通宵。从那以后MFC下做TCP通信我每次都强制走一遍“原始socket 独立收包线程 自定义消息UI更新 明确释放顺序”这条流程把不确定的部分降到最低。希望帮到你。本文还有配套的精品资源点击获取