ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C+UDP+MFC实现P2P聊天室:NAT穿透与打洞实战

2026/10/7 19:02:07 拓冰建站 浏览量
C+UDP+MFC实现P2P聊天室:NAT穿透与打洞实战 简介一份基于C与MFC的P2P聊天室项目资源面向网络编程学习者与需要UDP通信参考的开发者完整演示了如何利用CSocket实现非中心化的对等通信与多用户聊天场景。压缩包内共156个文件整体约29.55MB包含22个头文件与15个cpp源文件构成的完整工程代码、10个CHM格式的C/MFC/Windows API参考手册、编译生成的exe可执行程序以及dsp/dsw等工程配置文件另有少量图标、图片、备份和辅助文档。目前已有192人学习。项目覆盖P2P自定义协议报文设计、CSocket与UDP无连接通信的顺序恢复与丢包处理、MFC界面事件驱动以及多线程广播、节点动态加入等关键问题代码结构完整还附带可运行的exe和CHM手册。配合资料中的类库大全与网络编程手册可边查边学适合系统实践P2P通信原理与Windows套接字编程。1. P2P 聊天室难在哪两个 NAT 后面的进程怎么互相找到做 P2P 聊天室的人十有八九把“聊天室”看重把“P2P”看轻了。用 C 语言配合 CSocket 在 MFC 上写 UDP 聊天室界面部分一天能搭完——编辑框、发送按钮、消息列表剩下全是套接字的事。真正的分水岭在 P2P两个客户端都在各自路由器内网后面没有公网 IP怎么让对方直接找到你TCP 要三次握手NAT 后面根本听不见敲门UDP 无连接反而能靠打洞穿透两侧 NAT。下文沿 C CSocket UDP MFC 这条组合把注册服务器、打洞流程、消息协议、界面刷新完整走一遍再把丢包、对称 NAT、界面卡死这些躲不掉的坑按“现象—原因—解决”拆开适合做课程设计、P2P 毕设或想把局域网聊天室改成互联网直连的人。2. UDP 与 TCP 选型CSocket 在 MFC 里该拿基类还是本体2.1 为什么是 UDP 不是 TCP三次握手与 NAT 的死结P2P 聊天室的核心诉求是两个内网客户端不经过服务器中转直接交换消息。TCP 的特性是先 connect 再读写connect 要求对端有一个能被外部路由到的监听地址内网用户没有公网 IP路由器 NAT 也不会平白把入站连接转给内网机器。你监听 40000 端口外面对这个端口的敲门只会被 NAT 丢弃。UDP 不一样它无连接ip:port 只是“发给谁”的目标不是“谁必须先准备好”的约束。内网机器主动向外发一个 UDP 包路由器会顺带记一条映射随后对端往这个映射地址回包路由器认得出是“回包”会转进内网。这条映射就是打洞的基础TCP 在绝大多数家用 NAT 上没有对应特性所以 P2P 聊天室、P2P 文件传输这些方向约定俗成选 UDP。选它不是因为性能好而是因为只有它能穿。顺带解释一个高频误读拿 UDP 和 TCP 比“可靠”。TCP 有确认、重传、排序UDP 只有尽力而为。但放在 P2P 场景里可靠是应用层的工作——你在消息头里加序号、发心跳、超时重发效果一样还省掉 TCP 握手的来回。聊天室不需要 100% 按序送达丢一两个心跳无关痛痒重发一条文本也就几十毫秒的事。UDP 协议栈由操作系统实现应用层只需要面向数据报编程不需要从零写协议栈。2.2 CSocket 与 CAsyncSocket 的分工初始化 UDP 套接字的最小代码标题里的 CSocket 是 MFC 的套接字封装类但它是为 TCP 设计的阻塞封装内部配合 CSocketFile 和 CArchive 使用绑的是流语义。UDP 没有流只有一个个数据报必须走 SendTo 和 ReceiveFrom这两个函数在它的基类 CAsyncSocket 里。所以做 UDP P2P 的常见做法是用 CAsyncSocket很多人顺着 MFC 习惯统称“CSocket”落笔时拿的其实是 CAsyncSocket。一句话分工CSocket 阻塞、面向流适合 TCPCAsyncSocket 异步、支持数据报适合 UDP。MFC 的通知机制一套代码两边都能用下面是初始化 UDP 套接字的最小代码C 语言基础扎实的话看到结构体和 memcpy 就能直接上手改写。// 实现文件顶部整个工程只需要一处 #include afxsock.h // App::InitInstance 里先初始化 Winsock进程内只调一次 if (!AfxSocketInit()) { AfxMessageBox(_T(Winsock 初始化失败)); return FALSE; } // 投递到 UI 线程的数据报结构见下方说明 #define P2P_MAX_PACKET 2048 typedef struct _P2P_DATAGRAM { DWORD dwSrcIP; // 源 IP网络序 WORD wSrcPort; // 源端口已转主机序 WORD wDataLen; // 实际数据长度 BYTE byData[P2P_MAX_PACKET]; } P2P_DATAGRAM; // P2PSocket.h自定义套接字类重写收包回调 class CP2PSocket : public CAsyncSocket { public: virtual void OnReceive(int nErrorCode) override; HWND m_hTargetWnd; // 收包后通知哪个窗口 static UINT WM_P2P_DATA; // 自定义消息号 };// P2PSocket.cpp UINT CP2PSocket::WM_P2P_DATA ::RegisterWindowMessage(_T(P2P_Data_Arrived)); void CP2PSocket::OnReceive(int nErrorCode) { if (nErrorCode ! 0) { CAsyncSocket::OnReceive(nErrorCode); return; } BYTE byBuf[P2P_MAX_PACKET] { 0 }; SOCKADDR_IN addrFrom { 0 }; int nFromLen sizeof(addrFrom); int nRecv ReceiveFrom(byBuf, P2P_MAX_PACKET, (SOCKADDR*)addrFrom, nFromLen); if (nRecv 0 || !::IsWindow(m_hTargetWnd)) { CAsyncSocket::OnReceive(nErrorCode); return; } // 栈上缓冲不能直接 PostMessage 异步投递必须拷贝到堆上 P2P_DATAGRAM* pGram new P2P_DATAGRAM; pGram-dwSrcIP addrFrom.sin_addr.S_un.S_addr; pGram-wSrcPort ntohs(addrFrom.sin_port); pGram-wDataLen (WORD)nRecv; memcpy(pGram-byData, byBuf, nRecv); ::PostMessage(m_hTargetWnd, WM_P2P_DATA, 0, (LPARAM)pGram); CAsyncSocket::OnReceive(nErrorCode); }// 对话框 OnInitDialog 里创建套接字 BOOL CP2PChatDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 端口 0 系统自动分配SOCK_DGRAM UDP BOOL bOK m_sock.Create(0, SOCK_DGRAM, NULL); if (!bOK) { TRACE(_T(Create 失败: %d\n), GetLastError()); return FALSE; } m_sock.m_hTargetWnd GetSafeHwnd(); return TRUE; }Create 三个参数要说明白第一个是本地端口0 表示让系统分配固定聊天场景建议写死成 45678 这类值方便注册服务器区分客户端实例也方便用 netstat 核对第二个是套接字类型SOCK_DGRAM 决定 UDP默认的 SOCK_STREAM 是 TCP第三个是本地绑定地址NULL 表示任意网卡。把收包逻辑放在 OnReceive 里而不是自己开 recv 线程是因为 MFC 的 CAsyncSocket 已经用异步选择机制把 FD_READ 事件接好了数据到达会自动回调省掉线程同步的麻烦。注意MFC 程序里别自己调 WSAStartupAfxSocketInit 已经完成版本协商和全局清理。重复初始化在退出时会触发资源泄漏报告。3. 注册服务器与 UDP 打洞让内网双方对上话的最小实现3.1 打洞三步流程注册、查地址、互发探测包UDP 打洞至少需要一台带公网 IP 的注册服务器rendezvous server它不转发聊天数据只帮双方交换“你眼中的对方地址”。流程拆成三步第一步A、B 各自向服务器发一个注册包服务器从 recvfrom 的源地址里取到它们各自的公网 IP 和端口写进一张映射表第二步A 向服务器查 B服务器回一条“B 的公网地址是 x.x.x.x:pppp”B 查 A 时对称处理第三步也就是打洞最关键的一步A 和 B 在几乎同一时刻向对方的公网地址各发一个 UDP 探测包两侧 NAT 据此建立出站映射之后的聊天数据直接点对点走服务器退出链路。为什么“几乎同时”这么重要A 发给 B 的包到达 B 的 NAT 时只有当 B 也向外发过包、NAT 里先有了对应出站映射才可能被当成回包转进内网。如果 A 先到而 B 还没动B 的 NAT 眼里的 A 就是陌生入站流量直接丢。所以打洞不能只发一次要连续多轮发多个包每轮间隔几十毫秒去覆盖双方建立映射的时间差。服务器实现不复杂核心是一张表加两个命令字用纯 C 写控制台程序就能验证链路后续再让 MFC 对话框接上。// server.c 核心片段注册 查询用 6 字节明文回地址 #include winsock2.h #include map std::mapDWORD, SOCKADDR_IN g_clients; // 客户端ID - 公网地址 void HandlePacket(char* pBuf, int nLen, SOCKADDR_IN addr, SOCKET s) { if (nLen 5) return; // 至少 1 字节命令 4 字节ID DWORD dwId *(DWORD*)(pBuf 1); if (pBuf[0] 0x01) // REGISTER记下源地址 { g_clients[dwId] addr; // recvfrom 的源地址即公网地址 } else if (pBuf[0] 0x02) // QUERY回对端地址 { auto it g_clients.find(dwId); if (it g_clients.end()) return; // 6 字节明文IP 4字节 端口2字节避免结构体对齐差异 char szResp[6] { 0 }; *(DWORD*)szResp[0] it-second.sin_addr.S_un.S_addr; *(WORD*)szResp[4] it-second.sin_port; sendto(s, szResp, sizeof(szResp), 0, (SOCKADDR*)addr, sizeof(addr)); } } // main绑定 8000 端口循环 recvfrom SOCKET s socket(AF_INET, SOCK_DGRAM, 0); SOCKADDR_IN sa { 0 }; sa.sin_family AF_INET; sa.sin_addr.S_un.S_addr htonl(INADDR_ANY); sa.sin_port htons(8000); bind(s, (SOCKADDR*)sa, sizeof(sa)); char szBuf[64]; for (;;) { SOCKADDR_IN addrFrom { 0 }; int nFromLen sizeof(addrFrom); int nRecv recvfrom(s, szBuf, sizeof(szBuf), 0, (SOCKADDR*)addrFrom, nFromLen); if (nRecv 0) HandlePacket(szBuf, nRecv, addrFrom, s); } closesocket(s);这里有个容易踩的细节不要把 SOCKADDR_IN 整个结构体原样塞进回包。不同编译器、不同对齐设置下结构体布局可能不一样收端按自己的布局一解就是错的。显式拆成 IP 4 字节加端口 2 字节双方约定固定长度这条链路就没了歧义。3.2 定时器驱动的打洞代码候选端口与状态回填打洞在 MFC 里不需要单独开线程用定时器驱动最省事SetTimer 每 50 毫秒触发一次每次向对端已知公网地址的 4 个候选端口各发一个 1 字节探测包。为什么是候选端口而不是单端口因为 A 查到的 B 公网端口是 B 注册时那一次 NAT 分配的结果B 后续继续发包NAT 分配的源端口可能已经偏移。多端口探测是对现实妥协成本只是几个字节的小报文命中率却能明显提升。// 打洞状态机 #define HOLE_IDLE 0 // 等用户发起连接 #define HOLE_WAIT_PEER 1 // 已注册等服务器返回对端地址 #define HOLE_SHOOTING 2 // 已拿到地址互发探测包中 #define HOLE_OPEN 3 // 收到对端包链路可收发 // 对端地址dwIP 网络序wPort 主机序 struct PEER_ADDR { DWORD dwIP; WORD wPort; }; void CP2PChatDlg::OnHolePunchTimer() { if (m_nHoleState ! HOLE_SHOOTING) return; // 每轮尝试 4 个候选端口wPort 到 wPort3 static char szProbe 0x01; // 探测包内容不解析 for (int i 0; i 4; i) { SOCKADDR_IN addr { 0 }; addr.sin_family AF_INET; addr.sin_addr.S_un.S_addr m_peer.dwIP; addr.sin_port htons(m_peer.wPort i); m_sock.SendTo(szProbe, 1, (SOCKADDR*)addr, sizeof(addr)); } // 发满 20 轮约 1 秒还没通停止并提示 if (m_nShootRound 20) { KillTimer(TIMER_HOLE); m_nHoleState HOLE_IDLE; UpdateStatus(_T(打洞失败)); } }逻辑说明要强调两点。第一SendTo 返回成功不代表对方收到UDP 的语义是“交给系统就算完”收没收到只能靠对端回包验证这是 UDP 网络调试里最反直觉的地方一切判断要以 OnReceive 有没有触发为准。第二20 轮是经验值内网到公网 RTT 通常在 10 毫秒内50 毫秒间隔打满 1 秒还没通再等的边际收益很低不如提示用户换网络或走中继。候选端口 1 会不会扫到别人的服务现实中这个段内被其他映射占用的概率很低而且对方回包内容不匹配会被应用层丢弃风险可控。收到对端任意一个包时要做两件事把状态改成 HOLE_OPEN并用这个包的源端口回填 m_peer.wPort。因为对端后续聊天消息一律从它真实通信的端口出来不回填的话报文仍然发到旧端口洞等于白打。// 在 OnP2PData 入口处做打洞确认完整分发逻辑见 4.2 P2P_DATAGRAM* pGram (P2P_DATAGRAM*)lParam; if (m_nHoleState HOLE_SHOOTING pGram-dwSrcIP m_peer.dwIP) { m_peer.wPort pGram-wSrcPort; // 回填真实源端口 m_nHoleState HOLE_OPEN; KillTimer(TIMER_HOLE); UpdateStatus(_T(点对点已建立)); }wSrcPort 在 OnReceive 投递前已经做过 ntohs所以这里拿到的就是主机序直接赋值即可不用再转一次。4. 聊天消息协议与界面刷新把 UDP 数据报变成聊天记录4.1 二进制消息头设计字节对齐与 UTF-8 编码洞打通之后收发的就不能再是 1 字节探测包了。P2P 聊天室必须有一套自己的消息格式否则对端根本不知道收到的是一句话、一个心跳还是一个下线通知。常见做法是定长协议头加变长负载协议头用 #pragma pack(push, 1) 压缩对齐避免结构体字段间被编译器插入填充字节导致双方对“头有多长”的认知不一致。字段最少四个类型、序号、负载长度、负载内容。类型区分文本、心跳、下线序号用于 UDP 丢包后的重发和去重负载长度用来切分数据报——UDP 虽然是整包收发但一个数据报的边界不等于业务消息的边界长度字段是必需保险。#pragma pack(push, 1) #define P2P_VER 1 #define MSG_TEXT 0x10 #define MSG_HEART 0x11 #define MSG_BYE 0x12 typedef struct _P2P_MSG_HDR { BYTE byVer; // 协议版本握手时校验 BYTE byType; // 消息类型 WORD wSeq; // 序号重发与去重 WORD wLen; // 负载长度 } P2P_MSG_HDR; // 6 字节无填充 #pragma pack(pop) BOOL CP2PChatDlg::SendTextMessage(CString strText) { static WORD s_wSeq 0; if (m_nHoleState ! HOLE_OPEN) return FALSE; // CT2A 转 UTF-8发送端统一编码 CT2A ascii(strText, CP_UTF8); int nLen (int)strlen(ascii); if (nLen 1024) return FALSE; // 超限直接拒收 BYTE byBuf[sizeof(P2P_MSG_HDR) 1024] { 0 }; P2P_MSG_HDR* pHdr (P2P_MSG_HDR*)byBuf; pHdr-byVer P2P_VER; pHdr-byType MSG_TEXT; pHdr-wSeq s_wSeq; pHdr-wLen (WORD)nLen; memcpy(byBuf sizeof(P2P_MSG_HDR), (LPCSTR)ascii, nLen); SOCKADDR_IN addrPeer { 0 }; addrPeer.sin_family AF_INET; addrPeer.sin_addr.S_un.S_addr m_peer.dwIP; addrPeer.sin_port htons(m_peer.wPort); int nSent m_sock.SendTo(byBuf, sizeof(P2P_MSG_HDR) nLen, (SOCKADDR*)addrPeer, sizeof(addrPeer)); return (nSent ! SOCKET_ERROR); }编码这条单独强调Windows 下 CString 转多字节时默认代码页可能是 GBK不指定 CP_UTF8 的话两个字符集设置不同的客户端互发就乱码。协议统一 UTF-8 之后发送端转码、接收端解码问题从根上消失。C 语言基础扎实的读者会注意到整个编解码就是结构体指针加 memcpy没有多余抽象。4.2 异步收包与界面刷新OnReceive 里绝不碰控件CAsyncSocket 的好处是数据到达时框架自动调 OnReceive不用自己开 recv 线程。但 OnReceive 回调跑在套接字通知线程上不是 UI 线程如果在这里直接调 SetWindowText、AddString轻则界面闪烁重则断言崩溃或死锁。约定做法是“回调里收数据投递消息UI 线程里刷新控件”也就是第 2 章那个 PostMessage 设计。到对话框这层工作只剩解析和显示// 消息映射里注册 // ON_MESSAGE(CP2PSocket::WM_P2P_DATA, CP2PChatDlg::OnP2PData) LRESULT CP2PChatDlg::OnP2PData(WPARAM wParam, LPARAM lParam) { P2P_DATAGRAM* pGram (P2P_DATAGRAM*)lParam; BYTE* pData pGram-byData; int nLen pGram-wDataLen; // 打洞确认逻辑见 3.2 if (m_nHoleState HOLE_SHOOTING pGram-dwSrcIP m_peer.dwIP) { m_peer.wPort pGram-wSrcPort; m_nHoleState HOLE_OPEN; KillTimer(TIMER_HOLE); UpdateStatus(_T(点对点已建立)); } if (nLen (int)sizeof(P2P_MSG_HDR)) { delete pGram; return 0; } P2P_MSG_HDR* pHdr (P2P_MSG_HDR*)pData; if (pHdr-byVer ! P2P_VER) // 版本不符直接丢 { delete pGram; return 0; } // 长度字段可能被伪造用实际收包长度夹住再读 int nPayload min((int)pHdr-wLen, nLen - (int)sizeof(P2P_MSG_HDR)); if (pHdr-byType MSG_TEXT) { CString strMsg((LPCSTR)(pData sizeof(P2P_MSG_HDR)), nPayload); m_listChat.AddString(strMsg); // 到这一步才碰控件 } else if (pHdr-byType MSG_BYE) { UpdateStatus(_T(对方已下线)); } delete pGram; // 堆上内存统一在这里释放 return 0; }两个细节容易翻车。一是别信对端给的 wLen 就无脑读一个非法的 wLen 能让你从缓冲区里读出界先拿实际 nLen 夹一下再 memcpy成本只有一个 min。二是 delete pGram 必须在所有分支末尾统一执行任何提前 return 的分支都不能漏否则每收一包泄漏一块 2KB 内存挂机两小时任务管理器里的内存曲线就能看出来。堆上内存谁分配谁释放这个约定要写进规范比什么智能指针都好使。注意OnReceive 回调里严格不做控件访问连 TRACE 输出都尽量少通知线程一堵整个程序的事件循环都会跟着慢。5. 避坑NAT、丢包与界面卡死的 5 个真实现场5.1 服务器能注册打洞一直失败对称 NAT 是硬骨头现象注册、查询都正常双方定时器也在跑探测包发个不停但两边 OnReceive 始终等不到对端的包。 原因客户端所在网络是对称 NATNAT 根据“目标地址端口”为每个会话分配不同的外部端口。A 发给服务器的包映射出端口 10001发给 B 的包映射出另一端口 10002B 拿着 10001 那个地址回包A 的 NAT 根本不认。这类环境普通打洞无解。 解决先换家庭宽带复测很多“打洞失败”其实是办公网、校园网的对称 NAT 造成真要兼容做端口预测或直接降级成中继转发。工程上我一般把打洞失败做成普通状态文字而不是弹错误框——用户很可能就在公司网里弹框只会让他以为程序坏了。5.2 SendTo 返回成功对端就是收不到端口没绑死与防火墙拦截现象调试输出 SendTo 每次都是正数但对端 OnReceive 从未触发服务器端却能看到客户端注册成功。 原因Create(0, SOCK_DGRAM) 让系统随机分配本地端口对端回包时你这条 NAT 映射对应的端口变了或不一致另一个高频原因是 Windows 防火墙默认拦截 UDP 入站对未签名的小程序尤其严格。 解决Create 时写死本地端口注册、打洞、聊天共用同一个套接字和同一个源端口测试环境加一条防火墙入站规则放行该 UDP 端口。验证用 netstat -an 确认本地监听端口是你写的那个再用一个独立的 UDP 端口测试小工具从外部打一发能快速切开是防火墙还是代码的问题。5.3 界面卡死在 OnReceive 里做了阻塞操作现象收一条消息后整个对话框失去响应按钮点不动拖动窗口都费劲。 原因OnReceive 跑在套接字通知线程你在里面调了 DoModal、Sleep、WaitForSingleObject或者往一个锁竞争激烈的列表里塞数据通知线程被堵住后续收包事件全排到队尾。 解决OnReceive 里只做 ReceiveFrom、打包、PostMessage 三件事其他全部推到 UI 线程。这条规则没有例外写文件日志都别在通知线程做。5.4 中文乱码CString、GBK 与 UTF-8 三方混战现象自己发自己收正常换一台工程字符集不同的机器中文就花了。 原因CT2A 默认走系统 ANSI 代码页简体中文系统是 GBK对方按 UTF-8 解码或反过来中文按字节拆开自然乱。字符串逆序、按字节截断这类二次加工会把乱码放大到完全不可读。 解决负载一律 UTF-8协议头里加 1 字节编码标志位收发两边强制走 CP_UTF8 转换不允许直接 memcpy CString 内部缓冲。这条建议越早定越好等界面要加表情、图片时再改协议就来不及了。5.5 退出崩溃套接字与窗口的销毁顺序现象收发都正常但关窗口时 Debug 版偶发断言Release 版偶发访问违例。 原因对话框销毁时底层 Winsock 句柄先被释放OnReceive 还拿着旧句柄在等残留数据PostMessage 的目标窗口句柄也已失效new 过的数据报如果漏 delete关闭瞬间一并触发泄漏报告。 解决在 OnDestroy 里先 KillTimer 再 m_sock.Close()保证消息回路停在前OnReceive 和 OnP2PData 两端都用 IsWindow 判空投递出去的 P2P_DATAGRAM 全部走“分配—投递—处理—释放”单一路径。这套顺序我写过三次才固定成肌肉记忆。6. 把连接养住保活间隔、重发与打洞状态机打洞成功只代表链路建立不代表链路永久有效。NAT 映射是有生命周期的家用路由器常见的 UDP 映射超时从 30 秒到 2 分钟不等双方只要超过这个时长没有任何互发NAT 就会删掉映射下一次只能重新打洞。所以 HOLE_OPEN 之后马上启动保活定时器每 20 秒发一个 MSG_HEART 心跳包负载为空序号单独计数。作用有两个一是确认对端进程还活着二是让两侧 NAT 不断刷新这条映射的出站时间。心跳间隔别小于 10 秒纯属浪费带宽也别大于 30 秒在不少路由器上已经超时。如果你发现打洞成功后一两分钟又开始打洞先怀疑保活丢了而不是路由器的玄学。状态进入条件保活/重试策略界面提示IDLE启动或打洞失败无未连接WAIT_PEER注册包已发出3 秒超时重发注册等待服务器返回SHOOTING拿到对端地址50 毫秒发一轮探测包正在打洞OPEN收到对端包20 秒心跳保活点对点已建立ALIVE连续收到心跳3 次未回才判下线在线这个状态机我在三个项目里反复用了同样结构排障效率比翻日志高得多。用户说“连不上”先看状态栏停在哪个状态停在 WAIT_PEER问题在服务器方向停在 SHOOTING问题在 NAT 穿透从 OPEN 退回 IDLE问题在保活。每一条都能把排查范围压缩到单个模块。最后说一个我自己翻过车的地方。当初把心跳和文本消息共用一条 wSeq每发一个包序号都自增对端按序号去重时把真正的新文本当成重复包丢了查了整整一晚上。现在改成心跳和文本各自独立计数心跳包干脆不带负载这个 bug 再没回来过。P2P 聊天室的价值不在界面多花哨而在状态管理是否诚实——把每个阶段打在状态栏里用户和开发者都不会被蒙在鼓里。希望这些经验能帮你在自己的 C/Socket/UDP/MFC 聊天室里少踩几个坑。本文还有配套的精品资源点击获取