简介基于Socket通信使用MFC封装库实现TCP协议下的C/S架构程序面向需要在Windows环境中快速构建网络通信应用的C开发者。压缩包共4个文件约6KB含2个cpp与2个h源码分别对应客户端与服务端对话框类代码结构紧凑可通过CSocket对象完成连接建立、数据收发与连接关闭。已有1261人学习该资源适合作为MFC网络编程的入门参考。资料中既有客户端连接交互逻辑也有服务端监听、接受连接的实现重点展示了AfxSocketInit初始化、Bind、Listen、Accept、Connect、Send和Receive等关键步骤帮助读者跳过底层Winsock细节直接掌握基于CSocket的TCP通信框架便于后续扩展到多线程或异步通信场景。1. 用socket和MFC写TCP C/S程序到底在什么场景下值得做给一台老设备写上位机或在维护了七八年的MFC工程里加一个远程控制接口网上大部分资料会劝你换Qt但项目结构已经定死在MFC里。基于socket通信、用MFC实现TCP通信的C/S架构程序解决的就是这个场景不换框架直接在MFC对话框里把TCP服务端和客户端跑起来完成一对一的连接和收发。这篇不打算复读TCP三次握手而是顺着你真正要解决的问题走socket在Windows下到底是什么、MFC的CAsyncSocket回调运行在哪个线程、服务端怎么监听、客户端怎么收发、哪些错误码你一定会在调试时撞见。整篇文章按可落地的方式写代码是能编译的最小骨架不是面向教学的人为简化版。适合正在维护MFC项目、用Visual Studio写上位机工具、以及想给桌面软件接入socket网络编程的开发者。看完你可以直接照搬架构再根据业务调整协议内容。2. 先把原理立住socket、TCP和C/S架构里MFC究竟封装了什么2.1 socket不是协议是操作系统提供的通信端点socket网络编程里最容易被误解的一点是以为socket就是TCP协议本身。它其实是操作系统给你的一把句柄代表一个通信端点。TCP的三次握手、超时重传、滑动窗口这些脏活全部由内核里的tcp协议栈完成你的程序只需要创建socket、绑定端口、发送缓冲区、接收缓冲区。换句话说网络的可靠性不需要上层应用操心而消息的边界恰恰需要。在C/S架构中服务端创建一个监听socket绑定一个明确的tcp端口号进入监听状态客户端创建另一个socket向服务端地址发起tcp连接。连接建立后服务端的监听socket会得到一条新的socket句柄这条句柄对应一个具体的客户端连接而监听socket继续等待下一路。所以一个监听socket能服务大量客户端重点在于你如何保存和管理Accept出来的新socket。如果要用一句话讲udp和tcp协议的区别TCP是面向连接的字节流UDP是无连接的数据报。MFC的CAsyncSocket同时支持两种关键在你调Create时传SOCK_STREAM还是SOCK_DGRAM。第一次写的人容易在协议栈图上绕晕落回代码就很直白一个参数决定传输模式。MFC在这里做的事情是把Winsock的事件通知机制映射成窗口消息。Windows的WSAAsyncSelect模型允许你把FD_READ、FD_WRITE、FD_ACCEPT、FD_CONNECT、FD_CLOSE事件绑定到一个窗口句柄MFC再把这些窗口消息包装成OnReceive、OnAccept这一组虚函数。所以你不必自己写WndProc和消息分发核心逻辑都放在重载函数里。2.2 CAsyncSocket和CSocket为什么我不用CSocketMFC提供了两个socket封装类。CAsyncSocket是异步非阻塞的CSocket从它派生额外提供阻塞同步和CArchive流式读写。两种不是替代关系用错才会翻车。对比项CAsyncSocketCSocket工作模式非阻塞事件驱动阻塞同步等待数据读写直接在OnReceive里调Receive通过CArchive的和操作符线程敏感度回调跑在创建它的线程消息循环放界面线程会卡死界面适合场景实时交互、多连接、协议自定义辅助线程里的简单请求响应我一般只用CAsyncSocket。它在事件模型上和你在C#里写BeginReceive回调是同一个思路事件触发后你主动把缓冲区的数据读走读多少、读完之后怎么拼包都是自己控制。CSocket看起来用着省事但CArchive的流式读写会让你丢掉对半包和粘包的控制权而且阻塞模式一旦写在按钮响应里界面立刻变成白屏。还有个常见的错误认知因为类名带Async就认为回调会跑到别的线程去。CAsyncSocket的异步是指socket本身的I/O不阻塞调用者而OnReceive这些回调依然通过窗口消息派发和普通按钮消息一样。搞清楚这一点你才不会在回调里大胆地Sleep。2.3 回调跑在哪个线程这个理解错了后面全是坑CAsyncSocket创建时如果没有显式指定窗口句柄MFC会默默创建一个隐藏窗口来接收socket事件。事件发生到这个隐藏窗口后再触发对应虚函数。这里的关键点在于消息循环属于哪个线程回调就运行在哪个线程。你在对话框类里new一个socket它的OnReceive基本都会跑在界面线程。这引出一条铁律在OnReceive里不要Sleep不要弹MessageBox不要把耗时解析直接写在里面。数据到了之后先原样拷贝到缓冲区队列然后用PostMessage通知主窗口去处理。下面是创建socket的最小骨架也顺便把参数说明讲清楚CAsyncSocket sckListen; // 第一个参数: 端口, 传0表示由系统自动分配 // 第二个参数: SOCK_STREAM表示TCP, SOCK_DGRAM表示UDP // 第三个参数: 关注的事件类型, FD_ACCEPT表示只关心新连接到达 BOOL bCreated sckListen.Create(9001, SOCK_STREAM, FD_ACCEPT); // 参数是backlog, 内核允许排队的待Accept连接数, 不是超时时间 BOOL bListened sckListen.Listen(5);很多人把Listen的参数理解成超时秒数实际上它代表等待Accept的队列长度。填大了不会让连接变快只是允许更多客户端同时处于已连接但未处理的排队状态。端口号选择也有讲究9000这类固定业务端口很容易被其他程序占用调试期建议从49152到65535的动态端口段里挑。这里的Create是最小形式窗口句柄用默认值。到了真实项目中我建议在自定义socket类里保存一个主窗口的HWND方便事件发生时用PostMessage把自绘消息送过去。这样既能保持界面线程的消息泵不被阻塞也能让自定义消息承载socket指针和缓冲区指针实现跨类通信。3. 把TCP C/S跑通服务端监听与客户端收发的最小MFC实现3.1 初始化Winsock和工程AfxSocketInit必须只调一次用Visual Studio新建一个MFC对话框工程工程名随意向导里勾选“Windows Sockets”支持。如果工程已经存在没勾选也要补上这些依赖。MFC的socket类并不自动完成Winsock的启动需要你手动调用AfxSocketInit它内部封装了WSAStartup。注意一个进程只需要初始化一次重复初始化不仅浪费还会在退出时导致Winsock计数混乱。// TcpDemoDlg.cpp 顶部 #pragma comment(lib, ws2_32.lib) #include afxsock.h BOOL CTcpDemoDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 在窗口创建后、真正使用socket之前完成初始化 if (!AfxSocketInit()) { AfxMessageBox(_T(Winsock初始化失败)); return FALSE; } // 服务端监听socket和客户端socket都作为对话框的成员变量 // m_sckServer.Create(...) 时序上放在按钮事件里更合理 return TRUE; }逻辑说明AfxSocketInit内部调用WSAStartup并协商Winsock版本返回值如果FALSE说明系统Winsock服务有问题后续所有socket调用都会失败。如果你在工程里用了CAsyncSocket头文件必须包含afxsock.h否则编译器会说不认识这个类。链接库方面MFC头文件会隐式处理ws2_32.lib但为了单文件裁剪方便显式#pragma comment也不多余。3.2 服务端监听、Accept、把新连接交给主窗口服务端不要直接拿CAsyncSocket裸对象去Accept最好派生子类并重写OnAccept。OnAccept被触发时说明内核已经完成了一条连接的三次握手你的代码只需要把它接走。接走的方式是调用Accept传入一个已经构造好的CAsyncSocket对象。这里最容易被忽略必须用new创建连接对象不能用栈对象否则函数一结束对象析构句柄就丢了。// TcpServerSocket.h class CTcpServerSocket : public CAsyncSocket { public: void SetNotifyWnd(HWND hWnd) { m_hNotifyWnd hWnd; } protected: virtual void OnAccept(int nErrorCode); private: HWND m_hNotifyWnd NULL; }; // TcpServerSocket.cpp void CTcpServerSocket::OnAccept(int nErrorCode) { if (nErrorCode ! 0) return; // 构造一个崭新的socket对象来承接这条已建立的连接 CTcpClientSocket* pNewConn new CTcpClientSocket; if (Accept(*pNewConn)) { // 不要在这个回调里直接更新界面控件 // 用 PostMessage 把连接指针交给对话框线程 ::PostMessage(m_hNotifyWnd, WM_NEW_CONNECTION, (WPARAM)pNewConn, 0); } else { delete pNewConn; } // 基类回调必须透传, 内部会清理事件状态 CAsyncSocket::OnAccept(nErrorCode); }逻辑说明OnAccept里的nErrorCode判断必须放在最前面任何非0值都表示本次事件无效。Accept之后pNewConn的所有权就从socket类转移到了主窗口后续的OnReceive等回调由它自己触发。用PostMessage而不是SendMessage是为了让OnAccept立即返回界面线程稍后再处理连接通知避免嵌套消息。在对话框里这样启动监听m_sckServer.SetNotifyWnd(GetSafeHwnd()); m_sckServer.Create(9000, SOCK_STREAM, FD_ACCEPT); m_sckServer.Listen(5);Create的第三个参数FD_ACCEPT说明这个socket只关注新连接到达事件。这里有个细节如果客户端在服务端还没调用Listen时就发来连接内核会根据backlog决定是排队还是拒绝。监听程序启动瞬间最好连续调用Create和Listen中间不要查数据库或做耗时初始化。3.3 客户端异步Connect与OnReceive里的缓冲区管理客户端同样要派生CAsyncSocket重写OnConnect、OnReceive和OnClose。Connect是异步的它返回TRUE只代表请求已经发出不代表已经连接成功。真正的连接结果在OnConnect的nErrorCode里0表示成功。很多新手在这里直接判断Connect返回值结果连接没建立成功也继续发数据。// TcpClientSocket.h class CTcpClientSocket : public CAsyncSocket { public: BOOL ConnectTo(LPCTSTR lpszHost, UINT nPort); protected: virtual void OnConnect(int nErrorCode); virtual void OnReceive(int nErrorCode); virtual void OnClose(int nErrorCode); }; // TcpClientSocket.cpp BOOL CTcpClientSocket::ConnectTo(LPCTSTR lpszHost, UINT nPort) { // 客户端端口传0, 让系统分配临时端口, 不要写死 if (!Create(0, SOCK_STREAM, FD_CONNECT | FD_READ | FD_CLOSE)) return FALSE; // Connect是异步请求, 真正结果看OnConnect return Connect(lpszHost, nPort); }参数说明客户端Create的第一个参数端口必须传0表示让系统从动态端口范围中挑选一个空闲端口如果写死一个值连两个客户端就会撞端口报10048。关注的事件要按需声明这里加了FD_CONNECT、FD_READ、FD_CLOSE分别对应连接结果、数据到达、对端关闭。没有注册FD_WRITE因为发送永远不被阻塞就不需要事件通知。OnReceive是所有TCP程序的心脏。TCP是字节流一次Receive调用读到的数据不分消息可能只有一半也可能是两三条拼在一起。这段处理必须稳妥void CTcpClientSocket::OnReceive(int nErrorCode) { if (nErrorCode ! 0) return; // 单次读取缓冲, 8KB是兼顾性能和内存的常用大小 BYTE byBuf[8192]; int nRead Receive(byBuf, sizeof(byBuf), 0); if (nRead 0) { // 将byBuf按字节加入接收队列(如CByteArray或std::vector) // 然后通知主窗口解析, 不要在回调里做业务 ::PostMessage(m_hNotifyWnd, WM_DATA_READY, nRead, (LPARAM)byBuf); } else if (nRead 0) { // 对端正常关闭, 主动释放socket Close(); } else { // 读数据出错, 但WSAEWOULDBLOCK不是错误 // 表示缓冲区暂时没有数据, 等待下一次事件 if (GetLastError() ! WSAEWOULDBLOCK) { Close(); } } CAsyncSocket::OnReceive(nErrorCode); }逻辑说明Receive返回值有三类正数表示读取字节数0表示对端已经关闭连接负数是错误需要靠GetLastError区分。WSAEWOULDBLOCK是TCP非阻塞模式下最常出现的伪错误意味着当前内核缓冲区没有更多数据不是网络故障。如果不加这个判断稍微连续来两批数据程序就会误判连接断开。发送侧相对简单但要注意Send的返回值void CTcpDemoDlg::OnBnClickedBtnSend() { CStringA strData CT2A(m_strInput.GetString(), CP_UTF8); int nLen strData.GetLength(); int nSent m_sckClient.Send(strData, nLen); if (nSent SOCKET_ERROR) { if (m_sckClient.GetLastError() ! WSAEWOULDBLOCK) { m_sckClient.Close(); } // WSAEWOULDBLOCK: 发送缓冲区满了, 数据要排队稍后重发 } else if (nSent nLen) { // 只发出了一部分, 剩余数据要缓存起来继续发送 } }这段代码里CT2A把宽字符串转成UTF-8的窄字符串避免中文在网络上变成乱码。协议如果约定纯ASCII可以省掉这步。Send返回SOCKET_ERROR时一般不是网络断了而是内核发送缓冲区满了解决办法是缓存剩余数据等FD_WRITE事件或定时器触发后再发不能直接在按钮里重发否则界面会卡住。4. MFC TCP通信避坑端口占用、界面卡死与粘包的排查顺序4.1 启动就报错通常每个套接字地址(协议/网络地址/端口)只允许使用一次现象程序第二次启动点“开始监听”按钮弹出错误框文字是“windows socket error: 通常每个套接字地址(协议/网络地址/端口)只允许使用一次”。对应错误码10048。原因端口被占用。一是上一次程序退出时没有完整释放监听socket二是客户端断开后TCP连接进入TIME_WAIT状态这个状态最长持续2分钟期间旧的四元组不能重新建立一模一样连接三是其他程序已经占用了这个端口。解决先执行netstat -ano | findstr 9000看端口对应的PID再到任务管理器结束残留进程。如果是自己程序反复切换端口测试就别在状态栏里干等在Create之前用SetSockOpt设置SO_REUSEADDR允许内核复用处于TIME_WAIT状态的本地地址。还有一点Windows的TCP全局参数会影响端口和时序行为比如在命令行执行netsh int tcp set global timestampsenabled会改变TCP时间戳选项但这属于系统级调优不建议在没搞懂前改动先用netsh interface tcp show global查看当前状态再决定动不动。4.2 界面卡死在OnReceive里Sleep或在回调里弹MessageBox现象程序收到网络数据后窗口拖不动按钮点不了看起来像死机。原因这个坑几乎每个人都踩过。OnReceive回调看起来像是“后台接收”其实它由界面线程的窗口消息泵触发运行在UI线程中。你在回调里Sleep(1000)就等于让界面线程睡一秒钟期间所有鼠标键盘消息都排队等着。如果回调里再弹一个MessageBox消息泵彻底阻塞界面直接卡死。解决定义一条铁律回调里只做三件事读数据、存缓冲区、PostMessage通知。弹框、刷新列表、更新状态栏全部放到自定义消息处理函数里去做。如果你确实要用阻塞socket就把socket放到一个独立的工作线程里在辅助线程中构造CSocket不要把界面线程牵扯进去。4.3 粘包和半包TCP是字节流没有消息边界现象客户端连续发两条消息“hello”和“world”服务端一次Receive读到了“helloworld”或者客户端发一条很长的数据服务端分两次才收到完整内容。原因TCP协议栈只保证字节顺序不保证消息边界。内核会根据MSS和缓冲情况把小包合并成一个大包发送也会把大包拆成多个段。这不是bug是TCP的既定行为。真正有问题的是你的程序把“一次Receive”当成了“一条消息”。解决自定义帧协议。我惯用的格式是“包头长度正文”最简单实现是4字节长度头加数据体// 接收缓存满了N个字节后调用这个函数 void ParseRecvBuffer(CByteArray byBuf) { while (byBuf.GetSize() 4) { // 用memcpy取出前4字节作为消息长度, 避免内存对齐问题 UINT nFrameLen 0; memcpy(nFrameLen, byBuf.GetData(), sizeof(nFrameLen)); // 当前缓存不够一整条, 等待下一次OnReceive if (byBuf.GetSize() 4 nFrameLen) break; // 取出完整帧, 从4字节头开始, 长度是nFrameLen // 交给业务处理后再把这4nFrameLen个字节从缓冲区中移除 } }逻辑说明nFrameLen是帧正文的长度不包含头部它的上限要在协议里约定否则一个恶意端可以发送4字节的0xFFFFFFFF让程序无限等待。缓冲区用CByteArray是因为它对字节操作最直接GetData拿到的是底层缓冲区的指针。粘包解决之后一定还要记得处理半包缓存中长度不足4字节时不能解析只能继续等下一批数据到来。4.4 关闭程序后进程还在后台端口依然被占用现象关闭对话框进程没有退出在任务管理器里还能看到而服务端端口继续被占用重启程序再次报10048。原因接受连接的pNewConn是new出来的CAsyncSocket派生对象关闭时没有delete监听socket没有调用CloseOnClose事件里没有释放句柄。MFC的CAsyncSocket在析构时会检查底层句柄如果还开着它会报“Socket句柄未关闭”的调试断言Release版则悄悄泄漏。解决在连接对象的OnClose里先调用Shutdown(SD_BOTH)再调Close保证双方优雅断开同时PostMessage给主窗口让主窗口更新连接列表并delete对应的指针。对话框的OnDestroy里遍历所有连接对象逐个delete并调用监听socket的Close。5. 交付前最后一步把连接状态放到MFC状态栏再用抓包确认功能跑通后别急着交付先解决可观测性。我把连接数写到MFC状态栏右下角这是排查问题最快的方式。在CMainFrame的OnCreate里给状态栏增加一个窗格然后封装一个更新函数// MainFrm.cpp void CMainFrame::UpdateConnStatus(UINT nConnCount) { CString strInfo; strInfo.Format(_T(监听端口: 9000 当前连接数: %d), nConnCount); // ID_SEPARATOR_CONN 需要在VS资源编辑器的字符串表里定义 int nIdx m_wndStatusBar.CommandToIndex(ID_SEPARATOR_CONN); if (nIdx ! -1) m_wndStatusBar.SetPaneText(nIdx, strInfo); }函数逻辑不复杂关键是CommandToIndex的ID如果没在字符串表里定义返回-1SetPaneText会断言。每次OnAccept加一、OnClose减一之后就调用这个函数。改完代码验证步骤也别省在同一台机器上运行服务端和客户端客户端连127.0.0.1用Wireshark抓包过滤tcp.port 9000确认三次握手包完整关闭程序时能看到四次挥手。我现在的习惯是接手一个MFC通信程序先不读代码而是在状态栏放一个连接计数器然后抓包确认握手包存在。这个习惯帮我避免了太多次“明明连接成功却收不到数据”的翻车——多数时候不是逻辑有问题而是连接根本没建立。希望你从头搭这套C/S架构时也能把这一步做在业务开发之前能省下后面一整周的调试时间。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?