简介:这份资源面向希望理解 Windows 下 Socket 网络编程原理的 C++ 学习者与 MFC 开发者,提供一套基于 Visual C++ 6.0、MFC 与 C/C++ 编写的局域网双向通信示例,采用非指针机制实现消息收发,适合作为网络编程入门到进阶的练手项目。压缩包共 67 个文件,约 4.59MB,包含客户端与服务端两套源码及可执行 exe,主要文件类型有 h 头文件、cpp 源文件、rc 资源脚本、dsp/dsw 工程文件、ico 图标与 txt 说明等,工程结构完整,可直接编译运行。目前已有 158 人学习下载。读者可从中获得客户端与服务端双端通信的完整代码框架、Socket 监听与连接建立的具体实现、消息收发流程的调试思路,以及非指针机制下数据传递的替代方案,便于对照理解 socket 通信原理并在此基础上扩展功能。
1. 从一份 VC6 时代的 MFC 聊天源码说起:非指针机制到底解决了什么
翻到一份叫socket-non-point-mfc的压缩包,里面躺着 ChatServeDlg 和 ChatClient 两套完整工程,.dsp、.dsw、.clw一应俱全,还带着编译好的 Debug 目录和 exe。开发环境写得很直白:Visual C++ 6.0,MFC + C/C++,跑在局域网里收发消息。很多人第一次接触 Windows 网络编程就是从这个场景开始的——两台机器,一个服务端一个客户端,点按钮连上,敲字发过去,对面弹出来。听起来简单,但真动手写,十有八九会卡在同一个地方:socket 的收发到底怎么和 MFC 的窗口消息搅在一起。这份源码给出的答案是「非指针机制」,也就是不靠把 this 指针塞进 socket 结构体再强转回来的那套老写法,而是用更规矩的方式把网络事件和界面线程接起来。它适合谁?适合正在啃 MFC 网络编程、被WSAAsyncSelect或者阻塞 recv 搞得头大的人,也适合想找一个能直接编译、能改、能跑通的最小双向通信骨架的从业者。下面我按「这东西怎么搭起来 → 怎么编译跑通 → 哪里容易翻车 → 怎么改成自己的」这条线拆一遍。
2. 非指针机制下的 MFC 双向通信骨架:从 CAsyncSocket 派生到消息映射
2.1 为什么不用指针强转:CAsyncSocket 的回调路径
MFC 里做异步 socket,绕不开CAsyncSocket。它把 Winsock 的WSAAsyncSelect包了一层,窗口收到FD_READ、FD_WRITE、FD_ACCEPT这些网络事件后,会转成自定义消息,再派发到CAsyncSocket派生类的OnReceive、OnAccept、OnSend虚函数上。问题就出在这个派发过程:Winsock 的窗口消息只带一个 socket 句柄,MFC 内部得靠一张句柄到对象的映射表找到对应的 C++ 对象。早期很多教程图省事,直接在WSAAsyncSelect里把this指针当参数传进去,回调里再(CXXXDlg*)lParam强转回来。这种写法在单窗口单 socket 时能跑,一旦多连接、多窗口或者对象生命周期没管好,指针就悬空了,程序崩得莫名其妙。
这份源码的「非指针机制」走的是另一条路:服务端用ListenSocket专门监听,收到连接后OnAccept里 new 一个ServeSocket对象,把它挂进一个容器(常见做法是CPtrList或CArray)统一管理;客户端用ClientSocket直接连。所有收发都通过CAsyncSocket自己的虚函数回调,不往 lParam 里塞 this。这样每个 socket 对象自己知道该干什么,窗口类只负责界面刷新。代价是要自己维护连接列表,好处是对象归属清晰,不会因为一个野指针把整个进程带走。
2.2 服务端三件套:ListenSocket、ServeSocket 与对话框的协作
服务端工程里,ChatServeDlg是主对话框,ListenSocket负责监听,ServeSocket负责每条已建立的连接。启动流程大致是:对话框初始化时创建ListenSocket对象,调用Create绑定端口,然后Listen。一旦有客户端连进来,ListenSocket::OnAccept被触发,在里面 new 出ServeSocket,调用它的Accept接管这个连接,再把新对象指针存进对话框维护的列表。之后这个ServeSocket的OnReceive被触发时,读数据、拼消息、通过对话框指针刷新 ListBox 或 Edit 控件。
关键点在于对话框和 socket 对象之间的引用关系。非指针机制不是说不传指针,而是不把指针塞进系统回调的参数里做隐式传递。常见做法是ServeSocket持有一个ChatServeDlg*成员,在构造时由对话框传进去,回调里通过这个成员调用PostMessage或者直接更新控件。这个指针是显式管理的,对象销毁时一并清理,不会出现系统回调里强转错类型的情况。
// ListenSocket.cpp 片段:OnAccept 中创建新的 ServeSocket void CListenSocket::OnAccept(int nErrorCode) { CAsyncSocket::OnAccept(nErrorCode); if (nErrorCode == 0) { CServeSocket* pNew = new CServeSocket(); pNew->m_pDlg = m_pDlg; // 显式传入对话框指针,用于界面刷新 if (Accept(*pNew)) // 接管连接 { m_pDlg->AddConn(pNew); // 存入连接列表统一管理 } else { delete pNew; // 接管失败立即释放,避免内存泄漏 } } }这段代码里Accept(*pNew)是CAsyncSocket的标准用法,把新连接的 socket 句柄绑定到pNew对象上。m_pDlg->AddConn是对话框自己实现的容器追加,通常用CPtrList::AddTail。注意nErrorCode判断不能省,网络错误时OnAccept也会被调用,不判断就会 new 出一堆无效对象。
2.3 客户端连接与收发:ClientSocket 的最小闭环
客户端工程ChatClient结构类似,ChatClientDlg是主界面,ClientSocket继承CAsyncSocket。连接流程是:用户点「连接」按钮,对话框调用ClientSocket::Create,然后Connect到服务端 IP 和端口。连接成功后OnConnect被触发,这时才能开始Send。收消息走OnReceive,里面用Receive读缓冲区,再把数据追加到聊天记录控件。
// ClientSocket.cpp 片段:OnReceive 中读取数据 void CClientSocket::OnReceive(int nErrorCode) { CAsyncSocket::OnReceive(nErrorCode); if (nErrorCode != 0) return; char szBuf[1024] = {0}; int nRead = Receive(szBuf, sizeof(szBuf) - 1); if (nRead > 0) { szBuf[nRead] = '\0'; m_pDlg->AppendMsg(szBuf); // 刷新界面,注意跨线程问题 } else if (nRead == 0) { m_pDlg->AppendMsg(_T("[连接已断开]")); Close(); } }Receive返回值有三种情况:大于 0 是读到的字节数,等于 0 是对端正常关闭,小于 0 且GetLastError为WSAEWOULDBLOCK表示当前没数据可读、不是错误。很多新手看到Receive返回 -1 就慌,其实要配合错误码判断。缓冲区大小 1024 是示例值,实际项目里要么定长协议头带长度,要么用分隔符切包,否则粘包是迟早的事。
3. 在 VC6 里编译跑通这套源码:环境、依赖与调试配置
3.1 打开工程与编译顺序
压缩包里有两个独立工程:ChatServeDlg.dsw和ChatClient.dsw,分别对应服务端和客户端。VC6 直接双击.dsw打开,工作区里会加载对应的.dsp。编译前先确认Debug目录存在,不存在就手动建一个,VC6 的输出路径默认指向这里。服务端和客户端要分别编译,没有依赖关系,先编哪个都行。
编译时如果报winsock2.h找不到或者链接ws2_32.lib失败,检查两处:一是StdAfx.h里有没有#include <winsock2.h>,二是工程设置 Link 选项卡的 Object/library modules 里有没有ws2_32.lib。MFC 工程默认可能只链了wsock32.lib,那是 Winsock 1.1 的库,和CAsyncSocket用的 2.x 接口不完全兼容,常见做法是显式加上ws2_32.lib。
# 如果命令行编译,大致是这样的调用(VC6 环境变量已配置) msdev ChatServeDlg.dsw /MAKE "ChatServeDlg - Win32 Debug" msdev ChatClient.dsw /MAKE "ChatClient - Win32 Debug"/MAKE后面跟的是工作区里的配置名,VC6 默认是工程名 - Win32 Debug。命令行编译的好处是能写进批处理,改完代码一键出包。不过 VC6 的msdev命令行在较新系统上兼容性一般,常见做法还是直接在 IDE 里按 F7。
3.2 端口、IP 与防火墙:跑通前必须对齐的三件事
源码里服务端监听端口通常写死在ListenSocket::Create的调用处,常见是 6000 或 8888 这类值。客户端连接对话框里一般有个 IP 输入框,默认可能是127.0.0.1。本机自测时服务端和客户端都跑在同一台机器,IP 填回环地址没问题;两台机器联调时,客户端要填服务端的实际局域网 IP,服务端绑定地址用INADDR_ANY或者具体网卡地址。
防火墙是头号拦路虎。Windows 防火墙默认会拦入站连接,服务端第一次Listen后如果没弹允许提示,或者提示被点了取消,客户端Connect就会一直失败。排查方法:先在服务端机器上用netstat -an | findstr 6000看端口有没有处于 LISTENING 状态,有监听但连不上,基本就是防火墙。临时关闭防火墙测试可以,但别当成长期方案,正规做法是给 exe 加一条入站规则。
提示:VC6 编译出的 exe 在某些新系统上会因为缺少运行时库报错,装一个 VC6 运行库合集或者把工程改成静态链接 MFC 能省不少事。
3.3 调试双向通信:断点打在哪、日志怎么看
调试这类程序,断点位置很关键。服务端在OnAccept和OnReceive各打一个,客户端在OnConnect和OnReceive各打一个。正常流程是:客户端点连接 → 服务端OnAccept命中 → 客户端OnConnect命中 → 客户端发消息 → 服务端OnReceive命中 → 服务端回消息 → 客户端OnReceive命中。哪一步没命中,问题就卡在哪一步之前。
如果断点根本不进OnReceive,先确认Create之后有没有调用AsyncSelect或者CAsyncSocket是否自动处理了。CAsyncSocket在Create时会自动调用AsyncSelect注册所有网络事件,所以正常情况下不需要手动再调。如果手动调了AsyncSelect且参数不对,反而会把事件屏蔽掉。另一个常见问题是消息循环被阻塞,比如在按钮响应里写了Sleep或者死循环,窗口消息泵不转,网络事件自然派发不过来。
4. 避坑与排查:这份源码最容易翻车的五个地方
4.1 现象:客户端连上后发消息,服务端没反应
原因通常是服务端OnReceive里Receive的缓冲区太小,或者没循环读。TCP 是流式协议,一次OnReceive触发不代表只来了一条完整消息,可能只来了半条,也可能来了三条粘在一起。源码示例里如果只Receive一次就完事,消息一长就丢。
解决:在OnReceive里循环Receive直到返回WSAEWOULDBLOCK,把读到的数据追加到一个 per-connection 的接收缓冲区,再按协议切分。简单场景可以用\r\n做分隔符,复杂场景建议加长度头。
4.2 现象:程序运行一段时间后崩溃,崩溃点随机
原因多半是连接列表管理有问题。OnAccept里 new 了ServeSocket存进列表,但客户端断开时没有从列表移除并 delete,对象泄漏;或者移除时用了错误的索引,把还在用的对象删了。非指针机制虽然避免了强转野指针,但对象生命周期还是得自己管。
解决:在ServeSocket::OnClose里通知对话框从列表移除自己,然后delete this要格外小心,常见做法是PostMessage给对话框,让对话框在消息处理里做移除和释放,避免在回调里直接销毁对象。
4.3 现象:编译报错error C2065: 'CAsyncSocket' : undeclared identifier
原因是没有包含afxsock.h。MFC 的 socket 类不在afxwin.h里,需要单独包含。VC6 向导生成的工程有时不会自动加。
解决:在StdAfx.h里#include <afxsock.h>,并且确保它在afxwin.h之后。另外在InitInstance里要调用AfxSocketInit(),否则 socket 功能初始化不完整,运行时也会出问题。
4.4 现象:服务端能收不能发,或者发出去客户端收不到
原因可能是Send的调用时机不对。CAsyncSocket::Send在非阻塞模式下如果发送缓冲区满,会返回WSAEWOULDBLOCK,这时数据没发出去,需要等OnSend通知再发。很多示例代码直接Send完就当发出去了,小数据量时碰巧能跑,数据一多就丢。
解决:维护一个发送队列,Send返回WSAEWOULDBLOCK时把剩余数据存队列,在OnSend里继续发。或者简单点,控制单次发送数据量,别超过底层缓冲区。
4.5 现象:VC6 编译通过,但生成的 exe 在别的机器上跑不起来
原因是动态链接了 MFC 和 CRT,目标机器没装对应版本的运行库。VC6 的运行库现在很多系统默认不带。
解决:工程设置里改成静态链接。具体是 Project Settings → General → Microsoft Foundation Classes 选Use MFC in a Static Library,C/C++ 选项卡的 Code Generation 里 Runtime Library 选Multithreaded(非 DLL 版本)。重新编译后 exe 体积会大一些,但部署省心。
5. 把示例改成自己的协议:消息分帧、编码与扩展思路
跑通示例只是第一步,真拿去做项目,第一件事就是定协议。示例里大概率是直接把用户输入的字符串Send出去,对面Receive完直接显示。这种裸字符串在局域网小数据量下能用,但经不起考验。我一般会加一个最简单的分帧头:4 字节长度 + 实际负载。发送端先算负载长度,拼成一个 buffer 再Send;接收端先收 4 字节,解析出长度,再收够这么多字节才算一条完整消息。
// 发送端:带长度头的分帧 void CServeSocket::SendMsg(const CString& strMsg) { CT2A utf8(strMsg); // 统一转 UTF-8,避免中文乱码 int nLen = strlen(utf8); char* pBuf = new char[nLen + 4]; memcpy(pBuf, &nLen, 4); // 前 4 字节写长度 memcpy(pBuf + 4, utf8, nLen); // 后面跟负载 Send(pBuf, nLen + 4); // 实际项目要处理 WSAEWOULDBLOCK delete[] pBuf; }接收端对应地维护一个CByteArray缓冲区,每次OnReceive把新数据追加进去,然后循环检查:缓冲区长度是否 >= 4,是则读出长度 n,再看缓冲区长度是否 >= 4 + n,是则切出一条完整消息,从缓冲区移除,继续检查下一条。这样粘包和半包都能正确处理。
编码问题也值得单独说。VC6 默认是 MBCS,中文在CString和char*之间转换依赖本地代码页。如果服务端和客户端代码页不一致,或者中间经过了 UTF-8 转换,中文就会变问号。常见做法是统一用 UTF-8 传输,界面显示时再转回CString。CT2A和CA2T这两个宏在 VC6 里可能没有,需要自己写WideCharToMultiByte和MultiByteToWideChar的封装。
再往深一层,可以把ServeSocket的收发逻辑抽成一个独立的CNetSession类,和界面完全解耦。界面只负责调SendMsg和接收OnMsgArrived回调,这样以后换界面框架或者加业务逻辑都不用动网络层。这份源码的价值不在于它多完善,而在于它给了一个能跑的最小闭环,你可以在上面加协议、加线程、加心跳,而不用从WSAStartup开始一行行查文档。
从那以后我每次拿到这类老工程,都强制先做三件事:确认AfxSocketInit调了、确认ws2_32.lib链了、确认防火墙放行了。这三步走完,八成的问题在编译前就排掉了。希望帮到你。
本文还有配套的精品资源,点击获取