
简介资源包面向MFC网络编程初学者提供一套基于MFC实现的双向网络通信完整示例。示例采用非指针机制实现Socket管理简化了传统CSocket编程中容易出错的指针传递问题重点展示客户端与服务端在局域网内互相收发消息的完整流程帮助读者理解Windows网络编程中监听、连接、发送与接收的基本原理。包内共67个文件以C头文件.h和源文件.cpp为主同时包含可直接运行的exe、Visual C 6.0工程文件.dsp/.dsw、调试生成物.obj/.pdb以及说明文档压缩包整体约4.59MB解压后即可在VC6.0环境中打开查看。目前已有158人学习浏览适合刚接触Socket编程或希望避开指针复杂性、想通过完整例子入门网络通信的读者。资源附带客户端与服务端两套独立工程既提供源码也提供可执行程序对照工程结构可以快速梳理双向通信的代码组织方式其中的ReadMe与项目配置信息还能辅助搭建实验环境、排查常见编译或连接问题实用性较强。1. 用MFC做双向网络通信为什么“非指针机制”更值得采用MFC里做双向网络通信最容易踩的坑是从网上复制一段 winsock 代码new 出一个 SOCKET在对话框里再开一个线程去 recv()结果界面卡死、关窗崩溃最后连“这个 socket 到底归谁释放”都说不清。标题里说的“非指针机制”实际就是用 CAsyncSocket 这类 MFC 封装类把监听套接字、会话套接字作为窗口类的成员对象来管理不靠裸句柄到处传也不靠 new 出来的指针在类之间流转收发数据走事件回调MFC 的消息泵天然参与调度双向同时收发互不等待。这套方案解决三件事一是双向收发不互相阻塞二是收到的数据能直接驱动状态栏、图表对话框这类界面元素刷新三是窗口关闭时 socket 对象的生命周期跟着窗口走不会再出现句柄没人清理导致的崩溃。适合正在维护 MFC 老工程、想给设备通信加 TCP 通道或者在调研“上位机网络通信怎么写才不翻车”的开发者。2. 非指针机制的选型拆解CAsyncSocket、CSocket与裸Winsock三选一2.1 裸Winsock在MFC程序里的两处硬伤阻塞、句柄生命周期Winsock 的 API 是纯 C 风格阻塞模式下 recv() 会一直占着调用线程。MFC 对话框是消息循环驱动的 UI在主线程里调 recv() 等于让窗口失去响应放到工作线程里又得自己维护线程与窗口之间的数据同步。改用非阻塞模式则需要自己管理 FD_SET、select 或 WSAEventSelect每隔一段时间轮询 FD_ISSET代码一旦散落到各个对话框函数里连接状态、收发缓冲就变成一团乱麻。另一个更隐蔽的问题是 SOCKET 句柄没有 RAII。MFC 的 CWnd 在 DestroyWindow 时会自动清理窗口资源但裸 SOCKET 不会跟随窗口销毁常见的做法是重写 OnDestroy 去 closesocket漏写一处就是句柄泄漏。连接一多Leak 的句柄积累到上千个设备端就会报“无法创建新连接”。用 CAsyncSocket 封装类之后socket 句柄由 MFC 内部持有对象析构时自动 Close窗口成员对象随窗口一起回收生命周期问题从根上被约束住。2.2 CAsyncSocket把网络事件变成消息OnReceive不是轮询是通知CAsyncSocket 的核心是异步事件模型。调用 Create 时传入事件掩码例如 FD_READ | FD_WRITE | FD_ACCEPT | FD_CONNECT | FD_CLOSEMFC 内部通过 AsyncSelect 把这些网络事件翻译成窗口消息再分发到对应的虚函数OnReceive 表示可读、OnSend 表示可写、OnAccept 表示有客户端连入、OnConnect 表示连接建立、OnClose 表示对端关闭。这个模型对双向通信非常友好。接收和发送是两条独立的事件路径OnReceive 里读数据OnSend 里写数据不会出现“发送大文件时接收通道被堵死”的情况。回调里有一点要特别注意OnReceive 触发后如果数据没读完下层还会继续派发 OnReceive所以回调内必须避免阻塞操作也不能长时间占用。数据拷贝到自己的缓冲后立即返回这是后面第 5 章要展开的坑。2.3 CSocket的CArchive双向读写与CAsyncSocket的差异CSocket 继承自 CAsyncSocket但设计取向完全不同。CSocket 是阻塞、同步的搭配 CSocketFile 和 CArchive 使用可以用 和 直接读写字符串、整数等序列化数据写文件传输类代码很省事。但双向通信时两个方向各需要一个 CArchive还要在读写之间处理 Flush 时机否则一端在等数据另一端在等缓存清空就变成互相等待的死锁。对比下来CAsyncSocket 更贴近 MFC 的“消息驱动”哲学消息泵天然参与事件调度收发路径清晰。MFC 标准封装只覆盖 AF_INET 下的 TCP/UDP如果设备走的是蓝牙 RFCOMM 或者其它协议族CAsyncSocket 没法直接用那就要走 Windows Socket 扩展MFC 本身没有提供包装好的蓝牙接口这个边界需要先确认再动手。3. 按非指针方式搭服务端与客户端CAsyncSocket的成员对象与事件回调3.1 初始化与挂载AfxSocketInit必须在窗口创建前调用用 CAsyncSocket 之前MFC 程序里必须先调用 AfxSocketInit()。它负责加载 Winsock DLL 并完成版本协商相当于把 WSAStartup 的细节封装起来。常见翻车点是忘了在 InitInstance 里加这一句导致运行时 Create 返回 FALSE错误码 10047 之类或者把初始化放在窗口创建之后窗口消息循环还没准备好回调根本收不到。// App.cpp —— MFC应用入口InitInstance里必须加这一步 #include afxsock.h BOOL CMyApp::InitInstance() { // 加载Winsock DLL并协商版本失败说明本机没有可用协议栈 if (!AfxSocketInit()) { AfxMessageBox(_T(Socket 初始化失败请检查网络协议栈)); return FALSE; } // 其余初始化... return TRUE; }参数说明AfxSocketInit 无参数内部调用 WSAStartup 并保存版本信息整个进程只需调用一次。如果返回值是 FALSE后续所有套接字创建都会失败。3.2 服务端监听套接字与会话套接字作为窗口成员非指针机制的第一落点是把通信对象声明成对话框的成员。m_listen 负责监听m_session 负责已建立的连接。两者都是对象不是指针生命周期与对话框一致无需手工释放。// MainDlg.h —— 通信对象全部是对话框成员不在类之间传递SOCKET* #pragma once #include afxsock.h class CListenSocket; class CMainDlg : public CDialogEx { friend class CListenSocket; friend class CSessionSocket; private: CListenSocket m_listen; // 监听套接字负责FD_ACCEPT CSessionSocket m_session; // 会话套接字负责FD_READ/FD_WRITE/FD_CLOSE CStatusBar m_wndStatusBar; public: void StartServer(UINT nPort); BOOL OnClientAccepted(); void OnDataReceived(const CByteArray data); };m_session 是成员对象而不是 CMainDlg 里的指针这一点决定了“一个对话框同一时刻只能维护一个客户端连接”如果现场要多客户端就要引入对象池。下面是无对象池版本的服务端启动代码。// MainDlg.cpp —— 启动TCP服务端 void CMainDlg::StartServer(UINT nPort) { // SOCK_STREAM表示TCPlEvent只关心FD_ACCEPT有客户端请求连接时触发 if (!m_listen.Create(nPort, SOCK_STREAM, FD_ACCEPT)) { AfxMessageBox(_T(监听端口创建失败)); return; } // backlog5排队超过5个连接时第6个客户端会收到拒绝按需调大 if (!m_listen.Listen(5)) { AfxMessageBox(_T(Listen 失败)); return; } }参数说明Create 的第一个参数是端口号0 表示由系统分配第二个参数 SOCK_STREAM 对应 TCPSOCK_DGRAM 对应 UDP第三个参数是事件掩码。Create 失败时用 GetLastError 看具体原因端口被占用通常报 10048。3.3 OnAccept收到连接用成员对象接住会话Accept 的典型错误是在 OnAccept 里放一个局部 CSessionSocket 变量。函数一结束局部对象析构CAsyncSocket 析构时会自动关闭底层句柄客户端看到的连接就是“刚连上就断开”在客户端抓包还抓不到任何数据。// ListenSocket.cpp —— 监听套接字的OnAccept回调 void CListenSocket::OnAccept(int nErrorCode) { if (nErrorCode ! 0) return; // 错误码非0时事件无效不能继续Accept // 用对话框的成员对象接住连接而不是局部栈对象 if (!m_pOwner-m_session.Accept(*this)) { int err GetLastError(); TRACE(_T(Accept failed, error%d\n), err); return; } m_pOwner-OnClientAccepted(); }逻辑说明Accept 的第一个参数是 CAsyncSocket 引用不是指针这是 MFC 封装刻意隐藏底层 SOCKET 句柄的设计。m_session.Accept(*this) 调用内部会让 m_session 持有已接受的连接句柄。回调结束后监听套接字继续监听m_session 则独立管理这次会话。3.4 双向收发OnReceive读数据、Send回写数据会话建立后收发完全由事件驱动。服务端收到客户端数据时OnReceive 被调用需要回复时直接调用 Send。这里要处理两种情况Receive 返回 0 表示对端关闭了发送方向返回 SOCKET_ERROR 且错误码是 WSAEWOULDBLOCK 时表示暂时没有更多数据应该直接返回等待下一次回调。// SessionSocket.cpp —— 会话套接字OnReceive回调 void CSessionSocket::OnReceive(int nErrorCode) { if (nErrorCode ! 0) return; // 接收缓冲根据设备最大报文长度调整 char buf[4096] { 0 }; int nRead Receive(buf, sizeof(buf) - 1); if (nRead 0) { // 拷贝进CByteArray后立即交给主窗口处理不在回调里做UI操作 CByteArray data; data.SetSize(nRead); memcpy(data.GetData(), buf, nRead); m_pOwner-OnDataReceived(data); } else if (nRead 0) { // 对端已关闭发送方向等待FD_CLOSE再收尾 } else { int err GetLastError(); if (err ! WSAEWOULDBLOCK) { // 例如10054远程主机强制关闭进入后续清理流程 Close(); } } }参数说明Receive 的返回值是实际收到的字节数。缓冲区按 4096 字节设计时单次接收不超过 4KB更大的报文需要循环读取并自行拼接。WSAEWOULDBLOCK 是异步套接字的正常状态不是错误遇到它就直接结束本次回调数据到达后系统会再次触发 OnReceive。发送方向要调用 Send但 Send 不保证一次写完。发送缓冲区满时会返回 SOCKET_ERRORGetLastError 报 WSAEWOULDBLOCK此时余下数据要排队等 OnSend 事件继续发送这就是 4.1 里要实现的发送队列。4. 把通信接到界面上状态栏实时显示与按钮弹出图表对话框4.1 网络回调切UI线程用自定义消息而不是传指针网络事件回调发生在 MFC 消息派发线程内部严格说和 UI 线程是同一个线程——但回调执行期间消息泵不会处理其它窗口消息如果在 OnReceive 里直接 SetPaneText、InvalidateRect轻则界面刷新不及时重则回调重入导致死循环。正确做法是 OnReceive 里只把数据写进缓冲然后 PostMessage 通知 UI 线程稍后处理。PostMessage 的参数是 WPARAM 和 LPARAM习惯上用来传序号或数据长度不传指针、不传地址这恰好避开了跨线程使用指针的悬垂风险。// MainDlg.cpp —— 数据缓冲与UI消息触发 #define WM_NET_DATA (WM_APP 101) #define WM_NET_STATE (WM_APP 102) void CMainDlg::OnDataReceived(const CByteArray data) { // 写线程安全队列只做拷贝不碰任何界面控件 m_recvQueue.Write(data.GetData(), data.GetSize()); // 通知UI线程处理wParam传数据总长度不传内存地址 PostMessage(WM_NET_DATA, 0, (LPARAM)data.GetSize()); } LRESULT CMainDlg::OnNetData(WPARAM wParam, LPARAM lParam) { // 从队列读出积累的数据做协议解析并刷新界面 BYTE buffer[4096]; int nLen m_recvQueue.Read(buffer, sizeof(buffer)); if (nLen 0) { ParseAndDisplay(buffer, nLen); } return 0; }逻辑说明消息到达时数据已经安全地待在队列里UI 线程只负责读解析。wParam、lParam 传的是数值不携带地址即使队列在别处被处理也不会出现野指针。这是 MFC 程序里跨线程通知最稳的写法。4.2 状态栏显示连接状态与收发字节计数对话框不像 CMainFrame 那样自带状态栏需要手动创建 CStatusBar 并挂到对话框上。创建之后SetIndicators 决定状态栏分几格SetPaneText 更新每一格的文本。可以把第 0 格放提示信息第 1 格放连接状态第 2 格放收发字节计数。// MainDlg.cpp —— OnInitDialog里创建状态栏 BOOL CMainDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 3格状态栏提示、连接状态、流量计数 static UINT indicators[] { ID_SEPARATOR, ID_SEPARATOR, ID_SEPARATOR }; if (!m_wndStatusBar.Create(this)) return FALSE; m_wndStatusBar.SetIndicators(indicators, 3); m_wndStatusBar.SetPaneText(0, _T(网络状态)); m_wndStatusBar.SetPaneText(1, _T(未连接)); m_wndStatusBar.SetPaneText(2, _T(0 B)); return TRUE; }参数说明Create 的第一个参数是父窗口指针SetIndicators 的第二个参数是格子数。状态栏默认宽度不均可以用 SetPaneInfo 单独调整每格宽度。连接建立和断开时分别调用 SetPaneText 更新第 1 格每次 OnNetData 处理完累加字节数后更新第 2 格。收发计数要放在网络消息处理里而不是 OnReceive 回调里直接写避免界面刷新与回调重入互相叠加。4.3 按钮弹出实时数据图表对话框现有 MFC 工程上加一个“查看实时数据”按钮弹出非模态对话框显示曲线图是最常见的需求。这里的关键是对话框不能是模态的——模态对话框会阻塞主线程消息循环网络回调收不到图表自然就停了。用 Create 创建非模态对话框把窗口指针保存在成员变量里多次点击时避免重复创建。// MainDlg.cpp —— 按钮弹出图表对话框 void CMainDlg::OnBnClickedBtnChart() { // 非模态对话框避免阻塞主线程消息循环 if (m_pChartDlg nullptr) { m_pChartDlg new CChartDlg(this); m_pChartDlg-Create(IDD_CHART_DIALOG, this); } m_pChartDlg-ShowWindow(SW_SHOW); }窗口指针是这个通道里唯一的例外它由 MFC 窗口管理机制要求通信数据仍走 PostMessage 队列不通过这个指针传递。图表对话框在 OnInitDialog 里注册 WM_NET_DATA 消息映射收到消息后读共享队列把新数据追加到曲线数组并触发重绘。// ChartDlg.cpp —— 图表对话框接收网络数据并刷新曲线 LRESULT CChartDlg::OnNetData(WPARAM wParam, LPARAM lParam) { double value ReadLatestValue(); // 从共享队列取一个采样点 m_points.push_back(value); if (m_points.size() 200) m_points.erase(m_points.begin()); InvalidateRect(nullptr, FALSE); // 异步重绘不阻塞 return 0; } void CChartDlg::OnPaint() { CPaintDC dc(this); // 画坐标线和折线遍历m_points的200个点 // 实际工程可以换成TeeChart等第三方控件做法一样 }逻辑说明m_points 保存最近 200 个点超过就删最旧的形成滚动曲线。InvalidateRect 只是标记窗口需要重绘WM_PAINT 会在消息循环空闲时执行不会影响网络回调。若数据速率很高可以在 OnNetData 里加一个“距上次重绘超过 30ms 才 Invalidate”的限流判断避免 CPU 被无效重绘占满。5. 双向通信排障实录连接秒断、粘包重入与关闭崩溃的5个坑5.1 Accept后连接立即断开栈上会话socket的生命周期陷阱现象客户端用 Telnet 或网络调试助手能连上端口但“连接成功”之后立即收到对端关闭发送任何数据都无响应。在 OnAccept 里用 Accept 返回值判断一切正常。原因OnAccept 里声明了局部 CSessionSocket 变量Accept 之后函数返回局部对象析构CAsyncSocket 析构函数调用 Close底层连接被主动关闭。CAsyncSocket 的析构不是简单释放内存它会关闭 SOCKET 句柄。解决会话套接字必须是生命力覆盖整个连接周期的对象。最简单的是窗口类成员对象需要多客户端时用一个 std::vector 或对象池预分配固定数量的对象仍然不裸传 SOCKET*。判断是否踩坑可以在 OnClose 里加 TRACE 输出看是否在 Accept 后 1 秒内被调用。5.2 OnReceive里处理不当导致重入与数据顺序错乱现象客户端连续发两个小包服务端 OnReceive 被反复进入收到的字节顺序有时颠倒界面上的数据出现错位。原因TCP 是字节流两个小包可能在一次 OnReceive 中到达也可能分别触发两次。更隐蔽的是 OnReceive 回调里如果做了阻塞操作比如 Sleep 或等待 UI 响应底层消息机制可能在当前回调未返回时再次派发 OnReceive造成重入。解决OnReceive 里只做读缓冲区和入队两件事任何协议解析都放到 WM_NET_DATA 消息处理里。读取时用循环直到 Receive 返回 WSAEWOULDBLOCK 再退出保证粘包数据被完整读出来。5.3 TCP粘包与半包必须自己维护应用层帧边界现象收到的数据长度不固定有时一次回调里包含两条报文有时一条报文被切到两次回调里按“一次回调等于一包”是有问题的。原因TCP 本身是流协议讲究的是字节顺序和可靠性不管应用层报文边界。CAsyncSocket 的 OnReceive 只负责“有数据可读”不承诺给你一条完整报文。解决自定义帧格式最常用的是 4 字节长度头加正文。收到数据先拼进接收缓冲检查缓冲前 4 字节是否达到长度值达到就截取一帧余下继续留在缓冲。大端还是小端要看设备约定一般嵌入式设备多用大端用 ntohl 转换。这条规则要写成独立函数服务端和客户端共用避免两端各自维护一套边界逻辑。5.4 Close时机与对象析构顺序窗口关闭时崩溃现象程序退出时偶发崩溃调用栈显示在 CAsyncSocket::ProcessAuxQueue 或 OnClose 回调里崩溃点不确定。原因对话框正在析构成员对象 CAsyncSocket 析构顺序在前底层 Winsock 消息处理还在派发 FD_CLOSE回调访问的却是已经析构的窗口对象。解决在对话框 OnDestroy 里先主动关闭通信。正确顺序是先调用 m_session.ShutDown(2) 禁止后续收发再 Close()最后才让对话框继续销毁。ShutDown 的 2 表示同时禁止读和写。监听 socket 也要 Close否则端口被占用下次启动报 10048。关闭操作放在 OnDestroy 而不是析构函数里因为此时窗口句柄还有效消息泵还活着。5.5 字符集与设备数据乱码UTF-8和MBCS的转换现象设备发送中文文本界面显示乱码或者项目用 Unicode 字符集收到的是 UTF-8 字节序列直接用 CString 接收后显示异常。原因MFC 工程常见的字符集是 UnicodeCString 内部是 UTF-16。网络字节流大多是 UTF-8 或设备自定义编码直接转 CString 不会自动做转换。解决确定设备端编码并在入队前统一转成 UTF-16。Windows 下用 MultiByteToWideChar代码页 CP_UTF8 对应 UTF-8老设备常用 GBK则用 CP_ACP 或 936 代码页。转换要在从 OnReceive 进队列前完成界面层只处理 CString不碰原始字节。这个坑通常不在测试阶段暴露而是设备换了一台后突然出现排查时先确认两端编码表是否一致。6. 进阶把双向通道做成数据总线帧协议、压测验证与稳定性技巧到这一步通信链路已经通了但“能跑”和“能稳定跑”之间还差一层协议和验证。我会把双向通道再包装成总线式的收发接口对外只暴露 SendFrame(命令字, payload) 和 OnFrameRecv 回调内部统一处理组帧、拆帧、心跳和断线重连。先补上发送队列。Perform 发送时遇到 WSAEWOULDBLOCK把后续数据放进待发队列OnSend 回调里取队头发送这样大报文和突发小报文不会互相挤压。// SessionSocket.cpp —— 发送失败时进队列OnSend里续传 void CSessionSocket::OnSend(int nErrorCode) { if (nErrorCode ! 0) return; if (m_sendQueue.IsEmpty()) return; BYTE buffer[1024]; int nLen m_sendQueue.Peek(buffer, sizeof(buffer)); int nSent Send(buffer, nLen); if (nSent 0) m_sendQueue.Remove(nSent); }然后是心跳。设备端不一定支持改代码那就用 MFC 的定时器在服务端做超时判断每 2 秒遍历所有会话超过 5 秒没收到任何字节就判定掉线主动 Close通知 UI 层更新状态栏。客户端一般由设备端发心跳服务端只做超时容忍时间参数按现场网络环境调。验证方式我会在本地用 Python 模拟设备端完整跑一遍。脚本先连服务端发一帧数据等回包再连续发 1000 个小包验证粘包处理最后直接拔网线验证超时重连。import socket, struct, time def build_frame(cmd: int, payload: bytes) - bytes: # 与MFC端约定的帧格式2字节命令字 2字节长度 payload head struct.pack(HH, cmd, len(payload)) return head payload s socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((127.0.0.1, 8900)) # 单帧读写验证 frame build_frame(0x01, bhello) s.sendall(frame) resp s.recv(1024) print(recv:, resp) # 连续1000个小包验证粘包/拆帧逻辑 for i in range(1000): s.sendall(build_frame(0x02, str(i).encode())) time.sleep(1) s.close()参数说明脚本里 8900 是服务端监听端口“HH”表示大端序两个无符号短整型和 C 端用 ntohs 解析的方向一致。实际压测还可以把 1000 改成 10000观察状态栏字节计数是否与脚本发送总量一致。最后说下个人习惯。我现在搭 MFC 通信工程第一件事是把“谁拥有对象、谁负责析构”画成一张小图第二件事是规定网络回调里绝不碰 UI一律 PostMessage。非指针机制的价值在于把这两个规则逼出来对象是成员生命周期由窗口管数据是消息跨线程由队列管。按这个习惯做过三个上位机项目没有一次因为 socket 句柄或悬垂指针出过事故。希望帮到你。本文还有配套的精品资源点击获取