拓冰建站拓冰建站
首页 / 资讯中心 / 正文

MFC嵌入TCP服务器:Winsock编程与跨线程界面更新实战

简介这是一份基于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和CAsyncSocketMFC里面其实自带网络封装CSocket和CAsyncSocket都是现成的但我在这个项目里没有用它们而是直接用的Winsock API。主要原因有两个。第一CSocket的设计是以阻塞模式为主而且它内部是配合消息泵工作的在一个人机界面为主、网络流量又不大的程序里用CSocket反而会把简单的收发逻辑绕得很复杂。第二CAsyncSocket基于Window消息分发网络事件虽然是异步的但事件处理分散在多个消息响应里对于一个需要长期跑、逻辑要尽量集中的服务器来说调试起来不够直观。直接操作Winsock API相当于把网络部分的每一行代码都摊在自己面前。accept、recv、send、closesocket这些函数的行为是确定的网上资料也够多出了问题很容易定位。代价是要自己处理线程和界面刷新之间的同步但这部分本来就是MFC程序的常见工作躲不掉的。我后来在笔记里写了一句MFC写网络程序最麻烦的不是TCP本身而是TCP和界面线程之间的那点破事。2. 工程搭建从新建项目到第一个Socket2.1 开发环境与工程类型怎么选这个项目用的是VS2013MFC工程类型选的是“基于对话框”因为要做的服务器控制界面很简单不需要文档视图结构那一大套东西。如果你的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 初始化WinsockWSAStartup这一步为何省不掉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_INETIPv4第二个参数是类型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服务器按这个思路一步步来踩坑的概率会小很多。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门