☰
MFC双向网络通信非指针机制:基于CAsyncSocket的VC6源码解析
2026/9/28 1:31:52 网站建设 项目流程

简介:基于MFC的双向网络通信源码包,面向初涉Windows网络编程的C++开发者,旨在通过非指针机制的Socket实现,帮助理解双向通信原理。资源包含客户端与服务端完整源码、可直接运行的exe程序,开发环境为Visual C++ 6.0,采用MFC与C/C++编写。压缩包共67个文件,涵盖h/cpp源文件、dsw/dsp工程文件、rc资源文件及pdb调试信息等,整体约4.59MB,目录结构清晰,便于对照学习。目前已有158人学习下载,适合想要快速上手MFC网络编程、或作为局域网聊天通信参考实现的读者。通过该套源码,可直观看到监听套接字、连接套接字的建立与消息收发流程,对理解Socket阻塞模式及消息映射机制有直接帮助。

1. MFC双向网络通信:非指针机制?先看懂这份能跑通的VC6源码

当年学Windows网络编程,最难过的坎就是CAsyncSocket的回调机制——为什么收发消息会自己跑到OnReceive里,没人愿意先跟你讲清楚。这份基于MFC的双向网络通信工程,用Visual C++ 6.0写,服务端ChatServeDlg和客户端ChatClient两个工程都带编译好的exe,能在局域网内互相收发消息。它的核心价值在于用“非指针机制”组织socket对象:把Socket作为对话框成员变量,靠CAsyncSocket的消息路由完成双向收发,省掉了new/delete的配对烦恼。适合想弄懂socket事件回调的初学者,也适合需要一份能直接编译、改端口就能跑的MFC网络编程起步工程。

2. 工程结构与原理:非指针机制如何用CAsyncSocket实现双向收发

2.1 文件清单梳理:两个工程、四个Socket类

先看资源里有什么。服务端是ChatServeDlg,客户端是ChatClient,各自是独立的VC6工作区。服务端工程的核心代码文件是:

文件职责
ChatServeDlg.cpp / ChatServeDlg.h应用类与程序入口
ChatServeDlgDlg.cpp / .h服务端主对话框,持有socket成员并处理界面消息
ListenSocket.cpp / .h监听套接字类,负责监听端口和处理连接请求
ServeSocket.cpp / .h服务端数据套接字类,负责与已连接客户端收发数据
resource.h / ChatServeDlg.rc对话框资源和控件ID定义

客户端工程对应的是ChatClientDlg(主对话框)和ClientSocket(客户端数据套接字)。剩下那一堆.dsp、.dsw、.clw、.ncb、.aps、.opt都是VC6自动生成的工程辅助文件,不要手动改,少了的话VC6会自己重新生成。Debug目录下是编译好的exe和中间产物,ReadMe.txt和README.md两个文件建议先读,里面有作者留的编译说明和模块划分。

两个工程之间的关系不复杂:服务端先监听,客户端主动连接,连接建立后双方都能主动发消息,这就是双向网络通信。整体流程看着短,但四个Socket类之间的调用关系如果不先理清,改代码时很容易绕晕——尤其是服务端的监听对象和数据对象职责完全不同,不能混用。

2.2 非指针机制:把Socket对象挂到对话框成员上

常规的MFC网络编程教学,几乎都是这种写法:在监听Socket的OnAccept里new一个连接Socket,指针传出去,连接断开时再delete。这么写本身没问题,但新手经常会忘:OnClose里没有及时delete连接对象,或者Accept失败后对象泄漏,调试起来相当迷惑,总感觉socket生命周期是个玄学问题。

这个工程的不同之处在“非指针”三个字。严格说,它是把通信Socket作为对话框类的成员对象,而不是用CAsyncSocket*指针去管理。以服务端为例,主对话框头文件里的声明类似:

// ChatServeDlgDlg.h class CChatServeDlgDlg : public CDialog { // 标准MFC对话框的构造与数据交换部分略 protected: ListenSocket m_listenSocket; // 监听对象:随对话框生存 ServeSocket m_serverSocket; // 数据对象:接受一个客户端连接 };

服务端在OnInitDialog里把监听对象Create并Listen,客户端连接进来时,由监听对象或者对话框把连接交给m_serverSocket完成Accept。整个过程不需要手动new,也不需要操心在哪delete。对话框销毁时,成员对象析构,socket句柄随之释放,内存回收由编译器管。

这里有个底层前提需要说明:CAsyncSocket内部使用WSAAsyncSelect机制,把FD_READ、FD_WRITE、FD_ACCEPT、FD_CONNECT等网络事件映射为窗口消息,再在MFC的隐藏窗口中分发给各个虚函数。也就是说,你重写的OnReceive、OnAccept这些函数,本质上是在响应一条窗口消息,而不是在繁忙的for循环里轮询。理解了这一点,再回头看“对话框成员对象”的设计就很顺了:socket对象持有句柄,句柄对应的事件会路由到它自己的回调,界面刷新只需要再把消息抛回对话框。

提示:CAsyncSocket不支持拷贝语义,所以ServeSocket m_serverSocket这样的成员对象不能被放进按值存储的容器里。一对一聊天没问题,“非指针机制”的边界也在这里,后面避坑章节会展开。

2.3 双向链路:从监听、连接到双向收发的完整时间线

按程序跑起来后的顺序理一遍:

  1. 服务端启动,m_listenSocket.Create(9527)绑定端口并Listen,进入监听状态;
  2. 客户端启动,m_clientSocket.Create()获取随机本地端口,调用Connect("服务器IP", 9527);
  3. 服务端监听对象触发OnAccept,用ServeSocket完成Accept;
  4. 客户端触发OnConnect,界面显示连接成功,此时TCP链路已建立;
  5. 客户端Send一段文本,服务端ServeSocket触发OnReceive,Receive读缓冲区,把消息显示在服务端窗口;
  6. 服务端Send回复,客户端ClientSocket触发OnReceive,Receive读数据,显示在客户端窗口。

看上去是单向的两组收发,合在一起就是双向。时序上要分清楚:Connect是异步的,返回值不能表示连接成功;OnConnect中的nErrorCode等于0才是成功。收发同理,Send返回的字节数可能小于你要发的长度,Receive也可能一次只收到半个消息,这两个点都是新手最容易踩的,后面客户端章节会具体说。

3. 服务端实现:ListenSocket与ServeSocket的配合

3.1 启动监听:绑定端口与进入监听状态

服务端主对话框的OnInitDialog里,核心代码就几行:

BOOL CChatServeDlgDlg::OnInitDialog() { CDialog::OnInitDialog(); // 绑定固定端口9527,不指定IP表示绑定本机所有网卡地址 if (!m_listenSocket.Create(9527)) { AfxMessageBox("监听套接字创建失败,请检查端口是否被占用"); return FALSE; } // 进入监听模式,backlog用默认值5,同时等待处理的连接最多5个 if (!m_listenSocket.Listen()) { AfxMessageBox("监听失败"); return FALSE; } return TRUE; }

Create的参数是端口号,服务端必须固定,客户端才知道往哪连。这里有个细节:Create(0)是让系统自动分配端口,客户端可以用,服务端不行,因为自动分配的端口每次启动都不一样,别人没法连。Listen没显式传backlog,用默认值5,对教学场景足够;如果你在真实环境里同时有大量客户端发起连接,可以显式传10或20。

很多教程会在Create之前手动指定IP地址,其实没必要。端口绑定后,socket默认绑定到INADDR_ANY,也就是所有本机IP,局域网内任何一台机器用你的局域网IP都能连上。如果在本机测试,客户端地址填127.0.0.1同样能通。这是Windows socket的默认行为,不要误以为必须在Create里写IP才能被局域网访问。

3.2 接受连接:回调回对话框的封装方式

监听对象触发OnAccept时,要处理的不是监听socket本身,而是从等待队列里拿出一个连接,交给一个新的socket对象。非指针的做法是这样:

// ListenSocket.h class CListenSocket : public CAsyncSocket { public: HWND m_hOwnerWnd; // 保存主对话框窗口句柄,用于把事件转回界面 virtual void OnAccept(int nErrorCode); }; // ListenSocket.cpp void CListenSocket::OnAccept(int nErrorCode) { CAsyncSocket::OnAccept(nErrorCode); if (nErrorCode != 0) return; // 告知对话框有客户端连入,由对话框用成员ServeSocket去Accept ::PostMessage(m_hOwnerWnd, WM_ACCEPT_CLIENT, 0, 0); }

对话框收到WM_ACCEPT_CLIENT后执行真正的Accept:

LRESULT CChatServeDlgDlg::OnAcceptClient(WPARAM wParam, LPARAM lParam) { // 新连接进来前先关闭上一次的残留连接,保证同一时刻只有一个活动连接 m_serverSocket.Close(); // Accept的参数是CAsyncSocket的引用,这里传成员对象而非指针 if (!m_listenSocket.Accept(m_serverSocket)) { TRACE("Accept失败,错误码: %d\n", GetLastError()); } return 0; }

这段代码的逻辑说明:监听socket只负责接客,Accept之后真正收发数据的是m_serverSocket。Accept的参数要求是CAsyncSocket的引用,这正是“非指针机制”的落点——传的是对话框的成员对象,而不是new出来的堆对象。m_hOwnerWnd在OnInitDialog里赋值为对话框句柄即可,等于把socket事件转一道到对话框消息里,这个转发模式在收发消息时还会继续用。

如果不想转发,直接在ListenSocket里保存一个ServeSocket对象,OnAccept里直接Accept也是通的。这里用PostMessage是为了把socket回调与UI上下文隔开,避免在回调里访问对话框控件造成重入问题。记得在对话框头文件里声明afx_msg LRESULT OnAcceptClient(WPARAM, LPARAM);并在消息映射中加ON_MESSAGE(WM_ACCEPT_CLIENT, OnAcceptClient),这条映射漏了的话消息不会触达。

3.3 收到消息后转发与回复

ServeSocket收到客户端数据时:

void CServeSocket::OnReceive(int nErrorCode) { CAsyncSocket::OnReceive(nErrorCode); TCHAR szBuffer[4096] = {0}; int nLen = Receive(szBuffer, sizeof(szBuffer) - 1); if (nLen > 0) { // 用长度构造CString,避免依赖字符串的\0结尾 CString strMsg(szBuffer, nLen); // 转回主对话框显示,new出来的对象在接收侧务必delete ::PostMessage(m_hOwnerWnd, WM_RECV_MSG, 0, (LPARAM)(new CString(strMsg))); } }

m_hOwnerWnd是ServeSocket里保存的对话框句柄,与ListenSocket里的赋值方式一致。收到消息后,对话框的WM_RECV_MSG处理函数把内容追加到编辑框,然后可以回复。回复的发送就是另一个方向,直接调Send:

// 服务端界面点“发送”按钮 void CChatServeDlgDlg::OnBtnSend() { CString strMsg; GetDlgItemText(IDC_EDIT_INPUT, strMsg); if (strMsg.IsEmpty()) return; // Send的第二个参数是字节数,不是字符数 int nSent = m_serverSocket.Send(strMsg, strMsg.GetLength()); if (nSent != strMsg.GetLength()) { TRACE("发送不完整,已发送 %d 字节\n", nSent); } }

说明:Send的第一个参数是数据指针,第二个必须是字节数。在VC6默认的ANSI字符集下,CString::GetLength()返回的字符数和字节数一致;如果你把工程改成Unicode,这里就要乘sizeof(TCHAR),否则中文消息发出去会残缺。服务端的消息转发用PostMessage比SendMessage安全,因为网络回调就在窗口消息流里,嵌套SendMessage容易出重入问题;PostMessage传指针则需要接收侧delete,写的时候别漏。

4. 客户端实现:从Connect到OnReceive的收发闭环

4.1 客户端的套接字创建

客户端同样在OnInitDialog里创建socket:

BOOL CChatClientDlg::OnInitDialog() { CDialog::OnInitDialog(); // Create不带参数,让系统分配一个合法本地端口 if (!m_clientSocket.Create()) { AfxMessageBox("客户端套接字创建失败"); return FALSE; } return TRUE; }

客户端socket不需要固定端口,所以Create不传端口号。这段代码的坑点是:Create要在用户点连接之前就执行,否则Connect会因为socket句柄无效返回SOCKET_ERROR;同时也不能重复Create,同一个socket对象二次Create会报错。如果界面允许用户反复点连接,建议在连接前先判断m_clientSocket的句柄状态,或者每次连接前先Close再Create。

4.2 Connect的异步与OnConnect判活

连接按钮的处理函数:

void CChatClientDlg::OnBtnConnect() { CString strIP; GetDlgItemText(IDC_EDIT_IP, strIP); // Connect是异步操作,返回值只代表是否成功发起 if (!m_clientSocket.Connect(strIP, 9527)) { int nErr = GetLastError(); if (nErr != WSAEWOULDBLOCK) // 10035表示连接仍在进行中 { AfxMessageBox("连接失败,请检查服务端是否已启动"); } } }

很多新手在Connect返回FALSE后立刻弹“连接失败”,这是错的。CAsyncSocket的Connect在非阻塞模式下几乎总是返回FALSE,GetLastError返回WSAEWOULDBLOCK即10035,代表连接正在后台建立。真正的结果要看OnConnect回调:nErrorCode为0说明连接成功;非0则说明失败,比如服务端没启动时这里会收到10061(连接被拒)。把判断逻辑放在OnConnect里才是正确姿势:

void CClientSocket::OnConnect(int nErrorCode) { CAsyncSocket::OnConnect(nErrorCode); if (nErrorCode == 0) { ::PostMessage(m_hOwnerWnd, WM_NET_EVENT, CONNECT_OK, 0); } else { ::PostMessage(m_hOwnerWnd, WM_NET_EVENT, CONNECT_FAIL, nErrorCode); } }

这里我用WM_NET_EVENT一条消息携带WPARAM区分成功和失败,比定义两条消息更省事。收到CONNECT_OK后,界面把连接按钮置灰、发送按钮置亮,状态栏显示“已连接到服务器”。这个交互细节能避免用户在网络未就绪时就点发送,减少很多莫名其妙的SOCKET_ERROR。

4.3 客户端收包与界面刷新

客户端收数据与服务端对称:

void CClientSocket::OnReceive(int nErrorCode) { CAsyncSocket::OnReceive(nErrorCode); TCHAR szBuffer[4096] = {0}; int nLen = Receive(szBuffer, sizeof(szBuffer) - 1); if (nLen > 0) { CString strMsg(szBuffer, nLen); ::PostMessage(m_hOwnerWnd, WM_RECV_MSG, 0, (LPARAM)(new CString(strMsg))); } }

对话框的WM_RECV_MSG处理函数里,把消息追加到接收区的编辑框或列表控件。这一个闭环走通之后,双向网络通信的本质就清楚了:两个方向上各有一个socket,各自由对方的Send触发自己的OnReceive,谁都可以主动发,不需要区分服务器端和客户端。平时说的“server只能被动等”在TCP双向通信里不成立,建立连接后双方角色平等。

这段代码还有一个细节:Receive返回0表示对端关闭,返回SOCKET_ERROR时要检查是不是WSAEWOULDBLOCK也就是没有数据可读。演示程序在短连接场景下不容易触发,但如果你改成高频收发,这个判断早晚会用到。建议在OnReceive里把nLen等于0的情况单独处理,弹一个“对端已断开”并把socket关闭,避免连接已经断了界面还显示在线。

5. 避坑排查:非指针机制下的编译、乱码与崩溃问题

5.1 编译与对象生命周期的两个高频坑

记录1:CAsyncSocket不能被复制或赋值

现象:把ServeSocket对象放进CArray或者直接写m_serverSocket = otherSocket,编译报C2248,提示无法访问私有成员。

原因:CAsyncSocket的拷贝构造函数和赋值运算符被MFC禁用。它持有系统socket句柄,按值拷贝会让两个对象操控同一个句柄,析构时可能重复Close,所以MFC从源头堵住了这种用法。

解决:非指针机制下就老老实实让Socket对象做对话框成员,一对一场景够用。如果你确实需要管理多条连接,用CArray<CAsyncSocket*, CAsyncSocket*>存指针,对象生命周期自己管理,这是做多客户端的必由之路。

记录2:退出程序时Debug断言失败

现象:程序关闭时Debug版本弹ASSERT失败,调用栈指向CSocket::Close或CAsyncSocket::~CAsyncSocket。

原因:对话框窗口先销毁,socket成员对象析构时如果句柄还没关闭,MFC在清理阶段会断言失败。另外一种可能:socket回调还在路上,窗口已经没了,消息找不到目标窗口。

解决:在对话框的OnDestroy里显式关闭所有socket成员,顺序注意先关数据socket再关监听socket:

void CChatServeDlgDlg::OnDestroy() { m_serverSocket.Close(); m_listenSocket.Close(); CDialog::OnDestroy(); }

只要是成员对象,无论指针还是非指针,退出前把Close补上,能省很多深夜调试时间。这条也算血泪经验,我当时在这个断言上卡了整整一个晚上。

5.2 连接与收发的运行态问题

记录3:客户端Connect失败,错误码10061

现象:客户端点连接,OnConnect里nErrorCode为10061即WSAECONNREFUSED,连不上服务端。

原因:目标机器上没有程序在对应端口监听,或者防火墙拦截了端口。最常见的是服务端没启动、端口写错、服务端程序崩了但监听socket没释放。

解决:先确认服务端窗口已经出现监听成功提示;在客户端机器命令行验证端口通不通:

telnet 192.168.x.x 9527

能连通会进入空白界面或提示连接成功,连不通直接报错。Windows防火墙需要允许exe访问网络,VC6跑起来的Debug程序经常被拦,到防火墙里把exe加入允许列表,或者临时关闭防火墙做连通性测试。

记录4:发送中文乱码或消息被截断

现象:客户端发“你好”,服务端收到“浣犲ソ”或只收到半个字。

原因:字符集不一致,或者发送长度算错。VC6默认使用ANSI代码页即GBK,如果你在某个环节用了宽字符函数,或者发送时传的是sizeof(strMsg)而不是GetLength(),字节长度就会和实际内容对不上,中文多字节编码下极易出现半个字。

解决:工程统一保持ANSI字符集,不要混用TCHAR和WCHAR。发送用Send(strMsg, strMsg.GetLength()),接收用CString(szBuffer, nLen)强制定长构造,不依赖字符串里的\0结尾。CString内部可以存二进制数据,截断点永远是nLen,不是\0,这个点记住了能少踩不少坑。

5.3 资源边界与工程迁移补充

记录5:非指针机制只能同时保持一条连接

现象:第一台客户端连接正常,第二台客户端一直连接不上,服务端不再触发OnAccept,但监听socket看着没问题。

原因:Accept完成之后,这个工程是用同一个ServeSocket成员对象去接收连接,第一次Accept后如果没有释放连接,系统不会再生成新的可接受事件,或者Accept直接失败。这是非指针方案的结构性边界,不是偶发bug。

解决:一对一教学演示可以接受这个限制。需要一对多时有两个方向:一是回到指针方案,OnAccept里new一个ServeSocket并保存到数组,OnClose里断开前delete;二是用CSocket配合线程做阻塞收发。非指针机制的价值不在高并发,而在于把内存管理从socket编程里剥离出来,让你专注理解事件回调本身。如果只需要小范围改造成一对多,按最后一章的最小改动方案做即可。

另外补充一个迁移提醒:VC6的.dsp工程在新版Visual Studio里直接打开会提示升级并产生大量编译错误,这不代表代码本身有问题,而是工程格式和Windows SDK头文件差异导致的。想在VS2019/2022里跑,建议新建MFC对话框工程,把.cpp和.h逐个加进去,再处理ANSI与Unicode的差异;Socket那部分代码的迁移成本其实很低,主要工作量在资源文件和消息映射宏上。

6. 双向通信验证与多人消息转发的最小改造

6.1 三种方式验证双向链路

拿到资源后不用急着看代码,先把Debug目录下的Server.exe和Client.exe跑起来。验证顺序建议:本机回环,客户端IP填127.0.0.1,确认本机双向收发正常;局域网真实测试,两台电脑连同一网络,客户端填服务端的IPv4地址,确认防火墙没有只放行回环;对端主动发送测试,连接成功后双方轮流主动发消息,不要只由一端发起,这样才能确认两个方向的OnReceive都触发正常。

6.2 从一对一改成多人的最小改动

如果想拿这份代码继续做聊天室,最小改动是这样:

// 维护一个连接指针数组 CArray<CAsyncSocket*, CAsyncSocket*> m_arrClients; // OnAccept时创建新对象并加入数组 CServeSocket* pNew = new CServeSocket(); if (Accept(*pNew)) m_arrClients.Add(pNew); // 转发消息时遍历所有连接 for (int i = 0; i < m_arrClients.GetSize(); i++) { m_arrClients[i]->Send(strMsg, strMsg.GetLength()); }

这一步改完,你就从非指针机制回到了传统的指针方案。不是倒退,而是在理解非指针方案的Why之后,知道什么样的规模该回到指针管理。数组里对象的delete放在OnClose回调里做,断开一个删一个,同时用m_arrClients.RemoveAt(i)把指针从数组移除。

6.3 我现在的验证习惯

自从在这类工程上熬过几个夜晚之后,我拿到任何socket资源,第一件事不是编译,而是先在代码里搜三个点:OnReceive、OnAccept、OnClose的实现,确认错误码判断是否齐全;然后看工程字符集是ANSI还是Unicode;最后才编译。这套流程走完,资源能不能用、改动风险在哪大概就有底了。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询