简介:这是一份面向嵌入式与工控开发者的QT5串口调试助手V2.0完整工程,含可运行程序与全部源代码,适合需要快速验证串口通信、学习QT5界面与串口编程的初中级开发者。资源包共80个文件,以33个png界面图标、18个h头文件与17个cpp源文件为核心,另含qrc资源、ui界面、pro工程及css皮肤样式,压缩包约129KB,结构紧凑便于二次开发。功能覆盖刷新设备列表、选择串口并设置波特率/数据位/停止位/校验位、开关串口、数据发送与回显、数据保存及定时发送,并支持自定义皮肤样式,界面友好。代码中可见Modbus设备抽象、数据缓冲、高精度定时器与JSON配置等模块,可作为串口通信与QT5工程组织的参考范例。目前已有130人学习下载,适合拿来即用或按需裁剪。
1. 串口调试助手V2.0:为什么Qt5方案值得你重写一遍
手头攒了一堆USB转串口模块,每次调试都要在sscom串口调试助手、minicom和几个不知名小工具之间来回切换,这种体验相信做嵌入式的朋友都懂。基于Qt5的串口调试助手V2.0及源代码这个方向,本质上就是让你用Qt5的QSerialPort模块,从零搭一个能收能发、能存日志、能画波形的桌面工具,顺便把源代码攥在自己手里,想加什么协议解析就加什么。它解决的不是“有没有串口工具用”的问题,而是“工具不趁手、数据格式不匹配、二次开发没源码”的憋屈。适合谁?刚接触Qt想找个练手项目的学生、需要定制协议解析的嵌入式工程师、以及想用C++写上位机但不想从Win32 API啃起的开发者。V2.0这个版本号意味着它不是demo,得有配置持久化、多编码切换、定时发送这些正经功能。
2. 用QSerialPort搭最小可跑框架:从工程配置到收发闭环
2.1 为什么选Qt5而不是Qt6或纯Win32
Qt5的QSerialPort模块在5.1之后已经相当稳定,串口枚举、波特率设置、读写超时这些接口都齐了。Qt6虽然也带QSerialPort,但很多工控现场的上位机还锁在Qt 5.9到5.15这个区间,驱动兼容性经过大量验证。纯Win32写串口不是不行,但你要自己处理OVERLAPPED结构、线程同步和消息循环,一个ReadFile阻塞就能卡死界面。Qt5的信号槽机制天然适合串口这种异步IO场景:readyRead信号一来就读缓冲区,不用轮询。另一个现实原因是,网上能搜到的Qt5串口调试助手源代码大多基于5.12到5.15,你拿过来改比从Qt6迁移回去省事得多。
2.2 工程文件里必须加的那几行
新建一个Qt Widgets Application,在.pro文件里加上串口模块声明。这一步漏了,编译时就会报“QSerialPort: No such file or directory”。
QT += core gui serialport greaterThan(QT_MAJOR_VERSION, 4): QT += widgets TARGET = SerialAssistantV2 TEMPLATE = appQT += serialport是核心,它告诉qmake去链接Qt5SerialPort库。如果你在Linux下编译,还需要确认libqt5serialport5-dev已经装上,否则链接阶段会报undefined reference。Windows下用Qt官方安装器的话,这个模块默认勾选,不用额外操作。
2.3 枚举串口与打开设备的代码骨架
下面这段代码放在MainWindow的构造函数里,负责扫描当前可用串口并填充下拉框。
#include <QSerialPort> #include <QSerialPortInfo> void MainWindow::refreshSerialPorts() { ui->comboPort->clear(); const auto infos = QSerialPortInfo::availablePorts(); for (const QSerialPortInfo &info : infos) { // 显示端口名和描述,方便区分多个USB转串口 QString label = info.portName() + " (" + info.description() + ")"; ui->comboPort->addItem(label, info.portName()); } if (ui->comboPort->count() == 0) { ui->comboPort->addItem("无可用串口"); } }availablePorts()返回一个QList,每个元素包含portName、description、manufacturer等字段。把portName存进userData,后续打开串口时直接取,避免从显示文本里截取。注意在Windows下COM号大于9时,portName会带\\.\前缀,QSerialPort内部已经处理了,你不需要手动拼。
打开串口的逻辑要检查返回值,不能假设一定成功。
void MainWindow::openSerialPort() { if (serial->isOpen()) { serial->close(); } serial->setPortName(ui->comboPort->currentData().toString()); serial->setBaudRate(ui->comboBaud->currentText().toInt()); serial->setDataBits(QSerialPort::Data8); serial->setParity(QSerialPort::NoParity); serial->setStopBits(QSerialPort::OneStop); serial->setFlowControl(QSerialPort::NoFlowControl); if (serial->open(QIODevice::ReadWrite)) { ui->btnOpen->setText("关闭串口"); ui->statusBar->showMessage("已打开 " + serial->portName()); } else { QMessageBox::critical(this, "错误", serial->errorString()); } }setBaudRate接受int,但底层会映射到标准波特率。如果你要设非标准值比如14400,Qt5在Windows下会失败,Linux下取决于驱动。open(QIODevice::ReadWrite)以读写模式打开,如果设备被其他程序占用会返回false,errorString()会给出“Permission denied”或“Access is denied”。
2.4 接收数据与十六进制显示
串口数据到达时QSerialPort会发readyRead信号,你在槽函数里读全部缓冲区。
connect(serial, &QSerialPort::readyRead, this, &MainWindow::handleReadyRead); void MainWindow::handleReadyRead() { QByteArray data = serial->readAll(); if (ui->checkHexDisplay->isChecked()) { // 转成十六进制字符串,每字节两位,空格分隔 QString hexStr = data.toHex(' ').toUpper(); ui->textRecv->appendPlainText(hexStr); } else { // 按当前编码解析,默认UTF-8,可切GBK QTextCodec *codec = QTextCodec::codecForName(currentEncoding.toLatin1()); ui->textRecv->appendPlainText(codec->toUnicode(data)); } recvByteCount += data.size(); updateStatusBar(); }toHex(' ')是Qt5.9之后才有的重载,低版本要用循环拼。appendPlainText会自动换行,如果你不想每条都换行,改用insertPlainText并手动控制光标位置。接收字节计数用于状态栏显示,方便判断是否有数据丢失。
3. 发送区、定时发送与配置持久化:V2.0该有的样子
3.1 发送模式的三种形态与实现差异
发送区一般要做成三种模式:ASCII发送、十六进制发送、文件发送。ASCII模式直接把QString转成QByteArray写出去;十六进制模式要先把用户输入的“AA BB CC”解析成字节数组;文件发送则要分块读取避免一次性加载大文件卡死界面。
void MainWindow::sendData() { QByteArray payload; if (ui->radioHexSend->isChecked()) { QString hexText = ui->textSend->toPlainText().remove(QRegularExpression("\\s")); if (hexText.size() % 2 != 0) { QMessageBox::warning(this, "格式错误", "十六进制字符数必须为偶数"); return; } payload = QByteArray::fromHex(hexText.toLatin1()); } else { payload = ui->textSend->toPlainText().toLocal8Bit(); } qint64 written = serial->write(payload); if (written == -1) { ui->statusBar->showMessage("发送失败: " + serial->errorString()); } else { sendByteCount += written; updateStatusBar(); } }QByteArray::fromHex要求输入是纯十六进制字符,空格和换行必须先去掉。toLocal8Bit()在中文Windows下是GBK,Linux下是UTF-8,跨平台时要注意。write返回实际写入的字节数,如果串口缓冲区满可能小于payload长度,但Qt内部有写缓冲队列,一般不会丢。
3.2 定时发送的QTimer用法与精度陷阱
定时发送用QTimer,间隔设成毫秒。但QTimer的精度受事件循环影响,如果你在槽函数里做了耗时操作,定时会漂移。
QTimer *sendTimer = new QTimer(this); connect(sendTimer, &QTimer::timeout, this, &MainWindow::sendData); void MainWindow::toggleTimedSend(bool enabled) { if (enabled) { int interval = ui->spinInterval->value(); if (interval < 10) { QMessageBox::warning(this, "警告", "间隔小于10ms可能导致界面卡顿"); } sendTimer->start(interval); } else { sendTimer->stop(); } }start(interval)的参数是毫秒,实际触发间隔可能比设定值大几毫秒到几十毫秒,取决于系统负载。如果你需要微秒级精度,QTimer做不到,得用高精度计时器或硬件触发。另外定时发送时如果串口没打开,sendData里的write会失败,最好在toggleTimedSend里先检查serial->isOpen()。
3.3 用QSettings保存上次的串口参数
V2.0和V1.0的差别就在这些细节上。用户不希望每次打开都重新选COM口和波特率。
void MainWindow::saveSettings() { QSettings settings("MyCompany", "SerialAssistantV2"); settings.setValue("portName", ui->comboPort->currentData().toString()); settings.setValue("baudRate", ui->comboBaud->currentText()); settings.setValue("encoding", currentEncoding); settings.setValue("hexDisplay", ui->checkHexDisplay->isChecked()); } void MainWindow::loadSettings() { QSettings settings("MyCompany", "SerialAssistantV2"); QString port = settings.value("portName").toString(); int idx = ui->comboPort->findData(port); if (idx >= 0) ui->comboPort->setCurrentIndex(idx); ui->comboBaud->setCurrentText(settings.value("baudRate", "115200").toString()); currentEncoding = settings.value("encoding", "UTF-8").toString(); ui->checkHexDisplay->setChecked(settings.value("hexDisplay", false).toBool()); }QSettings在Windows下写注册表,Linux下写~/.config/MyCompany/SerialAssistantV2.conf。组织名和应用名自己定,但一旦发布就不要改,否则用户配置会丢。value的第二个参数是默认值,首次运行时用这个。
4. 避坑与排查:串口调试助手开发中最容易翻车的五个点
4.1 现象:打开串口提示“No such file or directory”,但设备管理器里明明有
原因:Linux下当前用户不在dialout组,没有权限访问/dev/ttyUSB0。Windows下通常是驱动没装好,或者COM口被其他程序占用。
解决:Linux执行sudo usermod -aG dialout $USER然后重新登录。Windows下换一个USB口试试,或者在设备管理器里卸载设备后重新扫描。代码里不要只弹错误框,把errorString()完整显示出来,方便定位。
4.2 现象:接收中文显示乱码,英文正常
原因:发送端用GBK编码,接收端按UTF-8解析。或者反过来。
解决:在界面上加一个编码切换下拉框,支持UTF-8、GBK、Latin1。用QTextCodec::codecForName动态切换。注意Qt5默认把QString转成UTF-8,如果你用toLocal8Bit()在中文Windows下会得到GBK,跨平台时行为不一致,建议统一用toUtf8()并在接收端也按UTF-8解析。
4.3 现象:定时发送间隔设成1ms后界面卡死
原因:QTimer在1ms间隔下频繁触发timeout信号,事件循环被占满,界面无法响应重绘和鼠标事件。
解决:最小间隔限制在10ms以上,并在界面上给出提示。如果确实需要高速发送,把发送逻辑放到独立线程,用QSerialPort的moveToThread或者直接在worker线程里操作串口。但要注意QSerialPort不是线程安全的,跨线程调用需要加锁或改用信号槽排队。
4.4 现象:十六进制发送时数据少发或多发
原因:用户输入的十六进制字符串里有全角空格、制表符或换行符,remove(QRegularExpression("\s"))在Qt5里对全角空格无效。
解决:先用QString::simplified()去掉首尾空白并把连续空白替换成单个空格,再手动过滤非十六进制字符。或者用QRegularExpression("[^0-9A-Fa-f]")把所有非十六进制字符删掉,再检查长度是否为偶数。
4.5 现象:程序关闭后串口没释放,重新打开提示被占用
原因:MainWindow析构时没有close串口,或者串口对象没有正确delete。
解决:在closeEvent里调用serial->close(),并把serial的父对象设为this,让Qt对象树自动管理。如果用了独立线程,先quit线程再wait,最后delete serial。Windows下如果程序崩溃,串口可能被系统锁定几秒,等一会儿再试。
5. 从能用到好用:协议解析插件与数据落盘的进阶技巧
5.1 用QPluginLoader做协议解析扩展
串口调试助手V2.0如果只做收发,和sscom串口调试助手差别不大。真正拉开差距的是协议解析。我一般会定义一个抽象接口类,把解析逻辑做成动态库,主程序用QPluginLoader加载。
// protocolinterface.h class ProtocolInterface { public: virtual ~ProtocolInterface() {} virtual QString name() const = 0; virtual QString parse(const QByteArray &raw) = 0; }; Q_DECLARE_INTERFACE(ProtocolInterface, "com.mycompany.SerialAssistant.ProtocolInterface/1.0")插件项目里实现这个接口,编译成.dll或.so,放到主程序的plugins目录。主程序启动时扫描目录,把每个插件的name()加到菜单里。用户选中某个协议后,接收区的数据先经过parse()再显示。这样你加Modbus RTU解析、自定义帧头帧尾解析都不用重新编译主程序。
5.2 接收数据自动落盘与滚动日志
长时间抓数据时,界面文本框会越来越卡。我习惯在接收槽里同时写文件,界面只保留最近500行。
void MainWindow::handleReadyRead() { QByteArray data = serial->readAll(); if (logFile.isOpen()) { logFile.write(data); logFile.flush(); // 确保崩溃时不丢数据 } // 界面显示逻辑... if (ui->textRecv->blockCount() > 500) { QTextCursor cursor = ui->textRecv->textCursor(); cursor.movePosition(QTextCursor::Start); cursor.select(QTextCursor::BlockUnderCursor); cursor.removeSelectedText(); } }logFile.flush()每包都刷会降低性能,但串口波特率一般不超过921600,数据量不大,刷盘开销可以接受。如果波特率很高,改成定时flush,比如每秒一次。文件名用时间戳,格式recv_20250101_120000.log,方便回溯。
5.3 用QCustomPlot画实时波形
如果串口传的是传感器数据,比如每帧带一个float,你可以解析出来后喂给QCustomPlot。QCustomPlot是一个单头文件库,加到工程里就能用。
// 在接收解析里提取数值 float value = bytesToFloat(data.mid(2, 4)); ui->customPlot->graph(0)->addData(QDateTime::currentMSecsSinceEpoch() / 1000.0, value); ui->customPlot->xAxis->setRange(QDateTime::currentMSecsSinceEpoch() / 1000.0 - 10, 10); ui->customPlot->replot(QCustomPlot::rpQueuedReplot);rpQueuedReplot把重绘请求排队,避免每来一个数据点就重绘一次导致CPU飙升。x轴范围设成最近10秒,实现滚动效果。注意QCustomPlot的addData是O(1)复杂度,但数据点太多时内存会涨,定期调用graph(0)->data()->removeBefore(t)清理旧数据。
5.4 一个我踩过的坑:串口热插拔后句柄失效
USB转串口模块拔掉再插上,COM号可能变,也可能不变。如果不变,QSerialPort对象还持有旧句柄,write会返回-1但errorString()可能不更新。我的做法是监听QSerialPort的errorOccurred信号,当错误是ResourceError或DeviceNotFoundError时,自动关闭串口并刷新端口列表。用户重新选一下就能恢复。不要试图在错误信号里直接重新open,因为设备可能还没枚举完,等500ms再试更稳。
写这个工具最大的收获是:串口调试助手看起来简单,但每个细节都影响手感。定时发送的间隔精度、十六进制输入的容错、编码切换的默认值,这些才是用户每天要碰的地方。我习惯在发布前用虚拟串口对(com0com或socat)跑一遍全流程,从打开到收发到保存配置,确认没有回归。希望帮到你。
本文还有配套的精品资源,点击获取