1. 项目概述:这不是一个“写个界面+连个设备”的简单活儿
MFC + Modbus-TCP 实现温湿度传感器采集,听起来像教科书里的一行课后习题——但真把它跑通、跑稳、跑进产线用,我前后踩了三轮坑,重写了四版通信模块,才敢把这套东西交到现场工程师手里。它不是单纯调个CWinThread开个线程、拖个CListCtrl显示两行数字就完事的事。核心在于:MFC 的消息循环与 Windows 网络 I/O 模型天然存在张力,而 Modbus-TCP 又是个对超时、重试、帧完整性极度敏感的工业协议。你用CAsyncSocket做异步收发?容易丢包;用WSAEventSelect?MFC 消息泵不认这个;直接send()/recv()阻塞在主线程?界面秒变灰色,用户点十次按钮只响应最后一次。更别说 DHT11 这类单总线传感器根本不能走 Modbus-TCP——标题里写的“温湿度传感器”,实际工程中几乎全是 SHT30、SHT85、BME280 这类带 I²C/SPI 接口、再通过 Modbus-TCP 网关(比如某国产 DTU 或西门子 SIMATIC IOT2000)桥接上来的以太网设备。所以第一步必须厘清:你面对的不是传感器本体,而是一个运行在 192.168.1.100:502 上、支持功能码 03(读保持寄存器)的 Modbus-TCP 从站设备,它内部已把原始 ADC 值换算成 0.01℃/0.01%RH 格式存入寄存器 40001~40002。MFC 在这里干的活,是当好一个“协议翻译官+界面调度员+异常守门人”——把二进制报文解析成 float,把用户点击“刷新”变成标准 ADU 帧,把网络断开、从站无响应、CRC 校验失败这些工业现场天天见的烂事,转化成弹窗提示或状态栏变色。适合谁?不是刚学完《VC++ 编程入门》的学生,而是手头有 FX5U PLC 要对接、正被客户催着交数采上位机的自动化工程师;是产线 QA 需要每小时导出 CSV 报表、但又不想装 LabVIEW 的产线技术员;是想用自己熟悉的 C++ 和 MFC 快速搭个监控看板、拒绝 Electron 那套 JS 堆栈的嵌入式老手。它解决的从来不是“能不能连”,而是“连得稳不稳、断了能不能自恢复、数据错不错、界面卡不卡”这四个直击生产痛点的问题。
2. 整体架构设计:为什么必须绕开 MFC 默认的消息泵做网络层?
2.1 传统思路的致命缺陷:主线程阻塞 vs 工业实时性
很多初学者会直接在OnBnClickedBtnRefresh()里写:
// ❌ 危险示范:绝对禁止! SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); connect(sock, (sockaddr*)&addr, sizeof(addr)); send(sock, modbus_frame, len, 0); recv(sock, buf, sizeof(buf), 0); // 主线程卡死在这里!这在实验室点一次可能成功,但放到车间:PLC 网络偶尔抖动 200ms,你的界面就冻结 200ms;若从站掉电,recv()会等满系统默认超时(通常 60 秒),整个程序假死一分多钟。MFC 的CWinApp::Run()消息循环本质是GetMessage()→TranslateMessage()→DispatchMessage()的死循环,它只处理 Windows 消息(WM_PAINT、WM_COMMAND),对 SOCKET 事件完全无感。你用CAsyncSocket?它底层用WSAAsyncSelect()把网络事件转成WM_SOCKET消息,看似能进消息泵——但问题来了:CAsyncSocket的OnConnect()、OnReceive()回调是在非 UI 线程触发的,而 MFC 控件(CListCtrl、CEdit)只能由创建它的线程(即 UI 线程)安全访问。你直接在OnReceive()里m_listCtrl.InsertItem(),十次有八次崩在AfxAssertValidObject断言上。这是 MFC 与 Winsock 二十年来最经典的冲突点,不是 bug,是设计哲学差异:MFC 为 GUI 而生,Winsock 为网络而生。
2.2 我们采用的方案:Worker Thread + PostMessage 桥接
最终落地的架构是三层解耦:
- 网络层(Worker Thread):独立
CWinThread承载,使用select()+ 非阻塞 socket,严格控制超时(读超时设为 1500ms,写超时 800ms),实现“发帧→等响应→超时重试(最多2次)→解析→打包结果”闭环; - 通信层(PostMessage 桥梁):Worker Thread 解析出
float temp = 25.3f, float humi = 62.7f后,调用PostMessage(m_hWnd, WM_MODBUS_DATA, (WPARAM)&data, 0),把数据地址发给主窗口; - UI 层(MFC 控件更新):主窗口在
ON_MESSAGE(WM_MODBUS_DATA)处理函数中,安全地更新CStatic文本、CProgressCtrl进度条(湿度可视化)、CListCtrl历史记录。
提示:
PostMessage是关键。它把数据传递行为变成“投递消息”,UI 线程在下一个消息循环中自然处理,彻底规避跨线程控件访问风险。别用SendMessage——它会同步等待,Worker Thread 卡在 UI 线程里,等于又回到阻塞原点。
2.3 为什么不用第三方库?比如 libmodbus 或 QModbus?
libmodbus 确实成熟,但引入它意味着:
- 需要额外编译
.lib,VS2010/2015/2017 工程配置易出错; - 它的
modbus_tcp_new()返回modbus_t*,和 MFC 的CWnd生命周期管理不兼容; - 错误码
ECONNREFUSED、ETIMEDOUT需要映射成 MFCAfxMessageBox可读的中文提示,中间层代码量不比手写少。
而手写 Modbus-TCP ADU 帧,其实就 12 字节固定结构:
[事务标识符 2B] [协议标识符 2B] [长度 2B] [单元标识符 1B] [功能码 1B] [起始地址 2B] [寄存器数量 2B]例如读 40001~40002(2个寄存器):
00 01 00 00 00 06 01 03 00 00 00 02 ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ TS PI Len UID FC SA QR自己拼包,可控性强,调试时抓包 Wireshark 一眼比对,比查第三方库文档快得多。QModbus 更是 Qt 生态专属,硬塞进 MFC 工程等于给自己挖坑。
2.4 关键决策:选择 CWinThread 而非 _beginthreadex
_beginthreadex创建的纯 C 运行时线程,无法使用 MFC 的AfxGetMainWnd()、AfxMessageBox()等,错误提示只能写日志文件,用户根本看不到。CWinThread继承自CObject,自带m_bAutoDelete、m_pThreadParams,且AfxBeginThread()创建后,线程内可安全调用AfxGetApp()->m_pMainWnd获取主窗口句柄——这是实现PostMessage桥接的前提。我们定义线程参数结构体:
struct ModbusThreadParam { HWND hWnd; // 主窗口句柄,用于 PostMessage CString ip; // 192.168.1.100 UINT port; // 502 WORD startAddr; // 40001 → 0x0000 WORD regCount; // 2 };启动线程:
ModbusThreadParam* pParam = new ModbusThreadParam{m_hWnd, _T("192.168.1.100"), 502, 0, 2}; AfxBeginThread(ModbusWorkerThread, pParam, THREAD_PRIORITY_NORMAL, 0, CREATE_SUSPENDED, NULL);3. 核心细节解析:Modbus-TCP 帧构造、解析与 MFC 控件联动
3.1 Modbus-TCP 请求帧的手工构造逻辑
Modbus-TCP 不需要校验码(CRC),但必须严格遵循 ADU(Application Data Unit)格式。重点在于:
- 事务标识符(Transaction ID):必须每次请求递增,用于匹配响应帧。我们用
static WORD s_transId = 0; s_transId++;全局静态变量维护,避免多线程竞争(Worker Thread 是单例,无需锁); - 协议标识符(Protocol ID):固定
0x0000,表示 Modbus 协议; - 长度字段(Length):表示后续字节数(单元标识符 + 功能码 + 数据),此处为
0x0006(1B UID + 1B FC + 2B SA + 2B QR = 6B); - 单元标识符(Unit ID):Modbus-RTU 中的 slave ID,TCP 中常设为
0x01,部分网关要求0x00,需查设备手册; - 功能码(Function Code):
0x03读保持寄存器; - 起始地址(Starting Address):寄存器地址减 1。40001 →
0x0000,40002 →0x0001,所以读 40001~40002,SA =0x0000; - 寄存器数量(Quantity of Registers):
0x0002。
构造代码(关键片段):
BYTE frame[12]; frame[0] = HIBYTE(s_transId); // 事务标识符高字节 frame[1] = LOBYTE(s_transId); // 事务标识符低字节 frame[2] = 0x00; frame[3] = 0x00; // 协议标识符 frame[4] = 0x00; frame[5] = 0x06; // 长度:6字节 frame[6] = 0x01; // 单元标识符 frame[7] = 0x03; // 功能码:读保持寄存器 frame[8] = HIBYTE(startAddr); // 起始地址高字节 frame[9] = LOBYTE(startAddr); // 起始地址低字节 frame[10] = HIBYTE(regCount); // 寄存器数量高字节 frame[11] = LOBYTE(regCount); // 寄存器数量低字节注意:
HIBYTE/LOBYTE是 Windows SDK 宏,#include <windows.h>即可。别用>>8 & 0xFF手动移位,易出错。
3.2 响应帧解析:如何从 15 字节中精准提取温度值?
正常响应帧结构:
[事务标识符 2B] [协议标识符 2B] [长度 2B] [单元标识符 1B] [功能码 1B] [字节数 1B] [寄存器值 N×2B]例如返回 25.3℃(0x00FA = 250 → 25.0℃?不对!),这里有个大坑:Modbus 寄存器存的是整数倍,需按设备手册比例换算。SHT30 网关常见设置:
- 温度寄存器 40001:值 × 0.01 = ℃,即 2530 → 25.30℃;
- 湿度寄存器 40002:值 × 0.01 = %RH,即 6270 → 62.70%RH。
所以响应帧后 4 字节(2个寄存器 × 2字节):
... [02] [09 FA] [18 7E] ↑↑ ↑↑ T=2530 H=6270解析代码:
// 响应帧 buf[0]~buf[14],共15字节 WORD tempRaw = MAKEWORD(buf[9], buf[8]); // buf[8]=0xFA, buf[9]=0x09 → 0x09FA = 2554 WORD humiRaw = MAKEWORD(buf[11], buf[10]); // buf[10]=0x7E, buf[11]=0x18 → 0x187E = 6270 float temperature = tempRaw * 0.01f; float humidity = humiRaw * 0.01f;MAKEWORD(low, high)是关键,它把高低字节正确组合成WORD。千万别写成buf[8] | (buf[9]<<8)——字节序搞反,数据全错。
3.3 MFC 控件联动:让 CListCtrl 显示带时间戳的历史记录
CListCtrl是显示历史数据的首选,但默认不支持自动滚动到底部、不支持列宽自适应、不支持数值右对齐。我们封装一个CModbusListCtrl类继承CListCtrl,重写InsertItem:
int CModbusListCtrl::InsertData(float temp, float humi) { SYSTEMTIME st; GetLocalTime(&st); CString timeStr; timeStr.Format(_T("%04d-%02d-%02d %02d:%02d:%02d"), st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond); int index = InsertItem(GetItemCount(), timeStr); // 第一列:时间 SetItemText(index, 1, CString().Format(_T("%.2f"), temp)); // 第二列:温度 SetItemText(index, 2, CString().Format(_T("%.2f"), humi)); // 第三列:湿度 // 自动滚动到底部 if (index >= 0) { EnsureVisible(index, FALSE); } return index; }在主窗口OnModbusData()中调用:
void CMainFrame::OnModbusData(WPARAM wParam, LPARAM lParam) { ModbusData* pData = (ModbusData*)wParam; m_listHistory.InsertData(pData->temperature, pData->humidity); m_staticTemp.SetWindowText(CString().Format(_T("%.1f℃"), pData->temperature)); m_staticHumi.SetWindowText(CString().Format(_T("%.1f%%RH"), pData->humidity)); }实操心得:
EnsureVisible(index, FALSE)的FALSE参数很重要,它表示“不滚动到顶部”,只确保可见即可,避免频繁刷新时列表疯狂跳动。另外,CListCtrl列宽必须手动设置,否则第一列时间戳会挤占所有空间。在OnInitDialog()或OnCreate()中加:
m_listHistory.SetColumnWidth(0, 150); // 时间列 m_listHistory.SetColumnWidth(1, 80); // 温度列 m_listHistory.SetColumnWidth(2, 80); // 湿度列3.4 状态可视化:用 CProgressCtrl 直观显示湿度百分比
CProgressCtrl默认最大值 100,最小值 0,完美匹配湿度 0~100%RH。但要注意:
- 必须在对话框资源中勾选 “Smooth” 属性,否则进度条是阶梯状;
- 更新时用
SetPos((int)humidity),而非SetRange(0, 100)——后者会重置当前值; - 若湿度超限(>100%),
SetPos(100)即可,视觉上顶格显示,比弹窗警告更直观。
在OnModbusData()中追加:
m_progressHumi.SetPos((int)pData->humidity); // 颜色随湿度变化:干燥(<30%)蓝色,舒适(30~70%)绿色,潮湿(>70%)橙色 if (pData->humidity < 30.0f) { m_progressHumi.SendMessage(PBM_SETBARCOLOR, 0, RGB(64, 153, 255)); } else if (pData->humidity <= 70.0f) { m_progressHumi.SendMessage(PBM_SETBARCOLOR, 0, RGB(0, 192, 0)); } else { m_progressHumi.SendMessage(PBM_SETBARCOLOR, 0, RGB(255, 128, 0)); }PBM_SETBARCOLOR是 Windows 7+ 支持的私有消息,需在OnInitDialog()中先m_progressHumi.ModifyStyle(0, PBS_SMOOTH);启用平滑模式。
4. 实操过程详解:从零搭建可运行工程的完整步骤
4.1 环境准备:VS2010/2015/2017 工程配置要点
我们以 VS2015 为例(兼容性最好),新建 MFC Application:
- 应用程序类型:基于对话框(Dialog based),不要选“单文档”——对话框更轻量,适合监控类工具;
- 高级功能:勾选“ActiveX 控件”(后续可能加 WebBrowser 查看趋势图)、“Windows Sockets”(关键!自动添加
#include <winsock2.h>和ws2_32.lib); - 其他选项:取消“使用 Unicode 库”(若需兼容老旧设备,用多字节字符集更稳妥)。
注意:
#include <winsock2.h>必须在#include <windows.h>之前,否则会因头文件顺序导致SOCKET重定义错误。这是 VS 里最经典的头文件陷阱。
4.2 添加网络线程:CWinThread 的完整实现
在MainFrm.cpp(或Dlg.cpp)中添加线程函数:
UINT __cdecl ModbusWorkerThread(LPVOID pParam) { ModbusThreadParam* p = (ModbusThreadParam*)pParam; if (!p) return 1; // 初始化 Winsock WSADATA wsaData; if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) { PostMessage(p->hWnd, WM_MODBUS_ERROR, 0, (LPARAM)_T("Winsock初始化失败")); delete p; return 1; } SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (sock == INVALID_SOCKET) { PostMessage(p->hWnd, WM_MODBUS_ERROR, 0, (LPARAM)_T("Socket创建失败")); WSACleanup(); delete p; return 1; } // 设置非阻塞 u_long nonBlocking = 1; ioctlsocket(sock, FIONBIO, &nonBlocking); sockaddr_in addr; addr.sin_family = AF_INET; addr.sin_port = htons(p->port); addr.sin_addr.s_addr = inet_addr(CT2A(p->ip)); if (connect(sock, (sockaddr*)&addr, sizeof(addr)) == SOCKET_ERROR) { if (WSAGetLastError() != WSAEWOULDBLOCK) { PostMessage(p->hWnd, WM_MODBUS_ERROR, 0, (LPARAM)_T("连接服务器失败")); closesocket(sock); WSACleanup(); delete p; return 1; } } // 发送请求帧 BYTE frame[12]; // ... 构造 frame 代码(见 3.1 节)... send(sock, (char*)frame, sizeof(frame), 0); // select 等待响应 fd_set readfds; timeval timeout = {1, 500000}; // 1.5秒超时 FD_ZERO(&readfds); FD_SET(sock, &readfds); int ret = select(0, &readfds, NULL, NULL, &timeout); if (ret == 0) { PostMessage(p->hWnd, WM_MODBUS_TIMEOUT, 0, 0); closesocket(sock); WSACleanup(); delete p; return 1; } else if (ret == SOCKET_ERROR) { PostMessage(p->hWnd, WM_MODBUS_ERROR, 0, (LPARAM)_T("网络I/O错误")); closesocket(sock); WSACleanup(); delete p; return 1; } // recv 响应 BYTE buf[256]; int recvLen = recv(sock, (char*)buf, sizeof(buf)-1, 0); if (recvLen <= 0) { PostMessage(p->hWnd, WM_MODBUS_ERROR, 0, (LPARAM)_T("接收数据失败")); closesocket(sock); WSACleanup(); delete p; return 1; } buf[recvLen] = '\0'; // 解析数据(见 3.2 节) ModbusData data; data.temperature = ...; data.humidity = ...; PostMessage(p->hWnd, WM_MODBUS_DATA, (WPARAM)&data, 0); closesocket(sock); WSACleanup(); delete p; return 0; }实操心得:
select()的第一个参数在 Windows 下必须为 0,和 Linux 不同;timeval的tv_usec是微秒,500000= 0.5 秒,加上tv_sec=1,总超时 1.5 秒,这是工业现场经验值——太短(<1s)易误判,太长(>3s)影响刷新频率。
4.3 UI 布局与控件关联:对话框资源编辑技巧
在IDD_MAIN_DIALOG中拖入:
CStatic:IDIDC_STATIC_TEMP,Caption “--℃”,字体加粗,字号 16;CStatic:IDIDC_STATIC_HUMI,Caption “--%RH”,同上;CProgressCtrl:IDIDC_PROGRESS_HUMI,属性勾选 “Smooth”;CListCtrl:IDIDC_LIST_HISTORY,View 属性设为 “Report”,插入三列(时间、温度、湿度);CButton:IDIDC_BTN_REFRESH,Caption “手动刷新”;CButton:IDIDC_BTN_AUTO,Caption “自动采集(5s)”,用SetCheck(BST_CHECKED)默认开启。
关联控件变量:
CStatic m_staticTemp;CStatic m_staticHumi;CProgressCtrl m_progressHumi;CListCtrl m_listHistory;CButton m_btnAuto;
注意:
CListCtrl的InsertColumn()必须在OnInitDialog()中执行,否则列标题不显示:
m_listHistory.InsertColumn(0, _T("采集时间"), LVCFMT_LEFT, 150); m_listHistory.InsertColumn(1, _T("温度(℃)"), LVCFMT_RIGHT, 80); m_listHistory.InsertColumn(2, _T("湿度(%RH)"), LVCFMT_RIGHT, 80);4.4 消息映射与事件处理:WM_MODBUS_DATA 的完整链路
在MainFrm.h(或Dlg.h)中定义消息:
#define WM_MODBUS_DATA (WM_USER + 101) #define WM_MODBUS_ERROR (WM_USER + 102) #define WM_MODBUS_TIMEOUT (WM_USER + 103)在MainFrm.cpp的消息映射中添加:
BEGIN_MESSAGE_MAP(CMainFrame, CFrameWnd) // ... 其他映射 ON_MESSAGE(WM_MODBUS_DATA, &CMainFrame::OnModbusData) ON_MESSAGE(WM_MODBUS_ERROR, &CMainFrame::OnModbusError) ON_MESSAGE(WM_MODBUS_TIMEOUT, &CMainFrame::OnModbusTimeout) END_MESSAGE_MAP()OnModbusData()实现见 3.3 节;OnModbusError()和OnModbusTimeout()用AfxMessageBox弹窗,并记录到CStatusBar:
void CMainFrame::OnModbusError(WPARAM wParam, LPARAM lParam) { CString msg = (LPCTSTR)lParam; AfxMessageBox(msg); m_wndStatusBar.SetPaneText(1, msg); // 状态栏第二格显示错误 }实操心得:状态栏
SetPaneText()比弹窗更友好——用户不会因频繁错误中断操作,但又能实时看到问题。我们把状态栏分三格:左格显示“就绪”,中格显示错误,右格显示 IP 地址,形成完整信息闭环。
4.5 自动采集定时器:用 SetTimer 实现 5 秒轮询
在OnInitDialog()中启动定时器:
SetTimer(1, 5000, NULL); // ID=1, 间隔=5000ms在OnTimer()中触发采集:
void CMainFrame::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == 1 && m_btnAuto.GetCheck()) { // 启动 Worker Thread ModbusThreadParam* pParam = new ModbusThreadParam{ m_hWnd, m_strIP, m_nPort, 0, 2 }; AfxBeginThread(ModbusWorkerThread, pParam, THREAD_PRIORITY_NORMAL, 0, 0, NULL); } CDialogEx::OnTimer(nIDEvent); }注意:
SetTimer的nIDEvent必须唯一,多个定时器需不同 ID;m_btnAuto.GetCheck()是复选框状态,用户可随时关闭自动采集。
5. 常见问题与排查技巧实录:那些让工程师熬夜的坑
5.1 网络层典型问题速查表
| 问题现象 | 可能原因 | 排查命令/工具 | 解决方案 |
|---|---|---|---|
connect()返回WSAECONNREFUSED | 从站未运行、防火墙拦截、端口非 502 | telnet 192.168.1.100 502 | 检查网关设备电源、网线、IP 配置;关闭 Windows 防火墙或添加入站规则 |
select()返回 0(超时) | 网络延迟高、从站响应慢、IP 地址错误 | ping 192.168.1.100,Wireshark抓包 | 增大timeval超时值至 2.5 秒;确认设备手册中响应时间规格 |
recv()返回WSAEWOULDBLOCK | 非阻塞 socket 无数据可读 | Wireshark观察是否有响应帧发出 | 此为正常现象,select()已保证有数据才recv,此错误不应出现,说明select()逻辑有误 |
recv()返回值 < 预期长度(如只收到 8 字节) | TCP 粘包、从站发送不完整 | Wireshark查看 TCP Stream | 在recv()后加循环,直到收满expectedLen字节,或检查从站固件版本 |
提示:Wireshark 过滤 Modbus-TCP 流量用
tcp.port==502,非常高效。看到请求帧发出但无响应帧,基本锁定网络或从站问题;看到响应帧但recv()收不全,就是粘包处理缺失。
5.2 MFC UI 层高频故障与修复
问题:
CListCtrl插入数据后不显示,或列标题空白
原因:未调用InsertColumn(),或View属性未设为Report。
修复:在OnInitDialog()中补全InsertColumn(),并在资源编辑器中右键CListCtrl→ Properties → View → Report。问题:
PostMessage(WM_MODBUS_DATA)后OnModbusData()不触发
原因:消息 ID 定义在.h文件中,但.cpp文件未#include该头文件;或ON_MESSAGE映射未添加。
修复:检查#include "MainFrm.h"是否存在;用Class Wizard(VS2015 右键类名 → Add Message Handler)重新生成映射。问题:
CProgressCtrl颜色不生效,始终蓝色
原因:未启用PBS_SMOOTH样式,或PBM_SETBARCOLOR消息在 Windows XP 下不可用。
修复:在OnInitDialog()中m_progressHumi.ModifyStyle(0, PBS_SMOOTH);若需 XP 兼容,改用CImageList绘制自定义进度条。
5.3 Modbus 协议层致命陷阱
陷阱1:寄存器地址换算错误
40001 是“逻辑地址”,Modbus 协议中叫“保持寄存器 0”,对应帧中0x0000。有人写成0x40001(0x9C41),导致send()发出乱码帧。
修复:牢记公式帧中地址 = 逻辑地址 - 40001,40001→0x0000,40002→0x0001,40003→0x0002。陷阱2:字节序(Endianness)混淆
SHT30 网关返回0x09FA表示 2554,但若误认为0xFA09,则得 64009,温度显示 640.09℃,明显荒谬。
修复:用MAKEWORD(low, high),永远以buf[8]为低字节,buf[9]为高字节。陷阱3:功能码与寄存器类型不匹配
对只读输入寄存器(3xxxx)误用功能码0x03(读保持寄存器),从站返回异常响应0x83(非法功能)。
修复:查设备手册,确认寄存器类型:4xxxx 是保持寄存器(0x03),3xxxx 是输入寄存器(0x04)。
5.4 性能优化实战技巧
技巧1:减少
PostMessage频率
自动采集设为 5 秒,但若用户快速点击 10 次“刷新”,会启动 10 个线程。在OnBnClickedBtnRefresh()中加锁:static BOOL s_isWorking = FALSE; if (s_isWorking) return; s_isWorking = TRUE; // ... 启动线程 ... // 在 OnModbusData() 结尾加 s_isWorking = FALSE;技巧2:
CListCtrl大量数据卡顿
历史记录超 1000 行时,InsertItem()变慢。启用虚拟模式:m_listHistory.ModifyStyle(LVS_TYPEMASK, LVS_REPORT | LVS_OWNERDATA);然后重写
OnRetrieveVirtualItem(),只加载可视区域数据,内存占用直降 80%。技巧3:防止界面假死的终极方案
在OnTimer()中不直接启动线程,而用PostMessage(WM_START_ACQUIRE),让 UI 线程在空闲时(OnIdle())再启动 Worker Thread,彻底释放 UI 线程压力。
6. 扩展可能性:从单点采集到小型 SCADA 系统
这套 MFC + Modbus-TCP 框架,绝不止于显示两个数字。我把它用在三个真实场景中:
- 产线环境监控:接入 8 个 SHT30 网关(IP 192.168.1.101~108),每个网关挂 4 个传感器,
CListCtrl改为CGridCtrl(开源 GridCtrl),行列冻结,支持按温度排序,导出 Excel 用libxl; - PLC 数据透传:FX5U 的 Modbus-TCP 从站功能开启后,MFC 上位机直接读取
D1000(温度