简介QT上位机串口实时温湿度显示完整工程是一份基于QT/C的实战项目面向嵌入式开发者、物联网爱好者和学生解决通过串口读取DHT11等传感器数据并实时可视化显示的问题。工程覆盖从串口参数配置、二进制数据解析到界面动态刷新的完整链路适用于Windows/Linux跨平台环境下的上位机学习。压缩包共31个文件包含5个cpp源码、4个h头文件、ui界面文件、pro工程配置、qrc资源及png图片另附可直接运行的exe与release/debug目录整体仅1.72MB结构紧凑。已有1294人学习适合初学者到中级开发者参考。代码中封装了串口读写、异常捕获、多线程数据读取等关键模块可帮助读者深入理解QSerialPort、信号槽和事件驱动机制同时提供了良好的界面设计范例基于此工程可快速扩展出温湿度记录、报警提醒等功能。1. 项目概述与整体设计思路1.1 核心需求解析最近帮一个做环境监测的朋友搓了一套基于QT的串口上位机用来实时显示温湿度数据。这套工程做完之后效果挺理想干脆整理出来分享给有类似需求的朋友。先说清楚这个工程到底解决什么问题底层传感器采集温度、湿度通过单片机STM32、ESP32这类把数据打包成固定帧格式走串口UART发出来。上位机的任务就是把这些字节流收下来拆包、解析、校验最后把温度湿度的数值实时显示在界面上同时最好能画一条趋势曲线。这类需求在实验室环境监控、温室大棚、机房温控、冷链运输、毕业设计里非常常见。技术选型上我用的是QT 5.15.2 Qt SerialPort模块 QCustomPlot绘图控件。选中QT而不是C#或者MFC主要看重三点第一QT的跨平台能力同一套代码在Windows、Linux、嵌入式ARM板子上都能编译跑后面如果想把这套上位机移植到树莓派的触摸屏上代码基本不用动第二QT的信号槽机制天生适合串口这类异步事件驱动的场景串口每收到一帧数据就触发一个信号界面自动刷新不用像MFC那样手动管理消息循环第三QCustomPlot画实时曲线非常顺滑比QT自带的Charts模块轻量得多。这套工程适合谁来参考如果你是刚入门Qt开发的学生或者在做设备调试的嵌入式工程师又或者需要一个简单够用的串口调试与显示工具的开发者这篇文章都能给你一条从零到能跑的完整路径。1.2 系统架构总览整个工程从架构上分三层数据链路层、协议解析层、界面显示层。数据链路层负责最底层的串口读写包括枚举可用串口、配置波特率数据位校验位、收发字节流。这一层直接用Qt SerialPort模块封装在自定义的SerialPortManager类里上层不直接接触QSerialPort对象方便后续替换成TCP或者CAN通信时不动业务代码。协议解析层是整个工程的灵魂。底层设备发来的数据不能直接拿来用必须按照约定的帧格式解包。我在这个工程里用的协议帧是帧头0xAA 0x55 功能码1字节 数据长度1字节 数据区N字节 校验和1字节。帧头用来同步功能码区分数据类型数据区放温湿度原始值校验和保证传输正确。只要是标准协议解析思路换成其他格式比如Modbus RTU也完全兼容后面我会细说怎么改。界面显示层就是用户直接看的窗口包括串口配置区、数据仪表盘、实时曲线图和日志区。这一层用Qt Designer拖控件搭建再用代码做动态更新。界面布局上我用的是左右分栏左侧窄栏放串口配置右侧主区域放曲线和数据面板。2. 环境准备与工程初始化2.1 开发环境搭建与驱动排查在动手写代码之前环境得先弄利索。我用的组合是Qt 5.15.2开源版 MinGW 8.1.0 32位编译器 Qt Creator 4.14。为什么不用MSVC因为MinGW开箱即用不用额外装Visual Studio那一大坨而且串口程序对编译器性能不敏感MinGW完全够用。安装Qt时有个易错点默认安装选项不包含Qt SerialPort模块必须勾选Qt 5.15.2 → MinGW 8.1.0 32-bit下的所有组件或者至少在Qt安装器里确认SerialPort相关的头文件和库被装上了。检查方法很简单装完以后在安装目录下搜一下是否有QtSerialPort文件夹一般在Qt/5.15.2/mingw81_32/include/下面。串口驱动这块如果用的是CH340方案的USB转串口模块插上电脑没反应八成是驱动没装或者系统自动装了个不兼容的版本。去芯片厂商官网直接下CH340驱动装完以后在设备管理器里能看到一个USB-SERIAL CH340这样的COM口。如果是FTDI芯片的线就用FTDI VCP驱动这个在Windows 10及以上版本多半会系统自动安装。排查驱动是否正常的最快方式设备管理器 → 端口(COM和LPT) → 能看到具体的COM编号说明硬件链路OK。看不到就换USB口、换线、重新插拔还不行就手动装驱动。2.2 创建Qt工程与串口模块配置新建工程时选择Qt Widgets Application基类选QMainWindow套件选MinGW 32位。工程名我建议用纯英文不要带中文和空格后面发布打包时能省掉很多不必要的麻烦。关键一步是工程文件.pro里的模块配置。要把串口模块加进去QT core gui serialport charts greaterThan(QT_MAJOR_VERSION, 4): QT widgets CONFIG c11 TARGET TempHumiMonitor TEMPLATE app SOURCES \ main.cpp \ mainwindow.cpp \ serialportmanager.cpp \ protocolparser.cpp HEADERS \ mainwindow.h \ serialportmanager.h \ protocolparser.h FORMS \ mainwindow.ui如果你要用QCustomPlot画曲线就额外把它加入工程把qcustomplot.h和qcustomplot.cpp拷贝到工程目录然后在.pro里加上这两个文件即可。QCustomPlot是开源的直接在官网下载源码包不用编译库直接加到工程里编译这点比很多商业控件方便太多。注意.pro文件里加了serialport模块以后如果编译报错找不到QSerialPort头文件先检查模块名是否拼错是serialport不是serial再确认安装Qt时是否勾选了对应组件。3. 串口通信核心代码实现3.1 串口初始化与参数配置串口类我单独封装了一个SerialPortManager继承QObject这样可以把串口对象、错误处理、数据接收信号都统一管理起来。核心代码如下// serialportmanager.h class SerialPortManager : public QObject { Q_OBJECT public: explicit SerialPortManager(QObject *parent nullptr); ~SerialPortManager(); bool openPort(const QString portName, qint32 baudRate); void closePort(); bool isOpen() const; void sendData(const QByteArray data); signals: void dataReceived(const QByteArray data); void portErrorOccurred(const QString error); private slots: void onReadyRead(); private: QSerialPort *m_serialPort; }; // serialportmanager.cpp bool SerialPortManager::openPort(const QString portName, qint32 baudRate) { if (m_serialPort-isOpen()) m_serialPort-close(); m_serialPort-setPortName(portName); m_serialPort-setBaudRate(baudRate); m_serialPort-setDataBits(QSerialPort::Data8); m_serialPort-setParity(QSerialPort::NoParity); m_serialPort-setStopBits(QSerialPort::OneStop); m_serialPort-setFlowControl(QSerialPort::NoFlowControl); if (!m_serialPort-open(QIODevice::ReadWrite)) { emit portErrorOccurred(打开串口失败: m_serialPort-errorString()); return false; } return true; } void SerialPortManager::onReadyRead() { QByteArray data m_serialPort-readAll(); if (!data.isEmpty()) emit dataReceived(data); }参数配置有几个坑要专门提醒波特率选择底层设备和上位机必须一致任何一端设错都会收到乱码或者完全没数据。这个工程用的是115200是STM32和大部分传感器模块的默认值。如果数据量不大9600也够用且抗干扰性更好。数据位、校验位、停止位常见默认是8位数据、无校验、1位停止位8N1。如果底层工程用的Modbus协议通常是8E1偶校验。这三个参数组合必须完全相同否则会报错或者收到的数据校验失败。打开串口前先检查占用如果你的串口调试助手、Arduino IDE的串口监视器还开着占用COM口程序会打开失败。所以测试时要确保同一时间只有一个程序占用同一个串口。3.2 枚举可用串口与动态刷新界面上需要一个下拉框来选串口号但用户插上USB转串口后又不想重启程序才能看到新串口所以在打开端口按钮里加一个自动刷新void MainWindow::refreshPorts() { ui-comboBoxPort-clear(); foreach (const QSerialPortInfo info, QSerialPortInfo::availablePorts()) { QString portDesc info.portName(); if (!info.description().isEmpty()) portDesc ( info.description() ); ui-comboBoxPort-addItem(portDesc, info.portName()); } }这里用addItem(显示文本, 用户数据)的方式把完整描述显示出来但实际打开端口时从itemData()里取纯端口的名称如COM3这样即使不同系统的串口描述不一样代码逻辑也完全一致。实测心得如果你用虚拟串口软件如VSPD创建一对虚拟串口来做回环测试这个枚举逻辑也能正常识别到不管是物理串口还是虚拟串口在Qt的QSerialPortInfo::availablePorts()里都统一呈现为COMx。开发调试时可以先创建一个虚拟串口对用串口调试助手在另一端发数据来验证上位机解析逻辑是否正确。但注意虚拟串口驱动本身有一些已知问题如果出现打开失败或收不到数据的情况考虑卸载虚拟串口后换个方案测试。3.3 数据发送实现上位机不仅要收还要会发。底层设备可能设计成上位机发指令设备才上报数据的问答模式而不是主动定时上报。所以发送功能必须有。我这里实现了两个发送方式一个是手动单次发送一个是自动定时轮询发送。void MainWindow::on_btnSend_clicked() { QByteArray data ui-txtSend-toPlainText().toUtf8(); if (ui-checkHexSend-isChecked()) { data QByteArray::fromHex(data); } m_serialPortManager-sendData(data); } void MainWindow::startAutoSend() { m_autoSendTimer-start(ui-spinAutoSendInterval-value()); } void MainWindow::onAutoSendTimeout() { QByteArray data QByteArray::fromHex(AA 55 01 02 00 00 02); m_serialPortManager-sendData(data); }发送框里如果勾选十六进制发送就把用户输入的十六进制字符串转换成字节数组不勾选就直接按ASCII字符串发。自动轮询则用Qt的QTimer定时器周期性地向设备发送查询指令。注意设置轮询间隔时不要设太短比如每500ms查询一次是合理的但如果设到10ms一次不仅底层设备可能应答不过来Windows系统对串口写入的调度也会占用较多CPU。4. 协议解析与温湿度数据实时显示4.1 帧格式设计与缓冲区拼接核心难点串口通信是流式的底层字节一个一个到达上层不能保证一次readAll()就能拿到完整的一帧数据。可能一次收到半帧可能一次收到两帧也可能在某一帧中间开始收到数据。所以必须有一个接收缓冲区把每次读到的数据追加进去然后不断从缓冲区里解析出完整的一帧。这一步非常关键也是很多新人踩坑的重灾区。我在ProtocolParser类里实现了一个状态机缓冲解析// protocolparser.h class ProtocolParser : public QObject { Q_OBJECT public: explicit ProtocolParser(QObject *parent nullptr); void appendData(const QByteArray data); void clear(); signals: void temperatureHumidityReceived(float temp, float humi); void logMessage(const QString msg); private: QByteArray m_buffer; void tryParse(); }; // protocolparser.cpp void ProtocolParser::appendData(const QByteArray data) { m_buffer.append(data); tryParse(); } void ProtocolParser::tryParse() { while (true) { // 1. 查找帧头 int headerIndex -1; for (int i 0; i m_buffer.size() - 1; i) { if ((unsigned char)m_buffer[i] 0xAA (unsigned char)m_buffer[i1] 0x55) { headerIndex i; break; } } if (headerIndex 0) { // 缓冲区里没有帧头说明收到的全是乱数据清空 m_buffer.clear(); return; } // 2. 丢弃帧头之前的数据 if (headerIndex 0) m_buffer.remove(0, headerIndex); // 3. 检查帧长是否足够 int frameLen 4 (unsigned char)m_buffer[3] 1; // 头2功能码1长度1数据区校验1 if (m_buffer.size() frameLen) return; // 数据还不够一帧等下次appendData再解析 // 4. 校验和 unsigned char sum 0; for (int i 0; i frameLen - 1; i) sum (unsigned char)m_buffer[i]; if (sum ! (unsigned char)m_buffer[frameLen - 1]) { emit logMessage(校验失败丢弃第一字节继续寻找下一帧头); m_buffer.remove(0, 1); continue; } // 5. 校验通过解析数据 unsigned char funcCode (unsigned char)m_buffer[2]; QByteArray payload m_buffer.mid(4, m_buffer[3]); if (funcCode 0x01) { // 温湿度上报帧 if (payload.size() 4) { qint16 tempRaw (qint16)((unsigned char)payload[0] 8 | (unsigned char)payload[1]); qint16 humiRaw (qint16)((unsigned char)payload[2] 8 | (unsigned char)payload[3]); float temperature tempRaw / 10.0f; float humidity humiRaw / 10.0f; emit temperatureHumidityReceived(temperature, humidity); } } // 6. 移除已解析的帧 m_buffer.remove(0, frameLen); } }这里解释一下为什么协议里温度湿度各用2字节int16然后除以10得到实际值。如果直接用1字节表示0到255的温度精度只有1度对于温湿度监测场景精度不够。用int16表示的原始值范围是-3276.8到3276.7度完全够用同时除以10以后保留一位小数满足一般的环境监测精度需求。如果你想要更高精度比如0.1%RH的湿度精度可以把除的系数改成100底层也要相应放大100倍发送。再次强调这类帧格式只是一种约定实际应用时务必以你自己底层设备的硬件协议为准。常见的协议格式有Modbus RTU功能码寄存器地址寄存器数量CRC16校验、帧头帧尾式如我上面用的、或者带转义字符的HDLC式。无论哪种格式缓冲拼接 → 找帧头 → 按长度取帧 → 校验 → 解析这个处理框架是通用的你只需要改帧头查找方式和校验算法即可。4.2 信号槽联动从解析到界面刷新数据解析完成之后怎么更新界面的显示靠的是信号槽机制。在MainWindow构造函数里建立连接// 串口收到的原始数据 → 喂给协议解析器 connect(m_serialPortManager, SerialPortManager::dataReceived, m_parser, ProtocolParser::appendData); // 解析出温湿度 → 刷新界面和曲线 connect(m_parser, ProtocolParser::temperatureHumidityReceived, this, MainWindow::onTempHumiReceived); // 日志输出 connect(m_parser, ProtocolParser::logMessage, this, MainWindow::appendLog);onTempHumiReceived里做的事是更新两个QLCDNumber控件显示当前值追加一条记录到表格把数据点喂给QCustomPlot画曲线同时判断是否超过阈值报警温度上限25度这种如果超了就弹窗或者改变背景色提示。这样设计的好处是每一层只管自己的事串口层不关心数据是什么格式解析层不关心界面显示方式界面层不关心数据从哪来。以后如果要改成TCP传输只需要换掉SerialPortManager后面完全不用动。4.3 实时曲线绘制曲线我用QCustomPlot实现比Qt Charts更轻快流畅。初始化时设置好曲线样式和坐标轴// 初始化QCustomPlot曲线 ui-plotWidget-addGraph(); ui-plotWidget-graph(0)-setPen(QPen(QColor(255, 100, 50))); ui-plotWidget-graph(0)-setName(温度(℃)); ui-plotWidget-addGraph(); ui-plotWidget-graph(1)-setPen(QPen(QColor(50, 150, 255))); ui-plotWidget-graph(1)-setName(湿度(%RH)); ui-plotWidget-xAxis-setLabel(时间); ui-plotWidget-yAxis-setLabel(数值); ui-plotWidget-xAxis-setRange(0, 60, Qt::AlignRight); ui-plotWidget-yAxis-setRange(0, 100); ui-plotWidget-legend-setVisible(true);每次收到新数据就往容器里追加void MainWindow::onTempHumiReceived(float temp, float humi) { m_tempData.append(temp); m_humiData.append(humi); // 只保留最近60个点 if (m_tempData.size() 60) { m_tempData.removeFirst(); m_humiData.removeFirst(); } // 更新曲线 QVectordouble x(m_tempData.size()); for (int i 0; i m_tempData.size(); i) x[i] i; ui-plotWidget-graph(0)-setData(x, QVectordouble::fromStdVector( std::vectordouble(m_tempData.begin(), m_tempData.end()))); ui-plotWidget-graph(1)-setData(x, QVectordouble::fromStdVector( std::vectordouble(m_humiData.begin(), m_humiData.end()))); ui-plotWidget-replot(); }这里的思路是维护两个QVector作为环形缓冲只保留最近60个数据点这样曲线窗口看起来就是最新数据从右边进来老数据从左边挤出去的滚动效果。窗口大小可以根据实际情况调整比如要展示一小时的曲线采样周期1秒那就保留3600个点。注意QCustomPlot的数据量大时replot会慢3600点以内基本无压力。4.4 数据保存与导出调试完以后想把数据导出来分析单纯截图不够。我在界面上放了一个导出CSV按钮实现简单可靠void MainWindow::on_btnExport_clicked() { QString filePath QFileDialog::getSaveFileName(this, 保存数据文件, QDateTime::currentDateTime().toString(yyyyMMdd_hhmmss) .csv, CSV文件 (*.csv)); if (filePath.isEmpty()) return; QFile file(filePath); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) return; QTextStream out(file); out 时间,温度(℃),湿度(%RH)\n; for (int i 0; i m_timestampList.size(); i) { out m_timestampList[i].toString(yyyy-MM-dd hh:mm:ss.zzz) , m_tempData[i] , m_humiData[i] \n; } file.close(); }实际使用中导出CSV有几个容易忽略的地方第一CSV文件用Excel打开时中文可能乱码解决办法是用QTextStream显式设置编码为QTextCodec::codecForName(GBK)因为Windows版Excel默认用GBK解析CSV第二如果数据量很大一次性拼接整个文件内容再写入内存占用较高最好用流式写入一行一行写第三导出的时间戳建议用固定的时区格式Qt::ISODate是合规的不要依赖系统时区设置否则换台电脑数据就对不上了。5. 串口调试流程与常见问题排查5.1 从串口调试助手到上位机的联调流程在真正和底层设备联调之前强烈建议先用串口调试助手把你的协议模拟一遍把链路跑通再写界面。我的推荐流程是这样第一步用串口调试助手网上随便下个免费的打开发送端手动按协议帧格式发送一帧数据比如发送AA 55 01 04 00 1E 02 58 A2其中温度原始值0x001E即30湿度原始值0x0258即600除以10就是3.0℃和60.0%RH。如果你的上位机能正确显示3.0和60.0说明协议解析没问题。第二步如果手上没有真实的单片机开发板可以用虚拟串口工具创建一对虚拟串口COM3和COM4注意不要用国内一些捆绑软件的虚拟串口用串口调试助手打开COM4发送帧数据你的上位机打开COM3接收。这样就能完整模拟底层设备→上位机的实际链路。也可以用另一个串口调试助手当作简易设备端来回发数据。第三步联调底层设备时先小数据量验证再上大流量压力测试。比如先让设备每秒发一帧看界面刷新是否正常再改成每50ms发一帧看上位机是否丢帧、界面会不会卡死。5.2 高频问题排查速查表我在整个开发和联调过程中整理了下面这张排查表基本上串口上位机遇到的所有问题都能在这里找到参考答案现象可能原因排查与解决方案打不开串口端口被占用关闭串口调试助手等其他占用程序打不开串口权限不足Linux下需要sudo usermod -aG dialout $USER后重新登录Windows下以管理员身份运行打不开串口驱动问题设备管理器检查CH340/FTDI驱动状态重装对应驱动能打开但收不到数据波特率不一致核对底层设备串口配置用调试助手确认发送参数收数据全是乱码波特率或数据格式不一致检查波特率、数据位、校验位、停止位四项是否完全一致收到的数据时灵时不灵接错线或干扰确认TX接RX、RX接TX、GND共地更换短线或用屏蔽线一次只能解析出半帧缓冲区处理没写好参考上文tryParse的缓冲拼接逻辑校验永远失败校验算法不一致和底层开发者确认是累加和、CRC16还是异或校验逐字节核对算法界面卡死、无响应在GUI线程里做了耗时操作大文件读写或批量解析放到QThread里做曲线刷新很卡数据点太多导致replot慢限制点数量或只在有新数据后才replot温度值显示负数不对有符号与无符号转换错误确认用的是qint16还是quint16比如-10度在int16里是0xFFF6这里要特别强调两个我踩过坑的细节。第一个是串口接收事件里不要做耗时操作。onReadyRead是在Qt的事件循环线程里执行的如果你在这里面写数据库、写文件、弹窗或者做了大量计算串口缓冲区可能会被填满新到的数据来不及读取就会丢。正确做法是onReadyRead里只做readAll()和发信号耗时的数据处理扔给工作线程或者用定时器延迟处理。第二个是连接信号槽时注意接收者的生命周期。如果你在解析器里emit信号后接收者对象已经被销毁了程序可能直接崩溃。特别是在关闭窗口时先断开所有连接再delete对象否则很容易出现QObject::connect: Cannot queue arguments这类报错。5.3 发布部署与绿色免安装打包开发完以后要把程序发给别人用有个经典的坑直接复制exe到别人电脑上双击报错缺少Qt5Core.dll。这是因为Qt程序依赖很多动态库需要一起打包发布。最简单可靠的方式是使用Qt自带的windeployqt工具。在Qt命令行环境开始菜单里的Qt 5.15.2 (MinGW 8.1.0 32-bit)里执行cd /d D:\build-TempHumiMonitor-Desktop_Qt_5_15_2_MinGW_32_bit-Release windeployqt TempHumiMonitor.exe它会自动把需要的DLL和插件拷到exe所在目录。然后为了免安装绿色发布可以再用NSIS或Enigma Virtual Box把整个目录打包成一个单独的exe发给别人直接双击就能跑不用装Qt环境。这里有个重点如果程序里用了QCustomPlot因为它只是两个源文件直接编译进程序的不需要额外部署但如果用了数据库QtSQL或QML等模块windeployqt可能不会自动带上所有插件需要在plugins目录里手动确认需要的组件存在。6. 扩展方向与我的实战心得这套工程目前是一个单串口、双变量温度和湿度、单设备的基础版本。如果你有进一步的需求扩展方向非常明确多设备接入底层如果有多块板子可以在协议帧的功能码里加入设备地址字段上位机解析时按设备地址分流界面用Tab页或不同颜色的曲线区分。数据存储与回放我自己最常用的扩展是把实时数据写入MySQL或SQLite数据库这样以后可以按时间段查询历史数据生成报表或者回放曲线。Qt的QSqlDatabase模块做这个很方便但要注意写库操作放到工作线程避免卡界面。Web远程监控如果不止在现场看还想在手机浏览器上远程看温湿度可以用Qt把数据再通过TCP/WebSocket转发到一个轻量级服务端或者直接换用QUdpSocket把数据广播到局域网内。这个上位机的串口解析核心代码可以完全复用只需要把数据链路层从串口换为网络通信。界面风格优化在工程属性里设置QApplication::setStyle(Fusion)或加载QSS样式表就能让界面看起来现代很多。QSS的语法和CSS类似改起来门槛很低。最后说几句实在话。开发这类串口上位机真正花时间的地方不在界面和代码量而在协议设计的合理性和边界情况的处理上。我当初调试一个帧头错位、校验和偶发不通过的问题前前后后花了整整一天。后来用十六进制显示模式把原始数据全部打出来仔细比对每一帧才发现是底层发送时在帧与帧之间多插入了一个0x00字节。所以如果你遇到解析不稳定最有效的排查手段就是把接收到的原始数据用hex形式完整打印到日志区肉眼比对比猜快得多。还有一点关于上位机开发面试或者课程设计的建议如果你把这个工程作为课程设计或者面试项目不要只说我做了个显示温湿度的上位机。更专业的说法是我设计了一套基于自定义帧格式的串口通信协议实现了数据的成帧、校验和异常恢复机制并用Qt的信号槽架构完成了从物理层到界面层的分层解耦。同样一个东西表述方式不同给面试官的感觉完全不同。这套工程代码量不大核心代码加起来大约600行却完整覆盖了串口通信、协议设计、界面交互、数据可视化、异常处理、发布部署这条全链路。希望这边分享的经验能帮你少走一些弯路快速跑通你自己的温湿度监控项目。如果你在实操中遇到这篇博客里没提到的问题欢迎在评论区把报错信息和现象贴出来我们一起排查。本文还有配套的精品资源点击获取
