简介:一份使用MFC框架编写的TCP服务器完整工程,面向需要学习Windows网络编程或MFC套接字应用的开发者。资源基于CAsyncSocket封装Winsock API,包含服务器初始化、监听、接受连接、收发数据及错误处理等核心逻辑,并支持多线程并发,适合作为入门TCP服务端编程的参考工程。压缩包共20个文件,约39KB,以C++头文件(h)、源文件(cpp)为主,另有工程配置(dsp、dsw、clw)、图标(ico)及说明文档(txt),工程由VC6.0创建,可编译生成Debug版本以便调试。目前已有311人学习下载。通过阅读和运行这份代码,可以直观理解MFC中OnAccept、OnReceive、OnSend等回调机制,掌握异步套接字服务器的基本架构与常见排错思路,直接在此基础上扩展业务逻辑。 最近整理代码库的时候翻出来一个老项目:MFC写的一个TCP服务器。说起来有点意思,很多人觉得MFC早该退休了,可真到实际工控、上位机、局域网小工具开发时,MFC依然是很多老项目的“主力军”,我自己也用它做过设备采集服务、PLC通信中转这类东西,积累了不少经验。
这篇文章我打算把这个项目讲透——为什么要在MFC里做TCP服务器,线程模型怎么搭,数据怎么收发,踩了哪些坑,直接给你能复现的实现思路。适合正在用VS2008/2013/2019做MFC开发、需要在桌面程序上集成TCP服务能力的朋友,也适合刚从Linux服务器转过来、搞不懂Windows界面线程怎么和socket相处的同学。我尽量把每个选型背后的“为什么”都说清楚,方便你迁移到自己的项目里。
1. 项目整体设计与方案选型
1.1 为什么要在MFC里做TCP服务器
先说个很现实的问题:真正的生产级TCP服务器,多数跑在Linux上,CPU天梯图上那些至强、霄龙,基本都服务端环境。那为什么还要费劲在MFC里做服务器?
因为实际场景里,有一大类需求是“边收敛数据,边可视化”。比如你接了好几台设备,设备通过TCP把温度、压力、开关量推过来,你既要负责监听连接,又要把实时数值显示在仪表盘上,还要能点按钮下发指令。这时候如果你拆成两个程序——一个Linux服务器收数据,一个MFC客户端显示——就会多出一层中间件,延迟和维护成本都上去了。
MFC做TCP服务器的核心价值,就是把网络监听、数据收发、业务逻辑、界面展示放在同一个进程里。数据到了就能直接刷新控件,不需要走IPC或数据库中转,开发效率明显更高。代价是,MFC的界面消息循环是单线程的,你必须在网络响应和UI流畅之间做好分工,否则程序必卡死。
1.2 四种实现方案对比
我最早接触这个项目时,第一反应是用socket原始API硬写。后来发现结构化之前得先想清楚线程模型,以下是MFC工程里接入TCP服务器的四种主流方式,我逐一分析过:
| 方案 | 基本原理 | 适用场景 | 难点 |
|---|---|---|---|
| CAsyncSocket + 消息回调 | MFC封装,socket事件映射为窗口消息 | 连接数少、消息量小 | 消息风暴时界面卡顿 |
| WSAAsyncSelect + 自定义消息 | Win32原生异步选择,注册到窗口句柄 | 中等连接数 | 网络事件和窗口生命周期耦合 |
| 工作线程 + 阻塞socket | 独立线程里accept/recv,结果投递回界面 | 最通用,适合长期运行 | 线程同步和退出处理 |
| IOCP完成端口 | 异步I/O,系统级高并发 | 大并发服务器 | 代码复杂度高,MFC里很少用 |
最终我选了工作线程 + 阻塞socket方案。核心原因是:MFC界面线程是消息泵驱动的,任何阻塞操作放进去,界面立刻假死。而独立工作线程里用阻塞socket,逻辑线性清晰,accept一个连接就开一个收发线程,符合大多数人“服务器=持续监听+每连接独立处理”的思维习惯。连接数不超过几十个时,这个方案的性能和可维护性都够用,线程开销也可以接受。
2. 核心细节解析:TCP协议与MFC的配合
2.1 TCP三次握手与listen/accept的关系
在看代码前,先梳理协议层。TCP三次握手不是accept触发的,而是内核协议栈自动完成的。你在服务端调用listen时,内核就已经开始接受SYN请求,并为完成握手的连接维护一个已完成队列。accept的职责,只是从队列里取一个已经握手成功的连接,返回给应用程序一个可用的socket句柄。
这里有个很重要的经验:如果客户端显示TCP连接已建立,但服务端accept迟迟不返回,往往不是网络问题,而是你没有及时调用accept,或者已完成队列被写满了。MFC里如果你把accept放在某个按钮点击事件里,就会出现这种怪现象——客户端connect秒成功,服务端毫无反应。处理办法是让accept持续运行在独立线程里,或者用异步事件通知。
另一个细节是listen的backlog参数,我习惯设为SOMAXCONN,让系统决定。手动设一个固定小值(比如5)会在连接突发时直接丢握手包,客户端那边表现为connect超时,排查起来相当隐蔽。
2.2 粘包与拆包处理思路
TCP是字节流协议,没有消息边界。设备一次发来1KB数据,recv可能分两次返回;两次间隔很短的数据,也可能合成一次返回。说白了,TCP不保证你每次recv拿到的,正好是一条完整业务消息。
做过串口的人比较好理解:串口也按字节流读,要靠帧头帧尾或者长度来卡边界。TCP服务器同理。实战中我见过三种拆包方案:
- 固定长度:每条消息固定N字节,按N字节切分,最简单,但灵活性差。
- 长度前缀:消息头固定2字节或4字节表示长度,后面跟着body。这是最常用的,Modbus TCP就是这么干的——MBAP头里就有长度字段。
- 特殊分隔符:以换行符或特定标记作结尾,适合文本协议,比如REST接口或自定义AT指令风格。
在这个MFC项目里,我采用长度前缀方式。收到数据后先攒入缓冲区,检查够不够读长度头,够了再根据长度字段判断整个消息是否完整,不完整就继续等下一段recv数据。这段逻辑虽然枯燥,但它是TCP服务器稳定性的分水岭。
2.3 跨线程更新UI的正确姿势
这是MFC项目最容易写崩的地方。工作线程收到数据后,绝对不允许直接调用CWnd::SetWindowText这类界面函数。MFC的控件不是线程安全的,子线程操作控件轻则显示错乱,重则直接崩溃。
正确做法是把数据通过消息投递给主线程,由界面线程统一更新控件。我常用PostMessage而非SendMessage,因为PostMessage只把消息扔进队列就返回,不阻塞网络线程;SendMessage会同步等待界面处理完,万一界面卡住,网络线程也被拖死。
自定义消息一般这样定义:在对话框头文件里写#define WM_NETWORK_DATA (WM_APP + 101),然后在消息映射表里加ON_MESSAGE(WM_NETWORK_DATA, &CMyServerDlg::OnNetworkData),工作线程里用::PostMessage(hWnd, WM_NETWORK_DATA, (WPARAM)socketId, (LPARAM)pBuffer)把数据指针传过去。注意pBuffer的生命周期,最好用new分配后在线程里post,界面线程处理完delete,不然会产生用后释放的随机崩溃。
3. 实操过程:从初始化到稳定收发的完整实现
3.1 初始化环节
第一件事是初始化Winsock库。在对话框的OnInitDialog里,或者一个专门的初始化函数中,调用WSAStartup。版本号我习惯用MAKEWORD(2, 2),兼容性好,也支持现代特性。对应的,程序退出前记得调WSACleanup。
然后创建监听socket。这里有个非常关键的选项SO_REUSEADDR,我建议你务必设置。它允许服务器重启时立刻复用TIME_WAIT状态的端口,否则服务端一崩溃重启,很可能报“bind失败”,让你误以为是代码问题。
初始化环节的代码骨架大致这样:
bool CMyServerDlg::InitWinsock() { WSADATA wsaData; if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) { AfxMessageBox(_T("WSAStartup failed")); return false; } m_listenSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (m_listenSocket == INVALID_SOCKET) { AfxMessageBox(_T("socket create failed")); WSACleanup(); return false; } BOOL bReuse = TRUE; setsockopt(m_listenSocket, SOL_SOCKET, SO_REUSEADDR, (const char*)&bReuse, sizeof(bReuse)); sockaddr_in addr; addr.sin_family = AF_INET; addr.sin_port = htons(6000); // 监听端口 addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有网卡 if (bind(m_listenSocket, (sockaddr*)&addr, sizeof(addr)) == SOCKET_ERROR) { // 常见错误: WSAEADDRINUSE AfxMessageBox(_T("bind failed")); closesocket(m_listenSocket); WSACleanup(); return false; } if (listen(m_listenSocket, SOMAXCONN) == SOCKET_ERROR) { AfxMessageBox(_T("listen failed")); closesocket(m_listenSocket); WSACleanup(); return false; } return true; }3.2 监听线程与每连接处理线程
bind和listen完成后,就可以启动一个线程专门循环accept。每次accept成功,就把返回的新socket交给一个新线程去收发。这样设计的好处是:一个连接卡住了不会影响其他连接,清理逻辑也清晰。
监听线程的循环结构大概长这样:
UINT ListenThreadProc(LPVOID pParam) { CMyServerDlg* pDlg = (CMyServerDlg*)pParam; while (pDlg->m_bRunning) { sockaddr_in clientAddr; int addrLen = sizeof(clientAddr); SOCKET client = accept(pDlg->m_listenSocket, (sockaddr*)&clientAddr, &addrLen); if (client == INVALID_SOCKET) { // 是被正常关闭还是异常? if (WSAGetLastError() == WSAEINTR) break; continue; } // 把客户端IP和socket传入工作线程处理 ClientInfo* pInfo = new ClientInfo; pInfo->socket = client; strcpy_s(pInfo->ip, inet_ntoa(clientAddr.sin_addr)); pInfo->port = ntohs(clientAddr.sin_port); AfxBeginThread(ClientWorkThread, pInfo); } return 0; }注意accept线程结束时对WSAGetLastError的判断。我在退出时习惯调用closesocket关闭监听socket,这会让阻塞中的accept返回SOCKET_ERROR,并且错误码是WSAEINTR,通过这个状态位我们就可以干净地跳出循环,而不是用TerminateThread暴力结束线程。
客户端收发线程里,主循环就是阻塞recv。收到数据后,按前面说的长度前缀方案解析完整消息,然后把解析结果PostMessage回主窗口。发送数据时则要考虑粘包与半包,发送缓冲区不满时调用send能一次发出还好,数据很大时send可能只发送一部分,必须循环发送直到全部写完。封装一个SendAll函数是很必要的。
3.3 优雅退出的细节
这个坑我踩过好多次。MFC对话框点关闭时,UI线程直接退出,但后台线程还在accept、recv,程序会报运行时错误或直接卡死在Debug模式。
处理的顺序很重要:
- 先把
m_bRunning置false,让线程循环条件失效。 - 关闭监听socket,令accept返回错误退出。
- 遍历所有客户端socket并closesocket,让recv返回0或错误,收发线程退出。
- 等待所有线程句柄结束,再关闭软件。
关闭socket时有个细节:直接closesocket正在阻塞recv的socket,在Windows上通常能立刻解除阻塞,但严谨的做法是先调用shutdown(sock, SD_BOTH),让对端也收到FIN,再closesocket。这样客户端不会莫名收到“连接被重置”的提示。
4. 常见问题与排查技巧实录
4.1 bind报错:only one usage of each socket address
这个错误我最早是在控制台程序里看到的,错误信息很长:bind: only one usage of each socket address。在Windows上对应WSAEADDRINUSE,意思就是端口被占了。
触发原因主要有三类:
- 上次程序退出时,端口处于TIME_WAIT状态,如果没设置SO_REUSEADDR就立刻重启,会报这个错。
- 你同时开了两个实例,都去绑定同一个端口。
- 端口被其他进程占用,比如系统服务或另一个程序抢先绑定。
排查手段很简单:命令行执行netstat -ano | findstr 6000,看看谁占着端口,最后一列是PID,再去任务管理器对照进程。如果确定是TIME_WAIT导致的,设置SO_REUSEADDR即可;如果被其他程序占用,就得改监听端口或结束占用进程。
4.2 TCP连接重复建立导致资源耗尽
MFC服务器跑着跑着,连接数越来越多,内存持续上涨,一查发现是有客户端反复断开重连。每来一次连接我就new一个线程,线程退出时如果没有彻底清理资源,句柄就会泄漏。
后来我总结了一个原则:任何用new创建的对象或线程,务必在函数的每条退出路径上释放干净。为了好维护,我会用一个std::map<SOCKET, ClientInfo*>统一管理所有连接,断开时从map里移除并delete,而不是让线程退出后自己在角落默默清理。这样即使有异常,主界面也能看到当前连接总数,排查泄漏容易得多。
4.3 客户端连上就报“连接被重置”
TCP连接建立后,服务端过一会儿才读到recv返回0或-1,客户端却报错。这个现象多半不是服务端代码问题,而是客户端自己关闭了socket,或者服务端收发线程在处理消息时抛了未捕获异常,导致线程退出,socket被操作系统回收,连接被硬断。
另一种常见情况是收发线程模型混乱:有人用一个线程同时做accept和recv。accept返回后不创建新线程,而是直接在当前线程里recv,结果新连接再进来时没人accept,客户端connect成功但服务端无响应,最终超时或重置。
4.4 常见问题速查表
| 现象 | 可能原因 | 处理思路 |
|---|---|---|
| bind报地址已被占用 | 端口处于TIME_WAIT或有进程占用 | 设置SO_REUSEADDR、netstat查占用 |
| connect成功但收不到任何数据 | accept线程未持续运行 | 确认accept循环在独立线程中 |
| 偶发数据错乱 | 粘包/拆包未处理 | 加长度前缀或分隔符协议 |
| 程序关闭时卡死 | 线程未优雅退出 | 先停循环,再关socket,再等线程 |
| 收到TCP connection reset by peer | 对端未正常关闭或服务端线程崩溃 | 检查关闭流程,用shutdown代替直接closesocket |
| 界面假死 | 网络逻辑跑了界面线程 | 所有socket操作移到工作线程 |
| 内存持续增长 | 连接对象未释放 | 用map统一管理连接生命周期 |
5. 结尾:实际开发中的延伸经验
这个项目后来我还做了几个扩展,简单分享下思路。如果哪一步卡住了,很可能是环境或数据格式问题,不一定是代码逻辑问题。
如果你打算把它用于生产环境,建议再补三件事:一是心跳机制,客户端意外断电时TCP不一定立刻感知,服务端要定期检测空闲连接并断开;二是日志系统,网络服务器最大的痛点就是不好复现bug,至少要把连接建立、断开、收发长度记下来;三是打包发布,MFC程序在别的机器上跑,别忘记带上VC运行库,用VS2013做的项目尤其要留意。
最后再分享一个调试技巧:接收线程里每次recv后,可以顺手把数据长度和首字节打印到调试窗口。很多时候你觉得是网络丢数据,其实是对端就没把数据发全,数据格式问题往往比网络问题更隐蔽。多看原始字节流,再判断是不是协议问题,能少走很多弯路。
本文还有配套的精品资源,点击获取