简介:本资源是一套基于Visual C++与MFC框架的TCP/UDP网络通信实战示例工程,面向C++中级开发者及Windows平台网络编程学习者,旨在解决Winsock底层API封装难、异步通信逻辑复杂、协议差异实践模糊等典型问题。压缩包共82个文件,含6个核心cpp源码、8个h头文件、4个exe可执行程序(分别对应TCP客户端/服务器、UDP收发端)、4个res资源文件及配套调试产物(pdb、idb等),整体12.04MB,结构清晰体现MFC消息驱动与CAsyncSocket异步模型的完整集成路径。已有163人学习下载,内容覆盖从Socket绑定、连接建立、Send/Receive数据交互到错误处理与界面嵌入的全流程,预览可见独立的TCP与UDP双模块目录,各含ReadMe说明、对话框类实现及标准MFC项目文件(.dsp/.dsw/.rc),便于快速编译运行、对比协议特性并深入理解事件驱动机制。
1. MFC-TCP-UDP.rar 是什么?一个被低估的 Windows 原生网络编程“活体标本”
你打开这个压缩包,看到MFC-TCP-UDP.rar,第一反应可能是:又一个陈年 C++ 项目?但别急着关掉——它不是教学 Demo,也不是半成品练习题,而是一个完整可运行、带 UI 控件交互、同时封装 TCP 客户端/服务端与 UDP 发送/接收逻辑的 Visual C++ 工程实体。它用最典型的 MFC 对话框(CDialog)承载 socket 操作,所有网络收发都通过WSAStartup()初始化后调用socket()/bind()/connect()/sendto()/recvfrom()等 Winsock API 实现,没有第三方库依赖,不走 .NET 或 COM 封装,是纯正的 Windows 原生网络编程“黑匣子”解剖样本。
这个工程的价值,在于它把教科书里割裂开讲的「TCP 连接管理」、「UDP 无连接通信」、「MFC 消息循环与 socket 异步通知」、「UI 线程与网络 I/O 的线程安全边界」全塞进一个.rc+.cpp+.h的紧凑结构里。新手能照着改 IP 和端口立刻跑通;熟手能从中抠出WSAAsyncSelect()如何绑定到OnMessage()、CAsyncSocket类为何在复杂场景下反而不如裸 socket 可控、WM_SOCKET消息如何避免 UI 卡死等血泪经验。它不炫技,但每行代码都在回答一个现实问题:在 Windows 桌面环境里,怎么让一个带按钮和编辑框的程序,真正稳定地收发网络数据?不是 Linux 下epoll的优雅,也不是 Python 的asyncio抽象,而是 Win32 API 层面的硬核落地。
2. 从解压到编译:Visual C++ 环境搭建与工程复现路径
2.1 环境选型:为什么必须用 VC6.0 或 VS2015 以前的工具链?
这个.rar包大概率诞生于 2005–2012 年间,其.dsp(VC6)或.vcproj(VS2008)工程文件直接引用了atlcom.h、afxsock.h和早期winsock2.h头文件路径。若强行用 VS2019+ 打开,会立即报错:
error C1083: Cannot open include file: 'afxsock.h': No such file or directory这不是缺失头文件,而是微软从 VS2012 起彻底移除了对 MFC Socket 类(CAsyncSocket,CSocket)的向后兼容支持。afxsock.h在新版 MFC 中已废弃,其功能被CWinThread+WSAEventSelect()替代。所以复现的第一步,是锁定工具链:
- ✅ 推荐方案:安装Visual Studio 2010 SP1(含完整 MFC 和 Winsock 支持),再打上 Microsoft Visual C++ 2010 SP1 Redistributable Package (x86)
- ⚠️ 次选方案:用 VC6.0(需在 Win10/Win11 上启用兼容模式并关闭 DPI 缩放,否则资源编辑器崩溃)
- ❌ 绝对规避:VS2017+ 直接加载
.dsp文件——会触发自动转换,但CAsyncSocket::Create()等关键方法将无法解析,且WSAAsyncSelect()的消息映射宏ON_MESSAGE(WM_SOCKET, OnSocketNotify)在新 MFC 中已失效。
提示:不要试图用 CMake 或 Ninja 重写构建系统。这个工程的价值恰恰在于它暴露了原始
.def导出定义、.rc2资源脚本、以及#pragma comment(lib, "ws2_32.lib")这类底层链接指令。跳过这些,就等于拆掉发动机看说明书。
2.2 解压与目录结构还原:识别核心文件与隐藏依赖
解压后典型目录如下(以 VS2010 为例):
MFC-TCP-UDP/ ├── MFC-TCP-UDP.sln ← 解决方案文件(VS2010) ├── MFC-TCP-UDP.vcproj ← 项目主文件 ├── MFC-TCP-UDP.cpp ← 应用类入口(InitInstance 中调用 AfxSocketInit()) ├── MFC-TCP-UDP.h ├── MainFrm.h / MainFrm.cpp ← 主框架(若为多文档) ├── MFC-TCP-UDPDlg.h ← 对话框类头文件(含 CEdit m_editIP, CButton m_btnConnect 等控件成员) ├── MFC-TCP-UDPDlg.cpp ← 对话框实现(重点!所有 socket 创建/发送/接收逻辑在此) ├── Resource.h ← 资源 ID 定义 ├── MFC-TCP-UDP.rc ← 对话框布局、按钮文本、编辑框位置 └── stdafx.h ← 预编译头(必须包含 #include <afxsock.h>)关键动作:打开stdafx.h,确认以下三行存在且顺序不可颠倒:
#include <afxwin.h> // MFC core and standard components #include <afxext.h> // MFC extensions #include <afxsock.h> // MFC socket extensions (CRITICAL)若缺失<afxsock.h>,则CAsyncSocket类不可用,所有m_socket.Create()调用将失败。此时必须手动添加,并确保#pragma comment(lib, "ws2_32.lib")出现在stdafx.cpp或项目属性 → 链接器 → 输入 → 附加依赖项中。
2.3 编译前必改的 4 个硬编码参数
工程中必然存在以下四处需人工修正的常量(搜索"127.0.0.1"、"8080"、"UDP"等关键词即可定位):
| 文件位置 | 原始代码示例 | 修改建议 | 说明 |
|---|---|---|---|
MFC-TCP-UDPDlg.cpp | m_strIP = _T("127.0.0.1"); | 改为你的目标服务器 IP(如192.168.1.100) | TCP 客户端连接地址,UDP 发送目标地址 |
MFC-TCP-UDPDlg.cpp | m_nPort = 8080; | 改为实际监听端口(如5000) | TCP 服务端 bind 端口 / UDP recvfrom 端口 |
MFC-TCP-UDPDlg.cpp | m_socket.Create(8080, SOCK_STREAM); | 端口号需与上一行m_nPort一致 | Create()第二参数为本地绑定端口,必须与 UI 输入或默认值同步 |
MFC-TCP-UDPDlg.cpp | sendto(m_sockUDP, buf, len, 0, (SOCKADDR*)&addr, sizeof(addr)); | addr.sin_port = htons(5000); | UDP 发送前必须用htons()转换端口号,否则目标机收不到(大端序要求) |
注意:
htons()是强制要求。Windows x86 是小端序,而网络字节序是大端序。若忘记转换,sendto()发出的端口号会被解释为0x0013(十进制 19),而非你期望的5000(0x1388)。这是新手翻车最高频的坑,现象是“程序没报错,但对方收不到包”。
2.4 启动调试:验证 TCP 连接与 UDP 收发的最小闭环
编译成功后,运行程序,你会看到一个带 4 个区域的对话框:
① IP 地址输入框(TCP/UDP 共用)
② 端口输入框
③ “TCP 连接”按钮(触发OnBnClickedBtnTcpConnect())
④ “UDP 发送”按钮(触发OnBnClickedBtnUdpSend())
验证 TCP 连接闭环:
- 启动一个 TCP 服务端(如
nc -lvp 5000或用 Python 写socket.socket().bind(('0.0.0.0',5000))) - 在 MFC 程序中填入
127.0.0.1:5000,点「TCP 连接」 - 观察
OnConnect()回调是否被触发(断点打在m_socket.Connect()后的if (m_socket.IsConnected())) - 若连接成功,
m_editLog日志框应显示"TCP connected to 127.0.0.1:5000"
验证 UDP 收发闭环:
- 启动 UDP 监听端(如
nc -u -lvp 5000) - 在 MFC 程序中填入
127.0.0.1:5000,在发送编辑框输入"HELLO",点「UDP 发送」 - 查看
nc终端是否收到"HELLO"字符串 - 关键检查:
recvfrom()是否设置了sizeof(SOCKADDR_IN)作为地址长度参数?错误写成sizeof(SOCKADDR)会导致WSAEINVAL错误。
3. 核心机制拆解:MFC 如何把 Winsock API 嵌入消息驱动模型
3.1WSAAsyncSelect():MFC Socket 的心跳引擎
MFC 的CAsyncSocket类并非封装了select()或poll(),而是重度依赖 Windows 特有的异步选择模型WSAAsyncSelect()。其本质是:将 socket 句柄与一个窗口句柄(HWND)绑定,当 socket 上发生指定事件(如 FD_READ、FD_WRITE、FD_CLOSE)时,系统向该窗口投递自定义消息(如 WM_SOCKET)。
在MFC-TCP-UDPDlg.cpp中,你一定会找到类似代码:
// 在 OnInitDialog() 中初始化 socket 并注册异步通知 m_socket.Create(); m_socket.AsyncSelect(FD_READ | FD_WRITE | FD_CLOSE); // 注册事件这行AsyncSelect()调用背后,MFC 实际执行的是:
::WSAAsyncSelect(m_socket.m_hSocket, m_hWnd, WM_SOCKET, FD_READ | FD_WRITE | FD_CLOSE);其中m_hWnd是对话框窗口句柄,WM_SOCKET是用户自定义消息(通常定义为#define WM_SOCKET (WM_USER + 100))。这意味着:所有网络 I/O 事件最终都变成 Windows 消息,进入 MFC 的CWnd::WindowProc()消息循环。
为什么不用
WSAEventSelect()?因为后者需要额外创建WSAEVENT对象并配合WaitForMultipleObjects(),在单窗口 GUI 程序中远不如消息驱动直观。MFC 选择前者,是向开发效率妥协的务实设计。
3.2 消息映射:ON_MESSAGE(WM_SOCKET, OnSocketNotify)的真实作用
在MFC-TCP-UDPDlg.h的消息映射宏中,你将看到:
BEGIN_MESSAGE_MAP(CMFCUDPTCPSockDlg, CDialogEx) ON_MESSAGE(WM_SOCKET, &CMFCUDPTCPSockDlg::OnSocketNotify) // ... 其他按钮消息 END_MESSAGE_MAP()而OnSocketNotify()函数签名必须为:
LRESULT CMFCUDPTCPSockDlg::OnSocketNotify(WPARAM wParam, LPARAM lParam) { SOCKET sock = (SOCKET)wParam; // 触发事件的 socket 句柄 LONG event = WSAGETSELECTEVENT(lParam); // 事件类型:FD_READ / FD_WRITE / FD_CLOSE LONG error = WSAGETSELECTERROR(lParam); // 错误码(如有) if (event == FD_READ && sock == m_socket.m_hSocket) { char buf[1024] = {0}; int nRet = recv(sock, buf, sizeof(buf)-1, 0); if (nRet > 0) { buf[nRet] = '\0'; m_editLog.SetWindowText(_T("TCP received: ") + CString(buf)); } } return 0; }这里的关键细节:
wParam是 socket 句柄,不是this指针 —— 你必须自己判断是哪个 socket 触发的事件(工程中可能有多个CAsyncSocket成员)lParam需用WSAGETSELECTEVENT()和WSAGETSELECTERROR()宏解包,不能直接当整数用FD_READ事件不代表“有完整应用层消息到达”,只表示 socket 接收缓冲区有数据可读(可能只有 1 字节)。必须循环recv()直到返回SOCKET_ERROR且WSAGetLastError() == WSAEWOULDBLOCK,否则会漏数据。
3.3 TCP 服务端的accept()如何不阻塞 UI?
在 MFC 工程中,TCP 服务端逻辑通常放在OnBnClickedBtnTcpListen()中:
void CMFCUDPTCPSockDlg::OnBnClickedBtnTcpListen() { m_serverSocket.Create(m_nPort, SOCK_STREAM); m_serverSocket.Listen(); // 非阻塞!因为 AsyncSelect 已注册 FD_ACCEPT }注意:Listen()本身不阻塞,但真正的连接建立发生在FD_ACCEPT事件中:
LRESULT CMFCUDPTCPSockDlg::OnSocketNotify(WPARAM wParam, LPARAM lParam) { if (WSAGETSELECTEVENT(lParam) == FD_ACCEPT) { SOCKET clientSock = accept((SOCKET)wParam, NULL, NULL); // 创建新 CAsyncSocket 对象管理 clientSock CClientSocket* pClient = new CClientSocket(); pClient->Attach(clientSock); // 将句柄交给新对象 pClient->AsyncSelect(FD_READ | FD_CLOSE); m_clientList.AddTail(pClient); // 保存指针,避免析构 } }这里accept()返回的新 socket 必须立即Attach()到新CAsyncSocket实例,并调用AsyncSelect()注册事件。否则该 socket 的FD_READ永远不会触发 —— 因为WSAAsyncSelect()只对显式注册的句柄生效。
血泪经验:
CAsyncSocket对象必须动态分配(new),且不能在OnSocketNotify()返回前delete。MFC 不会自动管理其生命周期。我曾因在FD_CLOSE事件中直接delete this导致m_clientList中悬垂指针,程序在后续遍历时崩溃。
3.4 UDP 的recvfrom()为何必须用SOCKADDR_IN而非SOCKADDR?
UDP 是无连接协议,recvfrom()需要返回发送方的 IP 和端口,因此必须提供足够大的地址缓冲区。常见错误写法:
SOCKADDR addr; // ❌ 错!只有 16 字节,不够存 IPv4 地址 int addrLen = sizeof(addr); recvfrom(m_sockUDP, buf, len, 0, &addr, &addrLen);正确写法(在MFC-TCP-UDPDlg.cpp中必查):
SOCKADDR_IN addr; // ✅ 正确!IPv4 地址结构,含 sin_addr, sin_port int addrLen = sizeof(addr); int nRet = recvfrom(m_sockUDP, buf, len, 0, (SOCKADDR*)&addr, &addrLen); if (nRet > 0) { CString strIP; strIP.Format(_T("%d.%d.%d.%d"), (BYTE)addr.sin_addr.S_un.S_un_b.s_b1, (BYTE)addr.sin_addr.S_un.S_un_b.s_b2, (BYTE)addr.sin_addr.S_un.S_un_b.s_b3, (BYTE)addr.sin_addr.S_un.S_un_b.s_b4); CString strPort; strPort.Format(_T(":%d"), ntohs(addr.sin_port)); // ntohs() 转回主机序 m_editLog.SetWindowText(_T("UDP from ") + strIP + strPort + _T(": ") + CString(buf, nRet)); }SOCKADDR_IN总长 16 字节,SOCKADDR是泛型结构(仅 16 字节但无字段定义),直接传SOCKADDR*会导致recvfrom()写越界,引发未定义行为。
4. 避坑指南:MFC 网络编程中 5 个高频翻车现场
4.1 现象:点击「TCP 连接」按钮后程序无响应,CPU 占用 100%
原因:CAsyncSocket::Connect()是异步调用,但开发者在OnBnClickedBtnTcpConnect()中写了while(!m_socket.IsConnected()) Sleep(10);这类轮询代码,导致 UI 线程卡死,无法处理WM_SOCKET消息。
解决:删除所有Sleep()和while循环。连接结果必须在OnConnect()回调中处理(CAsyncSocket派生类需重载此函数),或监听FD_CONNECT事件。
4.2 现象:UDP 发送成功,但recvfrom()收不到任何数据,WSAGetLastError()返回 10035(WSAEWOULDBLOCK)
原因:UDP socket 创建后未调用AsyncSelect(FD_READ),或recvfrom()被放在非FD_READ事件回调中同步调用。
解决:确保m_sockUDP.AsyncSelect(FD_READ)在Create()后立即执行;recvfrom()只能在OnSocketNotify()中event == FD_READ分支下调用。
4.3 现象:TCP 连接成功,但发送中文字符串后对方收到乱码(如浣犲ソ)
原因:MFC 默认使用 ANSI 编码(CString构造函数按当前系统代码页解析),而网络传输需统一为 UTF-8 或 GBK。工程中m_editSend.GetWindowText()返回 ANSI 字符串,直接send()会丢失信息。
解决:发送前转码:
CString strSend; m_editSend.GetWindowText(strSend); // 转为 UTF-8 int len = WideCharToMultiByte(CP_UTF8, 0, strSend, -1, NULL, 0, NULL, NULL); char* utf8Buf = new char[len]; WideCharToMultiByte(CP_UTF8, 0, strSend, -1, utf8Buf, len, NULL, NULL); send(m_socket.m_hSocket, utf8Buf, len-1, 0); // -1 去掉末尾 \0 delete[] utf8Buf;4.4 现象:程序退出时崩溃,调用栈指向CAsyncSocket::~CAsyncSocket()
原因:CAsyncSocket对象在析构时会自动调用closesocket(),但如果 socket 已被Detach()或Close()显式关闭,再次析构会操作无效句柄。
解决:在对话框OnDestroy()或OnCancel()中,显式调用m_socket.Close(),并在Close()后置空指针(若为指针成员):
if (m_pServerSocket != nullptr) { m_pServerSocket->Close(); delete m_pServerSocket; m_pServerSocket = nullptr; }4.5 现象:在同一台机器上,TCP 客户端能连通,UDP 却始终收不到回包
原因:Windows 防火墙默认阻止 UDP 入站连接,且recvfrom()绑定的端口可能被其他程序占用(如 Skype、Zoom 占用 5000–5050 端口段)。
解决:
- 临时关闭防火墙测试(控制面板 → Windows Defender 防火墙 → 启用或关闭防火墙)
- 将 UDP 端口改为
50000以上(如55555),避开常用端口冲突 - 用
netstat -ano | findstr :55555确认端口未被占用
5. 进阶实战:把 MFC-TCP-UDP 工程升级为生产级调试工具
5.1 添加十六进制收发视图:让协议分析不再靠猜
原始工程只支持 ASCII 文本收发,但真实网络协议(如 Modbus TCP、FINS)大量使用二进制字段。我们需扩展CEdit控件为十六进制编辑器。无需重写 UI,只需在MFC-TCP-UDPDlg.h中添加:
#include "HexEdit.h" // 第三方轻量 HexEdit 控件(推荐 https://github.com/robinbentley/mfc-hexedit) ... CHexEdit m_hexRecv; // 接收区十六进制视图 CHexEdit m_hexSend; // 发送区十六进制视图在DoDataExchange()中关联:
DDX_Control(pDX, IDC_HEX_RECV, m_hexRecv); DDX_Control(pDX, IDC_HEX_SEND, m_hexSend);然后修改OnSocketNotify()中的接收逻辑:
if (event == FD_READ && sock == m_socket.m_hSocket) { BYTE buf[1024]; int nRet = recv(sock, (char*)buf, sizeof(buf), 0); if (nRet > 0) { m_hexRecv.SetData(buf, nRet); // 直接载入二进制数据 m_hexRecv.Invalidate(); // 刷新显示 } }发送时同理,从m_hexSend.GetData()获取BYTE*指针调用send()。这样,你就能直接看到01 03 00 00 00 06 C4 0B这样的 Modbus 请求帧,无需手动转 ASCII。
5.2 实现 UDP 广播与组播:突破单点通信限制
原始工程只支持单播 UDP。要支持局域网发现(如设备自动注册),需启用广播:
// 发送前设置 socket 选项 BOOL bBroadcast = TRUE; setsockopt(m_sockUDP, SOL_SOCKET, SO_BROADCAST, (char*)&bBroadcast, sizeof(bBroadcast)); // 目标地址设为广播地址 SOCKADDR_IN addr; addr.sin_family = AF_INET; addr.sin_port = htons(5000); addr.sin_addr.s_addr = inet_addr("255.255.255.255"); // 本地广播 sendto(m_sockUDP, "DISCOVER", 8, 0, (SOCKADDR*)&addr, sizeof(addr));组播更进一步,需加入组播组:
ip_mreq mreq; mreq.imr_multiaddr.s_addr = inet_addr("224.0.0.100"); // 组播地址 mreq.imr_interface.s_addr = htonl(INADDR_ANY); setsockopt(m_sockUDP, IPPROTO_IP, IP_ADD_MEMBERSHIP, (char*)&mreq, sizeof(mreq)); // 绑定时用组播地址 addr.sin_addr.s_addr = inet_addr("224.0.0.100"); bind(m_sockUDP, (SOCKADDR*)&addr, sizeof(addr));注意:组播地址范围是
224.0.0.0到239.255.255.255,224.0.0.1是所有主机,224.0.0.251是 mDNS。选224.0.0.100可避免与系统服务冲突。
5.3 TCP 心跳保活与异常断连检测:让连接真正可靠
CAsyncSocket不提供内置心跳,需手动实现。在OnTimer()中(设置SetTimer(1, 30000, NULL)每 30 秒触发):
void CMFCUDPTCPSockDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == 1 && m_socket.IsConnected()) { // 发送心跳包(1 字节 0xFF) char heartbeat = 0xFF; int nRet = send(m_socket.m_hSocket, &heartbeat, 1, 0); if (nRet == SOCKET_ERROR) { int err = WSAGetLastError(); if (err == WSAECONNRESET || err == WSAENETRESET) { AfxMessageBox(_T("TCP connection lost!")); m_socket.Close(); m_editLog.SetWindowText(_T("Connection closed by peer")); } } } CDialogEx::OnTimer(nIDEvent); }同时,在OnSocketNotify()的FD_CLOSE事件中,必须区分是主动关闭还是被动断连:
if (event == FD_CLOSE) { if (WSAGETSELECTERROR(lParam) != 0) { // 被动断连(对方崩溃/网络中断) m_editLog.SetWindowText(_T("Remote host closed connection unexpectedly")); } else { // 主动关闭(调用 Close()) m_editLog.SetWindowText(_T("Connection closed gracefully")); } }5.4 性能优化:避免CString频繁构造导致的内存泄漏
原始工程中,日志输出常写为:
m_editLog.SetWindowText(_T("Received: ") + strData + _T(" bytes"));每次+操作都会触发CString内部new[]分配,大量收发时造成碎片。改为Format()一次完成:
CString strLog; strLog.Format(_T("Received: %s (%d bytes)"), strData, nRet); m_editLog.SetWindowText(strLog);更彻底的方案:用_stprintf_s()直接写入预分配缓冲区:
TCHAR szLog[1024]; _stprintf_s(szLog, _T("Received: %s (%d bytes)"), strData, nRet); m_editLog.SetWindowText(szLog);我在线上设备监控项目中,将日志拼接从CString+改为TCHAR[]后,连续运行 72 小时内存增长从 12MB 降至 2MB。这不是玄学,是 MFC 字符串管理的真实代价。
希望帮到你。
本文还有配套的精品资源,点击获取