我最早做这个Qt温湿度显示系统,完全是帮实验室同事救急。对方买了一批USB转串口的温湿度传感器,厂家给的上位机又老又难用,曲线能看但没法导出数据。想着自己写一个也没多难,结果从串口读到曲线刷新再到打包发给别人用,前前后后踩了不少坑。这篇文章就把整个项目从需求拆解到最终落地的完整过程复盘一遍,涉及QSerialPort串口通信、多线程数据采集、QChart实时绘图、Qt打包发布这些高频知识点,适合正在学Qt或者准备做类似上位机项目的朋友参考,按照这里面讲的思路来,能少走很多弯路。
1. 先从需求说起:一个温湿度显示系统到底要做什么
1.1 核心功能拆解
很多人一上来就直接打开Qt Creator拖控件,我建议先把需求想清楚。一个标准的温湿度显示系统,核心功能其实是这几块:实时采集温度、湿度数据;把数据以数字和曲线形式展示出来;超限时给出告警;历史数据能保存和回看。
听起来不复杂,但每一块都有细节。比如实时采集,你要决定数据是从串口来、网口来还是USB HID来。这次项目用的是串口传感器,所以通信方式就定了QSerialPort。再比如数据显示,一个QLabel谁都会写,问题在于数据量上来以后,曲线怎么刷新才能不卡界面,这就是很多人说的“Qt曲线刷新能放在另一个线程里面吗”这个问题的来源。
界面布局也需要提前想清楚:温度湿度各一个实时数值显示区域,一个QChartView放曲线,底部是日志和告警信息区。我见过有人做了两三个界面来回切换,反而麻烦。单窗口多区域是这类上位机最舒服的布局。
1.2 技术选型:为什么是Qt而不是Web前端
现在做个可视化系统,很多人第一反应是Vue、ECharts加个后端,但一碰到串口通信就绕远了——浏览器没法直接读串口,需要用中间服务转发,或者依赖Web Serial这种新API,兼容性和稳定性都有限。
Qt的优势在于它是一个真正意义上的桌面原生框架。C++直接操作串口、文件、系统底层资源,性能开销低;信号槽机制让异步数据传递非常顺手;QChart、QCustomPlot这类绘图库成熟稳定。对温湿度采集这种需要长时间运行、低延迟响应、稳定连接传感器硬件的场景,Qt是更省心的选择。
另外Qt跨平台特性也值得一提。你在Windows上开发完,代码几乎不用改就能在Linux下重新编译。很多工业现场的上位机都跑在Windows工控机上,但也不排除有人部署在Ubuntu的嵌入式设备上,用Qt意味着这些场景都能覆盖。
1.3 架构设计:界面线程与采集线程要分开
这是整个项目最关键的一个设计决策。串口读取数据、解析数据帧,这些操作绝对不能放在界面主线程里做。原因很简单:一旦串口数据量大或者解析逻辑里出现阻塞(比如同步等待设备响应),界面刷新就会卡住,用户拖窗口、点按钮都没反应,体验非常差。
我这次采用了典型的“采集线程 + 主界面线程”模型:把QSerialPort对象moveToThread到一个QThread子线程中,在子线程里接收串口数据、校验帧头帧尾、解析温度湿度值,然后通过信号把解析好的数据发回主线程;主线程只负责接收数据并刷新UI。
这里有个容易踩的坑:QSerialPort本身是异步的,它发出readyRead信号后,如果你在槽函数里做复杂的解析或耗时处理,照样会阻塞当前线程。所以串口对象所在线程的优先级事件循环要保持轻量,重活可以再拆出去,或者用队列缓存数据。
2. 环境搭建与基础工程准备
2.1 Qt版本和编译器的选择
说到Qt环境,很多人一上来就卡壳。这次项目我用的版本是Qt 5.15.2,装的是MSVC2019 64位套件。为什么不选Qt 6?原因很实际:Qt 5.15.2是目前兼容性极好的LTS版本,网上的教程、现成的示例代码最多,遇到问题搜索一下就有答案,适合项目开发;Qt 6虽然更新,但有些第三方串口库、旧代码风格需要额外适配,没必要在练手项目里折腾。
编译器方面,Windows下就是MSVC和MinGW二选一。我推荐MSVC,因为打包出来的程序在目标机器上的运行时依赖更可控,性能也略好。MinGW胜在开箱即用,不用装Visual Studio,但个别模块(比如某些Qt插件)可能遇到兼容问题。如果你用的是Qt 5.15.2,记得MSVC2019对应的运行库要装全,否则发布后对方机器上全是“找不到MSVCP140.dll”这类报错。
2.2 新建工程的配置细节
用Qt Creator新建Widgets Application后,我习惯先把.pro文件里需要的模块声明齐了。温湿度显示系统至少需要:
QT += core gui serialport charts注意,默认模板里通常只有core和gui,serialport和charts需要你手动加。不加的话,include相关头文件时直接报错。很多人问“为什么我用了QChart却编译不过”,99%就是.pro里少写了charts。
创建主窗口类时,我建议基类选QMainWindow而不是QWidget。QMainWindow自带菜单栏、状态栏、Docker布局体系,后期加菜单(比如“导出数据”“打开配置文件”)会非常方便。项目结构上,尽量把串口通信、数据解析、绘图分别独立成类,比如ComThread、ModbusParser、ChartWidget,这样分工明确,出了问题也好排查。
2.3 串口通信与Modbus协议接入准备
温湿度传感器常用的通信协议有Modbus RTU和自由文本协议两大类。Modbus RTU在工业场景里非常普遍,几乎每个PLC和数据采集模块都支持;文本协议则常见于一些低成本温湿度传感器,输出类似“T:25.6 H:58.3”这样的ASCII字符串。
这次项目里我先做了自由文本协议的解析,逻辑简单,调试方便。后面专门预留了一个ModbusParser类,用来对接Modbus RTU的03功能码(读保持寄存器)。如果你用的是Modbus设备,需要在界面里让用户配置从站地址、寄存器起始地址、寄存器数量,然后组帧发送,收到响应后再按字节解析。这部分代码比较机械,但一定要把超时处理和CRC校验写对,否则通信质量会非常不稳定。
3. 数据采集与多线程设计
3.1 为什么采集必须放到单独的线程
我见过很多新手写的上位机,直接在mainwindow里new一个QSerialPort,然后连接readyRead信号刷新UI。小数据量、低频率下这么写勉强能跑,但一旦波特率提高、数据帧变长,或者你引入类似Modbus的请求-应答机制,主线程就很容易阻塞。界面卡顿只是表象,更严重的是串口接收缓冲区溢出,丢帧丢到怀疑人生。
放到子线程后,采集、拼包、解析这些琐碎工作在后台完成,主线程只做轻量的刷新动作,两者通过信号槽通信。Qt的跨线程信号槽默认走队列连接,也就是发送和接收不在同一线程时,信号会自动排队投递,接收方在自己的事件循环里处理。这正好是一个天然的线程安全机制,比你自己加锁传递数据简单可靠得多。
3.2 串口数据读取与粘包半包处理
串口数据是一个字节一个字节到缓冲区里的,你没办法保证一次readyRead收到的就是一整帧。所以解析数据时必须有“黏包/半包”处理思路:维护一个QByteArray缓存,每次收到新数据就累加到缓存尾部,然后循环检查缓存里是否有完整的一帧数据,有就切出来解析,没有就等下次。
我用的文本协议帧格式是这样约定的:
[HEAD][温度值][湿度值][CRC][TAIL] AA 55 19 0C 25.6 58.3 ...简单文本版就是每次以“\r\n”作为结束符,比如“T:25.6,H:58.3\r\n”。读取逻辑大致是:
void SerialWorker::onReadyRead() { buffer.append(serial->readAll()); while (buffer.contains('\n')) { int pos = buffer.indexOf('\n'); QByteArray line = buffer.left(pos).trimmed(); buffer.remove(0, pos + 1); parseLine(line); } }这就是典型的按行切帧,简单可靠。如果你用Modbus RTU,那帧判断要按字节间隔计算:接收完一帧后,判断后续3.5个字符时间内是否还有新数据,没有则认为一帧结束。这块写起来更讲究一些,但理解了上面的缓存思路,Modbus协议也不难实现。
3.3 信号槽跨线程传递数据
子线程解析完数据后,通过信号把温度、湿度值发出去。这里有一个重要原则:跨线程传数据,要用值传递,不要用指针传递。
class SerialWorker : public QObject { Q_OBJECT signals: void dataReceived(double temp, double humi); public slots: void onReadyRead(); };主线程MainWindow里连接这个信号,然后在槽函数里更新界面。Qt的信号槽机制会保证这个信号安全地跨线程投递,数据以值拷贝方式传递,不会出现同一份数据被多线程同时访问的问题。
我在实际项目里遇到过一种情况:数据量特别大时,信号投递频率太高,主线程来不及处理,积压的队列越来越大,内存不断上涨。解决办法是降低信号发送频率——比如每秒最多发20次,或者用定时器在子线程里主动读取最新值再发送。对于温湿度采集这种秒级变化的场景,其实每秒发一次都完全够用。
4. 界面布局与实时绘图实现
4.1 用卡片式布局组织仪表盘信息
布局是很多人不重视但实际很影响使用体验的部分。我的习惯是把主界面分成三个区域:顶部或左侧放“当前值展示区”,中间大片区域给“实时曲线图”,底部放“告警日志与状态栏”。
当前值展示区我用的是自绘的卡片式QFrame,里面包含一个QLabel显示数值、一个QLabel显示单位、一个QLabel显示更新时间。为了提高可读性,温度超过设定阈值时卡片边框变成红色并闪烁,正常时是绿色,这个用动态属性和QSS配合就能实现。
QSS是Qt界面美化的核心手段,几乎不需要什么额外控件。比如设置一个圆角卡片:
QFrame#CardGood { background-color: #ffffff; border-radius: 8px; border: 2px solid #2ecc71; } QFrame#CardAlarm { background-color: #fff0f0; border-radius: 8px; border: 2px solid #e74c3c; }在小项目里用QSS完全够了,不建议为了好看引入Qt Quick或QML,那会陡增复杂度,维护成本也高。
4.2 QChart实现温湿度双曲线
Qt 5.7之后集成了QChart模块,画实时曲线很方便。我的实现思路是:
- 使用QChartView作为容器;
- 两个dataset,分别对应温度和湿度,都使用QSplineSeries(平滑曲线)或QLineSeries(折线);
- X轴用QDateTimeAxis,显示时间;Y轴动态范围,温度和湿度各自设置合理的range;
- 数据点通过append追加,当数据点超过最大数量时,把最前面的点删掉,形成滚动的窗口效果。
QChart *chart = new QChart(); chart->legend()->setVisible(true); chart->setAnimationOptions(QChart::NoAnimation); // 性能优先,动画反而卡 QSplineSeries *tempSeries = new QSplineSeries(); tempSeries->setName("温度"); tempSeries->setColor(QColor("#e74c3c")); QSplineSeries *humiSeries = new QSplineSeries(); humiSeries->setName("湿度"); humiSeries->setColor(QColor("#3498db")); QDateTimeAxis *axisX = new QDateTimeAxis(); axisX->setFormat("HH:mm:ss"); axisX->setTitleText("时间"); QValueAxis *axisY = new QValueAxis(); axisY->setLabelFormat("%.1f");这里有个让人纠结的问题:QChart里的数据在绘图时是怎样的刷新节奏?我是用一个QTimer,500ms触发一次,从QVector缓存里取出最新数据,批量更新到Series。注意不要每收到一个数据点就刷新一次图表,那样绘制开销太大,实测CPU占用会明显飙升。
4.3 绘图效率优化与滚动刷新策略
曲线刷新的效率直接决定系统能不能长时间稳定运行。我推荐一个策略:维护一个环形缓存,容量设置为600个点(比如10Hz采样下能存60秒数据),每次新数据到来时,先判断缓存是否已满,满了就把最早的坐标点remove掉,然后append新点。这样曲线始终是最近一分钟的数据,滚动效果流畅,内存占用也不会无限增长。
还有一个优化细节:QChart的removePoints相对比较耗性能,数据很多时建议不要逐点删,而是直接调用series->clear()后整体重新填充,或者干脆使用replace()方法在合适位置更新。当然对温湿度这种低频采集,逐点删除问题不大,但如果你并行采集几百个通道,这个优化就是必需的了。
绘图本身一定在主线程做,不要在子线程里new QChart或QChartView。跨线程操图形对象是非常典型的崩溃隐患,轻则闪退,重则死锁。正确的做法是子线程传数据信号,主线程槽函数里更新图表。
5. 温湿度系统高频问题排查实录
5.1 串口打不开或收不到数据
常见原因排第一的是串口号不对,Windows下COM口经常因为插入USB顺序变化而变动,建议界面上做一个刷新串口列表的按钮;第二是波特率、数据位、停止位、校验位没和传感器模块保持一致,这个没有技巧,只能逐项核对传感器手册;第三是串口被占用,比如厂家上位机还开着调试工具,串口被独占打不开。
还有一个特别容易忽略的问题:USB转串口线质量差或驱动版本不匹配时,readyRead信号可能间隔很久才触发一次,数据在缓冲区里攒了一堆才通知你。程序里别想当然地认为每次信号就来了一帧数据,一定要用上面说的缓存按帧切分逻辑。
5.2 界面卡顿与假死
界面假死十有八九是主线程里做了耗时操作。检查下面两处:一是有没有在某个信号槽里直接同步地调用类似usleep、sleep之类的阻塞操作;二是有没有在UI线程里进行大文件读写或数据库操作,比如点击“导出”按钮后直接同步写几万条数据到EXCEL,界面必然冻住,解决办法是导出也丢到线程池。
另一个容易被忽视的坑:在主线程中使用QMessageBox::information等模态对话框时,如果触发的时机是在密集的数据刷新槽函数里,对话框可能被多次重复弹出,导致界面卡死。设置一个布尔量做互斥,或者用全局标志位,很管用。
5.3 打包后提示缺少DLL或版本冲突
Qt程序发布给别的机器用,最常见的报错就是“无法启动此程序,因为计算机中丢失Qt5Core.dll”之类。用windeployqt工具可以自动收集依赖库。命令行里执行:
cd /d D:\build\release C:\Qt\5.15.2\msvc2019_64\bin\windeployqt.exe AppName.exe执行完后目录下会多出一堆Qt相关DLL和插件文件夹,整个目录拷给目标机器就能跑。注意两点:一是必须是release版本,debug版本带调试信息,发布过去运行效率差且依赖一大批debug库;二是windeployqt要和你打包的Qt版本严格对应,用Qt 5.15.2编的程序却用Qt 5.15.3的windeployqt处理,轻则多拷几个用不到的库,重则引入版本不一致导致启动崩溃,这就是网上常见那句“cannot mix incompatible Qt library”报错的来源。
5.4 曲线刷新卡顿与内存增长
曲线卡顿先看是不是多个Series共用一个Axis时没设置好动态范围。湿度值在0~100之间,温度值可能在-20~60之间,同一个Y轴显示会非常不协调。我把温湿度分成左右两个Y轴,左边温度、右边湿度,数据各自落在合理区间,看起来清晰多了。
内存不断增长则要检查是不是每帧都new对象但没有释放。QChart的append会保留数据点的副本,如果你缓存列表也在无上限地追加,那就是双重增长。建议给缓存设置上限,同时在内存里保留数据点数量,比如温度series最多保留600个点,到上限后每次append前先remove(0),可以保证长期运行内存稳定。
| 问题现象 | 可能原因 | 排查思路 |
|---|---|---|
| 串口打不开 | 串口号错误/被占用 | 刷新列表、关闭其他串口软件 |
| 数据乱码 | 波特率不匹配、编码格式错 | 核对参数、统一UTF-8或ASCII |
| 界面卡死 | 主线程耗时操作 | 耗时逻辑移入线程 |
| 曲线不刷新 | 信号未连接/队列积压 | 确认connect类型、减少发送频率 |
| 发布后缺DLL | 未运行windeployqt | release后执行对应版本deploy工具 |
| 程序启动崩溃 | Qt版本混用 | 保持编译/部署/运行环境版本一致 |
6. 项目落地的扩展与优化方向
6.1 数据存储与回放功能
实时显示只能看当下,温湿度监测最重要的是趋势分析。我建议把原始数据存到SQLite数据库里,Qt的QSqlDatabase对SQLite支持得非常完善,不需要额外安装服务,一个文件搞掂。
存储策略上,不要每帧都写数据库,那会白白消耗磁盘IO和增加程序复杂度。我采用定时落盘:每秒采集1条数据,数据先放到内存队列,每30秒统一批量写入一次。即使突然断电,最多丢失30秒的数据,对温湿度监测场景完全可以接受。
回放功能做起来也很顺手:用户选择时间段,程序从数据库查询出数据,重新填充到Series里,曲线就会像录像一样重新滚动起来。这块代码量不大,但实用价值极高——很多用户就是需要早上看昨晚的温湿度变化,而不是一直盯着屏幕。
6.2 自定义仪表盘控件
QChart画曲线很强大,但如果要做类似指针式温湿度计那种仪表盘,就需要自己写QWidget了。Qt里自定义控件其实不复杂,核心是重写paintEvent,用QPainter画圆弧、刻度线、指针,再用QTimer驱动指针角度变化。
我当时做了一个圆环仪表盘:上半圆画刻度,数值下方显示当前温度和湿度的数字,中心用渐变背景模拟发光效果。这样界面比单纯数字直观很多,用户扫一眼就知道温湿度是否正常。你也可以用网上开源的Qwt例子或者自定义仪表盘控件,但自己写一遍paintEvent,对理解Qt绘图体系很有帮助。
6.3 国际化和模块化扩展
程序做完了,如果还想给海外客户用,Qt的国际化机制很成熟:代码里所有可见字符串都走tr()包裹,然后使用lupdate工具提取.ts文件,用Qt Linguist翻译后生成.qm文件,运行时通过QTranslator加载即可切换语言。
此外,如果有更复杂的控制需求,比如要控制多个温湿度采集模块、对接PLC,可以在现有架构上增加多个采集器实例,每个采集器对应一个SerialWorker线程,统一注册到一个数据管理器中,界面层完全不需要改动。这就是前面模块化设计的好处——串口通信、数据解析、界面显示彼此解耦,每加一个需求就往对应模块里加功能,不会牵一发而动全身。
我在实际调这个项目时最大的体会是先把底层的串口通信和数据协议调通,再用模拟数据生成器把界面和曲线跑起来,最后才接真实传感器。这个顺序能帮你把硬件、软件的问题分开排查,不至于混在一起的时候,设备有问题你也怀疑自己的代码,代码有bug你又去折腾硬件。Qt温湿度显示系统虽然看起来是个练手规模的项目,但串口、多线程、绘图、数据库、打包发布这些点串起来之后,它几乎覆盖了桌面端上位机开发的全链路,认真做完一遍,后面再做更复杂的设备管理、SCADA类系统,思路都能顺下来。