资讯详情

资讯详情

从零手搓MFC TCP通信程序:Socket编程与界面联动实战

简介这是一份面向Windows平台网络编程初学者与C开发者的MFC TCP通信实战源码聚焦客户端-服务器C/S架构下的Socket编程帮助读者理解如何借助MFC封装的CSocket类完成连接建立、数据收发与连接关闭等核心流程。压缩包共4个文件包含2个cpp源文件与2个h头文件分别对应客户端与服务器端的对话框类实现整体约6KB体量轻巧便于快速通读与二次修改。资源围绕Winsock初始化、套接字绑定监听、Accept接收连接、Connect发起连接以及Send/Receive收发数据等关键环节展开可作为课程实验、毕业设计或自学网络通信的参考范例。目前已有1264人学习下载适合希望从底层API过渡到MFC封装、快速搭建TCP通信原型的开发者对照研读。1. 从零手搓一个 MFC TCP 通信程序为什么我劝你先搞懂 Socket 再碰界面很多人做网络编程的第一反应是直接上 Qt 或者现成的 HTTP 库结果遇到粘包、阻塞、线程卡死这些问题时完全不知道从哪下手。我当初也是这么想的直到接手一个工业数据采集的小工具要求用 MFC 做上位机、通过 TCP 跟下位机通信才被迫回头把 Socket 这套东西从头捋了一遍。这个项目就是一个典型的 C/S 架构程序服务端监听端口、等待连接、收发数据客户端主动连接、发送请求、接收响应整个通信链路完全基于原生 Socket API 实现界面部分用 MFC 的对话框框架搭起来。它适合两类人一是正在学 MFC 和网络编程、需要一个能跑通的最小完整项目来参考的开发者二是手头有类似需求、想找一个不依赖第三方库的纯 Win32 方案做二次开发的人。下面我按实际拆项目的顺序把这个东西从结构到代码到改造点讲清楚。2. 项目结构与环境搭建从 VS 工程配置到目录拆解2.1 技术选型与整体架构这个项目用的是 C 配合 MFC 框架通信层走的是 Windows 原生的 Winsock2 API没有引入任何第三方网络库。服务端和客户端各自是一个独立的 MFC 对话框工程编译出来是两个 exe运行时先启动服务端监听再启动客户端连接。整体架构分三层界面层负责按钮、编辑框、列表控件的交互通信层封装了 Socket 的创建、绑定、监听、连接、收发数据层就是简单的字符串或结构体序列化没有上 protobuf 或 JSON 库。为什么用 MFC 而不是控制台因为实际项目里上位机通常要给操作人员看状态、点按钮、看日志MFC 的对话框框架虽然老但在 Windows 桌面端做小工具足够稳而且跟 Winsock 的兼容性没有任何坑。为什么不用现成的网络库因为需求就是理解底层通信过程用第三方库反而把关键细节遮住了。常见做法是把通信逻辑单独抽一个类出来比如CTCPServer和TCPClient界面类只负责调用这个类的方法、通过自定义消息把收到的数据刷到 UI 上。这样后面要换协议或者加加密改动范围可控。2.2 工程目录与关键文件说明一个典型的 MFC 对话框工程目录大概长这样TcpServer/ ├── TcpServer.sln ├── TcpServer/ │ ├── TcpServer.h / .cpp # 应用入口InitInstance │ ├── TcpServerDlg.h / .cpp # 主对话框界面逻辑 │ ├── CTCPServer.h / .cpp # 通信封装类 │ ├── Resource.h # 控件 ID 定义 │ ├── TcpServer.rc # 资源文件对话框布局 │ └── res/ # 图标等资源客户端工程结构基本对称只是把CTCPServer换成CTCPClient。这里有个血泪经验.rc文件里对话框控件的 ID 一定要跟Resource.h里的宏对应上手动改 ID 很容易出现控件找不到、程序启动就崩的情况。我一般会在资源视图里改改完立刻编译一次确认。2.3 VS 工程配置要点用 Visual Studio 打开.sln之后有几处必须确认第一项目属性里字符集要统一。如果代码里用的是TCHAR和_T()字符集就选「使用 Unicode 字符集」如果直接写char*就选「使用多字节字符集」。两者混用会出现const char*不能转LPCWSTR的编译错误。第二链接器要加ws2_32.lib。路径是「项目属性 → 链接器 → 输入 → 附加依赖项」加上ws2_32.lib。或者在代码里用#pragma comment(lib, ws2_32.lib)也行我习惯后者省得换机器忘了配。第三如果编译报MFC相关头文件找不到检查「项目属性 → 常规 → MFC 的使用」是不是设成了「在共享 DLL 中使用 MFC」或「在静态库中使用 MFC」。// 在 stdafx.h 或 pch.h 里加上这两行 #include winsock2.h #include ws2tcpip.h #pragma comment(lib, ws2_32.lib)这三步做完工程应该能直接编译通过。如果还有报错大概率是 SDK 版本或者平台工具集不匹配在项目属性里把「Windows SDK 版本」和「平台工具集」调成你本机装了的版本就行。3. 服务端核心实现监听、接受连接与数据收发3.1 Winsock 初始化与端口监听服务端启动的第一步是初始化 Winsock 库然后创建监听 Socket、绑定地址、开始监听。这几步顺序不能乱任何一步失败后面都跑不起来。// CTCPServer.cpp 初始化部分 BOOL CTCPServer::StartServer(int nPort) { WSADATA wsaData; // 初始化 Winsock 2.2 if (WSAStartup(MAKEWORD(2, 2), wsaData) ! 0) return FALSE; // 创建监听 socketAF_INET 表示 IPv4SOCK_STREAM 表示 TCP m_hListenSocket socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (m_hListenSocket INVALID_SOCKET) { WSACleanup(); return FALSE; } // 绑定地址和端口 sockaddr_in addr; addr.sin_family AF_INET; addr.sin_addr.s_addr INADDR_ANY; // 监听本机所有网卡 addr.sin_port htons(nPort); // 端口号转网络字节序 if (bind(m_hListenSocket, (sockaddr*)addr, sizeof(addr)) SOCKET_ERROR) { closesocket(m_hListenSocket); WSACleanup(); return FALSE; } // 开始监听SOMAXCONN 表示最大等待队列 if (listen(m_hListenSocket, SOMAXCONN) SOCKET_ERROR) { closesocket(m_hListenSocket); WSACleanup(); return FALSE; } return TRUE; }这里几个参数值得说清楚AF_INET是 IPv4 地址族SOCK_STREAM指定 TCP 协议IPPROTO_TCP是协议号。INADDR_ANY表示绑定本机所有网卡地址如果只想监听某个特定 IP可以换成inet_addr(192.168.1.100)。htons把主机字节序的端口转成网络字节序这个转换在 x86 上不能省否则端口号会变成另一个值。3.2 接受连接与多客户端处理监听 Socket 本身不负责收发数据它只负责接受连接。每次accept成功会返回一个新的 Socket专门用来跟这个客户端通信。// 接受客户端连接 SOCKET CTCPServer::AcceptClient() { sockaddr_in clientAddr; int nAddrLen sizeof(clientAddr); // accept 会阻塞直到有客户端连上来 SOCKET hClient accept(m_hListenSocket, (sockaddr*)clientAddr, nAddrLen); if (hClient INVALID_SOCKET) return INVALID_SOCKET; // 把客户端信息存起来方便后续管理 CString strIP inet_ntoa(clientAddr.sin_addr); int nPort ntohs(clientAddr.sin_port); // ... 记录到列表 return hClient; }如果只处理一个客户端直接在对话框的按钮响应里调AcceptClient然后循环recv就行。但实际项目往往要支持多个客户端常见做法是每接受一个连接就开一个工作线程去处理这个 Socket 的收发主线程继续accept。线程函数里用recv阻塞接收收到数据后通过PostMessage把数据指针发给主对话框让 UI 线程去刷新控件。这里有个坑不能在工作线程里直接操作 MFC 控件会崩。3.3 数据接收与发送的封装收发数据用recv和send但 TCP 是流式协议recv返回的字节数不一定等于你期望的一条完整消息。如果协议是定长的可以循环收满指定长度如果是变长的通常要在消息头里带一个长度字段。// 发送数据 int CTCPServer::SendData(SOCKET hClient, const char* pData, int nLen) { int nSent 0; while (nSent nLen) { int nRet send(hClient, pData nSent, nLen - nSent, 0); if (nRet SOCKET_ERROR || nRet 0) return SOCKET_ERROR; nSent nRet; } return nSent; } // 接收定长数据 int CTCPServer::RecvData(SOCKET hClient, char* pBuf, int nLen) { int nRecv 0; while (nRecv nLen) { int nRet recv(hClient, pBuf nRecv, nLen - nRecv, 0); if (nRet SOCKET_ERROR || nRet 0) return nRet; // 0 表示对端关闭连接 nRecv nRet; } return nRecv; }send的返回值是实际发送的字节数可能小于请求发送的长度所以要用循环保证发完。recv返回 0 表示对方正常关闭了连接返回SOCKET_ERROR表示出错可以用WSAGetLastError看具体错误码。这套循环收发的写法是 TCP 编程的基本功少了这个循环大数据量下必然出问题。4. 客户端实现与 MFC 界面联动连接、发消息、刷 UI4.1 客户端连接服务端客户端比服务端简单不需要 bind 和 listen直接创建 Socket 然后 connect 就行。// CTCPClient.cpp BOOL CTCPClient::ConnectToServer(LPCTSTR lpszIP, int nPort) { WSADATA wsaData; if (WSAStartup(MAKEWORD(2, 2), wsaData) ! 0) return FALSE; m_hSocket socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (m_hSocket INVALID_SOCKET) { WSACleanup(); return FALSE; } sockaddr_in addr; addr.sin_family AF_INET; addr.sin_port htons(nPort); // 把 IP 字符串转成网络地址结构 addr.sin_addr.s_addr inet_addr(CStringA(lpszIP)); if (connect(m_hSocket, (sockaddr*)addr, sizeof(addr)) SOCKET_ERROR) { closesocket(m_hSocket); WSACleanup(); return FALSE; } return TRUE; }inet_addr接收的是char*类型的点分十进制 IP 字符串如果工程是 Unicode 字符集需要用CStringA转一下否则编译不过。connect成功返回 0失败返回SOCKET_ERROR常见失败原因是对端没启动、IP 写错、防火墙拦截。4.2 界面控件与通信逻辑的绑定MFC 对话框上一般放这几个控件IP 编辑框、端口编辑框、连接按钮、发送编辑框、发送按钮、接收显示编辑框或列表控件。在类向导里给控件绑定变量比如m_editIP、m_editPort、m_btnConnect、m_editSend、m_editRecv。连接按钮的响应函数里调ConnectToServer成功之后把连接按钮的文字改成「断开」同时禁用 IP 和端口编辑框防止误操作。void CTcpClientDlg::OnBnClickedBtnConnect() { if (!m_bConnected) { CString strIP, strPort; m_editIP.GetWindowText(strIP); m_editPort.GetWindowText(strPort); int nPort _ttoi(strPort); if (m_client.ConnectToServer(strIP, nPort)) { m_bConnected TRUE; m_btnConnect.SetWindowText(_T(断开)); m_editIP.EnableWindow(FALSE); m_editPort.EnableWindow(FALSE); // 启动接收线程 AfxBeginThread(RecvThread, this); } else { AfxMessageBox(_T(连接失败检查服务端是否启动)); } } else { m_client.Disconnect(); m_bConnected FALSE; m_btnConnect.SetWindowText(_T(连接)); m_editIP.EnableWindow(TRUE); m_editPort.EnableWindow(TRUE); } }这里用AfxBeginThread起了一个接收线程线程函数里循环recv收到数据后用PostMessage发给对话框。自定义消息比如WM_RECV_DATA在消息映射里绑到OnRecvData函数在那个函数里把数据追加到接收编辑框。4.3 自定义消息与线程安全刷新 UIMFC 的控件只能在创建它的线程里操作工作线程直接调SetWindowText是未定义行为轻则界面不刷新重则直接崩。正确做法是PostMessage把数据指针传回主线程。// 自定义消息 #define WM_RECV_DATA (WM_USER 100) // 工作线程 UINT RecvThread(LPVOID pParam) { CTcpClientDlg* pDlg (CTcpClientDlg*)pParam; char szBuf[1024]; while (pDlg-m_bConnected) { int nRet recv(pDlg-m_client.GetSocket(), szBuf, sizeof(szBuf) - 1, 0); if (nRet 0) { szBuf[nRet] \0; // 拷贝一份数据PostMessage 是异步的不能传栈上指针 char* pCopy new char[nRet 1]; memcpy(pCopy, szBuf, nRet 1); pDlg-PostMessage(WM_RECV_DATA, (WPARAM)pCopy, 0); } else { break; // 连接断开或出错 } } return 0; } // 消息响应函数 LRESULT CTcpClientDlg::OnRecvData(WPARAM wParam, LPARAM lParam) { char* pData (char*)wParam; CString strRecv(pData); // 追加到接收框 CString strOld; m_editRecv.GetWindowText(strOld); m_editRecv.SetWindowText(strOld strRecv _T(\r\n)); delete[] pData; // 记得释放 return 0; }这段代码里有两个关键点一是PostMessage是异步的消息发出去之后函数就返回了所以不能传栈上局部变量的地址必须new一块堆内存二是接收方处理完要delete[]否则每收一条消息泄漏一次。我见过有人图省事用SendMessage那是同步的工作线程会等主线程处理完才继续如果主线程正好在等锁直接死锁。5. 避坑与常见问题排查那些年我踩过的 Socket 坑5.1 连接被拒绝或超时现象是客户端调connect返回失败错误码WSAECONNREFUSED或WSAETIMEDOUT。原因通常是服务端没启动、端口写错、或者防火墙拦了。解决步骤先在服务端机器上用netstat -ano | findstr 端口号确认监听状态再从客户端ping一下服务端 IP最后检查 Windows 防火墙的入站规则有没有放行这个端口。如果是跨网段还要确认路由可达。5.2 中文乱码现象是发送「你好」收到一堆问号或方块。原因是发送端和接收端的字符编码不一致MFC 默认 Unicode 工程里CString是宽字符直接转char*会丢数据。解决办法是统一用 UTF-8 编码传输发送前用WideCharToMultiByte转成 UTF-8接收后用MultiByteToWideChar转回来。或者简单点两端都用多字节字符集但这不是长久之计。5.3 界面卡死无响应现象是点了连接按钮之后整个窗口拖不动、按钮点不了。原因是把阻塞的recv或accept放在了 UI 线程里。解决就是把所有可能阻塞的 Socket 操作放到工作线程UI 线程只负责界面刷新。如果不想开线程可以用非阻塞 Socket 加select模型但在 MFC 里开线程是最直观的做法。5.4 程序退出时报内存泄漏或崩溃现象是关闭窗口时弹异常或者调试输出里一堆泄漏报告。原因通常是 Socket 没关、Winsock 没清理、线程还在跑。解决是在对话框的OnDestroy里先置退出标志让线程结束再closesocket最后WSACleanup。顺序反了的话线程可能还在用已经关闭的 Socket直接崩。void CTcpClientDlg::OnDestroy() { m_bConnected FALSE; // 通知线程退出 Sleep(100); // 等线程跑完一轮 m_client.Disconnect(); // 关 socket WSACleanup CDialogEx::OnDestroy(); }5.5 粘包导致消息解析错乱现象是发送两条短消息接收端一次recv把两条都收上来了或者一条长消息被拆成两次收到。这是 TCP 流式协议的正常行为不是 bug。解决办法是在应用层定义消息边界定长消息就循环收满变长消息就在消息头加 4 字节长度字段先收头、解析出长度、再收 body。这个逻辑必须封装在通信类里不能指望每次recv刚好对应一条消息。6. 二次开发改造指南从能跑到好用还差哪几步这个项目跑通之后大多数人下一步就是想把它改成自己能用的样子。我按改动量从小到大说几个方向。第一个方向是加心跳机制。现在的代码是连接建立后一直阻塞收如果网络断了recv可能很久才返回界面看起来像卡住。常见做法是客户端每隔几秒发一个心跳包服务端收到后回一个应答连续几次没应答就判定断线、主动关闭重连。心跳包可以就是一个固定字符串比如PING服务端收到PING回PONG逻辑简单但很管用。第二个方向是把消息格式从裸字符串换成带头部的结构。比如定义一个MsgHeader包含魔数、版本、消息类型、body 长度后面跟 body。这样后面加文件传输、加指令类型都不用改通信层的收发逻辑只改解析部分。#pragma pack(push, 1) struct MsgHeader { unsigned int nMagic; // 固定值比如 0x12345678 unsigned short nVersion; // 协议版本 unsigned short nType; // 消息类型 unsigned int nBodyLen; // body 字节数 }; #pragma pack(pop)#pragma pack(push, 1)是取消结构体对齐保证收发两端算出来的头部长度一致。不加这个不同编译器或不同编译选项下sizeof(MsgHeader)可能不一样解析就错位了。第三个方向是支持多客户端并发。服务端现在如果只accept一次就只能服务一个客户端。改成主线程循环accept每来一个连接就new一个线程处理线程里循环收数据。客户端列表用一个std::vectorSOCKET或者CArray管理断开时从列表移除并closesocket。注意多线程访问这个列表要加临界区CRITICAL_SECTION否则并发增删会崩。第四个方向是加日志。通信程序出问题时没有日志基本靠猜。我一般会在通信类的收发函数里加一个回调或者直接写文件记录时间、方向、字节数、内容摘要。调试阶段全量记上线后只记异常和关键指令。日志文件按天切分避免单个文件无限增长。验证改造是否成功我习惯用三步第一步本机回环测试服务端和客户端都在同一台机器上跑确认基本收发没问题第二步两台机器对连确认网络配置和防火墙没问题第三步用工具模拟异常比如发送超长消息、中途拔网线、快速重连看程序会不会崩、会不会泄漏。这三步走完基本能覆盖大部分实际场景。从那以后我每次改通信相关的代码都会强制走一遍「本机回环 → 双机对连 → 异常注入」这个流程哪怕只改了一行收发逻辑。希望这个项目的拆解能帮到你少走一些我当年踩过的弯路。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →