简介:这是一份基于MFC框架的TCP服务器示例工程,面向C++初学者或需要快速上手Winsock网络编程的开发者,演示了如何在MFC对话框程序中监听端口、接受连接并收发数据。压缩包共20个文件,以server.h/serverDlg.h等头文件、server.cpp/serverDlg.cpp源文件为核心,辅以资源脚本server.rc、图标以及dsp/dsw工程文件,完整保留了可在VC6.0中直接打开编译的代码结构,整体仅39KB,轻量易用。已有311人学习下载。通过该工程,可以直观理解CAsyncSocket类的异步事件模型,重点掌握OnAccept、OnReceive、OnSend等回调函数的编写方式,也能看到TCP服务器初始化和错误处理的基本流程,适合配合MFC网络编程教材做本地实验或二次开发参考。 做这个项目其实挺偶然的。当时现场有一台运行了好多年的工控机,上面跑着一套MFC写的老程序,UI和业务逻辑都还好好的,就是缺一个网络数据入口。设备端走的是TCP协议,每隔几秒往服务器上推一组状态数据,我就琢磨着把TCP服务器直接嵌进这个MFC程序里,省得再单独开一个服务进程,部署和维护都麻烦。
做完以后我的感受是:用MFC写TCP服务器这件事,本身技术门槛不算高,但牵扯到的点非常杂,Socket状态管理、线程调度、界面跨线程刷新、资源释放,哪一个没处理好都会出一堆稀奇古怪的毛病。我踩了不少坑,也把这些坑记在了笔记里。这篇文章就把整个实现过程、关键代码、踩坑记录从头到尾梳理一遍,适合那些需要维护老MFC工程、或者打算在Windows原生环境下用C++做网络通信的朋友参考。
1. 为什么是MFC加TCP这个组合
1.1 需求背景:老工控机上的新增功能
那天接到需求,说要给现场的设备加一个数据采集功能,设备端通过以太网口把状态数据发上来。我第一反应是这不是什么难事,随便用Python写个socket服务就行,几分钟的事。但真正的限制条件在后面:数据要展示在那台老工控机的现有界面上,而且程序不能引入新的运行时依赖,最好是直接做到现有MFC工程里。在这种前提下,MFC加TCP几乎就是唯一合理的选项。
当时我也想过用C#写一个独立窗口,跟老程序之间通过文件或者消息通信,但现场工程师不希望多维护一个进程,而且老机器的操作系统版本比较旧,.NET运行时也要额外操心。最后拍板用MFC原生支持的方式,直接调Winsock API来做TCP通信。虽然代码量比C#多不少,但编译出来一个小EXE就能跑,跟现有程序共享同一个进程,数据直接刷新在相同界面上,稳定性反而更好。
1.2 技术选型的取舍:为什么不用CSocket和CAsyncSocket
MFC里面其实自带网络封装,CSocket和CAsyncSocket都是现成的,但我在这个项目里没有用它们,而是直接用的Winsock API。主要原因有两个。第一,CSocket的设计是以阻塞模式为主,而且它内部是配合消息泵工作的,在一个人机界面为主、网络流量又不大的程序里,用CSocket反而会把简单的收发逻辑绕得很复杂。第二,CAsyncSocket基于Window消息分发网络事件,虽然是异步的,但事件处理分散在多个消息响应里,对于一个需要长期跑、逻辑要尽量集中的服务器来说,调试起来不够直观。
直接操作Winsock API,相当于把网络部分的每一行代码都摊在自己面前。accept、recv、send、closesocket这些函数的行为是确定的,网上资料也够多,出了问题很容易定位。代价是要自己处理线程和界面刷新之间的同步,但这部分本来就是MFC程序的常见工作,躲不掉的。我后来在笔记里写了一句:MFC写网络程序,最麻烦的不是TCP本身,而是TCP和界面线程之间的那点破事。
2. 工程搭建:从新建项目到第一个Socket
2.1 开发环境与工程类型怎么选
这个项目用的是VS2013,MFC工程类型选的是“基于对话框”,因为要做的服务器控制界面很简单,不需要文档视图结构那一大套东西。如果你的MFC版本比较新,比如VS2019、VS2022,操作路径略有不同,但核心步骤差不多:新建项目时选择“MFC应用程序”,然后在应用程序类型里选“基于对话框”,语言选中文即可。
建好工程以后,有一件很容易被忽略的事:链接库。Winsock API的代码需要链接ws2_32.lib。VS里通常有两种方式处理,一种是在代码最开始加一行:
#pragma comment(lib, "ws2_32.lib")另一种是在项目属性->链接器->输入->附加依赖项里手动加上ws2_32.lib。我个人习惯用#pragma comment的方式,因为这样如果工程文件拷贝到别的机器上,不会因为工程配置丢失导致链接失败。
2.2 界面布局:别让监控面板变成信息垃圾场
对话框界面我做得比较克制,没有堆太多控件。核心就是这几样:一个监听端口输入框、一个启动/停止按钮、一个显示当前连接数的静态文本、一个客户端IP列表的列表框,还有一个日志显示区域。日志用MFC的ListBox控件,让数据一条条往下排,方便拷贝和排查问题。
列表框控件的使用有几个细节值得说一下。首先,日志列表要设置一个最大行数,比如只保留500条,超出就删掉最早那条,否则工控机跑上一个月,这个ListBox会积累几十万行数据,内存和刷新速度都会出问题。其次,ListBox默认下边条目不可见,每次添加新日志后要调用SetTopIndex把滚动条拉到底部,这样最新的日志才能自动显示出来。否则日志一直在追加,你看到的却永远是前几条,很容易误以为程序卡死了。
3. 核心网络逻辑:让数据真正流动起来
3.1 初始化Winsock:WSAStartup这一步为何省不掉
Winsock的启动初始化是所有网络操作的起点,调用时机在程序启动之后、创建Socket之前。WSAStartup主要做两件事,一是向系统申请Winsock库的版本支持,二是初始化内部的运行环境。这个函数在进程内只需要调用一次,但是要在最后一次Socket操作结束后调用WSACleanup做对应清理。
代码很简单,但很多人会忽略返回值检查:
WSADATA wsaData; int nResult = WSAStartup(MAKEWORD(2, 2), &wsaData); if (nResult != 0) { // 初始化失败,可以在这里弹出提示 return FALSE; }MAKEWORD(2, 2)表示请求使用Winsock 2.2版本。老的代码里经常看到MAKEWORD(1, 1),那是Winsock 1.1,功能上有很多限制,除非你明确知道目标系统很老,否则用2.2就好。
3.2 创建监听Socket、绑定端口、开始监听
初始化完成后,就是标准的Socket三步曲:创建、绑定、监听。创建Socket时第一个参数是地址族AF_INET(IPv4),第二个参数是类型SOCK_STREAM(流式套接字),第三个参数是协议IPPROTO_TCP。这三个参数基本是固定的,除非你要写UDP,用SOCK_DGRAM,那就是另一套逻辑了。
绑定端口时有个细节容易踩坑:端口号要用htons转换字节序。TCP/IP协议规定网络字节序是大端,而x86机器是小端,如果直接把端口号填进sockaddr_in结构体,就会导致绑定的端口跟你预期的不一致。htons这个函数就是干这个的。地址部分,如果服务器只在本机运行,可以用inet_addr("127.0.0.1"),但如果是工控机上要面对局域网设备,就要绑定INADDR_ANY,让它监听所有网卡地址:
SOCKET sListen = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (sListen == INVALID_SOCKET) { // 创建失败,WSAGetLastError()查看原因 } sockaddr_in addr; addr.sin_family = AF_INET; addr.sin_port = htons(9500); addr.sin_addr.s_addr = htonl(INADDR_ANY); if (bind(sListen, (sockaddr*)&addr, sizeof(addr)) == SOCKET_ERROR) { closesocket(sListen); } if (listen(sListen, SOMAXCONN) == SOCKET_ERROR) { closesocket(sListen); }listen的第二个参数是等待队列的长度,SOMAXCONN表示让系统决定可以排队等待的客户端连接数。对一般的中小规模应用足够了,不用费心思设一个固定值。
3.3 accept循环和每客户端一线程的模型
监听Socket创建好之后,真正的重头戏是accept循环。我选择在单独的工作线程里跑accept,因为如果放到主界面线程里,accept会阻塞在那里,界面就完全卡死了。线程的创建,我用的不是CreateThread,而是_beginthreadex。这两者有个关键区别:_beginthreadex是C运行时库提供的线程创建函数,它会为线程初始化CRT相关的内存结构,防止你在新线程里调用C库函数时出现内存泄漏或崩溃。网络处理线程里经常要处理字符串、格式化日志,CRT的支持很重要。
accept本身的逻辑不复杂,但每个成功accept返回的客户端Socket,不能直接在监听线程里处理收发,因为这样会阻塞住循环,导致后面的客户端连不进来。我的处理方式是每来一个客户端,就创建一个专门处理这个连接的线程。这在小规模场景下简单直接,比如同时在线几十个客户端以内,每客户端一线程完全扛得住。如果你的场景是几千上万个并发连接,那就要研究IOCP或者事件驱动了,但对于这个需求背景来说,是完全够用的。
unsigned int __stdcall AcceptThread(void* pParam) { while (bServerRunning) { SOCKET sClient = accept(sListen, (sockaddr*)&clientAddr, &addrLen); if (sClient == INVALID_SOCKET) { // 如果bServerRunning已置false,说明要退出了 break; } // 将客户端Socket交给新线程处理 ClientInfo* pInfo = new ClientInfo; pInfo->sock = sClient; memcpy(&pInfo->addr, &clientAddr, sizeof(clientAddr)); _beginthreadex(NULL, 0, ClientThread, pInfo, 0, NULL); } return 0; }3.4 数据接收、解析与客户端列表的管理
每个客户端线程的核心是一个recv循环。这个循环的退出条件有三个:对端正常关闭、对端异常断开、服务器自身要退出。每一种都要能正确识别并释放资源。
recv函数的返回值非常关键。返回0,说明对端正常关闭了连接,此时应该closesocket并退出循环。返回SOCKET_ERROR(即-1),说明连接出错,常见错误码有WSAECONNRESET,也就是对端没有正常关闭,直接发了RST包,这种错误在TCP里非常常见,比如对端程序直接崩溃、断电、或者主动调了setsockopt设置了SO_LINGER的强制关闭。遇到这些情况,唯一的正确处理就是closesocket,然后把这个客户端状态从列表里移除。
recv循环大致是这样:
char buf[2048]; while (bServerRunning) { int nRecv = recv(sClient, buf, sizeof(buf) - 1, 0); if (nRecv > 0) { buf[nRecv] = '\0'; // 这里是业务逻辑处理入口 ProcessRecvData(bytesBuf, nRecv); } else if (nRecv == 0) { // 客户端正常关闭 break; } else { // 出错,检查具体错误码 int err = WSAGetLastError(); if (err != WSAEWOULDBLOCK) { // 连接已经不可用 break; } } } closesocket(sClient);客户端列表的管理也是个容易出问题的地方。多个客户端线程可能会同时操作列表,如果不加锁,一个线程正在遍历列表,另一个线程在往里面加节点,程序会直接崩溃。这里我用的是CRITICAL_SECTION,这是Windows下最轻量级的线程同步机制,比互斥量开销小,适合这种临界区很短的操作场景。每次修改客户端列表,先EnterCriticalSection,处理完再LeaveCriticalSection,这个习惯一定要养成。
4. 把数据送进界面:跨线程更新的正确姿势
4.1 直接在子线程刷新控件的灾难
网络数据收发都在工作线程里,但界面控件的更新必须在主界面线程。新手最容易犯的错误,就是直接在网络线程里调用SetDlgItemText或者ListBox的AddString,我当时也这么干过,结果程序运行不稳定,偶尔闪退,而且是随机出现的,特别难排查。原因是MFC的窗口类控件并不是线程安全的,两个线程同时访问同一个控件对象,轻则刷新不及时、界面闪烁,重则直接触发断言或者内存读写冲突,程序崩溃。
有人可能会说,实际上我这么干过也没崩啊。那是你运气好,或者操作频率不够高。一旦网络流量上来,界面更新频繁,崩溃概率是指数上升的,而且这种问题在开发机上很难复现,到了现场才暴露,非常被动。
4.2 PostMessage自定义消息:网络线程与界面优雅解耦
正确的做法是让网络线程只负责把数据打包好,通过PostMessage投递消息给主窗口,然后由主窗口的消息处理函数去更新控件。PostMessage是非阻塞的,发送后立即返回,不会等待接收方处理。这跟SendMessage有本质区别,SendMessage要等接收方处理完消息后才返回,如果在网络线程里用SendMessage发消息给主界面线程,而主界面线程恰好又在等待网络线程的某个操作,就会死锁。
我自定义了一个消息,比如WM_USER + 100,用于日志刷新。消息的wParam和lParam可以携带数据指针。这里有个内存管理的坑:如果new了一个CString传给消息,接收方处理完后必须主动delete,否则每次发消息就泄漏一次内存。我后来统一用了一个简单约定:lParam传递CString指针,接收方delete。
// 网络线程中: CString* pLog = new CString(strLog); PostMessage(GetSafeHwnd(), WM_UPDATE_LOG, 0, (LPARAM)pLog); // 主窗口消息映射: ON_MESSAGE(WM_UPDATE_LOG, &CMyDlg::OnUpdateLog) LRESULT CMyDlg::OnUpdateLog(WPARAM wParam, LPARAM lParam) { CString* pLog = (CString*)lParam; m_listLog.AddString(*pLog); delete pLog; // 自动滚动到最后一行 m_listLog.SetTopIndex(m_listLog.GetCount() - 1); return 0; }这一套用下来,界面刷新跟网络收发完全解耦,子线程只管投递,界面线程根据消息处理的空闲情况去更新,从根上避免了跨线程操作控件的问题。
5. 实测中碰到的问题与排查记录
5.1 bind失败:端口被占用的典型报错
测试的时候我遇到过bind失败,返回的是WSAEADDRINUSE,错误码10048。当时系统提示的报错大概是什么“only one usage of each socket address”,大致就是一个端口只能被一个套接字绑定,除非设置了端口重用。这个问题的排查思路很固定,先用命令行确认端口被哪个进程占用:
netstat -ano | findstr 9500找到占用进程的PID后,再用任务管理器或者命令行查看是哪个程序。我记得有一次是程序没退干净,调试进程还在后台挂着,开发机上各种ide的调试器有时不会立刻替你把进程结束掉,就出现了端口占用。还有一种情况是,程序崩溃时没有调用closesocket,但系统回收Socket是需要一定时间的,如果你立刻重启程序就会bind失败。这种场景下,可以在bind之前调用setsockopt设置SO_REUSEADDR,允许Socket在TIME_WAIT状态时重新绑定端口:
int nOpt = 1; setsockopt(sListen, SOL_SOCKET, SO_REUSEADDR, (const char*)&nOpt, sizeof(nOpt));注意SO_REUSEADDR不是万能的,它解决的是TIME_WAIT状态下的端口重用问题,如果是别的进程正在占用端口,设置了也没用,必须先把占用释放掉。
5.2 recv返回0和-1的不同含义
我调试的时候发现,有的设备断网后,服务器这边并没有立刻感知到连接异常。这是因为TCP是一个面向连接的协议,但连接的“感知”是需要靠数据流动来确认的。如果应用层长时间不发送数据,TCP不会主动探测对方是否还活着。recv返回0,表示对端正常调用了closesocket,发了FIN包,这种是友好的关闭。而recv返回SOCKET_ERROR,错误码是WSAECONNRESET,表示对端发送了RST包,属于异常关闭,典型场景就是设备直接断电,或者程序崩溃。
处理这两种情况的逻辑其实是一样的:清理资源、移除客户端记录。但你在日志里最好区分一下,否则现场出问题的时候,你分不清到底是设备主动断开的还是意外掉线的。我在日志里就是这么写的:
if (nRecv == 0) { WriteLog("客户端[%s]正常断开连接", strIp); } else { WriteLog("客户端[%s]连接异常,错误码:%d", strIp, WSAGetLastError()); }5.3 TCP粘包和半包:简单够用的处理方案
做TCP服务器一定绕不开粘包和半包问题。设备上报的数据如果很短,而发送频率又高,TCP协议本身可能会把多个小数据包合并成一个,这就是粘包。相反的,一条完整的数据可能在中途被分片,recv一次只读到一半,这就是半包。解决思路通常有三种:固定长度、分隔符、带长度域的协议头。我这边设备协议定了简单的二进制格式:前4字节是整包长度,后面是数据体。收到数据后先解析出长度,再判断缓冲区里的数据是否足够,不够就继续recv,够了就切出来。
这个逻辑看着简单,代码写起来有一点小复杂,但属于TCP项目的基础功,值得认真实现一遍。如果协议设计成字符串,可以用换行符作为分隔符,每次recv后把接到的数据追加到一个缓冲字符串里,然后查找换行符,找到一条就解析一条,剩下不完整的保留在缓冲里等下次数据来再处理。我后来把这个缓冲逻辑封装成了一个简单的类,不管收多快的数据,都能稳定地切出完整消息。
5.4 线程退出时的资源清理顺序
这个坑我在最后清理阶段踩得很痛。程序要退出的时候,如果主线程直接return,监听线程和客户端线程还在阻塞的recv或accept里,程序就会出现无法退出的现象。正确的顺序是:先把bServerRunning这个全局标志置为false,然后调用closesocket关闭监听Socket,这样accept会立刻返回错误从而退出循环。对于每个客户端Socket,也要主动关闭,让阻塞在recv的线程立即返回。
有一种做法是给监听Socket设置非阻塞模式,然后在循环里检查bServerRunning标志,每100ms轮询一次。另一种做法是记录所有客户端Socket,退出时统一closesocket。我实际用下来,后者的可靠性更高,因为即使某个客户端线程卡在什么地方,只要对应的Socket被关闭,recv立刻会出错返回,线程就能顺利退出。
6. 测试、打包与交付后的一些体会
6.1 本地用telnet和Python脚本做压力测试
开发阶段,我用telnet做了最简单的连通性测试,确认端口能连上、数据能收发。telnet默认是给远程登录用的,但用来做TCP服务的调试也非常顺手,输入一行数据,服务器日志里就能看到,对快速验证协议很有帮助。
正式压测我用了Python脚本,一次性模拟100个客户端连接,每个客户端每隔几秒发送一条数据,重点观察服务器内存是否稳定、界面日志是否卡顿、客户端列表有没有异常。100个连接在真实场景里算是比较轻量的负载,但对验证线程模型和资源管理已经足够了。我还专门测试了客户端崩溃的场景,就是脚本里随机断开连接,看服务器能不能正确感知并把客户端记录清理掉,结果是基础模型能处理,但偶尔有内存增长,后来排查发现是某个字符串拼接的地方忘了释放CString指针,修复后就稳定了。
6.2 MFC项目打包和部署的注意点
最后是部署,MFC程序打包其实踩坑也不少。当时最省事的方式是在VS里把项目属性改成“在静态库中使用MFC”加“在静态库中使用ATL”,这样编译出来的EXE不用附带mfc120.dll、msvcr120.dll这些运行库,拷到目标机器上就能跑。代价是EXE体积会大一些,但对工控机来说完全无所谓。
如果目标机器和开发环境不在一个网络里,最稳妥的做法是拿一台干净的Windows虚拟机测一下EXE能不能直接运行,因为没装VS的环境里,缺运行库的问题暴露得最明显。另外,防火墙放行端口也是现场经常被忽略的点,Windows自带防火墙默认会拦截入站TCP连接,服务器跑起来以后客户端连不上,先看看防火墙有没有放行这个端口。我当时的做法是看现场能不能接受关闭防火墙,不能的话就在程序里用命令行netsh advfirewall firewall add rule命令添加放行规则,这样程序首次运行后自动配置好端口。
6.3 再聊聊MFC写网络程序的长期维护
这个项目做完到现在跑了一阵子,整体很稳定。回过头来看,用MFC写TCP服务器这件事,技术本身不新,但非常考验对操作系统网络机制和线程模型的理解。MFC负责界面和消息循环,Winsock负责网络收发,二者通过PostMessage这个桥梁连接起来,只要这个架构清晰了,剩下的就是业务逻辑的事。而且老工控机上很多存量软件都是MFC写的,学这套东西不是为了追求新,而是能实实在在解决眼前的问题。
我个人的体会是,别一听到MFC就觉得过时,很多时候不是技术新就适合现场。稳定性、可控性、部署简单,这些才是工业项目里最看重的指标。你如果也遇到类似的需求,不妨试试在MFC工程里嵌入一个Winsock的TCP服务器,按这个思路一步步来,踩坑的概率会小很多。
本文还有配套的精品资源,点击获取