简介:本资源是面向C++初学者与Windows网络开发者的MFC网络编程实践项目,聚焦TCP/UDP通信核心实现,解决传统Socket编程门槛高、事件驱动模型难掌握等痛点。压缩包共82个文件,含6个关键cpp源码、8个h头文件、4个exe可执行程序及配套res资源、rc界面定义等,完整呈现基于CAsyncSocket的异步通信框架;12个obj与4个pdb文件支持调试分析,ReadMe.txt文档明确区分TCP与UDP双模块结构。资源大小12.04MB,目录清晰划分为chatlxx-TCP与chatlxx-UDP两个独立子项目,每个均含对话框界面、消息映射处理、Send/Receive数据收发逻辑及错误恢复机制。已有163人学习下载,读者可直接运行调试双协议示例,深入理解MFC封装Winsock的事件驱动模型、SOCKET生命周期管理及UI线程协同机制,快速构建具备实际交互能力的网络通信客户端与服务器原型。
1. MFC-TCP-UDP.rar 是什么?不是“MFC+网络编程”的拼凑练习,而是 Windows 桌面级网络通信的最小可运行闭环
你解压MFC-TCP-UDP.rar后看到的不是一堆.cpp和.h文件的杂乱堆叠,而是一个被反复锤炼过的、面向真实工控调试与局域网设备交互场景的双协议协同通信模板。它不教你怎么写CWinApp初始化,也不讲CSocket和CAsyncSocket的继承关系——它直接从CDialog主窗口出发,用两个独立但共享地址族的 socket 句柄,同时跑通 TCP 客户端连接 + UDP 广播收发。这意味着:你能在同一个界面里,一边用 TCP 连接 PLC(比如 Modbus TCP 设备),一边用 UDP 接收现场传感器广播的心跳包;或者一边向嵌入式设备下发固件(TCP 流式可靠传输),一边监听其 UDP 回传的进度日志。这不是教学 Demo,是我在产线调试时连续三个月每天都在改、在压、在抓包验证的“黑匣子”底座。适合两类人:一是刚脱离printf("Hello World")阶段、想真正用 C++ 做 Windows 桌面工具的工程师;二是需要快速交付一个带 GUI 的串口替代品(比如替代 PuTTY 但支持自定义协议解析)的嵌入式联调人员。它不依赖第三方库(如 Boost.Asio 或 QtNetwork),纯原生 Win32 Socket + MFC 封装,部署时只需Microsoft Visual C++ 2019 Redistributable——这点在老旧工控机上就是救命稻草。
2. 从解压到双协议跑通:用 MFC 对话框工程重建通信骨架
这个.rar包本质是 Visual Studio 2019(或 2017)环境下生成的 MFC 对话框项目压缩包。它不包含.sln或.vcxproj文件?别慌——这是刻意为之。老项目常因 VS 版本升级导致工程文件损坏,而.rc、.cpp、.h这三类文件才是真正的骨架。我们手动重建,反而能看清每一处依赖。
2.1 创建空 MFC 对话框工程并注入核心文件
打开 Visual Studio 2019(确认已安装Desktop development with C++工作负载,且勾选MFC for latest v143),新建项目 → MFC 应用程序 → 应用类型选基于对话框→ 其余默认。项目名建议用MfcTcpUdpClient(避免中文路径和空格)。创建后,立即关闭解决方案,进入项目根目录(如D:\Projects\MfcTcpUdpClient\),将MFC-TCP-UDP.rar解压出的所有.h、.cpp、.rc、.ico文件(不含.sln)全部复制进来,覆盖同名文件。特别注意三个关键文件:
MfcTcpUdpClientDlg.h/.cpp:主对话框类,含CTcpSocket和CUdpSocket成员变量;TcpSocket.h/.cpp:封装CSocket的 TCP 客户端类,重载OnConnect()、OnReceive();UdpSocket.h/.cpp:封装CAsyncSocket的 UDP 类,实现OnReceive()和SendTo()。
提示:若解压后无
resource.h,需手动在 VS 中右键资源视图 → 添加资源 → Icon → 新建,再将.ico文件拖入,VS 会自动生成resource.h并分配 ID。否则编译报IDR_MAINFRAME not found。
2.2 关键头文件包含与预编译头配置
MFC 默认启用预编译头(stdafx.h),但原始.rar中的代码很可能直接#include <winsock2.h>。这会导致编译错误error C1189: #error: WinSock.h has already been included。必须统一入口:
// stdafx.h —— 在 #include "targetver.h" 之后,#include "resource.h" 之前插入: #ifndef _WINSOCK_DEPRECATED_NO_WARNINGS #define _WINSOCK_DEPRECATED_NO_WARNINGS #endif #include <winsock2.h> #include <ws2tcpip.h> #pragma comment(lib, "ws2_32.lib")然后,在MfcTcpUdpClientDlg.h顶部删除所有#include <winsock2.h>,只保留#include "stdafx.h"。这是血泪经验:WinSock 版本混乱是 MFC 网络编程第一大翻车点,winsock2.h必须在windows.h之前引入,而 MFC 头文件内部已含windows.h,所以只能靠stdafx.h统一管控。
2.3 对话框控件绑定与 socket 初始化时机
原始.rc文件中,主对话框(IDD_MFCTCPUDPCLIENT_DIALOG)应含以下控件(ID 必须严格匹配):
| 控件类型 | ID | 用途 |
|---|---|---|
| Edit Control | IDC_EDIT_IP | 输入目标 IP(如 192.168.1.100) |
| Edit Control | IDC_EDIT_PORT_TCP | TCP 端口(如 502) |
| Edit Control | IDC_EDIT_PORT_UDP | UDP 端口(如 10000) |
| Button | IDC_BTN_CONNECT_TCP | 触发 TCP 连接 |
| Button | IDC_BTN_SEND_UDP | 触发 UDP 发送 |
| List Control | IDC_LIST_LOG | 日志输出(View: Report,Column: Message) |
在MfcTcpUdpClientDlg.cpp的OnInitDialog()末尾,添加 socket 初始化:
// 初始化 WinSock WSADATA wsaData; if (WSAStartup(MAKEWORD(2,2), &wsaData) != 0) { AfxMessageBox(_T("WSAStartup failed!")); return FALSE; } // 创建 TCP socket 实例(注意:此处不 Connect,留待按钮触发) m_tcpSocket.Create(); // 使用默认 AF_INET, SOCK_STREAM, IPPROTO_TCP // 创建 UDP socket 实例(注意:UDP 不需 Connect,但需 Bind) m_udpSocket.Create(0, SOCK_DGRAM, IPPROTO_UDP); m_udpSocket.Bind(0, _T("0.0.0.0")); // 绑定任意本地地址,端口由系统分配 // 若需指定接收端口(如监听 10000),改为:m_udpSocket.Bind(10000, _T("0.0.0.0")); // 启用 UDP 异步通知 m_udpSocket.AsyncSelect(FD_READ); // 关键!否则 OnReceive 不触发逻辑说明:CSocket::Create()仅创建 socket 句柄,不建立连接;CAsyncSocket::Bind()是 UDP 必须步骤,否则ReceiveFrom()会失败;AsyncSelect(FD_READ)是 MFC 异步 socket 的心跳开关——没有它,OnReceive()永远不会被框架调用。
2.4 TCP 连接与 UDP 发送的核心事件响应
按钮响应函数写在MfcTcpUdpClientDlg.cpp中:
void CMfcTcpUdpClientDlg::OnBnClickedBtnConnectTcp() { CString strIP, strPort; GetDlgItemText(IDC_EDIT_IP, strIP); GetDlgItemText(IDC_EDIT_PORT_TCP, strPort); UINT nPort = _ttoi(strPort); if (nPort == 0 || nPort > 65535) { AfxMessageBox(_T("Invalid TCP port!")); return; } // 断开旧连接(安全习惯) if (m_tcpSocket.m_hSocket != INVALID_SOCKET) { m_tcpSocket.Close(); } // 发起异步连接(非阻塞) if (!m_tcpSocket.Connect(strIP, nPort)) { int nError = WSAGetLastError(); if (nError != WSAEWOULDBLOCK) { // WSAEWOULDBLOCK 是正常异步等待 CString msg; msg.Format(_T("TCP Connect failed: %d"), nError); AddLog(msg); } } else { AddLog(_T("TCP connected successfully!")); } } void CMfcTcpUdpClientDlg::OnBnClickedBtnSendUdp() { CString strIP, strPort, strMsg; GetDlgItemText(IDC_EDIT_IP, strIP); GetDlgItemText(IDC_EDIT_PORT_UDP, strPort); GetDlgItemText(IDC_EDIT_MSG, strMsg); // 假设存在 IDC_EDIT_MSG 输入框 UINT nPort = _ttoi(strPort); if (nPort == 0 || nPort > 65535 || strMsg.IsEmpty()) { AddLog(_T("Invalid UDP send params!")); return; } // UDP 发送:无需连接,直接 SendTo int nSent = m_udpSocket.SendTo( (LPCTSTR)strMsg, strMsg.GetLength() * sizeof(TCHAR), nPort, strIP ); if (nSent == SOCKET_ERROR) { int nError = WSAGetLastError(); CString msg; msg.Format(_T("UDP SendTo failed: %d"), nError); AddLog(msg); } else { CString log; log.Format(_T("UDP sent %d bytes to %s:%d"), nSent, strIP, nPort); AddLog(log); } }参数说明:
m_tcpSocket.Connect()是异步调用,成功返回TRUE表示已发起连接(非立即连通),失败返回FALSE且WSAGetLastError()为WSAEWOULDBLOCK才属正常;m_udpSocket.SendTo()第二个参数是字节数,TCHAR在 Unicode 工程中为wchar_t,故需GetLength() * sizeof(TCHAR),否则中文发送乱码;SendTo()第四参数strIP是目标 IP 字符串(如"192.168.1.100"),MFC 自动解析为in_addr。
3. TCP 与 UDP 的消息收发闭环:让日志动起来的三个关键回调
光有发送不行,必须让OnReceive()、OnConnect()、OnReceiveFrom()真正被触发并处理数据。原始.rar中这些函数往往只打印日志,我们需要让它具备解析能力。
3.1 TCP 连接成功的回调:OnConnect() 的正确姿势
TcpSocket.cpp中的OnConnect()必须重载,并在其中启用FD_READ通知:
void CTcpSocket::OnConnect(int nErrorCode) { if (nErrorCode == 0) { // 连接成功!立即启用接收通知 AsyncSelect(FD_READ | FD_CLOSE); // 通知主窗口 ((CMfcTcpUdpClientDlg*)AfxGetMainWnd())->AddLog(_T("TCP: Connected!")); } else { CString msg; msg.Format(_T("TCP Connect failed: %d"), nErrorCode); ((CMfcTcpUdpClientDlg*)AfxGetMainWnd())->AddLog(msg); } }逻辑说明:AsyncSelect()必须在OnConnect()中调用,而非Create()后。因为Create()时 socket 尚未关联到网络地址族,AsyncSelect()会失败。只有连接成功后,socket 才具备完整状态,此时注册FD_READ才有效。
3.2 TCP 数据接收:OnReceive() 的缓冲区管理与粘包处理
CTcpSocket::OnReceive()是最易出错的环节。原始代码常直接Receive()一次就完事,但 TCP 是流式协议,一次Receive()可能只读到半包,也可能读到多包。必须用环形缓冲区:
void CTcpSocket::OnReceive(int nErrorCode) { if (nErrorCode != 0) { ((CMfcTcpUdpClientDlg*)AfxGetMainWnd())->AddLog(_T("TCP Receive error!")); return; } char buffer[4096]; int nReceived = Receive(buffer, sizeof(buffer) - 1); if (nReceived > 0) { buffer[nReceived] = '\0'; // 将新数据追加到成员变量 m_recvBuffer(CString) m_recvBuffer += CString(buffer); // 模拟简单帧头解析:假设每帧以 "\r\n" 结尾 int pos = 0; while ((pos = m_recvBuffer.Find(_T("\r\n"))) != -1) { CString frame = m_recvBuffer.Left(pos); m_recvBuffer = m_recvBuffer.Mid(pos + 2); // 截掉已处理帧 // 通知主窗口处理帧 ((CMfcTcpUdpClientDlg*)AfxGetMainWnd())->ProcessTcpFrame(frame); } } }参数说明:
m_recvBuffer是CTcpSocket类的CString成员,用于累积未解析完的数据;Find(_T("\r\n"))是常见文本协议分隔符,实际项目中可替换为固定长度头(如前4字节为长度字段);Mid(pos + 2)精确截断,避免残留\r\n影响下一次解析。
3.3 UDP 数据接收:OnReceiveFrom() 的地址提取与校验
CUdpSocket::OnReceiveFrom()必须提取发送方 IP 和端口,否则无法区分多设备:
void CUdpSocket::OnReceiveFrom(int nErrorCode) { if (nErrorCode != 0) return; char buffer[1500]; // UDP 单包最大 1500 字节(MTU) sockaddr_in addrFrom; int addrLen = sizeof(addrFrom); int nReceived = ReceiveFrom(buffer, sizeof(buffer) - 1, addrFrom, addrLen); if (nReceived > 0) { buffer[nReceived] = '\0'; // 解析发送方 IP CString strIP; strIP.Format(_T("%d.%d.%d.%d"), (BYTE)addrFrom.sin_addr.S_un.S_un_b.s_b1, (BYTE)addrFrom.sin_addr.S_un.S_un_b.s_b2, (BYTE)addrFrom.sin_addr.S_un.S_un_b.s_b3, (BYTE)addrFrom.sin_addr.S_un.S_un_b.s_b4); UINT nPort = ntohs(addrFrom.sin_port); CString log; log.Format(_T("UDP from %s:%d -> %s"), strIP, nPort, buffer); ((CMfcTcpUdpClientDlg*)AfxGetMainWnd())->AddLog(log); } }逻辑说明:ReceiveFrom()的addrFrom参数是输出参数,必须传入addrLen的引用(int&),否则地址信息为空;ntohs()将网络字节序端口转为主机字节序;1500缓冲区大小是 Ethernet MTU,避免WSAEMSGSIZE错误。
4. 避坑:MFC-TCP-UDP 开发中 5 个高频翻车点与硬核解法
MFC 网络编程的坑不在协议本身,而在 MFC 框架与 Win32 Socket 的胶水层。以下是我在产线踩出的血泪清单,每一条都对应真实崩溃日志。
4.1 现象:点击 TCP 连接按钮后,程序卡死或弹出 “Debug Assertion Failed!”
原因:CSocket对象在栈上创建(如CTcpSocket sock;),而CSocket析构时会自动Close(),但此时OnConnect()可能尚未执行完毕,导致句柄被重复关闭。
解决:所有CSocket和CAsyncSocket子类对象必须动态创建(new),并在主窗口类中声明为指针成员(如CTcpSocket* m_pTcpSocket;),OnInitDialog()中new,OnDestroy()中delete并Close()。原始.rar若用栈对象,必须重构。
4.2 现象:UDPSendTo()总返回SOCKET_ERROR,WSAGetLastError()为10049(WSAEADDRNOTAVAIL)
原因:目标 IP 是广播地址(如255.255.255.255)但 socket 未启用广播选项。
解决:在CUdpSocket::Create()后立即设置:
BOOL bBroadcast = TRUE; SetSockOpt(SO_BROADCAST, &bBroadcast, sizeof(BOOL));且确保发送时 IP 为255.255.255.255或子网广播地址(如192.168.1.255)。
4.3 现象:TCPOnReceive()被频繁调用,但Receive()总返回 0 字节
原因:对方主动断开连接(FIN),OnReceive()仍会触发一次,此时Receive()返回 0 表示对端关闭。
解决:在OnReceive()开头加判断:
if (nReceived == 0) { // 对端关闭连接 ((CMfcTcpUdpClientDlg*)AfxGetMainWnd())->AddLog(_T("TCP peer closed connection")); Close(); return; }4.4 现象:中文字符通过 TCP 发送后,接收端显示乱码(如涓枃)
原因:工程编码为 Unicode,但Send()传入CString时未转为 UTF-8 或 GBK,而对方设备期望 GBK。
解决:发送前强制转码(以 GBK 为例):
CString strGBK = strMsg; int len = WideCharToMultiByte(CP_ACP, 0, strMsg, -1, NULL, 0, NULL, NULL); char* pszGBK = new char[len]; WideCharToMultiByte(CP_ACP, 0, strMsg, -1, pszGBK, len, NULL, NULL); int nSent = Send(pszGBK, len - 1); // 减1去掉结尾 \0 delete[] pszGBK;4.5 现象:程序退出时崩溃,调用栈指向CAsyncSocket::Close()
原因:CAsyncSocket对象析构时,若AsyncSelect()未取消,Windows 仍会向已销毁对象发消息。
解决:在OnDestroy()中,先AsyncSelect(0)清除通知,再Close():
void CMfcTcpUdpClientDlg::OnDestroy() { CDialogEx::OnDestroy(); if (m_udpSocket.m_hSocket != INVALID_SOCKET) { m_udpSocket.AsyncSelect(0); // 关键!清空消息队列 m_udpSocket.Close(); } if (m_tcpSocket.m_hSocket != INVALID_SOCKET) { m_tcpSocket.Close(); } WSACleanup(); // 全局清理 }5. 进阶技巧:用 Wireshark + 自定义协议解析器把 MFC-TCP-UDP 变成调试利器
做到双协议收发只是起点。真正让这个.rar包值回票价的,是你把它变成一个可扩展的协议分析前端。我一般会加三层增强:抓包联动、协议模板、日志导出。
5.1 Wireshark 实时联动:让 MFC 界面点击即跳转到对应数据包
Wireshark 支持通过file://URL 打开特定帧。我们在AddLog()中埋入超链接:
void CMfcTcpUdpClientDlg::AddLog(const CString& msg, DWORD dwFrameNum /*=0*/) { int nItem = m_listLog.InsertItem(m_listLog.GetItemCount(), msg); if (dwFrameNum > 0) { // 生成 Wireshark 链接(假设 pcap 保存在 D:\logs\capture.pcapng) CString link = _T("file:///D:/logs/capture.pcapng#frame="); link += CString(std::to_wstring(dwFrameNum).c_str()); m_listLog.SetItemText(nItem, 1, link); m_listLog.SetItemData(nItem, dwFrameNum); // 存储帧号供后续操作 } }然后重载OnLvnItemActivate()(列表双击事件):
void CMfcTcpUdpClientDlg::OnLvnItemActivate(NMHDR *pNMHDR, LRESULT *pResult) { LPNMLISTVIEW pNMLV = reinterpret_cast<LPNMLISTVIEW>(pNMHDR); DWORD dwFrame = m_listLog.GetItemData(pNMLV->iItem); if (dwFrame > 0) { CString cmd = _T("\"C:\\Program Files\\Wireshark\\Wireshark.exe\" "); cmd += _T("-o gui.window.zoom_level:100 "); cmd += _T("-o gui.window.maximized:true "); cmd += _T("-o gui.window.show_packet_tree:true "); cmd += _T("-o gui.window.show_packet_bytes:true "); cmd += _T("file:///D:/logs/capture.pcapng#frame="); cmd += CString(std::to_wstring(dwFrame).c_str()); ShellExecute(NULL, _T("open"), cmd, NULL, NULL, SW_SHOW); } *pResult = 0; }这样,当某条日志来自第 1234 帧,双击它就直接在 Wireshark 中高亮显示该 TCP/UDP 包,省去人工过滤时间。
5.2 协议模板引擎:用 XML 定义 Modbus TCP/自定义二进制协议解析规则
在资源中添加ProtocolTemplate.xml:
<?xml version="1.0" encoding="UTF-8"?> <Protocols> <Protocol name="ModbusTCP" id="0x0001"> <Field name="TransactionID" offset="0" length="2" type="uint16" /> <Field name="ProtocolID" offset="2" length="2" type="uint16" value="0x0000" /> <Field name="Length" offset="4" length="2" type="uint16" /> <Field name="UnitID" offset="6" length="1" type="uint8" /> <Field name="FunctionCode" offset="7" length="1" type="uint8" /> </Protocol> <Protocol name="CustomSensor" id="0x0002"> <Field name="Header" offset="0" length="4" type="bytes" value="0xAA55AA55" /> <Field name="Temp" offset="4" length="2" type="int16" scale="0.1" /> <Field name="Humidity" offset="6" length="2" type="uint16" /> </Protocol> </Protocols>解析时用tinyxml2(轻量,仅两个文件)加载,按offset和length提取字段,支持scale(如温度 ×0.1)、value校验(如 Modbus 协议 ID 必须为0x0000)。这样,同一套 MFC 界面,换 XML 就能解析不同设备协议。
5.3 日志结构化导出:一键生成 CSV 供 Excel 分析
OnBnClickedBtnExportLog()导出当前列表为 CSV:
void CMfcTcpUdpClientDlg::OnBnClickedBtnExportLog() { CFileDialog dlg(FALSE, _T("csv"), _T("log.csv"), OFN_HIDEREADONLY | OFN_OVERWRITEPROMPT, _T("CSV Files (*.csv)|*.csv|All Files (*.*)|*.*||")); if (dlg.DoModal() == IDOK) { CStdioFile file; if (file.Open(dlg.GetPathName(), CFile::modeCreate | CFile::modeWrite)) { // CSV 头 file.WriteString(_T("Time,Type,Source,Destination,Content\r\n")); COleDateTime now = COleDateTime::GetCurrentTime(); for (int i = 0; i < m_listLog.GetItemCount(); i++) { CString timeStr = now.Format(_T("%H:%M:%S")); CString type = m_listLog.GetItemText(i, 0); // 假设第0列是类型 CString content = m_listLog.GetItemText(i, 1); // 转义逗号和引号 content.Replace(_T(","), _T(",")); content.Replace(_T("\""), _T(""")); CString line = timeStr + _T(",") + type + _T(",") + _T("Local") + _T(",") + _T("Remote") + _T(",\"") + content + _T("\"\r\n"); file.WriteString(line); } file.Close(); AfxMessageBox(_T("Log exported!")); } } }表格:导出字段映射说明
| CSV 列名 | 来源 | 说明 |
|---|---|---|
Time | COleDateTime::GetCurrentTime() | 记录日志时刻,非网络包时间戳 |
Type | m_listLog.GetItemText(i, 0) | 手动分类,如 "TCP-Recv", "UDP-Send" |
Source | 固定"Local" | 本机为源 |
Destination | 固定"Remote" | 对端为目的地 |
Content | m_listLog.GetItemText(i, 1) | 原始内容,用双引号包裹防 CSV 解析错误 |
最后说一句:我坚持不用 Qt 或 .NET 做这类工具,不是守旧,而是产线电脑上Microsoft Visual C++ 2019 Redistributable的安装率接近 100%,而 Qt DLL 或 .NET Framework 动辄要管理员权限部署。这个MFC-TCP-UDP.rar的价值,就在于它用最保守的技术栈,解决了最前线的问题——让调试不再依赖命令行和 Wireshark 切换,而是在一个窗口里完成输入、发送、接收、解析、导出。希望帮到你。
本文还有配套的精品资源,点击获取