下面假设用 Qt Widgets,工程名叫 SerialTool。
(用 qmake 或 CMake 都行,这里用 qmake 举例。)
1. 工程目录结构
text
SerialTool/
SerialTool.pro
main.cpp
MainWindow.h
MainWindow.cpp
MainWindow.ui
SerialService.h // 串口收发层
SerialService.cpp
ProtocolParser.h // 协议组帧 + 校验层
ProtocolParser.cpp
CommandDispatcher.h // 按 CMD 分发
CommandDispatcher.cpp
Utils.h // 通用工具函数(时间戳等)
Utils.cpp
SerialTool.pro 示例
pro
QT += core gui serialport
greaterThan(QT_MAJOR_VERSION, 4): QT += widgets
TARGET = SerialTool
TEMPLATE = app
SOURCES += \
main.cpp \
MainWindow.cpp \
SerialService.cpp \
ProtocolParser.cpp \
CommandDispatcher.cpp \
Utils.cpp
HEADERS += \
MainWindow.h \
SerialService.h \
ProtocolParser.h \
CommandDispatcher.h \
Utils.h
FORMS += \
MainWindow.ui
2. main.cpp
cpp
#include <QApplication>
#include "MainWindow.h"
int main(int argc, char *argv[])
{
QApplication a(argc, argv);
MainWindow w;
w.show();
return a.exec();
}
没啥好说的,标准 Qt 入口。
3. MainWindow.ui 控件约定
在 Qt Designer 里放这些控件,并设置 objectName:
-
串口区域
QComboBox名为comboPort(串口号)QComboBox名为comboBaud(波特率)QPushButton名为btnOpenQPushButton名为btnClose
-
发送区域
QLineEdit名为lineEditSendQCheckBox名为chkHexSend(选中时按 hex 解析)QCheckBox名为chkAutoSend(自动发送)QSpinBox名为spinInterval(自动发送间隔 ms)QPushButton名为btnSend
-
接收区域
QTextEdit名为textEditRecvQCheckBox名为chkHexRecv(以 hex 显示)QPushButton名为btnClearRecv
-
日志区域
QTextEdit名为textEditLogQPushButton名为btnClearLog
-
统计区域
QLabel名为labelTxCountQLabel名为labelRxCount
4. Utils.h / Utils.cpp(时间戳等)
cpp
// Utils.h
#pragma once
#include <QString>
QString withTimestamp(const QString &msg);
cpp
// Utils.cpp
#include "Utils.h"
#include <QDateTime>
QString withTimestamp(const QString &msg)
{
return QDateTime::currentDateTime()
.toString("[yyyy-MM-dd HH:mm:ss.zzz] ") + msg;
}
评价:
公共小工具集中放在一个文件里,避免到处复制时间戳代码。独立出来之后也方便以后加别的工具函数。
5. 串口层 SerialService
cpp
// SerialService.h
#pragma once
#include <QObject>
#include <QSerialPort>
#include <QTimer>
class SerialService : public QObject
{
Q_OBJECT
public:
explicit SerialService(QObject *parent = nullptr);
bool open(const QString &portName, int baudRate);
void close();
bool isOpen() const { return port_.isOpen(); }
public slots:
void send(const QByteArray &data);
signals:
void received(const QByteArray &data);
void log(const QString &msg);
void opened();
void closed();
void sendCountUpdated(quint64 bytes);
void recvCountUpdated(quint64 bytes);
private slots:
void onReadyRead();
void onPortError(QSerialPort::SerialPortError error);
void tryReconnect();
private:
QSerialPort port_;
QTimer reconnectTimer_;
QString lastPortName_;
int lastBaudRate_ = 0;
quint64 sentBytes_ = 0;
quint64 recvBytes_ = 0;
};
cpp
// SerialService.cpp
#include "SerialService.h"
#include "Utils.h"
SerialService::SerialService(QObject *parent)
: QObject(parent)
{
connect(&port_, &QSerialPort::readyRead,
this, &SerialService::onReadyRead);
connect(&port_, &QSerialPort::errorOccurred,
this, &SerialService::onPortError);
reconnectTimer_.setInterval(2000); // 2 秒尝试一次
reconnectTimer_.setSingleShot(false);
connect(&reconnectTimer_, &QTimer::timeout,
this, &SerialService::tryReconnect);
}
bool SerialService::open(const QString &portName, int baudRate)
{
lastPortName_ = portName;
lastBaudRate_ = baudRate;
if (port_.isOpen())
port_.close();
port_.setPortName(portName);
port_.setBaudRate(baudRate);
if (!port_.open(QIODevice::ReadWrite)) {
emit log(withTimestamp("串口打开失败: " + port_.errorString()));
// 可以启动自动重连
reconnectTimer_.start();
return false;
}
reconnectTimer_.stop();
emit log(withTimestamp(QString("串口已打开: %1 @ %2")
.arg(portName).arg(baudRate)));
emit opened();
return true;
}
void SerialService::close()
{
reconnectTimer_.stop();
if (port_.isOpen()) {
port_.close();
emit log(withTimestamp("串口已关闭"));
emit closed();
}
}
void SerialService::send(const QByteArray &data)
{
if (!port_.isOpen()) {
emit log(withTimestamp("串口未打开, 发送失败"));
return;
}
qint64 n = port_.write(data);
if (n > 0) {
sentBytes_ += static_cast<quint64>(n);
emit sendCountUpdated(sentBytes_);
emit log(withTimestamp("发送: " + data.toHex(' ')));
}
}
void SerialService::onReadyRead()
{
QByteArray data = port_.readAll();
if (!data.isEmpty()) {
recvBytes_ += static_cast<quint64>(data.size());
emit recvCountUpdated(recvBytes_);
emit log(withTimestamp("接收: " + data.toHex(' ')));
emit received(data);
}
}
void SerialService::onPortError(QSerialPort::SerialPortError error)
{
if (error == QSerialPort::ResourceError) {
emit log(withTimestamp("串口错误: " + port_.errorString()));
port_.close();
emit closed();
reconnectTimer_.start();
}
}
void SerialService::tryReconnect()
{
if (port_.isOpen()) {
reconnectTimer_.stop();
return;
}
if (lastPortName_.isEmpty() || lastBaudRate_ == 0)
return;
emit log(withTimestamp("尝试自动重连..."));
open(lastPortName_, lastBaudRate_);
}
分析评价:
- 串口读写全部在同一个线程(默认就是 UI 线程),用的是
readyRead事件回调,不需要自己开线程。 - 自动重连用
QTimer,逻辑简单清晰。错误类型选择ResourceError,通常是设备断开。 - 使用
sentBytes_ / recvBytes_计数,单独用信号发给 UI。这样 UI 层不关心细节,只负责展示。 SerialService不依赖任何 UI 控件,只通过信号沟通,这点很重要,后面可以在别的工程复用。
6. 协议解析层 ProtocolParser
假设协议:55 AA LEN CMD PAYLOAD... CHECKSUM
LEN 不包含头和校验,CHECKSUM 是 CMD+PAYLOAD 的异或。
cpp
// ProtocolParser.h
#pragma once
#include <QObject>
class ProtocolParser : public QObject
{
Q_OBJECT
public:
explicit ProtocolParser(QObject *parent = nullptr);
public slots:
void feed(const QByteArray &data);
signals:
void frameParsed(quint8 cmd, const QByteArray &payload);
void log(const QString &msg);
private:
QByteArray buffer_;
void process();
};
cpp
// ProtocolParser.cpp
#include "ProtocolParser.h"
#include "Utils.h"
ProtocolParser::ProtocolParser(QObject *parent)
: QObject(parent)
{
}
void ProtocolParser::feed(const QByteArray &data)
{
buffer_.append(data);
process();
}
void ProtocolParser::process()
{
while (true) {
if (buffer_.size() < 4) // 至少 55 AA LEN CMD
return;
// 找头
int headerIndex = buffer_.indexOf("\x55\xAA", 0);
if (headerIndex < 0) {
emit log(withTimestamp("未找到帧头, 清空缓存"));
buffer_.clear();
return;
}
if (headerIndex > 0) {
buffer_.remove(0, headerIndex);
if (buffer_.size() < 4)
return;
}
quint8 len = static_cast<quint8>(buffer_[2]);
int frameSize = 2 /*55AA*/ + 1 /*LEN*/ + 1 /*CMD*/ + len /*payload*/ + 1 /*CHK*/;
if (buffer_.size() < frameSize)
return; // 数据不完整,等下次
QByteArray frame = buffer_.mid(0, frameSize);
buffer_.remove(0, frameSize);
quint8 cmd = static_cast<quint8>(frame[3]);
QByteArray payload = frame.mid(4, len);
quint8 recvChk = static_cast<quint8>(frame.last());
quint8 calc = cmd;
for (char c : payload)
calc ^= static_cast<quint8>(c);
if (calc != recvChk) {
emit log(withTimestamp("校验失败, 丢弃一帧"));
continue;
}
emit log(withTimestamp(QString("解析成功一帧, CMD=0x%1")
.arg(cmd, 2, 16, QChar('0'))));
emit frameParsed(cmd, payload);
}
}
分析评价:
- 使用内部 buffer 解决粘包、半包问题,这是协议解析必须要有的。
- 每次 feed 只做追加 + 循环解析,解析出一帧就 emit,一帧不够就等下一次 feed。
- 解析逻辑不依赖串口,只处理字节,适合以后换成 TCP、UDP 复用。
- CMD 和 payload 拆出来,方便后面 CommandDispatcher 分发。
7. 命令分发 CommandDispatcher
按 CMD 字节调用不同处理函数。
cpp
// CommandDispatcher.h
#pragma once
#include <QObject>
#include <QMap>
#include <functional>
class CommandDispatcher : public QObject
{
Q_OBJECT
public:
explicit CommandDispatcher(QObject *parent = nullptr);
void registerHandler(quint8 cmd, std::function<void(const QByteArray&)> handler);
public slots:
void dispatch(quint8 cmd, const QByteArray &payload);
signals:
void log(const QString &msg);
private:
QMap<quint8, std::function<void(const QByteArray&)>> handlers_;
};
cpp
// CommandDispatcher.cpp
#include "CommandDispatcher.h"
#include "Utils.h"
CommandDispatcher::CommandDispatcher(QObject *parent)
: QObject(parent)
{
}
void CommandDispatcher::registerHandler(quint8 cmd, std::function<void(const QByteArray&)> handler)
{
handlers_[cmd] = handler;
}
void CommandDispatcher::dispatch(quint8 cmd, const QByteArray &payload)
{
if (!handlers_.contains(cmd)) {
emit log(withTimestamp(QString("未注册 CMD: 0x%1")
.arg(cmd, 2, 16, QChar('0'))));
return;
}
handlers_[cmd](payload);
}
分析评价:
- 这是一个非常简单的"命令路由器"。解析层只管发 cmd+payload,具体每个 cmd 做什么由上层业务决定。
- 使用
std::function,可以在 MainWindow 里用 lambda 直接写业务逻辑,不用再新建很多子类。 - Dispatcher 不依赖 UI,不依赖串口,只依赖 cmd+payload,通用性很高。
8. MainWindow.h / MainWindow.cpp
cpp
// MainWindow.h
#pragma once
#include <QMainWindow>
#include <QTimer>
QT_BEGIN_NAMESPACE
namespace Ui { class MainWindow; }
QT_END_NAMESPACE
class SerialService;
class ProtocolParser;
class CommandDispatcher;
class MainWindow : public QMainWindow
{
Q_OBJECT
public:
MainWindow(QWidget *parent = nullptr);
~MainWindow();
private:
Ui::MainWindow *ui;
SerialService *serial_;
ProtocolParser *parser_;
CommandDispatcher *dispatcher_;
QTimer autoSendTimer_;
void initUi();
void initSignals();
void initDispatcher();
private slots:
void onOpenClicked();
void onCloseClicked();
void onSendClicked();
void onClearRecv();
void onClearLog();
};
cpp
// MainWindow.cpp
#include "MainWindow.h"
#include "ui_MainWindow.h"
#include "SerialService.h"
#include "ProtocolParser.h"
#include "CommandDispatcher.h"
#include "Utils.h"
#include <QSerialPortInfo>
#include <QMessageBox>
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent)
, ui(new Ui::MainWindow)
{
ui->setupUi(this);
serial_ = new SerialService(this);
parser_ = new ProtocolParser(this);
dispatcher_ = new CommandDispatcher(this);
autoSendTimer_.setSingleShot(false);
initUi();
initSignals();
initDispatcher();
}
MainWindow::~MainWindow()
{
delete ui;
}
void MainWindow::initUi()
{
// 列出串口
ui->comboPort->clear();
const auto ports = QSerialPortInfo::availablePorts();
for (const auto &info : ports) {
ui->comboPort->addItem(info.portName());
}
// 波特率
ui->comboBaud->clear();
ui->comboBaud->addItems({"9600", "19200", "38400", "57600", "115200"});
ui->comboBaud->setCurrentText("115200");
ui->labelTxCount->setText("TX: 0");
ui->labelRxCount->setText("RX: 0");
ui->spinInterval->setValue(1000);
}
void MainWindow::initSignals()
{
connect(ui->btnOpen, &QPushButton::clicked,
this, &MainWindow::onOpenClicked);
connect(ui->btnClose, &QPushButton::clicked,
this, &MainWindow::onCloseClicked);
connect(ui->btnSend, &QPushButton::clicked,
this, &MainWindow::onSendClicked);
connect(ui->btnClearRecv, &QPushButton::clicked,
this, &MainWindow::onClearRecv);
connect(ui->btnClearLog, &QPushButton::clicked,
this, &MainWindow::onClearLog);
// 串口 → 解析
connect(serial_, &SerialService::received,
parser_, &ProtocolParser::feed);
// 解析 → 分发
connect(parser_, &ProtocolParser::frameParsed,
dispatcher_, &CommandDispatcher::dispatch);
// 日志合流
auto logToUi = [this](const QString &msg){
ui->textEditLog->append(msg);
};
connect(serial_, &SerialService::log, logToUi);
connect(parser_, &ProtocolParser::log, logToUi);
connect(dispatcher_, &CommandDispatcher::log, logToUi);
// 统计
connect(serial_, &SerialService::sendCountUpdated,
this, [this](quint64 bytes){
ui->labelTxCount->setText(QString("TX: %1").arg(bytes));
});
connect(serial_, &SerialService::recvCountUpdated,
this, [this](quint64 bytes){
ui->labelRxCount->setText(QString("RX: %1").arg(bytes));
});
// 自动发送
connect(ui->chkAutoSend, &QCheckBox::toggled,
this, [this](bool on){
if (on) {
autoSendTimer_.start(ui->spinInterval->value());
} else {
autoSendTimer_.stop();
}
});
connect(&autoSendTimer_, &QTimer::timeout,
this, &MainWindow::onSendClicked);
// 改变间隔时实时更新
connect(ui->spinInterval, QOverload<int>::of(&QSpinBox::valueChanged),
this, [this](int v){
if (autoSendTimer_.isActive())
autoSendTimer_.start(v);
});
}
void MainWindow::initDispatcher()
{
// 示例: CMD = 0x01, 认为是 JSON 文本
dispatcher_->registerHandler(0x01, [this](const QByteArray &payload){
QString s = QString::fromUtf8(payload);
ui->textEditRecv->append(withTimestamp("CMD 0x01(JSON): " + s));
});
// 示例: CMD = 0x02, 以二进制十六进制显示
dispatcher_->registerHandler(0x02, [this](const QByteArray &payload){
ui->textEditRecv->append(withTimestamp(
"CMD 0x02(BIN): " + payload.toHex(' ')
));
});
}
void MainWindow::onOpenClicked()
{
QString port = ui->comboPort->currentText();
int baud = ui->comboBaud->currentText().toInt();
if (port.isEmpty()) {
QMessageBox::warning(this, "错误", "没有可用串口");
return;
}
serial_->open(port, baud);
}
void MainWindow::onCloseClicked()
{
serial_->close();
}
void MainWindow::onSendClicked()
{
QString text = ui->lineEditSend->text().trimmed();
if (text.isEmpty())
return;
QByteArray data;
if (ui->chkHexSend->isChecked()) {
// 解析为十六进制字节
#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0)
QStringList parts = text.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts);
#else
QStringList parts = text.split(QRegExp("\\s+"), Qt::SkipEmptyParts);
#endif
for (const QString &p : parts) {
bool ok = false;
int v = p.toInt(&ok, 16);
if (!ok || v < 0 || v > 255) {
QMessageBox::warning(this, "错误",
QString("无效字节: '%1'").arg(p));
return;
}
data.append(static_cast<char>(v));
}
} else {
data = text.toUtf8();
}
serial_->send(data);
}
void MainWindow::onClearRecv()
{
ui->textEditRecv->clear();
}
void MainWindow::onClearLog()
{
ui->textEditLog->clear();
}
分析评价:
- MainWindow 只做三件事:初始化 UI、连信号、简单业务决策(比如是否 hex 发送)。
发送、接收、解析、分发全部交给下面的类处理,结构清晰。 - 自动发送用一个 QTimer 调
onSendClicked(),充分复用已有逻辑,避免重复写发送代码。 - 解析出来的帧由 CommandDispatcher 按 CMD 处理。Demo 里给了一个 JSON 文本的例子、一个二进制显示的例子,你将来可以替换成自己的业务逻辑。
总体评价与扩展建议
-
解耦程度
- 串口层只负责 IO。
- 协议层只负责组帧、校验。
- 分发层只负责按 CMD 区分业务。
- UI 只负责交互和展示。
后续不管改协议、换设备、换 UI,都改自己的那一层即可。
-
可维护性
每个类职责单一,文件不长,不会出现一个 MainWindow.cpp 两千行全是逻辑的情况。
-
线程
全部在 UI 线程,不引入多线程复杂度。一般串口调试工具这点已经够用。如果后面数据量巨大,再把 SerialService 或解析层单独 moveToThread 即可。
-
扩展点
- 在 ProtocolParser 里加帧计数、错误计数。
- CommandDispatcher 增加默认处理策略,比如未知 CMD 也打印 payload。
- 用 QSettings 保存串口号、波特率、窗口大小等配置。
- 加一个"导出日志到文件"的功能。
如果你愿意,我可以下一步帮你做一件事:
你告诉我你当前工程里已经有的文件和控件名,我按这个结构帮你"对号入座",改成一份几乎可以直接复制进你项目的版本。