ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

MFC Socket TCP通信实战:C/S架构从搭建到避坑

MFC Socket TCP通信实战:C/S架构从搭建到避坑 简介这是一份面向Windows平台网络编程初学者与C开发者的MFC TCP通信实战源码围绕客户端-服务器C/S架构展开帮助读者理解如何借助MFC封装的CSocket类完成面向连接的可靠数据传输。资源包共4个文件包含2个cpp源文件与2个h头文件压缩后约6KB分别对应客户端连接对话框与服务器端监听对话框的实现涵盖Winsock初始化、套接字创建、绑定监听、接受连接、收发数据及关闭释放等关键环节。代码结构精简便于对照学习MFC消息机制与网络通信的配合方式。目前已有1260人浏览学习适合作为课程设计、网络编程实验或自学练手的参考范例也可在此基础上扩展为多客户端并发、文件传输等进阶功能。1. 从一次“连不上”的调试说起MFC Socket 的 C/S 通信到底在解决什么很多人第一次接触 socket 网络编程是在控制台里敲connect和send几行代码就能跑通。可一旦把场景换成 MFC 桌面程序事情立刻变得“玄学”起来界面卡死、连接建立后收不到数据、中文变成乱码、关窗口时进程还挂在后台。我当年做一个设备参数下发工具客户端用 MFC 对话框服务端跑在工控机上明明 TCP 三次握手在抓包里看得清清楚楚可recv就是返回 0查了一下午才发现是服务端把accept拿到的 socket 当成了监听 socket 在用。这类坑几乎每个从“能跑”走向“能用”的人都会踩一遍。这篇要讲清楚的就是基于 socket 通信、用 MFC 实现 TCP 通信的 C/S 架构程序从选型、搭骨架、收发数据到排错完整走一遍。它适合两类人一类是刚学完 MFC 四大类、想找个真实项目练手的同学另一类是要给现有 MFC 工具加一条网络通道、又不想引入第三方框架的工程师。核心结论先放这儿——MFC 本身不提供网络类真正干活的是 Winsock2MFC 只负责把界面线程和网络线程隔开。想明白这一点后面所有设计都顺了。2. 选型与骨架为什么是阻塞 socket 配工作线程而不是别的2.1 MFC 里做 TCP到底有哪几条路在 Windows 上做 TCP 客户端/服务端常见做法有三类。第一类是直接用 Winsock2 的阻塞 socket自己开线程收数据第二类是用 MFC 自带的CAsyncSocket和CSocket它们是对 Winsock 的薄封装能挂到 MFC 的消息机制上第三类是引入 asio、libevent 这类跨平台库。标题锁定了 MFC所以第三类先排除——不是不好而是和“用 MFC 实现”这个前提不搭。CAsyncSocket的卖点是非阻塞加事件回调OnReceive、OnConnect这些虚函数会在 socket 有事件时被调用。听起来很美但它有个硬伤回调是在 MFC 的消息泵里触发的一旦你在OnReceive里做耗时处理界面照样卡。CSocket更进一步内部用了阻塞加消息泵的模拟写起来像同步代码但它的阻塞是“假阻塞”依赖消息循环放在工作线程里用会出问题。我一般会选第一条路原生 Winsock2 阻塞 socket 独立工作线程。理由很实在——行为可预测。阻塞就是阻塞recv没数据就停在那儿不依赖消息泵不挑线程模型。服务端用accept循环加每连接一线程客户端用一条接收线程逻辑清晰出问题抓包就能定位。代价是要自己管线程生命周期和 socket 关闭顺序但这部分代码量不大而且一旦写好就是模板。2.2 服务端骨架监听、接受、收发的最小闭环先看服务端。它的职责是绑定端口、监听、接受连接、收发数据。下面这段是核心骨架省略了错误处理的细节但关键调用都在。// TcpServer.cpp —— 服务端核心逻辑阻塞 socket 每连接一线程 #include winsock2.h #include ws2tcpip.h #pragma comment(lib, ws2_32.lib) SOCKET g_listenSock INVALID_SOCKET; // 每个客户端连接的处理线程 DWORD WINAPI ClientThread(LPVOID lpParam) { SOCKET clientSock (SOCKET)lpParam; char buf[4096]; while (true) { int n recv(clientSock, buf, sizeof(buf) - 1, 0); if (n 0) { buf[n] \0; // 这里把数据抛给主线程更新 UI不要直接操作控件 ::PostMessage(g_hMainWnd, WM_APP 1, (WPARAM)clientSock, (LPARAM)_strdup(buf)); } else if (n 0) { break; // 对端正常关闭 } else { int err WSAGetLastError(); if (err ! WSAECONNRESET) { /* 记录日志 */ } break; } } closesocket(clientSock); return 0; } bool StartServer(unsigned short port) { WSADATA wsa; WSAStartup(MAKEWORD(2, 2), wsa); // 必须先初始化 g_listenSock socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (g_listenSock INVALID_SOCKET) return false; sockaddr_in addr{}; addr.sin_family AF_INET; addr.sin_addr.s_addr INADDR_ANY; // 监听所有网卡 addr.sin_port htons(port); if (bind(g_listenSock, (sockaddr*)addr, sizeof(addr)) SOCKET_ERROR) return false; if (listen(g_listenSock, SOMAXCONN) SOCKET_ERROR) return false; while (true) { sockaddr_in cliAddr{}; int len sizeof(cliAddr); SOCKET c accept(g_listenSock, (sockaddr*)cliAddr, len); if (c INVALID_SOCKET) break; // 每个连接开一条线程简单直接 CreateThread(nullptr, 0, ClientThread, (LPVOID)c, 0, nullptr); } return true; }逻辑说明WSAStartup是 Winsock 的入场券不调用后面所有 socket 函数都会失败返回WSANOTINITIALISED。bind把 socket 和本地地址端口绑死INADDR_ANY表示不限网卡。listen之后 socket 进入监听态accept会阻塞直到有客户端连上来返回一个全新的 socket 专门服务这个连接——这一点是新手最容易搞混的地方监听 socket 只负责“接客”不负责“聊天”。参数说明SOMAXCONN是系统允许的最大等待队列长度直接用宏即可。recv的缓冲区我给了 4096实际项目里如果单条消息可能超过这个值要么加大要么在应用层做分包。PostMessage里的WM_APP 1是自定义消息用WM_APP起步是为了避开系统消息编号这是 MFC 里跨线程更新 UI 的标准姿势。2.3 客户端骨架连接、接收线程与 UI 解耦客户端比服务端简单核心是connect加一条接收线程。注意MFC 对话框的按钮响应函数运行在主线程如果你在里面直接recv界面立刻假死。所以连接可以放在主线程connect通常很快但接收必须另起线程。// TcpClient.cpp —— 客户端连接与接收 SOCKET g_clientSock INVALID_SOCKET; DWORD WINAPI RecvThread(LPVOID) { char buf[4096]; while (true) { int n recv(g_clientSock, buf, sizeof(buf) - 1, 0); if (n 0) { buf[n] \0; ::PostMessage(g_hMainWnd, WM_APP 2, 0, (LPARAM)_strdup(buf)); } else { break; // 0 表示对端关闭-1 表示出错 } } return 0; } bool ConnectToServer(const char* ip, unsigned short port) { g_clientSock socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); sockaddr_in addr{}; addr.sin_family AF_INET; addr.sin_port htons(port); inet_pton(AF_INET, ip, addr.sin_addr); if (connect(g_clientSock, (sockaddr*)addr, sizeof(addr)) SOCKET_ERROR) { // 连接失败常见错误码 WSAECONNREFUSED / WSAETIMEDOUT return false; } CreateThread(nullptr, 0, RecvThread, nullptr, 0, nullptr); return true; }逻辑说明inet_pton把点分十进制 IP 转成网络字节序比老旧的inet_addr更安全能正确处理返回值。connect成功返回后socket 就处于已连接状态可以send和recv。接收线程里recv返回 0 是对端优雅关闭返回SOCKET_ERROR才是异常两者要分开处理否则日志里全是误报。参数说明htons把主机字节序的端口转成网络字节序忘了这一步的典型症状是“连不上但抓包看端口号不对”。WM_APP 2是客户端自定义消息和服务端的WM_APP 1区分开避免消息串台。3. 把收发做扎实粘包、编码与线程安全的三道坎3.1 TCP 粘包不是 bug是必须处理的常态TCP 是字节流协议它只保证字节顺序不保证消息边界。你send两次对端可能一次recv全收到也可能分三次收到。热词里那个“为什么 socket 接收到奇数字节后面会补一个随机数”的现象本质就是发送端和接收端的缓冲区节奏不一致。解决办法只有一个在应用层定义消息边界。常见做法有两种。一是定长包头加变长包体包头里放一个长度字段二是用分隔符比如每条消息以\n结尾。前者更通用后者更简单。我一般用前者包头固定 4 字节存包体长度。// 发送先发 4 字节长度再发包体 bool SendPacket(SOCKET s, const std::string body) { uint32_t len htonl((uint32_t)body.size()); // 转网络字节序 if (send(s, (char*)len, 4, 0) ! 4) return false; int sent 0; while (sent (int)body.size()) { int n send(s, body.data() sent, (int)body.size() - sent, 0); if (n 0) return false; sent n; // send 也可能只发一部分 } return true; } // 接收先收满 4 字节长度再按长度收包体 bool RecvPacket(SOCKET s, std::string out) { uint32_t lenNet 0; if (!RecvAll(s, (char*)lenNet, 4)) return false; uint32_t len ntohl(lenNet); if (len 10 * 1024 * 1024) return false; // 防御超大包 out.resize(len); return RecvAll(s, out[0], len); } // 辅助确保收满指定字节数 bool RecvAll(SOCKET s, char* buf, int need) { int got 0; while (got need) { int n recv(s, buf got, need - got, 0); if (n 0) return false; got n; } return true; }逻辑说明send不保证一次发完recv也不保证一次收满所以两边都要循环。htonl和ntohl处理 4 字节整数的字节序跨平台通信时这一步不能省。RecvAll是核心它把“收满 N 字节”这件事封装起来上层就不用关心底层分了几次。参数说明长度上限我设了 10MB这是防御性编程防止对端发一个畸形长度导致resize爆内存。实际项目里按业务最大消息体来定一般几百 KB 足够。3.2 中文乱码不是 socket 的锅是编码没对齐MFC 项目默认可能是 Unicode 字符集而send和recv操作的是字节。如果你把CString直接强转成char*发出去在 Unicode 工程里拿到的是宽字符的低字节对端收到的就是乱码。正确做法是发送前统一转成 UTF-8接收后按 UTF-8 解析再转回CString。// CString(Unicode) - UTF-8 std::string std::string CStringToUtf8(const CString str) { int len WideCharToMultiByte(CP_UTF8, 0, str, -1, nullptr, 0, nullptr, nullptr); std::string out(len - 1, \0); WideCharToMultiByte(CP_UTF8, 0, str, -1, out[0], len, nullptr, nullptr); return out; } // UTF-8 std::string - CString(Unicode) CString Utf8ToCString(const std::string str) { int len MultiByteToWideChar(CP_UTF8, 0, str.c_str(), -1, nullptr, 0); CString out; MultiByteToWideChar(CP_UTF8, 0, str.c_str(), -1, out.GetBuffer(len), len); out.ReleaseBuffer(); return out; }逻辑说明WideCharToMultiByte第一次调用传nullptr是为了拿所需缓冲区长度第二次才真正转换。len - 1是因为返回值包含结尾的\0而std::string不需要。接收方向反过来即可。这套转换在 MFC 里是标配写一次就能到处用。参数说明CP_UTF8是代码页常量别写成CP_ACP后者是本地代码页跨机器会出问题。GetBuffer和ReleaseBuffer必须成对出现忘了ReleaseBuffer会导致CString内部状态异常这是 MFC 字符串内存泄漏的常见来源之一。3.3 跨线程更新 UIPostMessage 是唯一稳妥的路工作线程里绝对不能直接调用SetWindowText、SetDlgItemText这类函数去操作控件。MFC 的控件不是线程安全的跨线程操作轻则界面错乱重则直接崩溃。标准做法是PostMessage把数据指针丢给主线程主线程在自定义消息处理函数里更新界面。// 主对话框消息映射 BEGIN_MESSAGE_MAP(CMainDlg, CDialogEx) ON_MESSAGE(WM_APP 1, CMainDlg::OnServerData) ON_MESSAGE(WM_APP 2, CMainDlg::OnClientData) END_MESSAGE_MAP() LRESULT CMainDlg::OnServerData(WPARAM wParam, LPARAM lParam) { char* p (char*)lParam; CString text Utf8ToCString(p); free(p); // 释放工作线程 _strdup 分配的内存 m_logBox.AddString(text); // 安全当前在主线程 return 0; }逻辑说明PostMessage是异步的投递完就返回不阻塞工作线程。_strdup分配的内存在主线程free责任清晰。如果用SendMessage会同步等待主线程处理完工作线程被拖住高并发下会成瓶颈。参数说明wParam和lParam都是WPARAM/LPARAM类型传指针时注意 32 位和 64 位下的宽度差异用reinterpret_cast更规范。自定义消息编号从WM_APP开始不要用WM_USER后者在某些控件内部已被占用。4. 避坑与排查五个让我加班到深夜的现场4.1 现象客户端连不上错误码 10061原因WSAECONNREFUSED目标端口没有程序在监听或者服务端bind的地址不对。常见于服务端绑了127.0.0.1却想让局域网其他机器连。解决服务端bind用INADDR_ANY并检查防火墙是否放行了该端口。用netstat -ano | findstr 端口号确认监听状态LISTENING才算正常。4.2 现象连接建立后服务端收不到数据原因accept返回的新 socket 没被正确传给工作线程或者工作线程里误用了监听 socket。另一个高频原因是客户端send后没等对端处理就关闭了连接。解决在accept后立刻打印新 socket 的值和工作线程里recv用的 socket 对比。客户端关闭前先shutdown(s, SD_SEND)等对端确认收到再closesocket避免数据还在缓冲区就被掐断。4.3 现象程序退出时崩溃报访问违例原因工作线程还在recv阻塞主线程已经把 socket 关了甚至把对话框销毁了。线程醒来后访问了无效内存。解决退出前先closesocket这会让阻塞的recv立刻返回错误线程得以退出。然后用WaitForSingleObject等线程句柄确认线程结束后再销毁窗口。顺序反了必崩。4.4 现象中文日志显示成问号或方块原因发送端用了本地代码页接收端按 UTF-8 解析或者反过来。MFC 工程的字符集设置和转换函数不匹配。解决全链路统一 UTF-8。发送前CStringToUtf8接收后Utf8ToCString中间不经过任何char*强转。检查项目属性里的“字符集”设置Unicode 和 Multi-Byte 的行为不同转换函数要对应。4.5 现象高频率发送时数据错位原因多个线程同时往同一个 socketsendTCP 流被交叉写入对端按长度解析时自然错位。解决给每个 socket 配一把锁send前加锁发完解锁。或者更彻底一点每个连接只用一个发送线程所有待发数据进队列由该线程串行发出。后者性能更好也更容易做流量控制。5. 进阶技巧用抓包和日志把“玄学”变成可验证的工程5.1 抓包是最终裁判但要会看Wireshark 抓本地回环流量时选Adapter for loopback traffic capture普通网卡抓不到127.0.0.1的包。看 TCP 三次握手SYN、SYNACK、ACK三次完成后才轮到应用层数据。如果只看到SYN没有SYNACK说明服务端没监听或防火墙拦了。如果看到RST说明端口没人接。这些判断比盯着代码猜快得多。5.2 日志要带 socket 句柄和时间戳多连接场景下日志不带 socket 句柄根本分不清是哪条连接出的问题。我习惯在每条日志前加[sockxxx][时间]排查时直接过滤。时间戳精确到毫秒能看出是发送慢还是接收慢。5.3 心跳与超时别让死连接占着资源TCP 连接可能因为网线拔掉、对端断电而进入“假死”状态双方都不发数据recv一直阻塞。解决办法是应用层心跳每隔 30 秒发一个空包或特定标记连续三次没收到回应就主动关闭。SO_RCVTIMEO也能设接收超时但它对recv的阻塞行为影响因平台而异不如心跳可控。// 设置接收超时毫秒作为心跳的补充 int timeout 5000; setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (char*)timeout, sizeof(timeout));参数说明SO_RCVTIMEO超时后recv返回SOCKET_ERROR错误码WSAETIMEDOUT。注意这不等同于连接断开只是这一轮没数据要继续判断。5.4 一个我踩过的坑closesocket不等于四次挥手完成有次做压力测试客户端发完最后一批数据立刻closesocket服务端偶尔丢最后几条。抓包发现客户端发了FIN但发送缓冲区里还有数据没发完closesocket直接把缓冲区丢了。正确做法是先shutdown(s, SD_SEND)告诉对端“我不再发了”然后继续recv直到收到对端的FIN最后才closesocket。这个顺序我后来写进了团队的网络模块规范再没出过丢数据的事。做这类 MFC socket 的程序我的习惯是先把服务端和客户端各自跑通单机回环再用抓包确认三次握手和四次挥手完整最后才上真实网络。每次出问题先看抓包再看日志最后才怀疑代码——顺序反了时间全浪费在猜上。希望帮到你。本文还有配套的精品资源点击获取
返回列表