拓冰建站拓冰建站
首页 / 资讯中心 / 正文

基于MFC CSocket的UDP P2P聊天室:协议设计、心跳机制与NAT穿透

简介面向C网络开发学习者的P2P通信与多用户聊天室实战项目基于MFC CSocket与UDP协议实现节点间直接通信覆盖自定义P2P协议、报文处理、多用户并发消息广播和界面交互等关键环节适合希望深入理解P2P架构与Windows套接字编程的中级开发者。压缩包共156个文件约29.55MB包含22个h头文件、15个cpp源文件、5个exe可执行程序以及dsp/dsw工程文件、rc/ico界面资源、chm帮助文档等这类文档可离线查阅C与Windows网络编程资料exe程序便于直接观察运行效果便于对照源码梳理工程结构。已有189人学习包内附带的C标准库、MFC类库详解、Windows API等chm参考手册也可作为日常开发查询资料。通过该项目可掌握UDP无连接通信的序列号排序、丢包重发、节点动态加入与离开等处理思路同时积累MFC多线程网络应用的排错与调试经验。1. 从 CSocket 到 UDP P2P 聊天室这份源码包到底在讲什么在局域网里用几台 Windows 电脑做演示不架设服务器只靠 UDP 数据报互相收发消息最后还要有一个能双击运行的 MFC 聊天窗口这是很多人拿到这份资源时的目标。解压后p2pclient.cpp.bak 旁边是 C Network Programming、MFC 类库详解等 chm 文档说明这套代码不是简单调用 sendto 和 recvfrom 的入门练习。它用 MFC 的 CSocket 类族处理 UDP 通信自定义协议头区分登录、广播、心跳消息最终形成不依赖中心服务器的多用户聊天室。对于正在准备课程设计或者想系统理解 UDP 协议、CSocket 消息映射机制的人来说把源码一行行拆开读是值得的。2. P2P 协议设计报文格式、状态机与序列号去重2.1 为什么 UDP 比 TCP 更适合 P2P 聊天室在 P2P 聊天室这种场景里TCP 看起来更可靠实际维护成本更高。每个用户都要与其他所有用户保持连接20 人在线时每个客户端要维护 19 个 TCP 连接中途有人断电或者断网内核超时检测可能需要几十秒体验很差。UDP 没有连接状态一个 socket 就能向任意地址发包消息边界天然清晰recvfrom一次取一条完整数据报不需要像 TCP 那样处理粘包和拆包。下面把两种协议放在 P2P 聊天室场景下做对比对比项TCPUDP连接数多连接维护复杂上限低无连接单 socket 可多目标数据边界字节流需要应用层分帧数据报边界固定可靠性内核重传、排序可能丢包乱序需应用层补偿NAT 穿透需要复杂打洞流程UDP 穿透成功率更高MFC 集成CSocket 阻塞模型易卡 UICAsyncSocket 事件驱动更合适P2P 这里指的是节点之间直接通信不是 BT 下载那种 DHT 全网寻址。做一个小范围聊天室广播加心跳就足够支撑用户在线管理和消息分发。2.2 自定义报文头结构体对齐与网络字节序不管应用层传输什么每条 UDP 报文的前若干字节必须是固定协议头。建议按下面的结构体定义#pragma pack(push, 1) struct P2P_MSG_HEADER { WORD wType; // 消息类型MSG_LOGIN / MSG_BROADCAST / MSG_HEARTBEAT 等 WORD wSeq; // 序列号用于去重和重传编号 DWORD dwTime; // 发送时间戳用于心超过期判断 DWORD dwPeerId; // 发送者 ID DWORD dwBodyLen; // 正文长度防止接收缓冲不足导致截断 BYTE byChecksum; // 简单校验和 }; #pragma pack(pop)#pragma pack(push, 1)强制按 1 字节对齐这是最容易踩的坑。Windows 默认对齐会让结构体插入空洞同一个结构体在两台机器上编译出来的大小可能不同网络包解到一半就错位。wType和wSeq用 WORD 足够dwBodyLen必须用 DWORD因为聊天消息可能超过 64KB 理论单包上限但实际局域网内建议控制单条消息在 4KB 以内避免 IP 分片。发送时统一转成网络字节序P2P_MSG_HEADER hdr; hdr.wType htons(MSG_BROADCAST); hdr.wSeq htons(m_uSendSeq); hdr.dwTime htonl(GetTickCount()); hdr.dwPeerId htonl(m_uPeerId); hdr.dwBodyLen htonl(strText.GetLength() * sizeof(TCHAR)); hdr.byChecksum CalcChecksum((BYTE*)hdr, sizeof(hdr) bodyLen); char sendBuf[P2P_MAX_MSG_SIZE]; memcpy(sendBuf, hdr, sizeof(hdr)); memcpy(sendBuf sizeof(hdr), lpBody, bodyLen); m_pSocket-SendTo(sendBuf, sizeof(hdr) bodyLen, addr, port);htons和htonl把主机字节序转成网络字节序小端机器上这一点会被忽略一旦遇到大端设备协议头全部会解析错误。CalcChecksum不需要做 CRC用简单的异或或者累加即可目的是快速排查数据坏包。2.3 消息类型枚举与客户端状态机协议不能只有结构体还需要定义消息类型。常见枚举如下enum P2P_MSG_TYPE { MSG_LOGIN 0x01, // 通知其他节点我上线了 MSG_LOGIN_ACK 0x02, // 回复登录确认 MSG_BROADCAST 0x03, // 广播聊天内容 MSG_PRIVATE 0x04, // 私聊消息 MSG_HEARTBEAT 0x05, // 心跳 MSG_LOGOUT 0x06 // 主动退出 };客户端状态至少有离线、登录中、在线、超时。UDP 没有真正的连接所以MSG_LOGIN只是告诉其他节点“我开始在线了”对方用收到的MSG_HEARTBEAT刷新在线列表。心跳周期一般 3~5 秒超时阈值设为心跳间隔的 3 倍比如间隔 5 秒超过 15 秒没收到包就标记离线。这里很多人会把 UDP socket 调用connect()去固定对端地址以为这样就建立了连接。实际上 UDP 的connect()只是绑定对端地址不产生握手既不能判断对方是否在线也会让SendTo无法向其他地址发包不建议在 P2P 场景使用。2.4 序列号窗口解决乱序和重复UDP 不保证消息按顺序送达也不保证不重复。接收方要维护一个最近收到序列号的窗口比如记录已经收到的最大连续序号m_uLastSeq。当新包到达时WORD uSeq ntohs(pHdr-wSeq); if (IsInWindow(uSeq, m_uLastSeq) m_setRecvSeq.find(uSeq) ! m_setRecvSeq.end()) { // 重复包直接丢弃 return; } if (uSeq m_uLastSeq (m_uLastSeq - uSeq) (WORD)2048) { // 老包丢弃 return; } m_setRecvSeq.insert(uSeq); m_uLastSeq max(m_uLastSeq, uSeq);这里窗口大小为 2048WORD溢出时会自动回绕所以差值判断要用无符号数。聊天室对消息顺序要求不高稍微乱序可以接受如果是文件传输还需要加上“缺失序号重传请求”那就复杂得多了。3. MFC CSocket 收发 UDP事件驱动与 UI 解耦3.1 CSocket 与 CAsyncSocket 在 UDP 下的选型MFC 的 CSocket 默认是基于 CAsyncSocket 的阻塞封装工作模式更偏 TCP 流式传输。如果直接用 CSocket 做 UDPReceiveFrom一旦没数据就会阻塞把整个 UI 线程卡死设置超时又会增加复杂度。更常见的是直接继承 CAsyncSocket重载OnReceive事件让 WinSock 网络事件驱动业务逻辑。类模式UDP 适配度典型问题CSocket阻塞同步低ReceiveFrom 阻塞 UICAsyncSocket非阻塞事件高OnReceive 回调需注意线程WinSock 原生异步可选中需要手动集成消息循环实际项目中继承CAsyncSocket是性价比最高的方案。3.2 继承 CAsyncSocket 重写 OnReceiveUDP 模式下CAsyncSocket 的底层仍然是SOCK_DGRAMReceiveFrom一次读一条完整数据报天然不需要处理半包问题。代码结构如下class CUdpSocket : public CAsyncSocket { public: virtual void OnReceive(int nErrorCode) { CAsyncSocket::OnReceive(nErrorCode); if (nErrorCode ! 0) { // 错误处理和日志主要是 WSAECONNRESET return; } sockaddr_in fromAddr; int nLen sizeof(fromAddr); char buf[P2P_MAX_MSG_SIZE]; int nRecv ReceiveFrom(buf, sizeof(buf), (SOCKADDR*)fromAddr, nLen); if (nRecv 0) { g_pChatRoom-OnRecvPacket(buf, nRecv, fromAddr); } } };nErrorCode不为 0 时最常见的是WSAECONNRESET。UDP 收到一个 ICMP 端口不可达包后下次ReceiveFrom可能返回这个错误MFC 的封装有时会直接触发OnReceive处理不好会导致程序频繁告警。实际遇到时忽略该错误继续等待后续数据包即可。3.3 工作线程 PostMessage 更新 UI如果不喜欢事件驱动也可以自己开收包线程。线程里循环recvfrom收到数据后用PostMessage通知主窗口。注意不能使用SendMessage否则收包线程会等待 UI 处理完成网络压力大时数据全堵在线程里。UINT RecvThreadProc(LPVOID lpParam) { CUdpChatDlg* pDlg (CUdpChatDlg*)lpParam; char recvBuf[P2P_MAX_MSG_SIZE]; sockaddr_in from; int fromLen sizeof(from); while (pDlg-m_bRunning) { int nRecv recvfrom(pDlg-m_hSocket, recvBuf, sizeof(recvBuf), 0, (SOCKADDR*)from, fromLen); if (nRecv 0) { RecvNode* pNode new RecvNode; pNode-nLen nRecv; memcpy(pNode-buf, recvBuf, nRecv); pNode-from from; pDlg-PostMessage(WM_NET_RECV, (WPARAM)pNode, 0); } Sleep(1); } return 0; }RecvNode放在堆上消息参数传指针UI 线程处理完必须delete否则每次收包泄漏几十字节长时间运行内存涨得很快。Sleep(1)是迫不得已的降频手段更好的做法是select或WSAEventSelect但代码量会变大。主窗口响应消息时解析数据LRESULT CUdpChatDlg::OnNetRecv(WPARAM wp, LPARAM) { RecvNode* pNode (RecvNode*)wp; ParsePacket(pNode-buf, pNode-nLen, pNode-from); delete pNode; return 0; }这个消息函数运行在 UI 线程因此可以放心操作对话框控件。MFC 所有控件类都要求在主线程创建和使用跨线程直接SetWindowText轻则刷新异常重则调试断言失败这是新手最容易犯的问题。3.4 MFC 控件自适应屏幕分辨率聊天室界面如果只在设计分辨率下正常换到高 DPI 或小屏笔记本会很难看。处理方式是在OnSize里用MoveWindow重新布局主要控件下面以聊天记录编辑框为例void CUdpChatDlg::OnSize(UINT nType, int cx, int cy) { CDialogEx::OnSize(nType, cx, cy); CWnd* pEdit GetDlgItem(IDC_EDIT_MSG); if (pEdit m_Initialized) { CRect rc; GetClientRect(rc); pEdit-MoveWindow(0, rc.Height() - 200, rc.Width(), 200); } }m_Initialized在OnInitDialog末尾置为 TRUE防止窗口创建过程中OnSize提前触发。MFC 对话框默认由资源管理器根据字体处理缩放普通控件跟随窗口拉伸的效果并不理想手动MoveWindow是最直接的办法。3.5 用网络调试助手和 Wireshark 验证 UDP 包程序写完后先用网络调试助手模拟对端。假设聊天室绑定本机 5000 端口调试助手也绑定 5000 端口发送一条MSG_LOGIN数据。如果在线列表没有变化抓包定位问题。Wireshark 过滤规则udp.port 5000 ip.addr 192.168.1.100Wireshark 里能看到完整的 UDP 数据包格式8 字节 UDP 头包含源端口、目的端口、长度和校验和后面就是应用层数据。重点检查载荷前几个字节是否等于自定义协议头。之前遇到wType永远解析错抓包后发现是结构体默认对齐和字节序没处理改成 1 字节对齐后立刻正常。4. 多用户聊天室的实现用户列表、广播与心跳4.1 用户列表结构怎么选在线用户列表是聊天室的核心数据。人数几十时用std::map按 IP 和端口排序方便遍历人数上千再考虑unordered_map。下面是推荐的结构struct PeerKey { DWORD dwIP; UINT uPort; bool operator(const PeerKey k) const { return dwIP k.dwIP || (dwIP k.dwIP uPort k.uPort); } }; struct PeerInfo { CString strName; DWORD dwLastTick; DWORD dwLastSeq; }; std::mapPeerKey, PeerInfo m_mapUsers;PeerKey直接用 IP 和端口作 key同一台机器开多个客户端也能区分。dwLastTick记录最后一次收到心跳的时间用于超时清理。MFC 的 CMap 对指针类型支持好用但对这种 POD 类型没有内置 hash不如 STL map 直观。4.2 广播消息的转发方式收到MSG_BROADCAST后需要把这包数据转发给除来源外的所有已知在线节点。UDP 的广播要遍历每个目标地址单独SendTo而不是直接发 255.255.255.255void CUdpChatDlg::BroadcastMsg(const char* pData, int nLen, const PeerKey from) { for (auto it m_mapUsers.begin(); it ! m_mapUsers.end(); it) { if (it-first.dwIP from.dwIP it-first.uPort from.uPort) { continue; } sockaddr_in addr; addr.sin_family AF_INET; addr.sin_port htons((unsigned short)it-first.uPort); addr.sin_addr.S_un.S_addr it-first.dwIP; m_pSocket-SendTo(pData, nLen, (SOCKADDR*)addr, sizeof(addr)); } }直接向255.255.255.255广播很容易被路由器和交换机丢弃VLAN 内还会影响其他无关主机。遍历用户列表做单播后续要增加“序列号去重”和“私聊过滤”也更方便。私聊时在wType上区分MSG_PRIVATE并在协议头后附加目标 PeerId 字段接收方看到不属于自己的私聊包直接丢弃。4.3 心跳包与超时踢出UDP 没有连接关闭通知唯一判断用户是否离线的办法就是心跳超时。MFC 中可以用SetTimer周期发送心跳并扫描用户列表典型配置如下void CUdpChatDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent ID_TIMER_HEARTBEAT) { SendHeartbeat(); DWORD now GetTickCount(); for (auto it m_mapUsers.begin(); it ! m_mapUsers.end();) { if (now - it-second.dwLastTick 15000) { auto removeIt it; m_mapUsers.erase(removeIt); RefreshUserList(); } else { it; } } } CDialogEx::OnTimer(nIDEvent); }心跳间隔 5000ms超时阈值 15000ms这样的配置在局域网内很稳。如果跨公网无线 Wi-Fi 抖动可能导致正常用户被误踢建议间隔和阈值都放大到 10 秒和 30 秒。GetTickCount()在系统运行超过 49.7 天时会溢出长时间运行的聊天室应改用GetTickCount64()。4.4 跨线程竞争与消息队列网络线程和 UI 线程不能共享控件指针这是多用户聊天室最常见的崩溃来源。除了用PostMessage还可以设计一个独立的消息队列网络线程只负责把解析好的RecvNode放入队列UI 线程每秒从队列批量取数据刷新界面。队列需要加锁但PostMessage本身是 FIFO 异步消息内部没有竞争因此对多数场景是足够且更简单的方案。如果聊天室消息频率很高比如每秒几百条PostMessage的窗口消息队列也可能成为瓶颈。这时可以改用std::mutex std::deque但要注意锁的粒度不要把控件更新放在锁内否则又会阻塞网络接收。5. 进阶调试丢包重传、iperf3 与 NAT 穿透5.1 模拟丢包并实现基于序列号的重传局域网内测试无法自动产生丢包可以借助网络调试助手的随机丢弃功能。实现重传时发送方把已发消息缓存起来等待接收方应答没有应答则超时重发最多重发 3 次。消息缓存可以用std::mapWORD, RetryNode序列号作为 key。要注意的是重发会导致重复消息接收方必须用序列号窗口去重这与第 2 章的窗口逻辑是同一套。5.2 用 iperf3 验证 UDP 吞吐聊天室上线前最好确认两台电脑之间的 UDP 链路质量。服务端和客户端分别安装 iperf3服务端运行iperf3 -s -p 5001客户端运行iperf3 -c 192.168.1.100 -p 5001 -u -b 10M -t 30-u表示 UDP-b 10M设置目标带宽 10Mbps-t 30持续 30 秒。输出结果里重点看 Loss 和 Jitter如果丢包率超过 5%或者抖动超过 50ms就要压缩单条消息长度避免多个小消息占用额外协议头。这里测的是局域网链路上限不代表聊天室实际吞吐但能作为协议缓冲区大小设置的参考。5.3 想支持 NAT 穿透必须知道的前提原项目不依赖中心服务器因此只适用于局域网或者双方都拥有公网 IP 的场景。想让聊天室在互联网上直接使用需要先引入一个信令服务器交换公网地址。UDP 打洞的基本流程是两端分别向服务器注册服务器把 A 看到的公网地址发给 B把 B 的公网地址发给 A随后两端同时向对方公网地址发送 UDP 包NAT 映射一旦建立后续消息就能直达。提示CSocket 的 SendTo 不会自动处理 NAT 映射关系能否打洞成功取决于路由器是否采用端点独立映射。家用路由器多数支持但严格对称型 NAT 无法用这种方式穿透。5.4 善用资源包里的 CHM 文档排错时不要只看在线文档资源包里的C Network Programming Volume 1.chm和MFC类库详解.chm对查 CSocket 和协议细节很有用。遇到CAsyncSocket::ReceiveFrom返回SOCKET_ERROR直接在 chm 里搜索WSAECONNRESET能看到 UDP 在 ICMP 端口不可达时的行为描述。The C Standard Library.chm适合对照std::map和std::unordered_map的复杂度差异写用户列表时翻一眼能避免低级错误。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门