串口調(diào)試助手V1.0完整指南)
簡介一份基于Qt框架的串口調(diào)試助手完整工程源碼面向Qt初學(xué)者與需要快速搭建串口通信工具的開發(fā)者解決從零創(chuàng)建圖形界面、配置串口參數(shù)以及收發(fā)數(shù)據(jù)的常見需求。壓縮包共31個文件包含9個C源文件、9個頭文件、3個UI界面文件以及圖標(biāo)、背景圖片、資源文件和工程配置文件整體大小約234KB結(jié)構(gòu)清晰便于直接編譯運行。目前已有1164人學(xué)習(xí)/下載。源碼涵蓋波特率、數(shù)據(jù)位等參數(shù)設(shè)置串口打開關(guān)閉與數(shù)據(jù)收發(fā)邏輯并封裝了QextSerialPort串口通信類界面部分配有UI設(shè)計文件和樣式表適合對照學(xué)習(xí)串口通信、槽函數(shù)事件處理及界面布局技巧也可作為功能擴展的起點。 很多做嵌入式、硬件調(diào)試或者上位機開發(fā)的朋友第一個想動手寫的桌面工具就是串口調(diào)試助手。Qt 生態(tài)里現(xiàn)成的例子不少但真要把功能做成自己順手的樣子還是得從零開始搭一遍。這篇文章分享我用 Qt Widgets 從零實現(xiàn)串口調(diào)試助手 V1.0 的完整過程包含源碼級別的實現(xiàn)思路和踩坑記錄適合想練手 Qt 串口編程、或者想定制自己調(diào)試工具的朋友參考。1. 動手之前串口調(diào)試助手的功能邊界1.1 一個串口工具需要哪些功能串口調(diào)試助手的核心就一句話把電腦的串口數(shù)據(jù)收進(jìn)來、發(fā)出去并且讓人看得懂。圍繞這個核心V1.0 我列了一個功能清單串口配置端口號、波特率、數(shù)據(jù)位、校驗位、停止位開關(guān)串口打開成功后配置區(qū)鎖死防止誤改數(shù)據(jù)接收支持 ASCII 和 Hex 兩種顯示模式數(shù)據(jù)發(fā)送支持 ASCII 和 Hex 兩種發(fā)送模式支持發(fā)送新行\(zhòng)n 和 \r\n清空接收區(qū)、清空發(fā)送區(qū)收發(fā)字節(jié)計數(shù)狀態(tài)欄實時顯示串口開關(guān)狀態(tài)這個清單看著簡單但每一項都對應(yīng)著實際調(diào)試場景。比如 Hex 收發(fā)幾乎每個搞單片機的人都要用因為很多下位機協(xié)議就是按字節(jié)定義的直接用 ASCII 看會亂套。再比如計數(shù)功能調(diào)試大數(shù)據(jù)量傳輸時用來判斷有沒有丟字節(jié)非常實用。1.2 V1.0 版本故意不做的事我見過不少人在第一個版本就想加各種復(fù)雜功能波形顯示、Modbus 解析、自動應(yīng)答、腳本執(zhí)行……我的建議是第一版別碰這些。原因很簡單這些功能全部依賴底層的串口收發(fā)邏輯底子沒打穩(wěn)上面堆再多都是空中樓閣。所以 V1.0 我刻意砍掉了這些東西不做 QThread 收發(fā)線程第一版在 UI 線程里直接讀寫串口串口本身有緩沖區(qū)只要不一次性讀上千字節(jié)完全夠用。后面發(fā)現(xiàn)問題再加線程也不遲。不做波形繪制那是 V2.0 的事。不做分包粘包智能分析V1.0 只做最樸素的讀一段、顯示一段。說白了V1.0 的目標(biāo)不是炫技而是把一個串口調(diào)試助手該有的骨架搭出來跑通了、用順了后續(xù)才有迭代的底氣。2. 環(huán)境準(zhǔn)備Qt 版本、編譯器與工程文件2.1 我的開發(fā)環(huán)境選型我用的組合是 Qt 5.15.2 MinGW 64-bit。選 5.15 LTS 而不是 Qt 6主要原因有三個Qt 5.15 的 QSerialPort 模塊非常穩(wěn)定網(wǎng)上資料最多遇到問題搜一下遍地都是答案。很多嵌入式開發(fā)板廠商提供的交叉編譯工具鏈、示例工程還是基于 Qt 5直接對齊省心。如果你還想把代碼移植到 ARM 板子上跑Qt 5.15 的支持面更廣。編譯器我選了 MinGW。因為它是開源工具鏈不依賴 Visual Studio下載安裝 Qt 時自帶配置零成本。MSVC 版本我也試過但 Qt 對 MinGW 的適配更順手Debug 和 Release 都不容易出幺蛾子。2.2 創(chuàng)建工程與 pro 文件配置打開 Qt Creator新建項目選擇Application → Qt Widgets Application類名我起了MainWindow基類保持QMainWindow不變。建好工程后第一件事是改.pro文件加上串口模塊QT core gui greaterThan(QT_MAJOR_VERSION, 4): QT widgets QT serialport TARGET SerialAssistant TEMPLATE app SOURCES \ main.cpp \ mainwindow.cpp HEADERS \ mainwindow.h FORMS \ mainwindow.ui這里有個細(xì)節(jié)QT serialport必須加否則#include QSerialPort編譯直接報找不到頭文件。很多新手卡在這一步其實根本不是代碼問題是模塊沒引入。2.3 確認(rèn)編譯器與運行庫用 MinGW 64-bit 編譯運行后Release 包需要帶上Qt5Core.dll、Qt5Gui.dll、Qt5Widgets.dll、Qt5SerialPort.dll和platforms/qwindows.dll這幾個關(guān)鍵文件才能在其他電腦上跑。V1.0 階段不用折騰打包開發(fā)機上直接運行就行但心里要有個數(shù)。3. 界面設(shè)計不是簡單擺控件3.1 串口配置區(qū)的交互邏輯界面我用QDesigner拖出來的但布局邏輯不是隨手放的。整個窗口分成三個區(qū)域頂部配置區(qū)、中間收發(fā)區(qū)、底部狀態(tài)欄。配置區(qū)我用了一個QGroupBox標(biāo)題叫串口設(shè)置里面放了兩行第一行端口號下拉框、波特率下拉框、數(shù)據(jù)位下拉框第二行校驗位下拉框、停止位下拉框、打開/關(guān)閉按鈕這幾個下拉框的值要提前填好。波特率我填了這些常用值波特率典型場景9600老式設(shè)備、工業(yè)儀表默認(rèn)值115200嵌入式開發(fā)最常用460800GPS/高速模塊調(diào)試921600串口屏、快速固件升級注意QSerialPort有現(xiàn)成的QSerialPort::BaudRate枚舉但我直接用QComboBox的文本再轉(zhuǎn)int這樣以后要加 250000、1000000 這種非標(biāo)準(zhǔn)波特率只要在界面里加一行就行不用改代碼。3.2 收發(fā)區(qū)與按鈕布局中間收發(fā)區(qū)我用了QSplitter分成左右兩個子區(qū)域左邊是接收區(qū)右邊是發(fā)送區(qū)。用QSplitter的好處是用戶可以自己拖分隔條接收區(qū)大了看數(shù)據(jù)舒服發(fā)送區(qū)大了編輯多行協(xié)議方便。接收區(qū)是一個只讀的QTextEdit上方一行復(fù)選框Hex 顯示、自動滾動。發(fā)送區(qū)是一個可編輯的QTextEdit上方一行復(fù)選框Hex 發(fā)送、追加回車換行下面還有一行按鈕發(fā)送、清空發(fā)送區(qū)、清空接收區(qū)、定時發(fā)送。定時發(fā)送我加了一個QSpinBox輸入框單位是毫秒旁邊放一個啟動/停止按鈕。這個功能調(diào)試周期性的心跳包非常管用。3.3 控件禁用/啟用管理的信號槽設(shè)計界面做好之后最重要的是狀態(tài)管理邏輯。核心原則是配置控件只在串口關(guān)閉時可編輯打開后一律禁用發(fā)送相關(guān)只在串口打開時可操作。這一步用代碼寫在打開和關(guān)閉函數(shù)里void MainWindow::setSerialEnabled(bool enabled) { ui-comboBoxPort-setEnabled(!enabled); ui-comboBoxBaud-setEnabled(!enabled); ui-comboBoxDataBit-setEnabled(!enabled); ui-comboBoxParity-setEnabled(!enabled); ui-comboBoxStopBit-setEnabled(!enabled); ui-btnSend-setEnabled(enabled); ui-btnTimerSend-setEnabled(enabled); ui-spinBoxTimerInterval-setEnabled(enabled); }別小看這個集中管理的函數(shù)。一開始我是在打開串口成功后一行行去 setEnabled在關(guān)閉函數(shù)里再一行行設(shè)回來代碼又長又容易漏。抽成函數(shù)之后無論從哪個入口切換串口狀態(tài)界面狀態(tài)都不會錯亂。4. 核心實現(xiàn)串口打開、接收、發(fā)送與 Hex 顯示4.1 打開串口前參數(shù)如何生效打開串口的代碼是重點直接決定后續(xù)收發(fā)是否正常。我的實現(xiàn)思路是先從界面上把所有參數(shù)讀出來再依次設(shè)置到QSerialPort對象上最后調(diào)用open()。void MainWindow::onBtnOpenClicked() { if (m_serial-isOpen()) { m_serial-close(); ui-btnOpen-setText(打開串口); setSerialEnabled(false); return; } m_serial-setPortName(ui-comboBoxPort-currentText().split( ).first()); m_serial-setBaudRate(ui-comboBoxBaud-currentText().toInt()); 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)) { ui-btnOpen-setText(關(guān)閉串口); setSerialEnabled(true); statusBar()-showMessage(tr(串口 %1 已打開) .arg(ui-comboBoxPort-currentText().split( ).first())); } else { QMessageBox::warning(this, 錯誤, tr(打開串口失敗%1).arg(m_serial-errorString())); } }這里有個經(jīng)驗端口下拉框的顯示文本我用了comName description的格式比如COM3 USB-SERIAL CH340所以在設(shè)置端口名時一定要.split( ).first()把純凈的COM3取出來否則串口打不開。還有一個細(xì)節(jié)串口打開之后如果參數(shù)設(shè)置不對很多設(shè)備不會立刻報錯而是收發(fā)數(shù)據(jù)亂碼。所以數(shù)據(jù)位、校驗位、停止位在 V1.0 里我先固定給了最常用的 8N1 組合界面上雖然留了下拉框但默認(rèn)值就是 8 位、無校驗、1 位停止位避免新手亂選導(dǎo)致調(diào)試時出詭異問題。4.2 readyRead 信號與 readAll 的配合接收數(shù)據(jù)的核心是QSerialPort::readyRead信號。這個信號只要串口緩沖區(qū)里來了新數(shù)據(jù)就會觸發(fā)一次我在構(gòu)造函數(shù)里把它連到了自定義槽函數(shù)onReadyRead。connect(m_serial, QSerialPort::readyRead, this, MainWindow::onReadyRead);void MainWindow::onReadyRead() { QByteArray data m_serial-readAll(); if (data.isEmpty()) return; m_recvBytes data.size(); ui-labelRecvBytes-setText(tr(接收: %1 字節(jié)).arg(m_recvBytes)); if (ui-checkBoxHexRecv-isChecked()) { QString hex QString(data.toHex( )).toUpper(); ui-textEditRecv-insertPlainText(hex ); } else { ui-textEditRecv-insertPlainText(QString::fromUtf8(data)); } if (ui-checkBoxAutoScroll-isChecked()) { QTextCursor cursor ui-textEditRecv-textCursor(); cursor.movePosition(QTextCursor::End); ui-textEditRecv-setTextCursor(cursor); } }這里有一個非常重要的概念readyRead信號不保證一次收到完整的一幀數(shù)據(jù)。串口數(shù)據(jù)是一個字節(jié)一個字節(jié)進(jìn)來的操作系統(tǒng)攢一批就會通知應(yīng)用去讀這個一批可能是半個協(xié)議幀、也可能是三五個協(xié)議幀。V1.0 我只是把收到的數(shù)據(jù)原樣追加到文本框里不做幀解析所以直接readAll()沒問題。如果你后面要解析協(xié)議一定要自己維護(hù)一個接收緩沖區(qū)做粘包處理這是后話。4.3 Hex 顯示與編碼處理Hex 顯示我用了QByteArray::toHex( )這個重載函數(shù)會用空格分隔每個字節(jié)的 Hex 值顯示出來像01 03 00 21肉眼很容易對齊校驗。再調(diào)用.toUpper()把字母轉(zhuǎn)大寫視覺上更清爽。實測發(fā)現(xiàn)小寫的de ad be ef和大寫的DE AD BE EF在協(xié)議對比場景下大寫明顯更好核對。ASCII 顯示這里有個容易忽視的坑串口下位機返回的字節(jié)不一定都是 UTF-8 編碼。很多老設(shè)備用的是 GBK 或者 GB2312直接QString::fromUtf8(data)會出現(xiàn)亂碼。V1.0 我先統(tǒng)一按 UTF-8 處理因為手頭調(diào)試的設(shè)備都是自己寫的代碼控制端也用 UTF-8 發(fā)送夠用了。如果哪天要接老設(shè)備再根據(jù)情況選擇QString::fromLocal8Bit或者QTextCodec。你要做自己的版本務(wù)必搞清楚下位機的編碼格式。4.4 發(fā)送邏輯與定時發(fā)送發(fā)送數(shù)據(jù)相對簡單但要分清楚 Hex 和 ASCII 兩條路徑Hex 模式下用戶輸入的是01 03 00 00 00 01這種字符串必須用QByteArray::fromHex轉(zhuǎn)成真正的字節(jié)數(shù)組再發(fā)送ASCII 模式下直接把字符串轉(zhuǎn) UTF-8 字節(jié)數(shù)組發(fā)送。void MainWindow::onBtnSendClicked() { if (!m_serial-isOpen()) { QMessageBox::warning(this, 提示, 請先打開串口); return; } QByteArray data; if (ui-checkBoxHexSend-isChecked()) { data QByteArray::fromHex(ui-textEditSend-toPlainText().toUtf8()); } else { data ui-textEditSend-toPlainText().toUtf8(); if (ui-checkBoxAppendNewline-isChecked()) { data.append(\r\n); } } if (data.isEmpty()) return; qint64 written m_serial-write(data); m_sendBytes written; ui-labelSendBytes-setText(tr(發(fā)送: %1 字節(jié)).arg(m_sendBytes)); }追加回車換行我默認(rèn)用\r\n不用單獨的\n。因為很多 MCU 端的串口中斷處理是按行掃描的\n之前如果需要\r才能正確解析少了就出問題。\r\n是兼容性最好的選擇。定時發(fā)送我用了QTimer在構(gòu)造函數(shù)里創(chuàng)建m_timer new QTimer(this); connect(m_timer, QTimer::timeout, this, MainWindow::onBtnSendClicked);啟動按鈕的槽函數(shù)里void MainWindow::onBtnTimerToggled(bool checked) { if (checked) { int interval ui-spinBoxTimerInterval-value(); m_timer-start(interval); ui-btnTimerSend-setText(停止定時發(fā)送); } else { m_timer-stop(); ui-btnTimerSend-setText(啟動定時發(fā)送); } }注意QTimer的最小精度受操作系統(tǒng)影響Windows 下如果定時間隔設(shè)成 1 毫秒實際誤差可能到 15 毫秒左右。所以定時發(fā)送我建議最小間隔設(shè) 10ms實際測試大多數(shù)設(shè)備 10ms 發(fā)一包也夠快了沒必要追求 1ms 極值。5. 踩坑與優(yōu)化V1.0 留下的經(jīng)驗5.1 Hex 輸入校驗不嚴(yán)謹(jǐn)V1.0 里有個 bug 一直沒修如果用戶在 Hex 發(fā)送模式下輸入了非法字符比如01 03 ZZQByteArray::fromHex會把非法字符當(dāng)成 0 來處理結(jié)果發(fā)出去的數(shù)據(jù)完全不是用戶想要的還不好排查。我的建議是如果你要繼續(xù)開發(fā)一定要在發(fā)送前校驗輸入合法性。簡單做法是檢查字符串中每個字符是否在0-9A-Fa-f和空白字符范圍內(nèi)不合法就彈窗提示別讓數(shù)據(jù)稀里糊涂發(fā)出去了。5.2 大流量接收時界面卡頓V1.0 在接收高頻數(shù)據(jù)時比如下位機每 10ms 發(fā) 100 字節(jié)QTextEdit每觸發(fā)一次readyRead就插入一段文本界面刷新壓力很大極端情況下 CPU 能跑到 40% 以上。這個問題最簡單的緩解方案是限制接收緩沖區(qū)長度。比如只保留最近 200KB 的顯示數(shù)據(jù)超過就丟棄前面的舊內(nèi)容if (ui-textEditRecv-document()-characterCount() 200000) { QTextCursor cursor ui-textEditRecv-textCursor(); cursor.setPosition(0); cursor.setPosition(10000, QTextCursor::KeepAnchor); cursor.removeSelectedText(); }但即使這樣V1.0 在 115200 波特率滿速接收時依然會有點卡。真要長期跑高速數(shù)據(jù)還是得把接收數(shù)據(jù)處理放到QThread里UI 線程只負(fù)責(zé)更新顯示。這是 V2.0 的重點優(yōu)化方向。5.3 端口下拉框的刷新問題程序啟動時QSerialPortInfo::availablePorts()只會獲取一次端口列表。如果用戶先把設(shè)備插好再打開程序一切正常但如果程序開著中途拔了 USB 轉(zhuǎn)串口再插一個不同號的端口下拉框不會自動更新。最簡單的人工刷新方案是加一個刷新端口按鈕void MainWindow::refreshPorts() { ui-comboBoxPort-clear(); const auto infos QSerialPortInfo::availablePorts(); for (const QSerialPortInfo info : infos) { QString desc info.description(); if (desc.isEmpty()) desc info.manufacturer(); ui-comboBoxPort-addItem(info.portName() desc); } }注意處理description為空的情況很多免驅(qū)芯片在部分系統(tǒng)上拿不到描述信息直接拼字符串會多一個空位看起來怪怪的。5.4 串口被占用時打開失敗的提示調(diào)試時最容易遇到的一個場景串口助手已經(jīng)打開了某個端口你再開一個串口終端去連同一端口此時open()會失敗。V1.0 的代碼里已經(jīng)彈了QMessageBox::warning但在實際使用中我還加了一行日志輸出把errorString()打印到控制臺方便對照。這里想特別提醒遇到端口打開失敗的時候先檢查是不是被別的工具占用了不要盲目懷疑代碼。6. 一些使用層面的心得V1.0 寫完之后我日常調(diào)試 STM32 和 ESP32 的串口日志、調(diào)傳感器模塊、看 GPS 報文用的都是這個小工具。雖然功能上完全沒法跟 SSCOM、XCOM 這種老牌工具比但自己寫的工具最大的優(yōu)勢就是聽話——想加什么功能直接改。比如我后來在 V1.0 基礎(chǔ)上加了一個定時發(fā)送遞增序號的功能用來測試一個設(shè)備長時間跑穩(wěn)定性。這個需求放在現(xiàn)成工具上很難實現(xiàn)但在自己的代碼里只是加一個計數(shù)器拼進(jìn)數(shù)據(jù)包的事情。另外一個小技巧調(diào)試時把接收區(qū)設(shè)置成自動滾動數(shù)據(jù)會一直往下刷但如果想回看之前的內(nèi)容記得把自動滾動關(guān)掉。這個看似不起眼的交互細(xì)節(jié)在真實調(diào)試中能省很多事。用 Qt 做串口調(diào)試助手的這條路走到這里算是真正入門了。從打開串口到了解readyRead的觸發(fā)機制、從toHex顯示到QTimer定時發(fā)送每一個功能點背后都有值得深挖的原理。你把 V1.0 跑通之后再去研究 V2.0 的線程優(yōu)化、協(xié)議解析、波形顯示會順暢得多。畢竟代碼骨架是你的想怎么長都是你說了算。本文還有配套的精品資源點擊獲取