Qt串口温度显示界面:实时曲线与数据帧解析实践
简介:这份资源是一套基于Qt框架实现的串口温度监测上位机示例工程,面向正在学习Qt串口通信、GUI绘图或嵌入式物联网监控界面的开发者,尤其适合已掌握C++基础、希望把串口收发与实时曲线显示串起来实践的中级学习者。压缩包共9个文件、约11KB,以3个cpp源文件和3个h头文件为主体,分别承载串口配置、数据解析与界面逻辑,另有1个ui界面文件、1个pro工程文件和1个user配置,导入Qt Creator即可对照阅读与编译调试。项目围绕QSerialPort完成波特率、数据位、校验位等参数设置与数据读取,借助信号与槽机制把新到数据推送到界面,并用QGraphicsView与QGraphicsScene绘制动态温度曲线,同时涉及QString与QByteArray的原始数据解析,构成一条较完整的采集—解析—展示链路。目前已有6220人浏览学习,可作为课程设计、毕业设计或自学练手的参考骨架,帮助读者理解上位机界面与串口通信如何协同工作。
1. Qt 串口温度界面:从“能收到数”到“看得出趋势”的那道坎
很多人第一次做 Qt 串口温度界面,卡住的地方不是QSerialPort打不开,也不是收到乱码,而是数据已经在跑了,界面却看不出“温度在变”——数字每 200 ms 跳一次,操作者盯十秒就疲劳,真正异常爬升时反而没人注意。这个资源要解决的就是这一层:基于 Qt 的串口接收温度显示界面,把下位机连续上报的温度数据变成可读的实时数值、走势曲线和状态提示。关键词 QT 串口 落到工程里,其实就是三件事:串口怎么配、帧怎么切、曲线怎么刷。它适合正在做设备上位机、实验数据采集界面、嵌入式调试工具的同学,也适合已经能收到数据、但界面刷新和数据处理还没理顺的开发者。
2. 串口接收链路:QSerialPort 配置与数据帧切分
2.1 为什么先定协议再写界面
串口温度界面最常见的翻车方式,是先拖控件、后想协议。界面画得再漂亮,只要下位机一帧数据的边界没约定清楚,收到的就是一堆拼接错位的数据。工程里通常把通信层和界面层分开:通信层负责打开端口、接收字节、拼帧、校验、转成温度值;界面层只接收“一条有效温度记录”并渲染。这样后面换传感器、改采样率、加第二路温度,都只动通信层。
需要先落到纸面的参数有四个:波特率、数据位/停止位/校验位、帧头帧尾、温度换算方式。常见下位机上报格式是“帧头 + 通道号 + 温度整数 + 小数 + 校验 + 帧尾”,也有直接上报 ASCII 字符串的,比如T1:25.6\r\n。ASCII 好调试,二进制省带宽。做界面这类低速采集,ASCII 完全够用,用串口助手就能肉眼验证,不必一上来就搞 CRC。
选型上,Qt 5.1 以后自带QSerialPort模块,不需要第三方串口库,工程文件里加一行QT += serialport即可。这也是我一般会推荐的方式:依赖少,跨平台,信号槽模型和界面天然贴合。
2.2 打开端口与参数设置的完整代码
// serialworker.h #ifndef SERIALWORKER_H #define SERIALWORKER_H #include <QObject> #include <QSerialPort> #include <QByteArray> class SerialWorker : public QObject { Q_OBJECT public: explicit SerialWorker(QObject *parent = nullptr); bool open(const QString &portName, int baudRate = 9600); void close(); signals: // 每解析出一条有效温度记录发一次,channel 为通道号,value 为摄氏度 void temperatureReady(int channel, double value); void errorOccurred(const QString &message); private slots: void onReadyRead(); void onSerialError(QSerialPort::SerialPortError err); private: QSerialPort *m_port = nullptr; QByteArray m_buffer; // 接收缓存,解决半包问题 static const int kMaxBuffer = 4096; }; #endif// serialworker.cpp #include "serialworker.h" #include <QSerialPortInfo> #include <QDebug> SerialWorker::SerialWorker(QObject *parent) : QObject(parent) { m_port = new QSerialPort(this); connect(m_port, &QSerialPort::readyRead, this, &SerialWorker::onReadyRead); connect(m_port, &QSerialPort::errorOccurred, this, &SerialWorker::onSerialError); } bool SerialWorker::open(const QString &portName, int baudRate) { if (m_port->isOpen()) m_port->close(); m_port->setPortName(portName); m_port->setBaudRate(baudRate); // 与下位机严格一致 m_port->setDataBits(QSerialPort::Data8); // 8 数据位 m_port->setParity(QSerialPort::NoParity); // 无校验 m_port->setStopBits(QSerialPort::OneStop); // 1 停止位 m_port->setFlowControl(QSerialPort::NoFlowControl); // 不用流控 m_port->setReadBufferSize(4096); if (!m_port->open(QIODevice::ReadWrite)) { emit errorOccurred(QStringLiteral("打开 %1 失败:%2") .arg(portName, m_port->errorString())); return false; } m_buffer.clear(); qDebug() << "serial opened" << portName << baudRate; return true; } void SerialWorker::onReadyRead() { m_buffer.append(m_port->readAll()); // 防止对端持续发脏数据把内存撑爆 if (m_buffer.size() > kMaxBuffer) m_buffer.remove(0, m_buffer.size() - kMaxBuffer); // ASCII 帧格式:T<通道>:<温度>\r\n int pos; while ((pos = m_buffer.indexOf("\r\n")) >= 0) { QByteArray frame = m_buffer.left(pos); m_buffer.remove(0, pos + 2); frame = frame.trimmed(); if (frame.isEmpty()) continue; // 形如 T1:25.63 int colon = frame.indexOf(':'); if (colon <= 1 || !frame.startsWith('T')) { qDebug() << "drop bad frame" << frame; continue; } bool chOk = false, valOk = false; int channel = frame.mid(1, colon - 1).toInt(&chOk); double value = frame.mid(colon + 1).toDouble(&valOk); if (!chOk || !valOk) { qDebug() << "parse failed" << frame; continue; } emit temperatureReady(channel, value); } }代码逻辑分三层看。第一层是配置:波特率、数据位、校验位必须与下位机逐个对齐,任何一项不一致,收到的都是废字节,readyRead照样触发,但永远解析不出正确值。第二层是缓存:readyRead回调里的readAll()不保证拿到完整帧,可能只有半帧,必须用一个持久QByteArray累积,再用帧尾\r\n定位边界,这是串口采集最重要的一个习惯。第三层是容错:解析失败直接丢弃该帧并打日志,不要让一条脏数据把整个缓冲区污染掉。参数上,setReadBufferSize(4096)是驱动侧缓冲上限,配合自己的kMaxBuffer做双保险。
提示:串口号不要写死在代码里,用
QSerialPortInfo::availablePorts()枚举成下拉框,界面启动时刷新一次,否则换一台机器就要改代码重编。
2.3 线程归属:别把串口读放进 UI 线程重活
温度采样频率不高时,串口读取放在 UI 线程也能跑。但只要加上曲线重绘、数据入库,UI 线程一卡,readyRead排队,缓冲膨胀,界面看起来就是“卡一下、跳一大截”。常见做法是把SerialWorker移到独立QThread:主线程创建 worker,moveToThread,通过信号槽跨线程触发open,结果用temperatureReady发回来。Qt 默认跨线程连接是队列式的,参数会自动拷贝,只要不直接操作对方的成员对象就安全。
// mainwindow.cpp 片段 m_serialThread = new QThread(this); m_worker = new SerialWorker; // 无 parent,才能 moveToThread m_worker->moveToThread(m_serialThread); connect(m_serialThread, &QThread::finished, m_worker, &QObject::deleteLater); connect(this, &MainWindow::requestOpen, m_worker, &SerialWorker::open); connect(m_worker, &SerialWorker::temperatureReady, this, &MainWindow::onTemperatureReady); // 自动回到 UI 线程 m_serialThread->start();注意open带默认参数,作为槽函数连接时参数个数要匹配,稳妥方式是在 UI 侧把端口名和波特率打包成结构体或额外声明一个无参槽。另外关闭窗口时先停线程再析构,顺序反了会出现QThread: Destroyed while thread is still running,这是很典型的一条血泪提示。
3. 实时曲线渲染:从 QChart 到自定义绘制
3.1 曲线方案选型:QChart 够不够用
Qt 官方给实时曲线提供了QtCharts模块,QLineSeries加QChartView十几行就能出一条会动的线,适合课程设计和原型验证。它的边界也很明确:数据点上千以后,replace或append的开销和重绘频率会明显上来;坐标轴自动缩放如果每帧都调,曲线会抖。常见做法是固定窗口内保留最近 N 个点(比如 600 点,对应 2 分钟 @5Hz),用环形缓冲思路删旧点,坐标轴范围只在越界时更新。
如果点数再多、或者要画多通道叠加、带阈值色带,自定义QWidget::paintEvent配合QPainter::drawPolyline会更可控。下面先给 QChart 的可跑版本,这是绝大多数温度界面够用的方案。
// chartwidget.cpp #include <QtCharts/QChartView> #include <QtCharts/QLineSeries> #include <QtCharts/QValueAxis> ChartWidget::ChartWidget(QWidget *parent) : QWidget(parent) { m_series = new QLineSeries; m_series->setName(QStringLiteral("温度")); m_chart = new QChart; m_chart->addSeries(m_series); m_chart->legend()->hide(); m_chart->setMargins(QMargins(4, 4, 4, 4)); m_axisX = new QValueAxis; m_axisX->setRange(0, kWindowPoints); // 横轴用点数,避免时间换算 m_axisX->setLabelFormat("%d"); m_axisX->setTitleText(QStringLiteral("采样点")); m_axisY = new QValueAxis; m_axisY->setRange(0, 50); // 温度量程,按传感器改 m_axisY->setTitleText(QStringLiteral("摄氏度")); m_chart->addAxis(m_axisX, Qt::AlignBottom); m_chart->addAxis(m_axisY, Qt::AlignLeft); m_series->attachAxis(m_axisX); m_series->attachAxis(m_axisY); m_view = new QChartView(m_chart, this); m_view->setRenderHint(QPainter::Antialiasing); m_view->setStyleSheet("background:transparent;"); auto *lay = new QVBoxLayout(this); lay->setContentsMargins(0, 0, 0, 0); lay->addWidget(m_view); } void ChartWidget::appendPoint(double value) { m_count++; m_series->append(m_count, value); // 只保留窗口内的点,超出就删最老的 while (m_series->count() > kWindowPoints) m_series->remove(0); // 横轴跟着滚动,纵轴仅在越界时扩,避免每帧重算导致抖动 m_axisX->setRange(m_count - kWindowPoints, m_count); if (value < m_axisY->min() || value > m_axisY->max()) { double lo = qMin(m_axisY->min(), value - 5); double hi = qMax(m_axisY->max(), value + 5); m_axisY->setRange(lo, hi); } }这里有两个参数值得单独说。kWindowPoints决定曲线的时间跨度,5 Hz 采样下 600 点约 2 分钟,太短看不出趋势,太长横轴刻度挤成一团。纵轴扩边界留 5 度余量,是为了避免温度贴着轴边缘时被裁掉半条线。remove(0)是 O(n) 操作,点数不大时完全可接受,点数量级上到几万再考虑换环形数组自绘。
3.2 刷新节流:10 Hz 采样不等于 10 Hz 重绘
串口可能以 20 Hz 上报,界面没必要跟着 20 Hz 重绘,人眼在 10 Hz 以上就分辨不出差别,多出来的重绘只是白烧 CPU。常见做法是数据到达先塞进一个缓存队列,用一个QTimer固定 100 ms 取一次、批量追加、只触发一次重绘。
// MainWindow 中 m_flushTimer = new QTimer(this); m_flushTimer->setInterval(100); // 10 Hz 刷新,够用且省 CPU connect(m_flushTimer, &QTimer::timeout, this, [this]{ if (m_pending.isEmpty()) return; for (double v : m_pending) m_chartWidget->appendPoint(v); m_pending.clear(); m_labelValue->setText(QString::number(m_lastValue, 'f', 1) + " ℃"); }); m_flushTimer->start();onTemperatureReady里只做两件事:把值推进m_pending,更新m_lastValue。这样 UI 线程的工作量被压成一个固定节拍,不会因为下位机突然加快上报而雪崩。
3.3 数值区与状态区:界面信息怎么排
温度界面真正被频繁读取的往往不是曲线,而是那个大字号当前值。布局上常见三段式:顶部一行是串口选择、波特率、打开/关闭按钮与状态灯;中部是曲线;底部是当前值、最大值、最小值、平均值,字号拉开层级。状态灯用一个小圆点加文字,未连接灰色、已连接绿色、接收超时橙色,比弹窗友好得多。
超时判断不要省略:下位机断电或线松了,界面不会收到任何事件,曲线会“假停”在那里,操作者以为温度稳定了。用一个QElapsedTimer,每次收到有效帧就restart(),定时器里检查超过 3 秒没更新就把状态置为“数据中断”。这是采集类界面上最容易漏、也最影响可信度的一环。
4. 避坑与排查:串口温度界面的五条实战记录
4.1 能打开端口但一条数据都收不到
现象:open返回 true,状态灯也亮了,日志里没有任何drop bad frame,缓冲区始终为空。
原因通常不在代码,而在三个物理层细节:TX/RX 没有交叉接、下位机没有上电、或者端口被别的串口助手占着。Windows 上端口独占,另一个进程打开了同名 COM 口,open有时还会成功但拿不到数据。
解决:先用系统串口工具或串口助手单独验证硬件通路,确认有字节流再回到 Qt。代码里补一条诊断日志,定时打印m_buffer.size()和m_port->bytesAvailable(),能立刻区分“没数据”和“数据没进来”。
4.2 数据乱跳、偶尔出现负几十度
现象:曲线偶尔冒出一个 -40 或 200 的尖峰,随后又正常。
原因:帧边界切错。要么下位机帧尾不是\r\n,要么一帧里包含多个字段,按字符串直接toDouble把后面的校验位也吃进去了。另一种情况是设备上电瞬间输出半帧,正巧被当成完整帧解析。
解决:严格按协议定位字段,不要用“找到第一个数字就当温度”的偷懒写法;上电后先丢弃前若干帧;对温度做合理性区间判断(如 -10 到 80 之外只记录不绘图)。这类玄学毛刺,九成都能追到解析层。
4.3 运行几分钟后界面越来越卡
现象:刚启动流畅,几分钟后拖动窗口明显掉帧,任务管理器里内存缓慢上涨。
原因:QLineSeries只增不删,或者m_pending在某些分支没有被清空。也有人把QChart::addSeries写在刷新回调里,每次都新建一个 series。
解决:确认appendPoint里的裁剪循环真的执行;series 只在构造时创建一次;把m_pending的清理放在刷新成功之后而不是之前。跑十分钟看内存曲线,是验证这类问题的低成本手段。
4.4 关窗口时崩溃或报线程未停止
现象:点关闭按钮,程序退出时输出QThread: Destroyed while thread is still running,偶发崩溃。
原因:QThread对象随主窗口析构,而工作线程还在跑事件循环;或者SerialWorker带了 parent,moveToThread直接无效。
解决:关闭流程固定为“停定时器 → 发信号让 worker 关串口 →m_serialThread->quit()→wait(2000)→ 再析构”。SerialWorker创建时不带 parent,销毁交给deleteLater。
4.5 换一台电脑端口列表就是空的
现象:在开发机上正常,拷到现场机器上,下拉框里一个串口都没有。
原因:没装 USB 转串口驱动,或者程序没权限枚举。也有把端口名硬编码成COM3的情况,换机器编号一变就失败。
解决:用QSerialPortInfo::availablePorts()动态枚举,显示端口名加描述(如COM4 (USB-SERIAL CH340)),让操作者能对上实物。找不到端口时在状态区给出明确提示,而不是静默留空。
5. 加一层数据落盘与回放:验证曲线是不是真的对
界面做得再顺,也要回答一个问题:这条曲线到底是不是传感器真实输出。我一般的做法是加一个轻量的 CSV 记录与回放能力,采集时把“时间戳、通道、原始帧、解析值”四列写盘,调试时用回放代替实物,既能把问题复现,也能拿数据去比对。
// 采集回调里顺手落盘,QFile + QTextStream 足够 void MainWindow::onTemperatureReady(int channel, double value) { m_lastValue = value; m_pending.append(value); m_lastRxTimer.restart(); // 原始记录,出问题时能回看是帧错了还是解析错了 QTextStream ts(&m_csvFile); ts << QDateTime::currentDateTime().toString("yyyy-MM-dd HH:mm:ss.zzz") << ',' << channel << ',' << QString::number(value, 'f', 2) << '\n'; }回放时把 CSV 按行读出来,用同一个QTimer节拍喂给appendPoint,就能在桌面上复现现场那段曲线。这里有个细节:写文件别在 UI 线程做重活,行数多了要改成批量缓冲,每 100 行 flush 一次;文件按启动时间命名,避免覆盖。另一个我踩过的坑是 CSV 里小数点受系统区域设置影响,某些环境会写成逗号,读回来就全错位,所以写的时候统一用QString::number固定格式,读的时候也按固定格式解析,不依赖QLocale默认值。
验证方法很朴素:拿一个已知温度的恒温源(或用手握住传感器看响应),观察曲线斜率是否连续、数值是否落在合理区间、超时提示是否按预期触发。三项都过,这个界面才算能交给别人用。从那以后我每次做串口采集界面,都会先把“断线检测、落盘、回放”这三件小事做掉,再谈曲线好不好看——毕竟界面是给人看的,数据是不是真的,才是它值不值得信的根本。希望帮到你。
本文还有配套的精品资源,点击获取