这东西看着简单,做起来全是坑。我在嵌入式项目里用 Qt 做过一套温湿度显示系统,从传感器数据采集到界面实时刷新再到最终打包发布,整条链路走下来,踩了不少雷,也沉淀了不少经验。这篇文章就围绕这个题目,把我在实际开发中遇到的典型问题、当时的排查思路和最终采用的可行方案完整地拆出来讲。如果你的需求也是通过串口或 Modbus 读取温湿度传感器,在界面上实时显示并存储历史数据,那这篇文章应该能帮你少走很多弯路。
1. 系统框架设计:先别急着写界面,想清楚数据从哪来、到哪去
很多初学者拿到"温湿度显示系统"这个需求,第一反应是打开 Qt Designer 拖一个界面出来,放两个 LCD Number 控件,然后开始琢磨怎么让数字动起来。这个顺序其实是反的。仪表盘上的数字只是数据流的终点,真正决定系统好不好用的是数据从传感器到界面这条链路设计得是否健壮。
1.1 核心需求解析:一个温湿度系统的完整任务清单
我在动手之前会把需求拆成下面这几块,缺一不可:
| 模块 | 功能点 | 说明 |
|---|---|---|
| 数据采集 | 串口/Modbus通信 | 读取温湿度传感器或采集器的寄存器数据 |
| 数据解析 | 把裸报文转成浮点数值 | 涉及大小端、精度位、校验位 |
| 数据存储 | 实时入库 | SQLite 轮表,用于历史曲线和断线补传 |
| 界面显示 | 实时刷新 | 温度、湿度、采集时间、设备状态 |
| 曲线绘制 | 历史趋势 | 用 QCustomPlot 或 QChart |
| 系统设置 | 串口参数、采样周期、报警阈值 | 持久化到配置文件 |
| 报警提醒 | 越限告警 | 界面变色 + 声音提示 |
| 发布部署 | 打包成免安装程序 | 依赖库、驱动、配置文件一键部署 |
这套表基本覆盖了我做过的大部分采集类桌面程序。温湿度显示只是外在表现,真正费精力的是采集、解析、存储、刷新这几块。
1.2 框架选择:为什么这个场景我用 Widgets 而不是 QML
Qt 有两个界面框架:Qt Widgets 和 Qt Quick(QML)。做温湿度显示这类工业上位机软件,我强烈建议用 Widgets。
原因有三。第一,目标硬件通常是工控机,性能一般,Widgets 对 OpenGL 没有强依赖,兼容性更好;第二,这类系统的界面以表格、曲线、按钮、输入框为主,Widgets 的控件体系正好是干这个的,QML 的优势在动画和触摸交互,在这类场景用不上;第三,国内做上位机的工程师绝大多数用 Widgets,遇到问题搜解决方案会非常方便,这点在开发调试期特别重要。
至于 Qt 版本,我用的 5.15.2 LTS,稳定,网上资料多,6.x 现在虽然也成熟了,但第三方库和例程的覆盖面还是 5.15 更广。编译器选的 MSVC2019 64 位,做 Windows 发布部署时兼容性最好。
2. 设备通信与数据解析:Modbus 温湿度报文是怎么变成界面上的数字的
这节是整个项目技术含量最高的部分。传感器数据为什么读不到、为什么读出来是乱码、为什么数值偶尔跳变,几乎都出在这个环节。
2.1 不用第三方库,从零手写 Modbus RTU 主站通信
现在网上很多教程推荐直接上 QModbusMaster,说省事。但我自己的经历是,QModbus 在 5.15 上的稳定性够用,但如果你用的是某些国产温湿度传感器或采集器,它内置的 Modbus 从站地址和寄存器定义未必标准,反而用原始串口收发报文更容易调试。
这里说明一下,Modbus RTU 主站轮询的报文格式是很固定的:
设备地址(1字节) + 功能码(1字节) + 起始寄存器地址(2字节) + 寄存器数量(2字节) + CRC16校验(2字节)比如我要读设备地址为 0x01 的温湿度采集器,从寄存器 0x0000 开始连续读 2 个寄存器(一个放温度,一个放湿度),报文就是这样:
01 03 00 00 00 02 C4 0B设备收到后返回的数据格式是:
01 03 04 [温度高字节] [温度低字节] [湿度高字节] [湿度低字节] [CRC低] [CRC高]因为大多数工业传感器的温湿度值是以 0.01 为精度的定点数存储的,所以读到 0x0BB8 就是 3000,除以 100 得到 30.00 摄氏度;如果读到的温度值是带符号的(有负温度场景),你还需要先把 uint16 转成 int16 再除以精度系数,否则零下温度会显示成一个很大的正数。
我在串口类里写了一个QByteArray buildModbusRequest(int devId, int regAddr, int regCount)方法,专门负责组装报文和追加 CRC 校验。CRC 校验用查表法实现,性能好,代码也简洁。一定不能省 CRC,否则数据偶尔错一个字节,你要排查半天。
2.2 数据解析的细节坑:大小端、符号位、异常值
数据解析这块我踩过最狠的坑是字节序。某些传感器返回温度是高字节在前,某些是低字节在前。我写了一个辅助函数,支持一键切换大小端:
float parseHexData(const QByteArray &buf, bool bigEndian) { quint16 raw; if (bigEndian) { raw = static_cast<quint8>(buf[0]) << 8; raw += static_cast<quint8>(buf[1]); } else { raw = static_cast<quint8>(buf[1]) << 8; raw += static_cast<quint8>(buf[0]) + static_cast<quint8>(buf[0]) * 0; } // 处理负数情况:最高位是符号位的话,转为有符号数 qint16 signedRaw = static_cast<qint16>(raw); float value = signedRaw / 100.0f; // 精度为 0.01 return value; }注意上面代码里的* 0是我故意留的一个无用表达式,实际不要这样写。正确写法是先判断传感器约定是 unsigned 还是 signed,再决定如何转换。有的传感器温度范围是 -40~+80 摄氏度,它就是有符号的;纯正温度范围的传感器就直接当 unsigned 处理,省一步转换。
异常值过滤也必须做。在实际项目里,数据不是平滑变化的,传感器偶尔会返回 0xFFFF 或 0x7FFF 这类边界值。如果不过滤,界面上会出现瞬间跳变到几百度的离谱数据。我的做法是维护一个结构体,包含上次值和本次值,如果本次值和上次值差值超过可配置范围(默认温度差 5 摄氏度,湿度差 10%RH),就判定为异常帧,丢弃本次数据,等待下一次轮询,并累计错误计数,连续错误超过 10 帧则提示"设备异常"。
3. 数据存储与断线续传:SQLite 轮表的实际工程写法
很多人在 demo 阶段只做实时显示,一重启程序就回到初始状态。做真正能交付的系统,本地数据存储是躲不掉的。Qt 自带 QSqlTableModel 和 SQLite 驱动,不需要额外装数据库服务。
3.1 数据库建表与轮表机制
我在项目里用一个本地 SQLite 文件env_data.db,核心表结构如下:
CREATE TABLE IF NOT EXISTS env_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp TEXT NOT NULL, temperature REAL NOT NULL, humidity REAL NOT NULL, device_id INTEGER NOT NULL );定期往这张表插入数据的逻辑我放在串口读线程处理好数据之后,通过信号把解析好的数据帧发给主界面,主界面收到后调用一个appendRecord()方法完成入库和界面刷新。这里有个非常容易犯的错误:不要在串口读取线程里直接写数据库。SQLite 的写锁会导致线程阻塞,一旦数据库文件所在磁盘繁忙,整个数据采集线程会被拖死,串口缓冲区溢出,产生丢帧。
实际做法是,串口线程只负责读数据和解析,解析完的数据通过 signal/slot 发到界面线程,界面线程再做数据库写入。就算写入慢,也只是界面卡一下,不会影响底层数据采集。这里信号槽连接方式用默认的 AutoConnection 就行。
为了不让表无限膨胀,我还做了轮表操作:保留最近 90 天的数据,超过部分按日期删除,保留精度越久越好。删除语句我用的是:
DELETE FROM env_history WHERE timestamp < datetime('now', '-90 day');注意 SQLite 的datetime函数要求时间字符串格式统一。我在插入时用QDateTime::currentDateTime().toString("yyyy-MM-dd HH:mm:ss"),这样上面这条语句就能能正常工作。
3.2 断线缓存与补传逻辑
系统运行过程中难免出现传感器掉线,比如总线接触不良、从站设备断电。我的设计是掉线期间在内存里建一个 QQueue 缓存区,只缓存最近 500 条记录,每 5 秒一条的话能覆盖约 40 分钟的数据。
恢复通信之后,缓存区里的数据按时间顺序补写入数据库。如果缓存区满了就丢弃最旧的数据,界面状态栏提示"离线数据缓存已满,部分数据丢失"。这套逻辑的代码其实很短,关键是缓存队列的锁保护要做对。我用 QMutex 控制队列的 push 和 pop,避免界面线程和采集线程同时操作。
4. 界面构建与自定义控件:数字显示仪表盘和布线的门道
界面不是简单的"摆俩控件",而是要让人一眼看清当前环境状态。这部分我主要做了三个事:主界面布局、自动刷新策略、自定义圆形仪表盘控件。
4.1 主界面布局思路
我的主界面参考了工业组态软件的风格:左侧是设备连接状态区,中间是温湿度数值面板,下方是实时曲线和历史查询区。整个界面用 QGridLayout 布局,禁止用户在运行时拖拽改变大小,省去很多布局随窗口变化的适配工作。
数值面板我用的是几组 QGroupBox 套 QLabel 和自定义仪表盘控件,字体统一用微软雅黑,温度显示为橙色大号字体,湿度显示为蓝色,设备离线时显示灰色并置为 "--"。
顶部状态栏显示采集时间、串口状态和数据库状态。串口状态用 QLabel + setStyleSheet 实现,绿色表示正常、红色表示掉线。
4.2 自定义圆形仪表盘控件:重写 paintEvent 实现自定义绘制
需求方要求界面"像仪表盘一样"显示温湿度,我花了一个下午写了一个GaugeWidget控件,继承 QWidget,重写paintEvent。核心绘制逻辑分三步:
- 用
QPainter画一个半圆弧,作为刻度背景 - 根据范围值把当前数值映射到圆弧角度
- 在角度位置绘制指针和数值文本
这里贴一个关键片段,展示角度映射的核心写法:
void GaugeWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); QRectF rect(8, 8, width() - 16, height() - 16); int startAngle = 210 * 16; // 起始角度 210度 int spanAngle = -240 * 16; // 扫过 240度 painter.drawArc(rect, startAngle, spanAngle); // 把数值映射到角度范围:-60度到+60度之间 double ratio = (m_value - m_min) / (m_max - m_min); double angle = startAngle / 16.0 - 240.0 * ratio; // 根据角度计算指针末端坐标 double rad = qDegreesToRadians(angle); int lineLen = width() / 2 - 24; QPointF endPoint = center + QPointF(lineLen * cos(rad), -lineLen * sin(rad)); painter.drawLine(QLineF(center, endPoint)); }这里有个细节,drawArc的角度单位是 1/16 度,不是度,所以 210 要乘 16。很多新手直接传一个 210 进去,画出来的弧永远不对,这是 Qt 绘图一个特别经典的坑。
自定义控件写完后,记得在代码里直接ui->tempGauge->setRange(-20, 60)和setValue(25.5)就能用,比用 QSS 画仪表盘好维护得多。
4.3 关于 Qt 绘图的效率思考
热搜词里有个"qt绘图效率比较",我在系统初期也研究过。QPainter 绘制静态图形效率没问题,但在实时曲线这种高频刷新场景下,全量重绘会造成 CPU 占用虚高。我的做法是只把新增点追加到绘图区,利用 QWidget 的update()局部刷新策略,把刷新区域限定在曲线控件的右端缺口处,并且把曲线刷新频率控制在 5 帧/秒(即每个采样周期 200ms 重绘一次),人眼看起来已经很流畅,CPU 占用不到 10%。
5. 实时曲线刷新与界面卡顿优化:多线程和 QCustomPlot 的取舍
温湿度历史曲线这块需求基本都有。我一开始用的 QChart,后来换成了 QCustomPlot。不是 QChart 不好,而是 QCustomPlot 在大量数据点下的表现更稳,而且它本质上就是一个 QWidget,和 Widgets 界面的亲和力更好。
5.1 用 QCustomPlot 做温湿度双 Y 轴曲线
QCustomPlot 是第三方开源库,需要把 qcustomplot.h 和 qcustomplot.cpp 两个文件拷进工程,然后在 .pro 里加一行QT += printsupport,否则编译会报QPrinter相关的错误。这一步特别容易忽略。
我做的是双 Y 轴曲线,左边 Y 轴是温度,右边 Y 轴是湿度,X 轴是时间。关键设置代码如下:
ui->plot->xAxis->setBasePen(QPen(QColor(80, 80, 80))); ui->plot->yAxis->setLabel("温度(°C)"); ui->plot->yAxis2->setLabel("湿度(%RH)"); ui->plot->yAxis2->setVisible(true); ui->plot->yAxis2->setRange(0, 100); curveTemp = new QCPGraph(ui->plot->xAxis, ui->plot->yAxis); curveHum = new QCPGraph(ui->plot->xAxis, ui->plot->yAxis2); curveTemp->setPen(QPen(QColor(255, 140, 0), 2)); curveHum->setPen(QPen(QColor(0, 160, 255), 2)); ui->plot->xAxis->setRange(QDateTime::currentMSecsSinceEpoch() - 60000, QDateTime::currentMSecsSinceEpoch());QCustomPlot 的时间轴用QCPAxisTickerDateTime来设置显示格式,否则 X 轴会显示一串时间戳数字而不是"时分秒"。我在代码里做了格式化:
QSharedPointer<QCPAxisTickerDateTime> dateTicker(new QCPAxisTickerDateTime); dateTicker->setDateTimeFormat("HH:mm:ss"); ui->plot->xAxis->setTicker(dateTicker);5.2 曲线刷新能不能放独立线程?我的明确结论
很多人在网上问"qt曲线刷新能放在另一个线程里面吗",我明确回答:建议不要,也不要这么做。
QCustomPlot(以及 QWidget 体系下绝大多数控件)的绘制必须在 GUI 线程执行。如果你把曲线刷新逻辑直接丢进一个子线程,编译能过,运行大概率崩溃,或者界面直接白屏,Qt 会打印 "QObject::startTimer: Timers can only be used with threads started with QThread" 这类错误。
正确的架构是:采集线程把数据通过信号发出来,GUI 线程收到后把数据点 append 到 QCustomPlot 的容器里,然后调用replot()。replot 是整个曲线刷新里最耗时的操作,两个曲线的场景一帧也就几毫秒,完全没必要为此专门开线程。
真正影响界面流畅度的其实是两个隐藏问题:一是数据点无限增长导致 replot 越来越慢,二是两路曲线时间轴不同步。前者我用滚动窗口解决,只保留最近 20 分钟的点;后者我统一在收到数据时以系统当前时间作为 X 轴基准,不做设备本地时间,因为有些传感器的内部晶振不准,走它自己的时间会让曲线出现锯齿和回退。
6. 发布打包与常见维护问题:依赖缺失、串口权限和崩溃日志
开发机上跑得好好的程序,拿到现场工控机上双击就是各种问题。这节内容全都是我实际部署过的机器上拿到的反馈。
6.1 Qt 打包:windeployqt 是对的,但光靠它不够
Qt 提供了 windeployqt 工具,基本流程是:
cd /d D:\projects\build-EnvMon-Desktop_Qt_5_15_2_MSVC2019_64-Release\release D:\Qt\5.15.2\msvc2019_64\bin\windeployqt.exe EnvMon.exe它会自动把 Qt 相关的 DLL、插件、QSS 资源考进来。但有几个目录它是管不到的:
- 数据库驱动插件:
sqldrivers\qsqlite.dll,需要手动从 Qt 安装目录复制 - 如果你用了 QCustomPlot,那是纯源码,不需要额外库
- 串口相关插件其实不需要单独拷,因为 QtSerialPort 在核心库里
我用了一个小技巧:把 windeployqt 后生成的一大堆 DLL 分门别类整理好,再用 Enigma Virtual Box 打包成单个 exe,现场部署就是拷一个文件过去,避免因为缺少某个 DLL 被现场运维骂。
6.2 经典的 "cannot mix incompatible Qt library" 错误
热搜词里有一条 "cannot mix incompatible qt library (5.15.3) with this library (5.15.2)",这个错我在混合使用 Debug 和 Release 库时也遇到过。原因是程序链接的 Qt 版本和你运行环境里实际加载的 Qt 版本不一致,通常是你把 5.15.2 编译的程序,放到一个装了 5.15.3 运行库的机器上执行导致的。
排查思路是这样的:
- 确认编译用的 Qt 版本和 windeployqt 用的版本一致
- 检查环境变量 PATH 里有没有其他 Qt 路径干扰
- 用 Dependency Walker 或 Process Explorer 查看程序实际加载的 Qt5Core.dll 路径
最后我用 Process Explorer 查到程序加载的 Qt5Core.dll 来自系统 PATH 里的另一个安装目录,把那目录从 PATH 里清掉之后就正常了。
6.3 现场运行崩溃问题:定位和预防
热搜词里还有"qt崩溃",我现场遇到过的问题是程序在 Win7 工控机上双击没反应,进程直接消失。排查结论是缺 VC++ 运行库。MSVC 编译的 Qt 程序依赖 VC++ Redistributable,我在部署包根目录放了一个vcredist_x64.exe,让现场部署人员先装一遍再运行程序,问题就解决了。
为了防后续再出诡异崩溃,我加了 qInstallMessageHandler 全局日志,把 qDebug/qWarning/qCritical 输出写入本地 logs 文件,崩溃后能回看最后的打印信息,定位速度快很多。
7. 界面和业务设计上容易被忽略的几个细节
最后这节不说代码,说几件我在项目复盘时觉得"要是当时早点想到就好了"的事。
7.1 初始范围和历史数据的加载
曲线控件刚启动时,X 轴范围如果设为当前时间,会导致刚启动时显示的是空窗口,直到 20 分钟后才有曲线。改进方法是在程序启动时从数据库读取最近 60 秒的历史记录,一次性绘制在曲线上,这样操作人员一打开软件就能看到之前的趋势,不会觉得软件有问题。
7.2 报警灵敏度的设置
有些客户对报警要求很敏感,但又不想误报。我把报警判断做得保守了一点:连续 3 个采样周期都超过阈值才触发报警,中间只要有一次正常就清零计数。实际使用下来,误报率直线下降。
7.3 编译时使用 release 而非 debug
调试期用 debug 没问题,但交付给客户的一定要编译 release 版本。debug 版明显更慢,而且界面的表现和 release 不完全一致,某些奇怪的卡顿在 release 下可能根本不存在。我之前因为拿 debug 版本去演示被客户说"明显卡顿",换 release 之后完全改观。
关于温湿度显示系统,我目前这套方案从采集、解析、存储、显示到发布,已经形成了一条比较稳的流水线。你在做的过程中如果也遇到了界面刷新卡死、串口掉线、打包完跑不起来之类的问题,可以把具体现象发我,我帮你一起看是什么环节出的问题。