十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于Qt的串口与UDP双通道调试助手开发实践

基于Qt的串口与UDP双通道调试助手开发实践 简介这是一款基于Qt开发的串口与网口调试助手面向嵌入式开发、上位机编程及网络通信调试场景适合需要快速验证串口收发或UDP数据交互的工程师与学习者。程序实现了串口数据收发和UDP网口数据收发支持ASCII与十六进制两种显示与发送格式串口参数、网络地址等均可在界面上灵活配置实用性强。资源包共33个文件以C源文件、头文件、Qt界面文件和工程文件为主辅以配置文件、makefile及编译产物压缩包仅2.34MB结构完整、轻量易用。目前已有1506人浏览学习程序内包含详尽注释从界面布局到通信逻辑均有清晰说明代码层次分明关键函数均有注释便于快速掌握QSerialPort和QUdpSocket的典型用法。作者还提供了release可执行文件下载后可直接运行体验非常适合作为Qt通信编程的入门参考和工具模板。1. Qt 调试助手为什么值得自己写串口与 UDP 同框的工程选型串口调试助手和网口调试助手通常是两个独立的工具调一块同时带串口和以太网的板卡时经常要在两个窗口之间来回切换波特率、目标 IP、端口这些参数还要分别在两个工具里维护。这个工程把串口和 UDP 收进同一个 Qt 界面底层用 QSerialPort 和 QUdpSocket支持 ASCII 与 16 进制收发参数统一保存到 uartConfig.ini。源码按 uart.cpp、udp.cpp、iniconfigrw.cpp 拆成独立模块注释密度很高适合从零研究 Qt 串口通信和 UDP 网口数据收发的完整流程。如果你正在做自己的调试工具或者想找一个能二次改造的底座这套代码的主线很清楚可以边拆边改。2. 工程结构与数据流从 qmake 工程到串口/UDP 双通道驱动拿到工程先不要急着编译把文件之间的关系看明白。根目录的drive.pro是 qmake 工程文件drive.cpp负责主界面和控件联动uart.cpp封装串口通道udp.cpp封装 UDP 通道iniconfigrw.cpp统一读写配置。像Makefile.Debug、Makefile.Release、.qmake.stash都是 Qt Creator 影子构建生成的产物删掉后重新构建会自动生成。数据流可以概括为UI 把控件参数交给驱动类驱动类操作 QSerialPort / QUdpSocket收到数据后通过信号回传给主窗口。先把这条链路理清后面改功能才不会把逻辑堆在按钮槽函数里。2.1 drive.pro 组织MinGW32 下的模块划分drive.pro里最关键的是 Qt 模块引入少了serialport或network编译阶段直接找不到头文件QT core gui serialport network greaterThan(QT_MAJOR_VERSION, 4): QT widgets TARGET drive TEMPLATE app SOURCES main.cpp \ drive.cpp \ uart.cpp \ udp.cpp \ iniconfigrw.cpp HEADERS drive.h \ uart.h \ udp.h \ iniconfigrw.h FORMS drive.ui其中QT serialport network是串口和 UDP 两个通道的基础FORMS drive.ui会在编译时由 uic 工具生成ui_drive.h这个文件不要手工修改。项目里实际出现了ui_drive.h就是因为drive.ui存在。命令行编译时我一般直接走 qmake 加 makeqmake drive.pro mingw32-make -j4 release/drive.exe第一行生成 Makefile第二行并行编译第三行直接运行 release 目录下的可执行程序。机器上同时装多套 Qt 时要注意 PATH 里排前面的 qmake 和 mingw32-make 必须是同一套工具链否则会出现missing separator或找不到 Qt 头文件的报错。这个工程从构建目录build-drive-Desktop_Qt_5_12_11_MinGW_32_bit-Debug来看用的是 Qt 5.12.11 加 32 位 MinGW部署时也要用同一架构的 windeployqt不能用 64 位版本替代。2.2 串口通道 uart.cpp 的状态机和配置读取串口通道本质上是一个带打开、关闭、发送三种状态的封装类。打开串口的常见做法是先把可能处于打开状态的句柄关掉再用 8N1 参数重新打开bool Uart::openPort(const QString name, int baud) { if (m_serial-isOpen()) { m_serial-close(); } m_serial-setPortName(name); m_serial-setBaudRate(baud); m_serial-setDataBits(QSerialPort::Data8); m_serial-setParity(QSerialPort::NoParity); m_serial-setStopBits(QSerialPort::OneStop); m_serial-setFlowControl(QSerialPort::NoFlowControl); if (!m_serial-open(QIODevice::ReadWrite)) { m_lastError m_serial-errorString(); // 保留错误信息给 UI 层显示 return false; } connect(m_serial, QSerialPort::readyRead, this, Uart::onDataReady); return true; }参数说明setBaudRate接收 int可以直接传115200这样的整形setDataBits设置为 8 位数据、无校验、1 位停止位即最常见的 8N1 格式绝大多数设备手册默认都是这个组合。open(QIODevice::ReadWrite)表示串口要支持双向收发如果设备只收不发可以改成 WriteOnly但调试助手场景下基本都用 ReadWrite。把connect放在 open 成功之后是为了避免未打开时就收到 readyRead 信号。errorString()返回的是驱动层可读信息串口被占用时通常能直接看到AccessError比 UI 层自己猜原因可靠得多。接收侧要把字节流转成 UI 能显示的字符串同时保留原始数据用于 hex 模式void Uart::onDataReady() { const QByteArray bytes m_serial-readAll(); m_rxBytes bytes.size(); if (m_hexMode) { emit dataReceived(bytes.toHex( ).toUpper()); } else { emit dataReceived(QString::fromLatin1(bytes)); } }这里toHex( )会在每个字节之间插入空格方便肉眼对照设备手册上的报文。QString::fromLatin1是让每个字节按 Latin1 映射成字符不解析多字节编码真正的中文编码问题放到 UI 层解决否则串口驱动类会被具体的字符集绑死。高频数据到来时readyRead可能一次携带几百字节readAll可以全部取走不会出现只读一半的情况。2.3 UDP 通道 udp.cpp 的 QUdpSocket 收发与端口管理UDP 和串口的最大差异是串口是字节流没有消息边界UDP 是数据报一次 send 对应一次 recv。所以 UDP 通道的第一步是绑定本地端口而不是连接目标bool Udp::bind(int localPort) { if (localPort 0 || localPort 65535) { return false; } if (m_socket-isBound()) { m_socket-close(); } m_socket-abort(); // 清空历史缓冲避免旧包干扰 bool ok m_socket-bind(QHostAddress::AnyIPv4, localPort, QUdpSocket::ShareAddress | QUdpSocket::ReuseAddressHint); if (ok) { connect(m_socket, QUdpSocket::readyRead, this, Udp::onReadyRead); } return ok; }端口范围限制在 1 到 65535小于 1024 的端口在 Windows 上一般也能绑定但容易被系统服务占用所以调试工具默认建议用 9000 以上的端口。ShareAddress配合ReuseAddressHint可以让调试人员在另一个进程里打开同一个端口观测流量如果只是普通工具不加这两个 flag 也能用但遇到网卡较多或程序二次启动时会麻烦一些。绑定AnyIPv4表示所有 IPv4 网卡上的这个端口都会收到数据适合开发板通过有线网口连接、PC 同时又连着 WiFi 的场景。接收数据报时要注意 UDP 的readyRead只表示“至少有一个数据报到达”必须用 while 循环取完void Udp::onReadyRead() { while (m_socket-hasPendingDatagrams()) { QByteArray datagram; datagram.resize(int(m_socket-pendingDatagramSize())); QHostAddress sender; quint16 senderPort 0; m_socket-readDatagram(datagram.data(), datagram.size(), sender, senderPort); emit datagramReceived(datagram, sender.toString(), senderPort); } }pendingDatagramSize()返回当前缓冲区里第一个待取数据报的大小resize 到该大小后 readDatagram 才能完整取出。如果把 QByteArray 声明在 while 外复用可能因为上一次 resize 的大小不同造成截断或越界。发送侧用writeDatagram(datagram, QHostAddress(ip), targetPort)它是异步的返回值只表示是否进入系统发送缓冲不能代表对端已经收到所以需要联调的场合要主动做应用层应答。下面把两条通道的关键差异放在一起对比通道底层类打开方式数据边界参数保存串口QSerialPortopen(ReadWrite)字节流无边界uartConfig.ini 的 serial 段UDP 网口QUdpSocketbind(localPort)数据报有边界uartConfig.ini 的 udp 段3. 收发链路实现ASCII 与 16 进制模式切换及 UI 联动驱动层把数据通道打开后UI 层的核心工作就集中在两件事上把输入框里的字符串按用户选择的模式转换成真正的字节以及把收到的字节按模式显示出来。很多初学者会直接在发送按钮里写serial-write(ui-sendEdit-toPlainText().toUtf8())这在设备要求 ASCII 或 hex 报文时都会出问题。下面按串口参数面板、hex 转换、定时发送三个环节拆开讲。3.1 串口参数面板与 QSerialPort 的开启/关闭时序串口面板上的参数在打开之后就不应该再允许修改否则用户改了波特率但底层句柄没有重建会产生“看起来改了、实际没生效”的误解。打开按钮的槽函数我是这么组织的void Drive::onOpenSerialButton() { if (m_uart-isOpen()) { m_uart-closePort(); ui-serialGroup-setEnabled(true); ui-openSerialButton-setText(打开串口); return; } const QString portName ui-portCombo-currentText(); if (portName.isEmpty()) { QMessageBox::warning(this, 提示, 请先选择串口号); return; } int baud ui-baudCombo-currentText().toInt(); if (baud 0) { QMessageBox::warning(this, 提示, 波特率不合法); return; } if (m_uart-openPort(portName, baud)) { ui-serialGroup-setEnabled(false); ui-openSerialButton-setText(关闭串口); } else { QMessageBox::critical(this, 打开失败, m_uart-lastError()); } }打开成功后禁用serialGroup这个细节能避免调试过程中误改参数。portCombo里的串口列表应该在窗口构造函数里刷新一次同时在打开失败时刷新一次因为 USB 转串口设备经常是后插上的。刷新列表用QSerialPortInfo::availablePorts()最简单void Drive::refreshPortList() { ui-portCombo-clear(); const auto infos QSerialPortInfo::availablePorts(); for (const QSerialPortInfo info : infos) { ui-portCombo-addItem(info.portName()); } }注意 Windows 上返回的是COM3这样的名字Linux 上返回的是/dev/ttyUSB0不要对字符串做平台相关的过滤直接交给 QSerialPort 即可。3.2 Hex 编码转换QByteArray 与 QString 的互转细节发送区转换成字节建议统一封装成一个入口不要在按钮槽里散落两套逻辑QByteArray Drive::buildSendBytes(const QString input, bool hexMode) { if (!hexMode) { return input.toLatin1(); } QByteArray bytes; const QStringList parts input.split(QRegularExpression(\\s), Qt::SkipEmptyParts); for (const QString part : parts) { QString hexPart part; if (hexPart.startsWith(0x, Qt::CaseInsensitive)) { hexPart hexPart.mid(2); // 去掉 0x 前缀toInt 不识别 } bool ok false; int value hexPart.toInt(ok, 16); if (!ok || value 0 || value 255) { continue; // 非法字节不参与组包 } bytes.append(static_castchar(value)); } return bytes; }ASCII 模式优先用toLatin1()很多串口设备的协议栈只按单字节处理中文用 UTF-8 发出去会变成多字节设备端很难判断帧边界。如果你确认设备支持 UTF-8再换成toUtf8()。Hex 模式允许输入AA BB或0xAA 0xBB两种风格切分后先去掉0x前缀。QString::toInt在 base 为 16 时并不会自动处理0x所以必须手动移除。非法字节直接跳过比起把半个错误帧发出去更安全。接收侧正好反向mid。我们已经在uart.cpp里生成显示用的字符串。这里有一个容易踩的坑如果设备返回中文且使用 GB2312 编码QString::fromLatin1会显示成乱码。排查方法很简单先切到 hex 模式看原始字节如果看到C4 E3 BA C3再换成QString::fromLocal8Bit(bytes)就能正确显示“你好”。下面这张表是我平时选择编码时的判断依据场景推荐转换原因通用 ASCII 字符设备QString::toLatin1()一个字符一个字节与设备端 ASCII 表一致含中文的私有协议QString::toUtf8()只有双方明确约定 UTF-8 时才使用16 进制帧发送toInt(ok, 16)后拼 QByteArray输入直观便于写固定报文接收显示 hexQByteArray::toHex( )空格分隔便于比对手册报文3.3 定时发送与计数器的实现姿势定时发送用 QTimer 就能实现但要注意 start 之前先设置好间隔并且超时槽里不要创建新对象m_txTimer new QTimer(this); m_txTimer-setInterval(ui-intervalSpin-value()); connect(m_txTimer, QTimer::timeout, this, Drive::sendFromEdit);开关定时发送时if (ui-timerCheck-isChecked()) { m_txTimer-start(); } else { m_txTimer-stop(); }定时周期小于 50 ms 时QTimer 的精度会受到 Windows 消息循环粒度的影响实际触发间隔可能漂移。需要精确到毫秒级就要把发送放到独立线程或者改用QElapsedTimer做更精细的控制但 UI 线程不能阻塞。计数器统计要放在驱动层不要放在界面刷新逻辑里。例如在onDataReady里累加m_rxBytes再用每秒定时器读取差值ui-rxRateLabel-setText(QString(RX %1 B/s).arg(m_uart-rxBytes() - m_lastRxBytes));这样即使接收区刷新频率不高速率统计也不会漏数据。4. 配置持久化与常见故障uartConfig.ini 的读写和排错清单程序里保留uartConfig.ini是因为每个项目的串口号、目标 IP 都不同写死在代码里每次都要重新编译。QSettings读写 ini 很直接但如果 key 规划不清晰配置项会越加越乱。下面结合iniconfigrw.cpp讲一下我常用的封装方式再列一份实打实的排错清单。4.1 iniconfigrw 的 QSettings 封装与 key 规划封装的最小单元是读写函数把 section 和 key 作为参数传进去调用方不需要关心 QSettings 的细节QVariant IniConfigRW::read(const QString section, const QString key, const QVariant defaultValue) const { QSettings settings(m_iniPath, QSettings::IniFormat); settings.beginGroup(section); QVariant value settings.value(key, defaultValue); settings.endGroup(); return value; } void IniConfigRW::write(const QString section, const QString key, const QVariant value) { QSettings settings(m_iniPath, QSettings::IniFormat); settings.beginGroup(section); settings.setValue(key, value); settings.endGroup(); }m_iniPath建议用QCoreApplication::applicationDirPath() /uartConfig.ini拼接绝对路径。如果只写文件名程序运行目录变了就会生成一份新 ini看起来像是“配置丢失”。beginGroup对应 ini 文件中的[section]段落配置结构规划如下[serial] portCOM3 baud115200 dataBits8 parityN stopBits1 [udp] localPort9000 targetIp192.168.1.10 targetPort9001 [display] hexReceivefalse hexSendfalse showTimestamptrueparity用N/E/O表示无校验、偶校验、奇校验字符串里存中文“无校验”在跨平台读取时容易遇到编码问题。display段保存界面状态比如 hex 开关和时间戳开关否则每次启动程序都要重新设一遍调试条件。保存时机最好放在主窗口的closeEvent或者程序退出前统一执行而不是每次控件的值变化都写磁盘。4.2 常见故障打开失败、乱码、端口被占用、UI 卡顿网口调试助手和串口调试助手名声在外但很多问题不是代码逻辑不行而是外部环境导致。下面是我实际调试中遇到最多的四类现象最常见原因排查方向串口打开后立刻返回失败被另一款调试工具或设备驱动占用关闭其它串口工具查看 errorString发送后设备无反应波特率或校验位不匹配对照设备手册检查 8N1检查 TX/RX 是否接反接收区中文乱码设备用 GB2312程序按 UTF-8 解码切 hex 模式看原始字节再换 fromLocal8BitUDP 收不到数据防火墙拦截或端口被占绑定高端口先 ping 对端网卡确认监听地址打开失败时errorString()比任何自定义提示都有用。在Uart::openPort中先保存m_serial-errorString()UI 层再弹窗显示能直接看出是AccessError还是DeviceNotFound。很多 USB 转串口芯片在 Windows 上被系统识别成两个 COM 口比如 CH340 是COM3和COM4其中一个可能是不可用的列表刷新后要试第二个。乱码问题的本质是字节序列和字符集不匹配。先用 hex 模式观察例如收到C4 E3 BA C3按 GB2312 解码是“你好”按 UTF-8 解码就是乱码这时在上位机里改用QString::fromLocal8Bit解码即可。对于不需要显示中文的协议fromLatin1是最稳定的保底方案因为每个字节都能找到对应的 Latin1 字符不会像 UTF-8 那样遇到非法序列就变成\uFFFD。UI 卡顿是高频数据下最常见的问题。不要在QTextEdit里每收到一包就append一次高频时很可能一秒钟收到上千个包界面线程根本刷不过来。我一般用缓冲加定时刷新的方式void Drive::onDataReceived(const QByteArray data) { m_recvBuffer.append(data); if (!m_recvTimer-isActive()) { m_recvTimer-start(100); // 100ms 刷新一次界面 } } void Drive::flushRecvBuffer() { if (m_recvBuffer.isEmpty()) return; ui-recvEdit-moveCursor(QTextCursor::End); ui-recvEdit-insertPlainText(QString::fromLatin1(m_recvBuffer)); ui-recvEdit-ensureCursorVisible(); m_recvBuffer.clear(); }QTextEdit::append会在末尾自动加换行高频数据下每一包占一行QTextEdit 的内容越积越多滚动和重绘都会变慢insertPlainText按缓冲批量插入刷新率控制在 10 Hz 左右人眼观察足够程序流畅度能明显改善。5. 让这个助手更适合日常调试回环测试与性能观察技巧代码跑通之后先别急着连设备用回环测试把整条链路验证一遍能省下大量排查时间。5.1 串口回环短接 TX 与 RX拿出 USB 转串口模块用杜邦线把 TX 和 RX 短接。打开调试助手设好波特率后发送AA 55接收区如果显示AA 55说明程序、驱动、线材、串口芯片这条链路是通的。如果收不到先看设备管理器里能不能看到 COM 口再看短接线有没有接触不良换一个模块再试。这个测试还能验证波特率是否真实生效波特率不匹配时回环数据会变成乱码或直接丢字节。配合串口助手里的定时发送把间隔设成 100 ms观察接收计数是否稳定增长就能快速判断整条链路是否可靠。5.2 UDP 回环本机地址自测UDP 通道可以把目标 IP 设为127.0.0.1目标端口和本地端口填同一个值发送一帧任意数据能收到说明 QUdpSocket 的收发链路正常。连开发板之前先用ping -n 3 目标IP确认网口物理链路可通。Windows 防火墙第一次运行 Qt 程序时会弹窗必须允许访问否则程序里 bind 成功但收不到外部网口数据。这个测试是网口调试助手里“本地正常、现场不通”的典型分界点。5.3 在接收区加入时间戳与吞吐量回环通过后可以给接收显示加上时间戳用来观察设备响应延迟。时间戳要在 flush 缓冲时打不要在onDataReady里打否则每个小包都会产生一次时间字符串性能开销反而掩盖了真实时序QString timestamp QDateTime::currentDateTime().toString(HH:mm:ss.zzz); ui-recvEdit-append(timestamp QString::fromLatin1(m_recvBuffer)); m_recvBuffer.clear();吞吐量统计用 1 秒窗口内的字节差放到独立 QTimer 槽函数里int rxSpeed m_uart-rxBytes() - m_lastRxBytes; m_lastRxBytes m_uart-rxBytes(); ui-statusBar-showMessage(QString(RX %1 B/s).arg(rxSpeed));把速度值配合回环测试一起看如果速度一跳一停说明设备发送不是匀速可能需要在应用层加一个缓存队列来平滑处理。本文还有配套的精品资源点击获取
返回列表