简介:这份基于Qt框架的串口温度监测示例,面向正在学习串口通信与桌面界面开发的初学者及嵌入式、物联网方向开发者,重点解决温度数据实时接收、解析与曲线展示的入门实践问题。压缩包共9个文件,约11KB,包含C++头文件与cpp源文件、ui界面文件、pro工程文件及用户配置,分别承载串口逻辑、界面布局与工程构建配置,体积轻量却保留了可编译运行的工程骨架。已有6220人学习下载,说明其作为Qt串口入门案例具有较高参考价值。读者可从中了解QSerialPort的使用方式、信号与槽驱动界面刷新、QGraphicsView/QGraphicsScene绘制动态温度曲线,以及原始串口数据到可读温度值的解析思路,适合对照搭建自己的监测界面并扩展为更完整的物联网监控应用。
1. 温度曲线画不出来,先看串口那头到底吐了什么
去年帮一个做恒温设备的朋友救火,他桌上摆着一台烘箱、一个热电偶采集模块和一台笔记本,模块每 500 ms 往串口吐一行$T,23.6*3F,笔记本上那个基于 Qt 的串口温度显示界面是别人留下的,能收到数,但曲线一会儿一卡,跑两小时内存飙到 1.2 G。这类“Qt 串口 + 实时数据 + 曲线显示”的小工具,是工控、实验台、嵌入式调试里最常被临时攒出来的东西,需求极短:连上、收数、画线、看得清。这个项目就是干这件事的完整实现——QSerialPort 收数据、环形缓冲存历史、QPainter 自绘实时曲线,带当前值、极值统计和超限提示。适合手上有点 Qt 基础、想要一个真能跑通再拿去改的开发者,也适合第一次写上位机、不想在第三方图表库上折腾半天的人。
2. 工程结构与选型:为什么最后没用现成图表库
拿到这类项目,我第一件事不是看 mainwindow.cpp,而是先看目录。目录里如果serial、chart、ui各占一块,说明作者分得清“通信”和“显示”两条线,后面加功能就不至于把业务逻辑塞进 paintEvent 里。这个小工具的结构不大,但边界划得比较干净:串口线程只管收字节和拆帧,曲线控件只管把一串 double 画成折线,主窗口做粘合。
2.1 目录结构:一个能编译的最小闭环
tempmon/ ├── tempmon.pro # qmake 工程,也可以用 CMakeLists.txt 替代 ├── main.cpp ├── mainwindow.h/.cpp/.ui # 窗口、按钮、状态栏、定时刷新 ├── serial/ │ ├── serialworker.h/.cpp # QSerialPort 封装:打开/关闭/重连/收数 │ └── frameparser.h/.cpp # 拆帧与校验,纯函数,方便单测 ├── chart/ │ ├── curvewidget.h/.cpp # 自绘曲线控件 │ └── ringbuffer.h # 定长环形缓冲(模板,头文件实现) └── resources/ └── style.qss # 深色主题,工控现场看着不刺眼这个划分有个好处:frameparser不依赖 QSerialPort,你拿一段录好的十六进制日志就能在单元测试里反复跑,不用每次都插硬件。curvewidget也不关心数据从哪来,它只暴露一个setBuffer(const RingBuffer<double,600>*)和一个update(),换成模拟数据、换成从 CSV 回放,都不用动这一层。
2.2 选型对比:QChart、QCustomPlot、自绘三条路
实时曲线在 Qt 里就三条常见路,我一般按“刷新频率 × 部署难度”来选。
| 方案 | 上手成本 | 打包体积 | 2000 点 / 30fps | 适合场景 |
|---|---|---|---|---|
| Qt Charts(QChartView) | 低 | 多带一个 Charts 模块 | 吃紧,频繁 append 容易掉帧 | 报表、静态图、点数少 |
| QCustomPlot | 中 | 单 .h/.cpp 拖进工程 | 好,自带 setData 批量替换 | 要缩放、游标、导出 |
| QPainter 自绘 | 中偏高 | 零额外依赖 | 很好,画多少点自己说了算 | 界面固定、只要一条实时线 |
这个项目选了自绘。原因很实在:界面就一条温度曲线,横轴固定最近 5 分钟,纵轴自适应,不需要缩放拖动,用 QChart 反而要处理QChartView的 setRubberBand、动画开关、序列清空一大堆边角;QCustomPlot 是好东西,但一旦要出图给现场看,自绘能把线宽、颜色、网格、字体全部按客户要求一次调死,不留“这个能不能改一下”的扯皮空间。要加缩放再说,那时候换 QCustomPlot 也只是把curvewidget换掉,接口不变。
2.3 .pro 里必须写对的那几行
很多人卡在第一步不是代码错,是QSerialPort根本没被链进来,报一堆undefined reference to QSerialPort::...。
QT += core gui serialport greaterThan(QT_MAJOR_VERSION, 4): QT += widgets CONFIG += c++11 TARGET = tempmon TEMPLATE = app SOURCES += main.cpp mainwindow.cpp \ serial/serialworker.cpp serial/frameparser.cpp \ chart/curvewidget.cpp HEADERS += mainwindow.h \ serial/serialworker.h serial/frameparser.h \ chart/curvewidget.h chart/ringbuffer.h用 CMake 的话对应两行:
find_package(Qt5 COMPONENTS Widgets SerialPort REQUIRED) target_link_libraries(tempmon PRIVATE Qt5::Widgets Qt5::SerialPort)顺便提醒一句模块版本:Qt 5.x 里QSerialPort属于serialport模块,Qt 6 里模块名没变但QSerialPort::error信号改成了errorOccurred,两者不兼容。我一般会在serialworker.h里加一层#if QT_VERSION < QT_VERSION_CHECK(6,0,0)的宏,把信号名统一到一个槽上,省得换版本时全工程搜改。
3. 串口链路打通:从 openPort 到 readyRead 的完整入口
上位机的血泪经验:出问题的地方九成不在画图,在收数。收数这块又有三层——端口能不能开、字节流怎么切成一帧一帧、断了之后能不能自己接回来。这三层写扎实,界面再丑都能用;写不扎实,界面再漂亮都是摆件。
3.1 端口枚举与八条参数,一次配全
打开串口前先扫端口,别让用户手敲COM3。QSerialPortInfo::availablePorts()能拿到端口名、描述和厂商,用描述过滤掉一堆虚拟串口(比如蓝牙模拟出来的),现场就不会选错。
// 枚举可用端口,填进 QComboBox void MainWindow::refreshPorts() { ui->cbPort->clear(); const auto ports = QSerialPortInfo::availablePorts(); for (const QSerialPortInfo &info : ports) { // 名称 + 描述一起显示,用户认的是描述不是 COM 号 ui->cbPort->addItem(QString("%1 (%2)").arg(info.portName(), info.description()), info.portName()); } ui->btnOpen->setEnabled(ui->cbPort->count() > 0); }真正打开的时候,八个参数一个都不能省。默认值看着能用,实际现场只要有一个不对,收到的就是一堆乱码或者干脆一个字都没有。
| 参数 | 本项目取值 | 设错的典型表现 |
|---|---|---|
| PortName | COM3/ttyUSB0 | 打开失败,PermissionError |
| BaudRate | 9600 或 115200 | 全是乱码,或长度对但数值离谱 |
| DataBits | 8 | 数据位错,高位被吃 |
| Parity | NoParity | 偶发单字节错,看起来像干扰 |
| StopBits | OneStop | 边界多一个字节,帧头对不上 |
| FlowControl | NoFlowControl | 一发大数据就卡住不返回 |
| ReadBufferSize | 0(交给驱动) | 设太小会丢包 |
| 打开模式 | ReadWrite | 只读模式下发不了配置指令 |
bool SerialWorker::open(const QString &portName, int baud) { if (m_port.isOpen()) { m_port.clear(QSerialPort::AllDirections); m_port.close(); // 先关再开,避免句柄泄漏和重复连接 } m_port.setPortName(portName); m_port.setBaudRate(baud); m_port.setDataBits(QSerialPort::Data8); m_port.setParity(QSerialPort::NoParity); m_port.setStopBits(QSerialPort::OneStop); m_port.setFlowControl(QSerialPort::NoFlowControl); if (!m_port.open(QIODevice::ReadWrite)) { emit errorOccurred(m_port.errorString()); // 错误往上抛,UI 弹状态栏 return false; } m_buf.clear(); // 上一次残留的半包必须清掉 m_aliveTimer->start(1000); // 顺带起心跳 return true; }m_buf.clear()这行别省。拔插过一次的设备,缓冲区里往往会留着半个帧,下次一连上就先把这半截当数据处理,数值直接跳到一个不可能的值。
3.2 readyRead 里最该防的两件事:粘包和半包
readyRead的触发时机跟你的帧边界没有任何关系。串口一次可能给你 1 个字节,也可能一次给你三帧半。指望“一次 readyRead 就是一帧”,项目上线第一天就会翻车。标准做法是:全部readAll()追加到缓冲区,然后循环找帧尾,找到就切走,切不完整就留着等下一批。
void SerialWorker::onReadyRead() { m_buf.append(m_port.readAll()); // 兜底:缓冲区异常增长说明帧尾永远没来,砍掉旧数据防止吃满内存 if (m_buf.size() > 8192) { m_buf = m_buf.right(1024); emit errorOccurred(QStringLiteral("缓冲区溢出,疑似帧尾丢失")); } int idx; while ((idx = m_buf.indexOf('\n')) >= 0) { QByteArray frame = m_buf.left(idx).trimmed(); // 顺手去掉 \r m_buf.remove(0, idx + 1); if (!frame.isEmpty()) { handleFrame(frame); } } }indexOf('\n')是这套写法里最省事的一步:只要协议保证以换行结尾,就不用自己数长度、不用维护状态机。切出来的frame交给解析函数,解析失败就直接丢,绝不能让一条坏帧污染曲线。
void SerialWorker::handleFrame(const QByteArray &f) { // 帧格式:$T,23.6*3F 校验 = '$' 与 '*' 之间所有字节异或 if (!f.startsWith('$')) return; const int star = f.indexOf('*'); QByteArray body = (star > 0) ? f.mid(1, star - 1) : f.mid(1); if (star > 0) { bool ok = false; const quint8 want = f.mid(star + 1).toUInt(&ok, 16); if (!ok || xorSum(body) != want) { // 校验不过,静默丢弃并计数 ++m_badFrames; return; } } const QList<QByteArray> parts = body.split(','); if (parts.size() < 2 || parts.at(0) != "T") return; // 类型不认识的帧直接跳过 bool ok = false; const double t = parts.at(1).toDouble(&ok); if (!ok) return; // 物理量程兜底:热电偶不可能报 300,出现就是解析错位 if (t < -55.0 || t > 150.0) { ++m_badFrames; return; } emit temperatureArrived(t, QDateTime::currentMSecsSinceEpoch()); }几个参数值得说清楚:xorSum(body)算的是$与*之间的异或值,十六进制两位;m_badFrames是个只增不减的计数器,界面上显示成“错误帧 12”,现场调试时这个数字比日志好用;量程判断-55 ~ 150是按常见热电偶模块的上限拍的,换成 PT100 就改成-200 ~ 600,这一步看着多余,实际是防错位最便宜的一招。
3.3 断线检测与自动重连:不能等用户来点按钮
USB 转串口最烦人的地方就是热插拔后设备名会变,COM3拔了再插可能变成COM7。用户不会知道这件事,他们只会说“软件卡住了”。做法是加一个 1 秒心跳:连续 N 秒没收到任何字节就判定掉线,然后按“描述匹配”重新找端口,找到了自动重开。
void SerialWorker::onHeartbeat() { if (!m_port.isOpen()) return; if (m_lastByteMs > 0 && QDateTime::currentMSecsSinceEpoch() - m_lastByteMs > 3000) { emit errorOccurred(QStringLiteral("超过 3 秒无数据,尝试重连")); m_port.close(); // 按描述找回同一个设备,端口号变了也不影响 for (const auto &info : QSerialPortInfo::availablePorts()) { if (info.description().contains(m_deviceHint)) { open(info.portName(), m_port.baudRate()); return; } } } }m_deviceHint在第一次打开成功时存下来(取QSerialPortInfo::description()),是这套重连逻辑的关键。用端口名匹配基本没戏,因为名字本来就会变;用描述匹配,只要现场不是同时插了五个同型号模块,命中率很高。
4. 实时曲线绘制:环形缓冲加自绘折线,30 帧稳住
数据进得来之后才是显示。这块的坑不在“会不会画折线”,在“画多久会卡”。新手最常见的写法是QList<double> m_data; m_data.append(t);,然后paintEvent从头遍历到尾巴。跑十分钟没事,跑两小时开始一秒一卡,因为点数从 600 涨到了七万。
4.1 数据容器:定长环形缓冲,别让容器无限长
实时曲线的横轴只有“最近 N 个点”有意义,历史数据要么落盘、要么扔掉,绝不该留在绘制用的容器里。搞一个固定容量 600 的环形缓冲(600 点 × 500 ms = 5 分钟),写满之后新数据覆盖最老的一条,内存占用从启动到关机是一条平线。
template <typename T, int N> class RingBuffer { public: void push(const T &v) { m_data[m_head] = v; m_head = (m_head + 1) % N; if (m_size < N) ++m_size; } int size() const { return m_size; } bool full() const { return m_size == N; } // i = 0 取最老的一条,i = size()-1 取最新的一条 const T &at(int i) const { return m_data[(m_head - m_size + i + 2 * N) % N]; } void clear() { m_head = 0; m_size = 0; } private: T m_data[N]; int m_head = 0; // 下一个写入位置 int m_size = 0; // 当前有效元素个数 };+ 2 * N是为了处理m_head - m_size为负的情况,C++ 的%对负数返回负值,不加这层会越界读,而且这种越界不一定会立刻崩,属于典型的黑匣子问题。这个容器只有五行有效逻辑,但它是整个曲线控件能不能长时间跑稳的地基。
4.2 paintEvent:坐标映射、网格和抗锯齿
自绘曲线的核心就两个 lambda:xOf(i)把序号映射成像素横坐标,yOf(v)把温度映射成像素纵坐标。纵轴量程不要用“当前最大最小”,那样曲线会一直贴边抖,看着像噪声;做法是先留 1 ℃ 余量,再保证最小跨度 5 ℃,视觉上就稳了。
void CurveWidget::paintEvent(QPaintEvent *) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing, true); // 抗锯齿,折线才不毛刺 p.fillRect(rect(), QColor(18, 18, 20)); const QRectF plot = rect().adjusted(56, 16, -16, -36); // 留出刻度区和时间轴 double lo = m_min - 1.0, hi = m_max + 1.0; if (hi - lo < 5.0) { // 最小跨度,防止曲线被放大成锯齿 const double mid = (hi + lo) / 2.0; lo = mid - 2.5; hi = mid + 2.5; } auto xOf = [&](int i) { return plot.left() + plot.width() * i / qMax(1, m_buf->size() - 1); }; auto yOf = [&](double v) { return plot.bottom() - plot.height() * (v - lo) / (hi - lo); }; // 横向网格 + 纵轴刻度 for (int k = 0; k <= 5; ++k) { const double y = plot.top() + plot.height() * k / 5.0; p.setPen(QPen(QColor(45, 45, 48), 1)); p.drawLine(QPointF(plot.left(), y), QPointF(plot.right(), y)); p.setPen(QColor(150, 150, 150)); p.drawText(QRectF(0, y - 8, 50, 16), Qt::AlignRight | Qt::AlignVCenter, QString::number(hi - (hi - lo) * k / 5.0, 'f', 1)); } // 折线本体 QPainterPath path; for (int i = 0; i < m_buf->size(); ++i) { const QPointF pt(xOf(i), yOf(m_buf->at(i))); if (i == 0) path.moveTo(pt); else path.lineTo(pt); } p.setPen(QPen(QColor(0, 220, 160), 2)); p.drawPath(path); // 最新点画个实心圆,一眼能看出数据在不在动 if (m_buf->size() > 0) { const int last = m_buf->size() - 1; p.setPen(Qt::NoPen); p.setBrush(QColor(0, 220, 160)); p.drawEllipse(QPointF(xOf(last), yOf(m_buf->at(last))), 3, 3); } }几个细节:setRenderHint(Antialiasing)只开一次,放在函数开头;网格线用QColor(45,45,48)这种接近背景的灰,太亮会抢曲线;qMax(1, size()-1)是防止只有一个点时除零。要用QPainterPath而不是逐段drawLine,点多了以后性能差一个量级——drawLine是 600 次调用,drawPath是一次。
4.3 刷新节流:数据到达频率不等于重绘频率
串口 500 ms 来一条,重绘也跟着 500 ms 一次,看起来没问题。但一旦把波特率调高到 50 ms 一条,update()就会被调用 20 次每秒,而屏幕刷新只有 60 Hz,中间那些重绘纯属浪费;更糟的是如果每条数据都顺手更新一下 QLabel 文本,界面线程会被排版计算吃满。
正确做法是“数据到达只改模型,定时器统一重绘”。数据槽只做三件事:压缓冲、更新极值、置脏标记。真正碰控件的是 33 ms 的定时器。
// 数据到达:只改数据,不碰界面 void MainWindow::onTemperature(double t, qint64) { m_buf.push(t); m_now = t; m_max = qMax(m_max, t); m_min = qMin(m_min, t); m_dirty = true; } // 33 ms 定时器:统一刷新,脏了才画 void MainWindow::onRefreshTick() { if (!m_dirty) return; m_dirty = false; m_curve->update(); // 触发 paintEvent ui->lbNow->setText(QString::number(m_now, 'f', 2)); // 当前值 ui->lbMax->setText(QString::number(m_max, 'f', 2)); ui->lbMin->setText(QString::number(m_min, 'f', 2)); const bool over = m_now > m_limitHigh || m_now < m_limitLow; ui->lbNow->setStyleSheet(over ? "color:#ff5252;" : "color:#00dc9c;"); }m_dirty这个布尔量值不了几个字节,却能在低频数据时把 CPU 从“每秒 30 次空转绘制”压到“每秒 2 次”,笔记本风扇都不转了。超限提示也不要弹 QMessageBox,现场没人有空点确定,把数字变红、状态栏写一行就够了。
5. 避坑与排查:串口上位机最容易翻车的六件事
这个项目的代码量不大,但把代码跑起来之后的问题,比代码本身更费时间。下面六条是我在不同设备上反复遇到的,每条按“看到什么、为什么、怎么改”写,能省掉不少来回。
第一条:打开返回 true,一个字节都收不到。现象是open()成功、isOpen()也是 true,但bytesAvailable()永远是 0。原因通常有两个:一是 USB 转串口的 TX/RX 接反了或者没共地,电气层根本没通;二是波特率对上了但数据位/校验位不匹配,收到的字节被硬件判成帧错误直接丢弃,驱动层不报错。解决顺序是先短接模块的 TX/RX 做回环测试,能收到自己发的字节,说明电脑侧没问题,问题在设备和线序;回环都收不到就换一根线、换一个 USB 口,别在代码里找。
第二条:跑十分钟开始卡,鼠标拖动窗口有明显延迟。原因是readyRead槽里直接调了update()甚至repaint()。repaint()是同步重绘,会强制立刻走完整个 paintEvent,数据来得快的时候界面线程直接被堵死。解决办法就是上面那套脏标记加定时器,任何从 IO 回调里触发的重绘都走节流;顺便把QTextEdit的日志也限行,setMaximumBlockCount(500)一行搞定,不然日志控件自己就能吃掉几百兆。
第三条:内存一路涨,任务管理器里数字只增不减。九成是绘制容器用了QList::append没清理,或者日志没有最大行数。判断方法很简单:把曲线控件隐藏(不绘制),如果内存还在涨,那就是数据容器或者日志;如果隐藏后不涨了,是绘制路径里分配了什么没释放,比如每次 paintEvent 都 new 一个 QPainterPath 却没析构。环形缓冲的写法从根上掐掉第一种,第二种靠QPainterPath用成员变量或者栈上对象解决。
第四条:数值偶尔跳到一个不可能的数,比如 6553.5。这是典型的拆帧错位:半个帧被当成完整帧解析,toDouble()把一段乱码解释成了数字。原因往往是缓冲没清、或者协议里用了变长字段却没有长度校验。解决办法三层:打开串口时清缓冲、帧尾用固定分隔符不要靠超时判断、解析完加物理量程兜底。加完量程判断之后,这类脏数据基本进不到显示层,最多让“错误帧”计数加一。
第五条:拔掉再插上,软件就再也连不上了。现象是端口列表里明明有新端口,点打开却报“设备不存在”或者一直失败。原因是上一次的QSerialPort对象没正确 close,句柄还挂在系统里;同时端口名从COM3变成了COM7,界面上存的还是旧名字。解决是把 open 写成“先 close 再 open”,并且重连逻辑按设备描述匹配而不是按端口名;再稳一点就在aboutToClose里把缓冲区、心跳定时器一起停掉。
第六条:本机跑得好好的,拷到现场机器双击就提示找不到串口模块。这是发布阶段最经典的坑:QSerialPort属于插件式模块,动态库名字是Qt5SerialPort.dll,同时还需要qtbase/plugins/...下的串口相关插件目录。用windeployqt打包时它默认只带 Widgets 相关的东西,漏掉 SerialPort 就会出现“界面能开、端口列表为空”。解决办法是在部署命令里显式指定模块,或者手工确认Qt5SerialPort.dll和serialport插件目录都在。这一条我在现场翻过车,后来每次发版都先在一台干净机器上双击一遍再交付。
6. 二次开发改造:从单路温度到多通道落盘的具体走法
这个项目把一条链路跑通了,真要用起来,改动集中在三个方向:多通道、落盘、报警策略。结构上不用大动,因为frameparser和curvewidget都是可替换的零件。
第一步扩多通道。协议里加通道号就行,比如$T,1,23.6*3F,解析函数多返回一个int ch,MainWindow里把单个RingBuffer换成QVector<RingBuffer<double,600>>,CurveWidget的setBuffer改成接收整个 vector,paintEvent 外面套一层按通道遍历、按通道取颜色的循环。改动量大概三十行,难点只在配色别选太接近的。
| 改造点 | 原实现 | 改法 | 注意 |
|---|---|---|---|
| 多通道 | 单个环形缓冲 | QVector<RingBuffer<double,600>> | 各通道时间戳对齐,别混用一个 x 轴 |
| 落盘 | 无 | QFile+QTextStream按行写 CSV | 每 200 行 flush 一次,别每条都 flush |
| 报警 | 单一上下限 | 每通道独立阈值 + 回差 | 回差防抖,否则边界上会疯狂闪烁 |
| 回放 | 无 | 读 CSV 走同一套push接口 | 复用curvedwidget,别写第二套绘制 |
第二步落盘。别在数据到达的槽里直接write加flush,500 ms 一条无所谓,50 ms 一条时磁盘 IO 会变成瓶颈。做法是先进一个QQueue<QString>,用一个 1 秒的定时器批量写,一次写几十行再一次 flush,文件头在打开时先写一行timestamp,ch,temp。
void DataRecorder::onTick() { if (m_queue.isEmpty()) return; QTextStream out(&m_file); while (!m_queue.isEmpty()) { out << m_queue.dequeue() << '\n'; // 一行一条,CSV 直接可读 } out.flush(); // 一秒一次,够用且不伤盘 }第三步验证。改完别急着上设备,先写一个FakeSource类,用QTimer每 100 ms 造一个正弦加噪声的值,走和真实串口完全相同的temperatureArrived信号。这样做的好处是:曲线卡不卡、内存涨不涨、多通道配色能不能分清,全都能在没有硬件的情况下压测半小时以上。我一般会把FakeSource留一个快捷键开关,交付的时候不删,下次现场出问题,先切到模拟源,能复现就是软件问题,不能复现就去查线和设备,排查路径一下短一半。
那次内存飙到 1.2 G 的事故之后,我给自己定了个硬规矩:凡是带实时曲线的上位机,绘制容器必须定长、重绘必须节流、部署必须在干净机器上双击一遍。这三条现在写进了我自己的项目模板里,新建工程就带着,省得每次靠记性。希望这些能帮到你,少走一遍我踩过的路。
本文还有配套的精品资源,点击获取