简介:一份基于Qt框架实现串口温度采集与实时曲线显示的小型工程源码,面向正在学习Qt上位机开发、串口通信与嵌入式数据可视化的初学者及在校学生。工程围绕串口接收温度数据这一主线,演示了QSerialPort的打开与参数配置、原始数据的解析与数值转换,以及借助信号与槽机制把新数据推送到界面刷新的过程;温度曲线依托QGraphicsView与QGraphicsScene绘制,配合QTimer定时读取,保证显示连续、刷新及时。压缩包共9个文件,体积约11KB,其中3个.h与3个.cpp承担类声明和逻辑实现,1个.ui描述窗口布局,1个.pro工程文件用于qmake构建,导入Qt Creator即可编译运行。目前已有6220人学习下载。读者可获取串口调试、数据解析、曲线绘制与界面刷新的可复用写法,并以此为骨架扩展多通道采集、数据存储或报警提示等功能。
1. 需求先落地:Qt 串口温度实时显示界面到底要解决什么
设想一台正在运行的恒温设备,本地表头只显示当前温度值,历史趋势、超限报警、数据存档全靠人盯着。这类需求在课程设计和毕业设计里出现频率极高:用 Qt 做一个串口温度显示界面,把下位机通过串口上报的温度数据实时画成曲线,同时在界面上给出当前值、极值、报警状态。听上去是拖几个控件、再调一次readAll()的事,真正做起来,粘包、坏帧、拔插重连、曲线卡顿会一个接一个冒出来。这套方案按可交付的标准拆开讲:帧协议怎么定、线程怎么分、实时曲线怎么刷、数据怎么落盘、验收怎么测,适合要交一份完整上位机设计的同学,也适合需要给自研设备配一个监控界面的开发者。
2. 总体设计:三层结构、一条帧协议与四个模块的职责划分
动手写第一行代码之前,最值得花时间的是把「谁负责读串口、谁负责拆帧、谁负责画图」这三件事分开。串口是阻塞型外设,界面是事件驱动,两者塞进同一个线程,短时间看不出问题,跑上十几分钟就会出现拖动窗口时曲线停顿、拔插串口时整个界面假死的现象。方案按三层来切:采集层只碰字节流,解析层只认帧结构,展示层只处理已经合法的温度样本。
2.1 三层架构与线程模型
采集层是SerialWorker,内部持有QSerialPort和一个字节累积缓冲区,运行在独立的工作线程里;解析层与采集层同线程,负责帧头定位、长度校验、CRC 校验、负载解码,输出一个Frame结构体;展示层是MainWindow,只接收已经通过校验的样本,更新数值面板、追加曲线缓存、触发报警判定和落盘。
跨层的数据传递统一走信号槽,连接方式用默认的Qt::AutoConnection即可:对象分属两个线程时 Qt 会自动排队投递,等价于Qt::QueuedConnection。这条规则带来的约束是Frame必须注册为元类型,否则排队投递时参数会被丢弃并只在运行期打印一句警告——这类警告在控制台日志里很容易被忽略,属于典型的前期埋雷。
| 层次 | 承载类 | 所在线程 | 输入 | 输出 |
|---|---|---|---|---|
| 采集层 | SerialWorker | 工作线程 | 串口字节流 | 原始QByteArray |
| 解析层 | FrameParser | 工作线程 | 原始字节 | Frame(已校验) |
| 展示层 | MainWindow | UI 线程 | Frame | 曲线、面板、CSV |
线程划分的判定标准很朴素:凡是可能阻塞超过 10 毫秒的操作,都不能放在 UI 线程。串口读取本身不慢,但打开串口、等待设备枚举、异常重连这三件事都可能卡住几百毫秒,所以它们必须留在工作线程。
2.2 串口帧协议:帧头、长度、命令字与 CRC16
协议是整个方案的地基。下位机上报温度时如果只是简单地把浮点数转成字符串加个换行,前期联调很舒服,一旦线路有干扰或者缓冲区被截断,界面就会出现 −127.00、85.00 这种诡异数值,而且很难定位是传感器坏了还是解析错了。方案采用带帧头和校验的定长头 + 变长负载结构。
| 偏移 | 字段 | 字节数 | 说明 |
|---|---|---|---|
| 0–1 | 帧头 | 2 | 固定0xAA 0x55,用于重同步 |
| 2 | 负载长度 | 1 | 取值范围 1–64,负载字节数 |
| 3 | 命令字 | 1 | 0x01温度数据,0x02心跳,0x03设备信息 |
| 4 起 | 负载 | N | 通道号 1 字节 + float32 温度 4 字节 |
| 末尾 2 字节 | CRC16 | 2 | Modbus 多项式,小端存放 |
单帧总长度 = 2 + 1 + 1 + N + 2。温度负载取 5 字节,一帧 11 字节,在 9600 波特率下满速上报也不会挤占带宽。命令字单独留出来的好处是后续加心跳、加设备序列号、加多点温度巡检都不需要改帧结构,只在解析分支里加 case。
提示:CRC 的计算范围必须和下发固件严格对齐,通常约定为「长度 + 命令字 + 负载」,不含帧头也不含 CRC 自身。协议文档里写清楚这一句,能省掉半天的联调。
2.3 四个核心模块与数据流
模块划分的目标是让每个类的修改理由只有一个。串口配置改了只动采集模块,协议升级只动解析模块,换绘图库只动展示模块,换存档格式只动存储模块。
| 模块 | 关键类 | 职责 | 对外接口 |
|---|---|---|---|
| 串口采集 | SerialWorker | 打开/关闭端口、读字节、异常重连 | openPort、closePort、frameReady |
| 协议解析 | FrameParser | 切帧、CRC、负载解码、坏帧统计 | feed(const QByteArray&)、takeFrames() |
| 显示 | MainWindow、ChartWidget | 曲线、数值面板、状态灯、报警 | appendSample、setAlarm |
| 存储配置 | CsvLogger、QSettings | CSV 按天轮转、参数持久化 | writeRow、load/save |
数据流的完整路径是:readyRead触发 → 字节追加进缓冲区 → 解析器循环取帧 → 合法帧通过信号发往 UI 线程 → 主窗口写入环形缓存并更新面板 → 200 毫秒定时器统一重绘曲线 → 同时把样本交给 CSV 记录器。注意最后两步是解耦的:绘图有节流,落盘不节流,两者互不等待。
2.4 工程骨架与构建配置
Qt 的串口模块属于附加模块,安装器和.pro里都要显式声明,否则编译期报QSerialPort: No such file or directory,很多同学会误以为是环境变量没配好。
# TempMonitor.pro QT += core gui serialport greaterThan(QT_MAJOR_VERSION, 4): QT += widgets CONFIG += c++17 TEMPLATE = app TARGET = TempMonitor SOURCES += \ main.cpp \ mainwindow.cpp \ serialworker.cpp \ frame.cpp \ csvlogger.cpp HEADERS += \ mainwindow.h \ serialworker.h \ frame.h \ csvlogger.h FORMS += mainwindow.ui # QCustomPlot 以单文件方式引入,编译进工程即可,不额外依赖模块 include(qcustomplot/qcustomplot.pri)QT += serialport是硬性依赖;c++17让std::optional、结构化绑定在解析代码里可用;QCustomPlot 用.pri引入而不是编成动态库,好处是发布时少一个 DLL,坏处是首次编译时间长一点。如果学校机房装的是精简版 Qt,串口模块很可能没勾选,重新跑一遍维护工具补装即可,不需要重装整个 Qt。
Frame定义成值类型,避免在信号里传指针带来生命周期问题:
// frame.h struct Frame { quint8 channel; // 通道号,从 0 开始 float celsius; // 摄氏温度 qint64 timestampMs; // 上位机接收时刻,用于画曲线横轴 quint8 quality; // 0 正常 1 传感器断线 2 超量程 3 CRC 曾出错 }; Q_DECLARE_METATYPE(Frame) // 必须注册,否则跨线程信号会被丢弃quality字段是这套设计里比较关键的一笔。很多方案把坏数据直接丢掉,结果曲线出现断点却不知道原因;把质量位带上,界面就能显示「数据可疑」而不是假装一切正常。
3. 串口采集与协议解析:从 readyRead 到一条可信的温度记录
这一章是整套方案里最容易翻车的部分。串口数据不是按帧到达的,可能一次来半个帧,也可能一次来三个半帧,还可能因为接触不良夹着随机噪声。解析器必须假设「输入永远是脏的」,才能在各种现场条件下活下来。
3.1 QSerialPort 初始化:参数、线程归属与打开失败的判定
QSerialPort对象必须在它所使用的线程里创建,或者至少在被使用前moveToThread,不能在 UI 线程 new 出来再拿到工作线程去读。方案的写法是把 worker 整体移到线程,端口对象作为它的成员在openPort里按需创建。
// serialworker.cpp void SerialWorker::openPort(const QString &portName, int baudRate) { if (m_port && m_port->isOpen()) m_port->close(); if (!m_port) { m_port = new QSerialPort(this); // 归属工作线程,随 worker 一起销毁 connect(m_port, &QSerialPort::readyRead, this, &SerialWorker::onReadyRead); connect(m_port, &QSerialPort::errorOccurred, this, &SerialWorker::onPortError); } m_port->setPortName(portName); m_port->setBaudRate(baudRate); // 与下位机一致,常见 9600 / 115200 m_port->setDataBits(QSerialPort::Data8); m_port->setParity(QSerialPort::NoParity); m_port->setStopBits(QSerialPort::OneStop); m_port->setFlowControl(QSerialPort::NoFlowControl); m_port->setReadBufferSize(64 * 1024); // 防止上层处理慢时无限增长 if (!m_port->open(QIODevice::ReadWrite)) { emit portError(QStringLiteral("打开 %1 失败:%2") .arg(portName, m_port->errorString())); return; } m_buffer.clear(); // 换端口必须清空残留半帧 emit portStateChanged(true); }参数里唯一需要和下位机逐字对齐的是波特率、数据位、校验位、停止位这四项,任何一项不一致的表现都是「能打开但全是乱码」。setReadBufferSize设成 64 KB 是给上层留缓冲余量:如果解析逻辑偶发卡顿,驱动层不会立刻丢数据。open失败最常见的原因是端口被别的程序占用,比如串口助手没关,errorString()会明确写出来,界面上原样显示即可,不要自己编一句「串口错误」。
3.2 粘包与半包:环形缓冲 + 帧头重同步的解析循环
解析器的核心是一个循环:只要缓冲区里还存在完整且校验通过的帧,就不断取出来;取不出来就退出等待下一次readyRead。帧头定位用indexOf而不是假定数据从第 0 字节开始,这一步是抗干扰的关键。
// serialworker.cpp void SerialWorker::onReadyRead() { m_buffer.append(m_port->readAll()); Frame f; while (m_parser.tryParseOne(m_buffer, f)) { emit frameReady(f); // 队列投递到 UI 线程 } // 缓冲区保护:长时间找不到帧头就丢弃,避免内存缓慢增长 if (m_buffer.size() > 4096) m_buffer = m_buffer.right(256); }tryParseOne的逻辑按顺序做四件事,每一步失败都返回 false 并保留剩余数据等待下一次:
// frame.cpp bool FrameParser::tryParseOne(QByteArray &buf, Frame &out) { // 1. 找帧头,找不到就只保留最后 1 字节(可能是被截断的半个帧头) const int head = buf.indexOf(QByteArray::fromHex("AA55")); if (head < 0) { if (buf.size() > 1) buf = buf.right(1); return false; } if (head > 0) buf.remove(0, head); // 丢掉帧头之前的噪声 if (buf.size() < 4) return false; // 头 + 长度 + 命令字 const quint8 len = quint8(buf.at(2)); const int total = 2 + 1 + 1 + len + 2; // 含 CRC 的整帧长度 if (len == 0 || len > 64) { // 长度字段非法,跳过一个字节重找 buf.remove(0, 1); return false; } if (buf.size() < total) return false; // 半包,等下一次数据 // 2. CRC 校验,范围是「长度 + 命令字 + 负载」 const QByteArray body = buf.mid(2, 2 + len); const quint16 crcRecv = quint16(quint8(buf.at(total - 2))) | (quint16(quint8(buf.at(total - 1))) << 8); if (crc16Modbus(body) != crcRecv) { ++m_badFrameCount; buf.remove(0, 1); // 只丢一个字节,允许帧头重同步 return false; } // 3. 解码负载:通道号 1 字节 + float32 小端 out.channel = quint8(body.at(2)); const quint32 raw = qFromLittleEndian<quint32>( reinterpret_cast<const uchar *>(body.constData() + 3)); memcpy(&out.celsius, &raw, sizeof(float)); out.timestampMs = QDateTime::currentMSecsSinceEpoch(); out.quality = 0; buf.remove(0, total); // 4. 消费整帧 return true; }校验失败时buf.remove(0, 1)而不是丢掉整帧,是一个刻意的取舍。如果噪声恰好夹在帧中间,只删一个字节可以让下一次indexOf重新对齐到真正的帧头;如果直接删total字节,很可能把后面一帧的开头一起吃掉,表现为「偶尔丢一条数据」。代价是极端情况下会多循环几次,但每次循环都是内存操作,开销可以忽略。
float的字节序必须显式指定。下位机(常见 STM32、ESP32 一类)通常是小端,但协议文档里不写清楚,换一块板子就可能整条曲线数值离谱。用qFromLittleEndian转成quint32再memcpy到float,比直接reinterpret_cast安全,也避免了对齐问题。
3.3 校验、超时与坏帧处理:把 −127 和 85 挡在界面外面
CRC 只能挡住传输错误,挡不住「传感器本身出问题」。数字温度传感器断线时经常返回 −127,上电瞬间返回 85,这两个值如果原样画进曲线,纵轴范围会被瞬间拉爆,前面几十分钟的趋势全被压成一条直线。方案在解析层之后加一道合理性判定。
// 合理性判定,放在解析成功之后、发信号之前 static quint8 checkQuality(float t) { if (t <= -100.0f || t >= 150.0f) return 2; // 超量程,多半是断线或短路 if (qFuzzyCompare(t, 85.0f)) return 1; // 上电默认值,标记为可疑 return 0; }判定阈值按实际被测对象来定:恒温槽一般工作在 0–100 ℃,量程放宽到 −100–150 ℃ 足够;如果做的是低温或高温场景,把常量换成配置项。质量位非 0 的样本照常写进 CSV,但在曲线上用不同颜色或断点表示,数值面板显示--而不是一个假数字。
另一道保险是超时看门狗。下位机可能死机、可能线被拔掉,此时串口仍然打开但不再有数据。主窗口持有一个 3 秒定时器,每收到一帧就重置;超时未重置就把状态灯打成黄色并记录一条「数据超时」,不要直接弹窗,弹窗会打断正在进行的操作。
3.4 断线重连:errorOccurred + 端口热插拔扫描
USB 转串口设备被拔掉时,QSerialPort会发出errorOccurred,其中ResourceError表示设备消失,PermissionError表示被占用,DeviceNotFoundError表示端口不存在。三种情况的重连策略不同:设备消失要等它回来,被占用要提示用户,端口不存在则刷新列表。
// serialworker.cpp void SerialWorker::onPortError(QSerialPort::SerialPortError err) { if (err == QSerialPort::NoError) return; const bool needReconnect = (err == QSerialPort::ResourceError || err == QSerialPort::DeviceNotFoundError); emit portStateChanged(false); emit portError(m_port->errorString()); if (needReconnect && !m_reconnectTimer->isActive()) m_reconnectTimer->start(2000); // 2 秒后重试,避免高频刷日志 } void SerialWorker::tryReconnect() { const QString target = m_lastPortName; if (target.isEmpty()) return; // 端口列表里重新出现同名设备才尝试打开,避免无效 open 刷屏 const auto ports = QSerialPortInfo::availablePorts(); for (const QSerialPortInfo &info : ports) { if (info.portName() == target || info.systemLocation() == target) { openPort(target, m_lastBaudRate); return; } } }重连定时器跑在工作线程里,重试动作也发生在工作线程,界面上只反映状态变化。这里有个容易踩的坑:在errorOccurred槽里直接调用open()会形成递归——打开失败又触发错误信号,栈很快溢出。方案用定时器把重连推迟到下一个事件循环,天然打断递归。
4. 实时显示与数据落盘:曲线、面板、CSV 三件事怎么不打架
采集侧稳定之后,界面侧的矛盾才开始显现:曲线要平滑、面板要即时、CSV 要完整,三者的刷新频率和开销根本不在一个量级。做法是把它们拆成三种节奏——数据到达即入缓存,曲线按固定频率重绘,CSV 按行追加并按批落盘。
4.1 绘图库选型:QCustomPlot 与 Qt Charts 的取舍
Qt 生态里画实时曲线主要两条路,选型直接影响后面几个月的开发体验。
| 维度 | QCustomPlot | Qt Charts |
|---|---|---|
| 引入方式 | 两个文件编进工程 | QT += charts模块 |
| 千点级刷新 | 流畅,支持队列重绘 | 较流畅,早期版本有抖动 |
| 交互定制 | 源码在手,改起来自由 | 受限于官方 API |
| 授权 | GPL / 商业双授权 | 随 Qt 模块授权 |
| 上手成本 | 中等,文档偏示例式 | 较低,官方示例多 |
毕业设计和小型上位机场景里,QCustomPlot 更常见,原因是单文件引入、发布少一个依赖、遇到性能问题能直接改源码。Qt Charts 的优势是官方维护、和 Qt Designer 集成更好。两条路都能交差,选一条走到底就行,中途换库的代价远大于选错库。
4.2 滑动窗口与刷新节流:1Hz 数据画成 60fps 的手感但只花 5Hz 的算力
曲线卡顿的头号原因不是数据多,而是「来一帧就重绘一次」。串口一次readyRead可能带来十几帧,如果每帧都调replot(),绘图线程会被彻底占满。方案的写法是数据到达只进缓存,重绘由固定定时器驱动。
// chartwidget.cpp void ChartWidget::appendSample(qint64 ms, double value) { m_keys.append(ms / 1000.0); m_values.append(value); // 滑动窗口:1Hz 上报下保留 600 点,约 10 分钟趋势 constexpr int kMaxPoints = 600; while (m_keys.size() > kMaxPoints) { m_keys.removeFirst(); m_values.removeFirst(); } m_dirty = true; // 只打标记,不在这里重绘 } void ChartWidget::onRefreshTimer() { if (!m_dirty) return; // 没有新数据就不动,省掉无谓重绘 m_dirty = false; m_graph->setData(m_keys, m_values); m_plot->xAxis->setRange(m_keys.isEmpty() ? 0 : m_keys.last() - 600, m_keys.isEmpty() ? 60 : m_keys.last()); m_plot->replot(QCustomPlot::rpQueuedReplot); // 合并重绘请求 }刷新定时器设 200 毫秒,即 5 Hz。人眼对曲线滚动的感知在这个频率下已经足够顺滑,而绘图开销只有「每帧重绘」的二十分之一左右。rpQueuedReplot的作用是把多次重绘请求合并成一次,连续调用不会叠加。
纵轴范围不要每帧自适应,否则曲线会随着一个尖峰上下猛跳,看着非常晕。方案固定纵轴范围,超范围的点用裁剪显示;只有在用户手动缩放时才临时切换成自适应。
4.3 数值面板、报警与状态灯:QSS 和阈值判定
面板部分要做的是「一眼能看出正常还是异常」。温度值用大字号,超限变红并加粗,状态灯用色块表示串口连接、数据超时、报警三种状态。样式的切换用动态属性驱动 QSS,比在代码里硬编码颜色好维护。
// mainwindow.cpp void MainWindow::refreshPanel(const Frame &f) { const bool valid = (f.quality == 0); ui->tempValue->setText(valid ? QString::number(f.celsius, 'f', 2) : QStringLiteral("--")); const bool alarm = valid && (f.celsius < m_lowLimit || f.celsius > m_highLimit); ui->tempValue->setProperty("alarm", alarm); ui->tempValue->style()->unpolish(ui->tempValue); ui->tempValue->style()->polish(ui->tempValue); // 属性变了必须重新 polish }/* 温度数值的两种状态,属性选择器直接读 setProperty 的值 */ QLabel#tempValue { font-size: 40px; color: #1f2937; } QLabel#tempValue[alarm="true"] { color: #dc2626; font-weight: bold; }unpolish+polish这两行是 QSS 动态属性的固定套路,漏掉就会出现「属性改了但颜色不变」的玄学现象。报警除了变色,还要记一条事件日志(时间、通道、数值、阈值),答辩演示时这条日志比曲线更有说服力。
4.4 CSV 按天轮转与 QSettings 配置持久化
存档格式选 CSV,理由是简单、能直接用表格软件打开、答辩时能当场展示原始数据。按天轮转可以避免单个文件无限膨胀,也方便按日期回溯。
// csvlogger.cpp void CsvLogger::writeRow(const Frame &f) { const QString day = QDateTime::fromMSecsSinceEpoch(f.timestampMs) .toString("yyyy-MM-dd"); if (day != m_currentDay) rotate(day); // 跨天换文件并写表头 QTextStream ts(m_file); #if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) ts.setCodec("UTF-8"); #else ts.setEncoding(QStringConverter::Utf8); #endif ts << f.timestampMs << ',' << int(f.channel) << ',' << QString::number(f.celsius, 'f', 3) << ',' << int(f.quality) << '\n'; if (++m_pending >= 20) { // 每 20 行强制落盘一次 m_file->flush(); m_pending = 0; } }每 20 行flush一次是可靠性和性能的折中:逐行 flush 会让磁盘 IO 成为瓶颈,完全不 flush 则在程序异常退出时丢掉最后一批数据。如果对数据完整性要求更高,可以改成QSaveFile或者定时 fsync。
配置持久化用QSettings,把串口名、波特率、报警上下限、通道名称、窗口大小全部存进 ini 文件,下次启动自动恢复。这一步在答辩时体现为「关掉程序再打开,参数还在」,是个很直观的加分项。
QSettings s(QCoreApplication::applicationDirPath() + "/config.ini", QSettings::IniFormat); s.setValue("serial/port", m_lastPortName); s.setValue("serial/baud", m_lastBaudRate); s.setValue("alarm/low", m_lowLimit); s.setValue("alarm/high", m_highLimit); s.setValue("ui/geometry", saveGeometry());5. 避坑与排查:这套串口温度界面最容易翻车的五处
下面五条都是我在类似项目里真实遇到过或者帮别人排查过的问题,按「现象 → 原因 → 解决」写,方便对着症状找。
5.1 采集与解析链路上的三个坑
坑一:曲线越跑越卡,最后整个界面拖不动。现象是刚启动很流畅,跑十几分钟后拖动窗口有明显延迟,任务管理器里 CPU 占用持续走高。原因是readyRead里直接调了replot(),而串口一次可能送来十几帧,等于一秒钟重绘几十次,绘图开销随数据量线性增长。解决办法是把「收数据」和「画数据」拆开:收到即入缓存,200 毫秒定时器统一重绘,并且加rpQueuedReplot合并请求。改完之后同样数据量下 CPU 从 40% 降到 5% 以内。
坑二:温度偶发跳到 −127 或者 85,曲线被拉成一条直线。现象是趋势图每隔一段时间出现一个极端尖峰,然后纵轴范围自动放大,前面的正常数据被压平。原因有两层:一是传感器断线或上电瞬间输出的固定值被当成真实温度;二是纵轴每帧自适应,一个异常点就能毁掉整张图。解决办法是解析后加合理性判定,把超量程和默认值标记成质量位而不是直接画出来;纵轴改用固定范围,只在用户手动操作时切换。这两处一起改,曲线立刻稳。
坑三:拔掉 USB 转串口再插回来,程序崩溃或者再也打不开端口。现象是热插拔之后程序闪退,或者界面上一直显示「打开失败」,重启程序才能恢复。原因是重连逻辑写在errorOccurred槽里直接调open(),失败又触发错误信号,形成递归;另外端口对象可能在错误的线程里被销毁。解决办法是用定时器把重连推迟到下一个事件循环,重连前先用QSerialPortInfo::availablePorts()确认设备已经回来;端口对象统一作为 worker 的成员,随 worker 在线程结束时销毁,不在别处delete。
5.2 显示与部署上的两个坑
坑四:自己机器上跑得好好的,拷到别人电脑上打不开串口。现象是双击 exe 之后提示找不到串口模块,或者干脆启动就报缺少平台插件。原因是 Qt 的串口模块是附加模块,动态链接方式下需要把对应 DLL 一起带上,平台插件目录platforms/也不能少。解决办法是用windeployqt生成完整依赖目录,再手动确认串口 DLL 在不在;打包前在一台没装过 Qt 的机器上跑一遍,这一步不能省。Linux 环境下还要确认当前用户对串口设备有读写权限。
坑五:连续运行一整天,内存缓慢上涨,CSV 少了几百行。现象是任务管理器里内存曲线一直往上爬,翻看存档文件发现某些时段缺数据。原因通常是两处:曲线缓存用了QVector::append但没做窗口裁剪,点数无限增长;CSV 用了QTextStream但从不flush,缓冲区里的数据在异常退出时全部丢失。解决办法是缓存严格按固定长度裁剪,CSV 按行数或时间批量落盘,日志文件按天轮转。这两个问题都不会在短时间演示里暴露,答辩前建议挂机跑两小时再看。
6. 验收与答辩:一份能当场演示的测试清单和三条讲解主线
功能写完不等于能交。验收要拿清单一项一项打勾,演示要能在三分钟内讲清楚「做了什么、为什么这么做、怎么证明它是对的」。下面这份清单可以直接抄进文档。
| 编号 | 验收项 | 判定标准 | 测试方法 |
|---|---|---|---|
| 1 | 串口打开 | 选择端口后 1 秒内连接成功,状态灯转绿 | 逐个端口试开,含被占用的情况 |
| 2 | 数据解析 | 连续 10 分钟无错帧、无丢帧 | 下位机 1 Hz 上报,对照计数 |
| 3 | 实时曲线 | 窗口滚动平滑,CPU 占用低于 10% | 任务管理器观察 10 分钟 |
| 4 | 异常值处理 | 断线时显示--,曲线标记断点 | 拔掉传感器信号线 |
| 5 | 报警 | 超阈值 1 秒内变色并记录事件 | 手动改阈值触发 |
| 6 | 断线重连 | 拔插 USB 后 5 秒内自动恢复 | 反复拔插 10 次 |
| 7 | 数据存档 | CSV 行数与上报帧数一致 | 用脚本统计比对 |
| 8 | 配置持久化 | 重启后串口、阈值、窗口位置不变 | 关闭再打开 |
没有硬件的时候,用 Python 造帧是最快的联调手段,也是答辩演示的备选方案——万一现场设备罢工,直接跑脚本就能让界面动起来。
# simulator.py,向虚拟串口按 1Hz 发送温度帧 import struct, time, math, serial def crc16_modbus(data: bytes) -> int: crc = 0xFFFF for b in data: crc ^= b for _ in range(8): crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1 return crc def build_frame(ch: int, temp: float) -> bytes: payload = struct.pack('<Bf', ch, temp) # 通道号 + float32 小端 body = bytes([len(payload), 0x01]) + payload # 长度 + 命令字 + 负载 return b'\xAA\x55' + body + struct.pack('<H', crc16_modbus(body)) port = serial.Serial('COM3', 9600, timeout=1) # 换成虚拟串口对的一端 t = 0.0 while True: temp = 25.0 + 5.0 * math.sin(t / 30.0) # 模拟缓慢升温降温 port.write(build_frame(0, temp)) t += 1.0 time.sleep(1.0)脚本里的struct.pack('<Bf', ...)对应解析端的「通道号 1 字节 + float32 小端」,CRC 计算范围是「长度 + 命令字 + 负载」,和上位机完全一致。跑之前先用虚拟串口工具建一对互连端口,一端给脚本,一端给界面。
答辩讲解我一般收成三条主线:第一条讲协议,为什么不用字符串而用带 CRC 的二进制帧,现场展示一个被篡改的帧如何被丢弃;第二条讲线程,说明为什么串口读取必须离开 UI 线程,展示拔插时界面不卡;第三条讲数据完整性,把 CSV 打开,用表格统计行数对上上报帧数。三条讲完,工作量和技术判断都摆在台面上了,比念 PPT 有效得多。
从那以后我每次做带实时曲线的上位机,都会在动手写界面之前先把造帧脚本和验收清单写出来,界面还没成型就能验证解析链路,联调时间至少省一半。希望这套结构能帮到你。
本文还有配套的精品资源,点击获取