QT上位机串口通信协议助手 —— 从零基础到项目实战

本文档基于编号 72~105 的 QT 串口助手课程源码,面向 QT 初学者,系统讲解串口通信协议助手的完整开发流程。文档涵盖开发环境搭建、信号与槽机制、串口通信实现、协议包解包处理、多线程编程等核心知识点,配有逐行注释的完整代码片段与可视化图示。


目录

第一章 QT 开发环境搭建
第二章 QT 核心机制:信号与槽
第三章 串口通信基础
第四章 串口助手 UI 设计
第五章 串口核心功能实现
第六章 定时器与定时发送
第七章 Hex 十六进制收发
第八章 自定义控件与串口刷新
第九章 指令系统与多按钮发送
第十章 多线程自动发送
第十一章 数据保存与加载
第十二章 串口协议包解包详解(核心)
第十三章 调试技巧与错误排查
第十四章 最终版完整代码解析
附录 学习路径总结


第一章 QT 开发环境搭建

1.1 QT 简介

QT 是一个跨平台的 C++ 应用程序开发框架,广泛应用于桌面软件、嵌入式设备、上位机工具等领域。对于串口通信开发,QT 提供了 Qt Serial Port 模块,封装了底层操作系统的串口 API,使开发者无需关心平台差异即可实现串口收发。

为什么选择 QT 开发串口助手?

跨平台:一套代码可在 Windows、Linux、macOS 运行
信号槽机制:天然适合异步事件驱动的串口通信
丰富的 UI 控件:快速搭建专业级界面
内置串口模块:无需第三方库

1.2 开发环境安装

步骤一:下载 QT Creator

访问 QT 官网下载 QT 在线安装器,选择社区版(开源免费)。

步骤二:安装组件

安装时勾选以下组件:
Qt 5.14.2(或更高版本)
MinGW 64-bit 编译器
Qt Creator IDE
Developer and Designer Tools

步骤三:验证安装

打开 Qt Creator,新建项目,如果能正常编译运行空窗口,说明环境搭建成功。

1.3 创建第一个 QT 项目

新建项目时选择 Application (Qt) -> Qt Widgets Application,项目结构如下:

SerialPROFinal/
├── 105-SerialPROFinal.pro   # 工程配置文件
├── main.cpp                  # 程序入口
├── widget.h                  # 主窗口头文件
├── widget.cpp                # 主窗口实现
├── widget.ui                 # 界面描述文件(XML)
└── res.qrc                   # 资源文件

1.4 工程配置文件 .pro 详解

.pro 文件是 QT 项目的核心配置文件,决定了编译哪些源码、链接哪些模块。以下是本项目(105 最终版)的完整配置:

# ===== 模块声明 =====
QT       += core gui          # 引入核心模块和GUI模块(默认包含)
QT       += serialport        # 引入串口通信模块(关键!)

# ===== 版本兼容 =====
greaterThan(QT_MAJOR_VERSION, 4): QT += widgets  # Qt5以上启用widgets模块

CONFIG += c++11               # 启用C++11标准支持

# ===== 编译警告 =====
DEFINES += QT_DEPRECATED_WARNINGS  # 使用已废弃API时发出警告

# ===== 源文件列表 =====
SOURCES += \
    main.cpp \                # 程序入口
    mycombobox.cpp \          # 自定义下拉框实现
    widget.cpp                # 主窗口实现

# ===== 头文件列表 =====
HEADERS += \
    mycombobox.h \            # 自定义下拉框声明
    widget.h                  # 主窗口声明

# ===== 界面文件 =====
FORMS += \
    widget.ui                 # Qt Designer设计的界面

# ===== 资源文件 =====
RESOURCES += \
    res.qrc                   # 图片等资源

关键点: QT += serialport 是串口通信的核心,没有这一行,编译器将无法识别 QSerialPort 类。

1.5 程序入口 main.cpp

#include "widget.h"
#include <QApplication>

int main(int argc, char *argv[])
{
    QApplication a(argc, argv);   // 创建QT应用程序对象,管理GUI程序的控制流
    Widget w;                      // 实例化主窗口对象
    w.show();                      // 显示主窗口
    return a.exec();               // 进入QT事件循环,等待用户操作
}

QApplication:每个 GUI 程序有且仅有一个,负责处理事件循环、窗口系统设置
w.show():窗口默认隐藏,必须调用 show() 才能显示
a.exec():进入事件循环,程序在此阻塞直到窗口关闭


第二章 QT 核心机制:信号与槽

2.1 信号槽概念

信号与槽(Signals and Slots)是 QT 的核心机制,用于实现对象间的通信。当一个对象的状态发生变化时,它可以发出一个信号(signal);其他对象可以通过连接(connect)将自己的槽函数(slot)与该信号关联,当信号发出时,槽函数会被自动调用。

通俗理解: 信号槽就像"广播站"和"收音机"的关系。广播站发出信号,收音机调到对应频道就能接收。在 QT 中,按钮被点击就是"广播站发信号",执行某个函数就是"收音机接收"。

2.2 信号槽连接方式

QT 提供了多种 connect 语法,本项目使用了三种典型写法:

方式一:传统宏语法(SIGNAL/SLOT)

// 项目82-QTimer中的用法
connect(getSysTimeTimer, SIGNAL(timeout()), this, SLOT(time_reflash()));
// 参数1:信号发出者对象指针
// 参数2:信号名称(用SIGNAL宏包裹)
// 参数3:信号接收者对象指针
// 参数4:槽函数名称(用SLOT宏包裹)

方式二:函数指针语法(推荐)

// 项目105最终版中的用法
connect(serialPort, &QSerialPort::readyRead, this, &Widget::on_SerialData_readyToRead);
// 参数1:信号发出者对象指针
// 参数2:信号函数指针(&类名::信号名)
// 参数3:接收者对象指针
// 参数4:槽函数指针(&类名::槽函数名)

方式三:Lambda 表达式(灵活)

// 项目105最终版中的用法
connect(timer, &QTimer::timeout, [=](){
    on_btnSendContext_clicked();   // 定时器触发时自动发送数据
});
// [=] 捕获外部变量,使得Lambda内部可以调用类的成员函数

2.3 自动关联槽函数

QT 提供了一种便捷的自动关联机制:只要槽函数命名遵循 on_控件名_信号名 的格式,QT 会在调用 setupUi() 时自动建立连接,无需手动写 connect

// 以下函数无需手动connect,QT自动关联:
void Widget::on_btnCloseOrOpenSerial_clicked(bool checked);  
// on_btnCloseOrOpenSerial_clicked  ->  控件名: btnCloseOrOpenSerial, 信号: clicked

void Widget::on_btnSendContext_clicked();
// on_btnSendContext_clicked  ->  控件名: btnSendContext, 信号: clicked

命名规则: on_ + 控件objectName + _ + 信号名

2.4 自定义信号

在某些场景下需要自定义信号,例如本项目的自定义下拉框 MyComboBox

// mycombobox.h
class MyComboBox : public QComboBox
{
    Q_OBJECT  // 必须包含此宏,否则信号槽无法工作

signals:
    void refresh();  // 自定义信号,只需声明,无需实现
};

发出信号使用 emit 关键字:

// mycombobox.cpp
void MyComboBox::mousePressEvent(QMouseEvent *e)
{
    if(e->button() == Qt::LeftButton){
        emit refresh();  // 鼠标左键按下时,发出refresh信号
    }
    QComboBox::mousePressEvent(e);  // 调用父类的鼠标事件,保持原有行为
}

2.5 信号槽的五种连接类型

connect(sender, signal, receiver, slot, Qt::ConnectionType type);
连接类型 说明 使用场景
Qt::AutoConnection(默认) 自动选择:同线程用直连,跨线程用队列 大多数场景
Qt::DirectConnection 直接调用:槽函数在信号发出的线程执行 需要同步执行
Qt::QueuedConnection 队列调用:槽函数在接收者线程的事件循环中执行 跨线程通信
Qt::BlockingQueuedConnection 阻塞队列:发出者阻塞直到槽函数执行完毕 跨线程同步等待
Qt::UniqueConnection 唯一连接:防止重复连接 避免多次触发

第三章 串口通信基础

3.1 串口通信原理

串口通信(Serial Communication)是指数据一位一位地顺序传输,是最常见的设备间通信方式之一。在嵌入式开发、工业控制、传感器数据采集等领域应用广泛。

串口通信核心参数:

参数 说明 常见值
波特率 每秒传输的比特数 9600, 115200
数据位 每帧数据的有效位数 5, 6, 7, 8
停止位 标记一帧结束的位数 1, 1.5, 2
校验位 用于错误检测的额外位 无校验、奇校验、偶校验
流控 控制数据传输速度的机制 无、硬件、软件

3.2 QT 串口模块

QT 的串口功能由 Qt Serial Port 模块提供,核心类有两个:

QSerialPort:提供串口数据的收发功能
QSerialPortInfo:提供系统可用串口的信息查询

使用前需在 .pro 文件中添加:QT += serialport

3.3 QSerialPort 核心 API

// ===== 串口配置 =====
serialPort->setPortName("COM3");              // 设置串口名称
serialPort->setBaudRate(QSerialPort::Baud115200);  // 设置波特率
serialPort->setDataBits(QSerialPort::Data8);  // 设置数据位
serialPort->setParity(QSerialPort::NoParity); // 设置校验位
serialPort->setStopBits(QSerialPort::OneStop); // 设置停止位
serialPort->setFlowControl(QSerialPort::NoFlowControl); // 设置流控

// ===== 串口操作 =====
serialPort->open(QIODevice::ReadWrite);  // 打开串口(可读可写)
serialPort->close();                     // 关闭串口

// ===== 数据收发 =====
serialPort->write("Hello");              // 发送数据,返回写入字节数
QByteArray data = serialPort->readAll(); // 读取所有可用数据

// ===== 信号 =====
connect(serialPort, &QSerialPort::readyRead, this, &Widget::on_readyRead);
// 当串口收到数据时,自动发出readyRead信号

3.4 QSerialPortInfo 枚举可用串口

// 获取系统中所有可用串口
QList<QSerialPortInfo> serialList = QSerialPortInfo::availablePorts();

// 遍历并添加到下拉框
for(QSerialPortInfo serialInfo : serialList){
    ui->comboBox_serialNum->addItem(serialInfo.portName());
    // serialInfo.portName()  返回如 "COM3"、"ttyUSB0"
    // serialInfo.description()  返回设备描述
    // serialInfo.manufacturer()  返回制造商
}

3.5 串口通信数据流向

串口通信的数据流分为发送(TX)和接收(RX)两个方向:

发送方向:用户输入 → 数据转换 → serialPort->write() → 硬件TX引脚
接收方向:硬件RX引脚 → readyRead信号触发 → serialPort->readAll() → 界面显示

这种基于信号的事件驱动模型,使得串口接收不需要轮询,大幅提升效率。


第四章 串口助手 UI 设计

4.1 UI 演进过程

本项目的 UI 设计经历了从简单到复杂的演进:

72-Serial001UI:最初版本,仅有基础控件,widget.ui 约 13KB
74-Serial004UIALL:完整控件布局,widget.ui 约 24KB
75-SerialPROJ 及之后:引入串口模块,UI 稳定在约 30KB

4.2 布局管理

本项目使用 QGridLayout(网格布局)作为主布局:

Widget::Widget(QWidget *parent)
    : QWidget(parent)
    , ui(new Ui::Widget)
{
    ui->setupUi(this);
    this->setLayout(ui->gridLayoutGlobal);  // 设置全局网格布局
}

网格布局的优势:
控件自动对齐,窗口缩放时保持比例
适合多区域划分的复杂界面
QVBoxLayout/QHBoxLayout 更灵活

4.3 控件命名规范

本项目采用 前缀+功能名 的命名方式,便于代码中识别:

控件类型 命名前缀 示例
按钮 btn btnCloseOrOpenSerial, btnSendContext
下拉框 comboBox comboBox_serialNum, comboBox_boautrate
复选框 checkB checkBHexDisplay, checkBSendInTime
文本编辑框 textEdit textEditRev, textEditRecord
单行输入框 lineEdit lineEditSendContext, lineEditTimeeach
标签 label labelSendStatus, labelRevcnt

命名建议: 建议使用驼峰命名法,前缀小写表示控件类型,后缀描述功能。

4.4 控件初始状态控制

在构造函数中,根据业务逻辑设置控件的初始可用状态:

// 未打开串口前,发送相关控件不可用
ui->btnSendContext->setEnabled(false);     // 发送按钮禁用
ui->checkBSendInTime->setEnabled(false);   // 定时发送复选框禁用
ui->checkSendNewLine->setEnabled(false);   // 发送新行复选框禁用
ui->checkBHexSend->setEnabled(false);      // Hex发送复选框禁用

// 设置默认波特率和数据位
ui->comboBox_boautrate->setCurrentIndex(6);  // 默认115200
ui->comboBox_databit->setCurrentIndex(3);    // 默认8位数据位

第五章 串口核心功能实现

5.1 串口对象的创建与初始化

// widget.h 中声明
private:
    QSerialPort *serialPort;  // 串口对象指针

// widget.cpp 构造函数中创建
Widget::Widget(QWidget *parent)
    : QWidget(parent)
    , ui(new Ui::Widget)
{
    ui->setupUi(this);
    this->setLayout(ui->gridLayoutGlobal);

    // 控制参数初始化
    writeCntTotal = 0;     // 发送字节计数器清零
    readCntTotal  = 0;     // 接收字节计数器清零
    serialStatus = false;  // 串口状态标记为关闭

    // 在窗口加入一个串口控制对象
    // this作为父对象,当Widget销毁时serialPort自动释放
    serialPort = new QSerialPort(this);

    // 连接串口的readyRead信号到接收槽函数
    // 当串口收到数据时,自动调用on_SerialData_readyToRead
    connect(serialPort, &QSerialPort::readyRead, this, &Widget::on_SerialData_readyToRead);
}

5.2 打开与关闭串口

本项目使用带 checked 参数的槽函数,配合可选中按钮实现开/关切换:

void Widget::on_btnCloseOrOpenSerial_clicked(bool checked)
{
    if(checked){
        // ===== 打开串口 =====

        // 1. 选择端口号(从下拉框获取)
        serialPort->setPortName(ui->comboBox_serialNum->currentText());

        // 2. 配置波特率(字符串转整数)
        serialPort->setBaudRate(ui->comboBox_boautrate->currentText().toInt());

        // 3. 配置数据位(字符串转无符号整数,再转为枚举类型)
        serialPort->setDataBits(QSerialPort::DataBits(
            ui->comboBox_databit->currentText().toUInt()));

        // 4. 配置校验位(根据下拉框索引选择)
        switch (ui->comboBox_jiaoyan->currentIndex()) {
        case 0: serialPort->setParity(QSerialPort::NoParity);   break;
        case 1: serialPort->setParity(QSerialPort::EvenParity); break;
        case 2: serialPort->setParity(QSerialPort::MarkParity); break;
        case 3: serialPort->setParity(QSerialPort::OddParity);  break;
        case 4: serialPort->setParity(QSerialPort::SpaceParity);break;
        default: serialPort->setParity(QSerialPort::UnknownParity); break;
        }

        // 5. 配置停止位
        serialPort->setStopBits(QSerialPort::StopBits(
            ui->comboBox_stopbit->currentText().toUInt()));

        // 6. 配置流控
        if(ui->comboBox_fileCon->currentText() == "None")
            serialPort->setFlowControl(QSerialPort::NoFlowControl);

        // 7. 打开串口(读写模式)
        if(serialPort->open(QIODevice::ReadWrite)){
            // 打开成功:禁用配置控件,防止运行时修改
            ui->comboBox_databit->setEnabled(false);
            ui->comboBox_fileCon->setEnabled(false);
            ui->comboBox_jiaoyan->setEnabled(false);
            ui->comboBox_stopbit->setEnabled(false);
            ui->comboBox_boautrate->setEnabled(false);
            ui->comboBox_serialNum->setEnabled(false);

            // 更新按钮文字和启用发送相关控件
            ui->btnCloseOrOpenSerial->setText("关闭串口");
            ui->btnSendContext->setEnabled(true);
            ui->checkBSendInTime->setEnabled(true);
            ui->checkSendNewLine->setEnabled(true);
            ui->checkBHexSend->setEnabled(true);

            ui->labelSendStatus->setText(
                ui->comboBox_serialNum->currentText() + "isOpen!");
        }else{
            // 打开失败:弹出错误提示
            QMessageBox msgBox;
            msgBox.setWindowTitle("打开串口错误");
            msgBox.setText("打开失败,串口可能被占用或者已拔出!");
            msgBox.exec();
        }
    }else{
        // ===== 关闭串口 =====
        serialPort->close();

        // 恢复配置控件的可用状态
        ui->btnCloseOrOpenSerial->setText("打开串口");
        ui->comboBox_databit->setEnabled(true);
        ui->comboBox_fileCon->setEnabled(true);
        ui->comboBox_jiaoyan->setEnabled(true);
        ui->comboBox_stopbit->setEnabled(true);
        ui->comboBox_boautrate->setEnabled(true);
        ui->comboBox_serialNum->setEnabled(true);

        // 禁用发送相关控件
        ui->btnSendContext->setEnabled(false);
        ui->checkBSendInTime->setEnabled(false);
        ui->checkBSendInTime->setCheckState(Qt::Unchecked);

        // 关闭定时发送的定时器
        timer->stop();
        ui->lineEditTimeeach->setEnabled(true);
        ui->lineEditSendContext->setEnabled(true);
        ui->checkSendNewLine->setEnabled(false);
        ui->checkBHexSend->setEnabled(false);

        ui->labelSendStatus->setText(
            ui->comboBox_serialNum->currentText() + "isClose!");
    }
}

设计要点:

  1. 打开串口前需要完成所有参数配置(端口号、波特率、数据位等)
  2. 打开成功后禁用配置控件,防止运行时误改导致异常
  3. 打开失败时给出友好提示,常见原因是串口被其他程序占用
  4. 关闭串口时恢复控件状态,并停止定时发送

5.3 数据发送

void Widget::on_btnSendContext_clicked()
{
    int writeCnt = 0;  // 记录本次发送的字节数

    // 读取用户输入的发送内容,转为本地8位编码
    const char* sendData = ui->lineEditSendContext->text()
                           .toLocal8Bit().constData();

    // 判断是否16进制发送
    if(ui->checkBHexSend->isChecked()){
        // ===== Hex模式发送 =====
        QString tmp = ui->lineEditSendContext->text();
        QByteArray tmpArray = tmp.toLocal8Bit();

        // 校验1:Hex必须是偶数位(每两位表示一个字节)
        if(tmpArray.size() % 2 != 0){
            ui->labelSendStatus->setText("Error Input!");
            return;
        }

        // 校验2:所有字符必须是合法的十六进制字符
        for(char c : tmpArray){
            if(!std::isxdigit(c)){
                ui->labelSendStatus->setText("Error Input!");
                return;
            }
        }

        // 如果勾选了"发送新行",追加回车换行
        if(ui->checkSendNewLine->isChecked())
            tmpArray.append("\r\n");

        // 将Hex字符串转换为实际字节数据
        // 例如用户输入"3132",fromHex后变成两个字节: 0x31 0x32
        QByteArray arraySend = QByteArray::fromHex(tmpArray);
        writeCnt = serialPort->write(arraySend);

    }else{
        // ===== 普通文本模式发送 =====
        if(ui->checkSendNewLine->isChecked()){
            // 带换行发送
            QByteArray arrySendData(sendData, strlen(sendData));
            arrySendData.append("\r\n");
            writeCnt = serialPort->write(arrySendData);
        }else{
            // 不带换行直接发送
            writeCnt = serialPort->write(sendData);
        }
    }

    // 处理发送结果
    if(writeCnt == -1){
        // write返回-1表示发送失败
        ui->labelSendStatus->setText("SendError!");
    }else{
        // 发送成功:累加计数并更新显示
        writeCntTotal += writeCnt;
        ui->labelSendStatus->setText("SendOK!");
        ui->labelSendcnt->setText("Sent:" + QString::number(writeCntTotal));

        // 记录发送历史(去重处理)
        if(strcmp(sendData, sendBak.toStdString().c_str()) != 0){
            ui->textEditRecord->append(sendData);
            sendBak = QString::fromUtf8(sendData);
        }
    }
}

关键 API 说明:
toLocal8Bit():将 QString 转为本地编码的 QByteArray,适用于中文环境
QByteArray::fromHex():将 Hex 字符串转为实际字节,如 “48656C6C6F” → “Hello”
serialPort->write():返回实际写入的字节数,-1 表示失败

5.4 数据接收

串口接收采用事件驱动模式,当有数据到达时 QSerialPort 会发出 readyRead 信号:

void Widget::on_SerialData_readyToRead()
{
    // 读取串口缓冲区中的所有数据
    QString revMessage = serialPort->readAll();

    if(revMessage != NULL){
        // 如果勾选了"自动换行",追加回车换行符
        if(ui->checkBLine->isChecked())
            revMessage.append("\r\n");

        // 判断是否16进制显示
        if(ui->checkBHexDisplay->isChecked()){
            // ===== Hex显示模式 =====
            // 将收到的数据转为Hex字符串(大写)
            QByteArray tmpHexString = revMessage.toUtf8().toHex().toUpper();

            // 获取文本框中已有的Hex内容
            QString tmpStringHex = ui->textEditRev->toPlainText();

            // 拼接旧内容和新接收的Hex数据
            tmpHexString = tmpStringHex.toUtf8() + tmpHexString;

            // 重新显示
            ui->textEditRev->setText(QString::fromUtf8(tmpHexString));
        }else{
            // ===== 普通文本显示模式 =====
            if(ui->checkBrevTime->checkState() == Qt::Unchecked){
                // 不带时间戳
                ui->textEditRev->insertPlainText(revMessage);
            }
            else if(ui->checkBrevTime->checkState() == Qt::Checked){
                // 带时间戳显示
                getSysTime();
                ui->textEditRev->insertPlainText("【" + myTime + "】 " + revMessage);
            }
        }

        // 累加接收字节计数
        readCntTotal += revMessage.size();
        ui->labelRevcnt->setText("Received:" + QString::number(readCntTotal));

        // 光标移动到末尾,确保最新内容可见
        ui->textEditRev->moveCursor(QTextCursor::End);
        ui->textEditRev->ensureCursorVisible();
    }
}

接收设计要点:

  1. readAll() 一次性读取缓冲区所有数据,避免数据残留
  2. Hex 显示模式需要将新旧数据拼接,保持连续显示
  3. insertPlainText() 不会自动换行,适合追加显示
  4. moveCursor(QTextCursor::End) 确保光标始终在末尾,类似终端效果

5.5 串口列表刷新

void Widget::refreshSerialName()
{
    // 清空下拉框中的旧列表
    ui->comboBox_serialNum->clear();

    // 查询系统中所有可用串口
    QList<QSerialPortInfo> serialList = QSerialPortInfo::availablePorts();

    // 遍历并添加到下拉框
    for(QSerialPortInfo serialInfo : serialList){
        ui->comboBox_serialNum->addItem(serialInfo.portName());
    }

    ui->labelSendStatus->setText("Com Refreshed!");
}

第六章 定时器与定时发送

6.1 QTimer 基础

QTimer 是 QT 提供的定时器类,可以周期性地发出 timeout 信号。本项目使用定时器实现两个功能:
刷新系统时间显示(每 100ms)
定时发送串口数据(用户自定义间隔)

6.2 QTimer 基础用法(项目 82-QTimer)

// widget.h
private:
    QTimer *timer;  // 定时器指针

// widget.cpp 构造函数
Widget::Widget(QWidget *parent)
    : QWidget(parent)
    , ui(new Ui::Widget)
{
    ui->setupUi(this);

    // 创建定时器
    timer = new QTimer(this);

    // 连接定时器的timeout信号到Lambda表达式
    connect(timer, &QTimer::timeout, [=](){
        qDebug() << "timer out!";  // 每次定时器触发时打印日志
    });
}

// 启动定时器(参数为毫秒,1000ms = 1秒)
void Widget::on_pushButton_clicked()
{
    timer->start(1000);  // 每1000毫秒触发一次
}

// 停止定时器
void Widget::on_pushButton_2_clicked()
{
    timer->stop();
}

6.3 定时刷新系统时间

Widget::Widget(QWidget *parent)
{
    // ... 其他初始化代码 ...

    // 创建系统时间刷新定时器
    QTimer *getSysTimeTimer = new QTimer(this);

    // 连接timeout信号到time_reflash槽函数
    connect(getSysTimeTimer, SIGNAL(timeout()), this, SLOT(time_reflash()));

    // 启动定时器,每100毫秒刷新一次
    getSysTimeTimer->start(100);
}

// 刷新界面上的时间显示
void Widget::time_reflash()
{
    getSysTime();                          // 获取当前系统时间
    ui->labelCurrentTime->setText(myTime); // 更新标签显示
}

// 获取系统时间并格式化
void Widget::getSysTime()
{
    QDateTime currentTime = QDateTime::currentDateTime();

    // 处理日期
    QDate date = currentTime.date();
    int year = date.year();
    int month = date.month();
    int day = date.day();

    // 处理时间
    QTime time = currentTime.time();
    int hour = time.hour();
    int minite = time.minute();
    int second = time.second();

    // 格式化为 "2024-01-01  23:12:05" 的形式
    // arg(数值, 最小宽度, 进制, 填充字符)
    myTime = QString("%1-%2-%3  %4:%5:%6")
        .arg(year, 2, 10, QChar('0'))    // 年,2位宽度,10进制,用'0'填充
        .arg(month, 2, 10, QChar('0'))   // 月
        .arg(day, 2, 10, QChar('0'))     // 日
        .arg(hour, 2, 10, QChar('0'))    // 时
        .arg(minite, 2, 10, QChar('0'))  // 分
        .arg(second, 2, 10, QChar('0')); // 秒
}

6.4 定时发送实现

Widget::Widget(QWidget *parent)
{
    // ... 其他初始化代码 ...

    // 创建定时发送用的定时器
    timer = new QTimer(this);

    // 定时器触发时,调用发送函数
    connect(timer, &QTimer::timeout, [=](){
        on_btnSendContext_clicked();  // 复用发送按钮的逻辑
    });
}

// 定时发送复选框状态切换
void Widget::on_checkBSendInTime_clicked(bool checked)
{
    if(checked){
        // 勾选定时发送:
        // 1. 禁用时间间隔和发送内容输入框(防止运行时修改)
        ui->lineEditTimeeach->setEnabled(false);
        ui->lineEditSendContext->setEnabled(false);

        // 2. 启动定时器,间隔由用户输入(毫秒)
        timer->start(ui->lineEditTimeeach->text().toInt());
    }else{
        // 取消定时发送:
        // 1. 停止定时器
        timer->stop();

        // 2. 恢复输入框可用状态
        ui->lineEditTimeeach->setEnabled(true);
        ui->lineEditSendContext->setEnabled(true);
    }
}

第七章 Hex 十六进制收发

7.1 为什么需要 Hex 模式

在串口通信中,很多设备使用二进制协议(而非文本协议)。例如:
发送指令 0x01 0x03 0x00 0x00 0x00 0x0A 给 Modbus 设备
接收传感器返回的原始字节数据

如果用普通文本模式,这些字节可能包含不可打印字符,无法正确显示。Hex 模式将每个字节显示为两位十六进制数(如 0x41 显示为 “41”),便于调试和分析。

7.2 Hex 显示切换

void Widget::on_checkBHexDisplay_clicked(bool checked)
{
    if(checked){
        // ===== 切换到Hex显示 =====
        // 1. 读取文本框中的当前内容
        QString tmp = ui->textEditRev->toPlainText();

        // 2. 转为QByteArray并调用toHex()
        QByteArray qtmp = tmp.toUtf8();
        qtmp = qtmp.toHex();  // "Hello" -> "48656c6c6f"

        // 3. 每两位之间加空格,便于阅读
        tmp = QString::fromUtf8(qtmp);  // "48656c6c6f"
        QString lastShow;
        for(int i = 0; i < tmp.size(); i += 2){
            lastShow += tmp.mid(i, 2) + " ";  // "48 65 6c 6c 6f "
        }

        // 4. 转大写显示
        ui->textEditRev->setText(lastShow.toUpper());  // "48 65 6C 6C 6F "
    }else{
        // ===== 切换回文本显示 =====
        // 1. 读取Hex字符串
        QString tmpHexString = ui->textEditRev->toPlainText();

        // 2. 去除空格后用fromHex还原
        QByteArray tmpHexQBytearray = tmpHexString.toUtf8();
        QByteArray tmpQByteString = QByteArray::fromHex(tmpHexQBytearray);

        // 3. 显示原始文本
        ui->textEditRev->setText(QString::fromUtf8(tmpQByteString));
    }

    // 光标移到末尾
    ui->textEditRev->moveCursor(QTextCursor::End);
    ui->textEditRev->ensureCursorVisible();
}

7.3 Hex 发送

// 在 on_btnSendContext_clicked() 中的Hex发送逻辑
if(ui->checkBHexSend->isChecked()){
    QString tmp = ui->lineEditSendContext->text();
    QByteArray tmpArray = tmp.toLocal8Bit();

    // 校验1:必须是偶数位(两个字符表示一个字节)
    if(tmpArray.size() % 2 != 0){
        ui->labelSendStatus->setText("Error Input!");
        return;
    }

    // 校验2:必须是合法的十六进制字符(0-9, a-f, A-F)
    for(char c : tmpArray){
        if(!std::isxdigit(c)){
            ui->labelSendStatus->setText("Error Input!");
            return;
        }
    }

    // 追加换行(如果勾选)
    if(ui->checkSendNewLine->isChecked())
        tmpArray.append("\r\n");

    // 关键转换:fromHex将Hex字符串转为实际字节
    // 例如 "3132" -> 两个字节 0x31 0x32
    QByteArray arraySend = QByteArray::fromHex(tmpArray);
    writeCnt = serialPort->write(arraySend);
}

7.4 Hex 与文本的区别示例

用户输入 普通发送 Hex 发送
“31” 发送 2 字节:‘3’(0x33) ‘1’(0x31) 发送 1 字节:0x31
“AB” 发送 2 字节:‘A’(0x41) ‘B’(0x42) 发送 1 字节:0xAB
“48656C6C6F” 发送 10 字节(原样) 发送 5 字节:“Hello”

核心区别: 普通发送把每个字符当作 ASCII 码发送;Hex 发送把每两个字符当作一个十六进制字节发送。


第八章 自定义控件与串口刷新

8.1 问题背景

默认的 QComboBox 下拉框不会在点击时自动刷新串口列表。如果用户在使用过程中插入了新的 USB 转串口设备,下拉框中不会显示新设备。解决方案是创建一个自定义下拉框,在鼠标点击时发出信号触发刷新。

8.2 自定义 MyComboBox 类

// ===== mycombobox.h =====
#ifndef MYCOMBOBOX_H
#define MYCOMBOBOX_H

#include <QWidget>
#include <QComboBox>

// 继承自QComboBox,添加点击刷新功能
class MyComboBox : public QComboBox
{
    Q_OBJECT  // 必须包含此宏,支持信号槽机制

public:
    MyComboBox(QWidget *parent);

protected:
    // 重写鼠标按下事件
    void mousePressEvent(QMouseEvent *e) override;

signals:
    void refresh();  // 自定义信号:通知父窗口刷新串口列表
};

#endif
// ===== mycombobox.cpp =====
#include "mycombobox.h"
#include <QMouseEvent>

MyComboBox::MyComboBox(QWidget *parent) : QComboBox(parent)
{
    // 构造函数,调用父类构造即可
}

void MyComboBox::mousePressEvent(QMouseEvent *e)
{
    // 当鼠标左键按下时
    if(e->button() == Qt::LeftButton){
        emit refresh();  // 发出自定义的refresh信号
    }
    // 调用父类的mousePressEvent,保持下拉框的默认行为
    // 如果不调用这一行,下拉框将无法弹出列表
    QComboBox::mousePressEvent(e);
}

8.3 在主窗口中使用自定义控件

Widget::Widget(QWidget *parent)
{
    // ... 其他初始化 ...

    // 连接MyComboBox的refresh信号到refreshSerialName槽函数
    // 当用户点击下拉框时,自动刷新串口列表
    connect(ui->comboBox_serialNum, &MyComboBox::refresh,
            this, &Widget::refreshSerialName);

    // 程序启动时刷新一次
    refreshSerialName();
}

自定义控件要点:

  1. 继承目标类(QComboBox
  2. 必须包含 Q_OBJECT
  3. 重写事件函数时,记得调用父类的同名函数保持原有行为
  4. 使用 emit 发出自定义信号

8.4 在 Qt Designer 中使用自定义控件

.ui 文件中,右键普通 QComboBox 控件,选择"Promote to…"(提升为…),输入自定义类名 MyComboBox,添加并提升。这样界面上的控件就会使用自定义类。


第九章 指令系统与多按钮发送

9.1 指令系统设计

最终版串口助手提供了 9 个可配置的指令按钮,每个按钮对应一个预设指令。用户可以:
在对应的 lineEdit 中输入指令内容
通过 checkBox 选择是否以 Hex 模式发送
点击按钮快速发送

9.2 动态控件查找与属性绑定

Widget::Widget(QWidget *parent)
{
    // ... 其他初始化代码 ...

    // 动态查找9个指令按钮、输入框和复选框
    for(int i = 1; i <= 9; i++){
        // ===== 查找按钮 =====
        QString btnName = QString("pushButton_%1").arg(i);
        QPushButton* btn = findChild<QPushButton *>(btnName);
        if(btn){
            // 为按钮设置动态属性buttonId,用于后续识别
            btn->setProperty("buttonId", i);
            buttons.append(btn);  // 添加到按钮列表

            // 连接所有按钮的clicked信号到同一个槽函数
            connect(btn, SIGNAL(clicked()), this, SLOT(on_command_button_clicked()));
        }

        // ===== 查找输入框 =====
        QString lineEditName = QString("lineEdit_%1").arg(i);
        QLineEdit *lineEdit = findChild<QLineEdit *>(lineEditName);
        lineEdits.append(lineEdit);  // 添加到输入框列表

        // ===== 查找复选框 =====
        QString checkBoxName = QString("checkBox_%1").arg(i);
        QCheckBox *checkBox = findChild<QCheckBox *>(checkBoxName);
        checkBoxs.append(checkBox);  // 添加到复选框列表
    }
}

9.3 指令按钮点击处理

void Widget::on_command_button_clicked()
{
    // 获取发出信号的按钮对象
    QPushButton *btn = qobject_cast<QPushButton *>(sender());
    if(btn){
        // 从按钮的动态属性中获取buttonId
        int num = btn->property("buttonId").toInt();

        // 根据buttonId查找对应的输入框
        QString lineEditName = QString("lineEdit_%1").arg(num);
        QLineEdit *lineEdit = findChild<QLineEdit *>(lineEditName);
        if(lineEdit){
            if(lineEdit->text().size() <= 0){
                return;  // 输入框为空则不发送
            }
            // 将指令内容复制到主发送框
            ui->lineEditSendContext->setText(lineEdit->text());
        }

        // 根据buttonId查找对应的复选框,设置Hex发送模式
        QString checkBoxName = QString("checkBox_%1").arg(num);
        QCheckBox *checkBox = findChild<QCheckBox *>(checkBoxName);
        if(checkBox)
            ui->checkBHexSend->setChecked(checkBox->isChecked());

        // 调用发送函数
        on_btnSendContext_clicked();
    }
}

核心技术点:

  1. sender():在槽函数中获取信号发出者的指针
  2. qobject_cast<>:安全的类型转换,类似 dynamic_cast
  3. setProperty() / property():动态属性系统,可以为任何 QObject 添加自定义属性
  4. findChild<T>():按对象名查找子控件

9.4 指令批量自动发送

Widget::Widget(QWidget *parent)
{
    // ... 其他初始化 ...

    // 创建指令批量发送定时器
    buttonsConTimer = new QTimer(this);
    connect(buttonsConTimer, &QTimer::timeout, this, &Widget::buttons_handler);
}

// 批量发送处理器:依次触发各指令按钮
void Widget::buttons_handler()
{
    if(buttonIndex < buttons.size()){
        // 获取当前按钮并模拟点击
        QPushButton *btnTmp = buttons[buttonIndex];
        emit btnTmp->clicked();    // 发出clicked信号,触发on_command_button_clicked
        buttonIndex++;
    }else{
        // 所有按钮发送完毕,重置索引
        buttonIndex = 0;
    }
}

// 批量发送复选框切换
void Widget::on_checkBox_send_clicked(bool checked)
{
    if(checked){
        // 启动批量发送:禁用间隔设置框,启动定时器
        ui->spinBox->setEnabled(false);
        buttonsConTimer->start(ui->spinBox->text().toUInt());
    }else{
        // 停止批量发送
        ui->spinBox->setEnabled(true);
        buttonsConTimer->stop();
    }
}

9.5 指令列表重置

void Widget::on_btnInit_clicked()
{
    // 弹出确认对话框(防止误操作)
    QMessageBox msgBox;
    msgBox.setWindowTitle("提示");
    msgBox.setIcon(QMessageBox::Question);
    msgBox.setText("重置列表不可逆,确认是否重置?");

    // 自定义按钮文字
    QPushButton *yesButton = msgBox.addButton("是", QMessageBox::YesRole);
    QPushButton *noButton = msgBox.addButton("否", QMessageBox::NoRole);
    msgBox.exec();

    // 根据用户点击的按钮执行相应操作
    if(msgBox.clickedButton() == yesButton){
        // 确认重置:遍历所有输入框和复选框
        for(int i = 0; i < lineEdits.size(); i++){
            lineEdits[i]->clear();              // 清空输入框
            checkBoxs[i]->setChecked(false);    // 取消复选框勾选
        }
    }
}

第十章 多线程自动发送

10.1 为什么需要多线程

在主线程(UI 线程)中执行耗时操作会导致界面卡顿。虽然本项目使用 QTimer 实现定时发送已经能满足需求,但在更复杂的场景下(如高频数据采集、协议解析),使用独立线程可以避免阻塞 UI。

10.2 QThread 基础

QThread 是 QT 的线程类。使用方式有两种:

  1. 继承 QThread,重写 run() 方法(本项目采用)
  2. 使用 moveToThread() 将对象移动到新线程

10.3 自定义 CustomThread 类

// ===== customthread.h =====
#ifndef CUSTOMTHREAD_H
#define CUSTOMTHREAD_H

#include <QWidget>
#include <QThread>

class CustomThread : public QThread
{
    Q_OBJECT

protected:
    // 重写run方法,线程启动后执行的代码写在这里
    void run() override;

public:
    CustomThread(QWidget *parent);

signals:
    void threadTimeout();  // 线程定时信号
};

#endif
// ===== customthread.cpp =====
#include "customthread.h"

// run()方法是线程的入口函数
// 调用start()后,run()会在新线程中执行
void CustomThread::run()
{
    while(true){
        msleep(1000);        // 睡眠1000毫秒(1秒)
        emit threadTimeout(); // 每隔1秒发出一次信号
    }
}

CustomThread::CustomThread(QWidget *parent) : QThread(parent)
{
    // 构造函数
}

10.4 多线程使用示例

// 在主窗口中使用CustomThread
CustomThread *thread = new CustomThread(this);

// 连接线程的threadTimeout信号到主线程的槽函数
// 注意:跨线程的信号槽连接,QT会自动使用队列连接
connect(thread, &CustomThread::threadTimeout, this, [=](){
    // 这里的代码在主线程执行,可以安全更新UI
    on_btnSendContext_clicked();
});

// 启动线程
thread->start();   // 调用start()后,run()在新线程中运行

// 停止线程
thread->terminate();  // 强制终止(不推荐)
// 或者设置退出标志,让run()中的循环自然结束(推荐)

10.5 线程安全注意事项

  1. 不要在子线程中直接操作 UI 控件:QT 规定 UI 控件只能在主线程操作
  2. 跨线程通信使用信号槽:QT 会自动处理跨线程的信号槽连接
  3. 避免共享数据竞争:如果多线程访问同一数据,需使用 QMutex 互斥锁
  4. 优雅退出:不要使用 terminate(),应设置标志位让 run() 自然退出
// 推荐的线程退出方式
class CustomThread : public QThread
{
    // ...
private:
    std::atomic<bool> m_stop{false};  // 原子变量,线程安全

public:
    void stop() { m_stop = true; }

protected:
    void run() override {
        while(!m_stop){
            msleep(1000);
            emit threadTimeout();
        }
    }
};

第十一章 数据保存与加载

11.1 接收数据保存到文件

void Widget::on_btnrevSave_clicked()
{
    // 弹出文件保存对话框
    // 参数1:父窗口
    // 参数2:对话框标题
    // 参数3:默认保存路径
    // 参数4:文件过滤器(只显示txt文件)
    QString fileName = QFileDialog::getSaveFileName(this,
        tr("Save File"),
        "D:/QT/serialData.txt",
        tr("Text (*.txt)"));

    if(fileName != NULL){
        // 创建文件对象
        QFile file(fileName);

        // 以只写、文本模式打开文件
        if (!file.open(QIODevice::WriteOnly | QIODevice::Text))
            return;  // 打开失败直接返回

        // 使用QTextStream简化文本写入
        QTextStream out(&file);

        // 将文本框中的内容写入文件
        out << ui->textEditRev->toPlainText();

        file.close();  // 关闭文件
    }
}

11.2 指令配置保存

void Widget::on_btnSave_clicked()
{
    // 弹出保存对话框
    QString fileName = QFileDialog::getSaveFileName(this,
        tr("保存文件"),
        "D:/",
        tr("文本类型 (*.txt)"));

    QFile file(fileName);
    if (!file.open(QIODevice::WriteOnly | QIODevice::Text))
        return;

    QTextStream out(&file);

    // 遍历9个指令配置,每行保存一个:复选状态|指令内容
    for(int i = 0; i < lineEdits.size(); i++){
        out << checkBoxs[i]->isChecked() << "|" << lineEdits[i]->text() << "\n";
        // 输出示例: 1|AABBCCDD
        //          0|hello
    }
    file.close();
}

11.3 指令配置加载

void Widget::on_btnLoad_clicked()
{
    int i = 0;
    // 弹出打开文件对话框
    QString fileName = QFileDialog::getOpenFileName(this,
        tr("打开文件"),
        "D:/",
        tr("文本类型 (*.txt)"));

    if(fileName != NULL){
        QFile file(fileName);
        if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
            return;

        QTextStream in(&file);

        // 逐行读取,最多读取9行
        while(!in.atEnd() && i <= 9){
            QString line = in.readLine();  // 读取一行

            // 按"|"分割字符串
            QStringList parts = line.split("|");
            if(parts.count() == 2){
                // 第一部分是复选状态(0或1),转为bool设置到checkBox
                checkBoxs[i]->setChecked(parts[0].toInt());
                // 第二部分是指令内容,设置到lineEdit
                lineEdits[i]->setText(parts[1]);
            }
            i++;
        }
    }
}

文件操作核心类:
QFile:文件读写操作
QTextStream:文本流,简化文本的读写(自动处理编码)
QFileDialog:文件选择对话框
QString::split():字符串分割


第十二章 串口协议包解包详解(核心)

本章是文档的核心章节。 前面的章节实现了基础的串口收发功能,但在实际工程中,设备间通信通常采用协议帧格式。本章详细讲解如何设计协议帧、解析协议包,包括字节序转换、结构体对齐、校验和计算等关键技术。

12.1 为什么需要协议帧

直接发送裸字节虽然简单,但存在严重问题:
无法判断数据边界:接收端不知道一帧从哪里开始、到哪里结束
无法检测错误:传输过程中如果丢字节,后续数据全部错位
无法区分指令类型:所有数据混在一起,无法区分温度数据还是控制命令

协议帧通过添加帧头、长度、校验等字段,解决了上述问题。

12.2 典型协议帧格式

一个完整的协议帧通常包含以下字段:

+--------+------+------+------+----------+--------+--------+
| 帧头   | 长度 | 命令 | 数据区   | 校验和 | 帧尾   |
| 0xAA55 | 1字节| 1字节| N字节变长| 2字节  | 0x0D0A |
+--------+------+------+------+----------+--------+--------+

各字段说明:

字段 长度 说明 示例
帧头 2 字节 固定标识,用于帧同步 0xAA 0x55
长度 1 字节 数据区的字节数 0x04 表示4字节数据
命令字 1 字节 标识指令类型 0x01=读温度, 0x02=读湿度
数据区 N 字节 实际业务数据 变长,由长度字段决定
校验和 2 字节 CRC16校验值 用于检测传输错误
帧尾 2 字节 帧结束标识 0x0D 0x0A(\r\n)

12.3 字节序转换

字节序(Byte Order) 是指多字节数据在内存中的存储顺序。

大端序(Big-Endian,网络字节序):高位字节存储在低地址
整数 0x12345678 在内存中:12 34 56 78
小端序(Little-Endian,x86/STM32默认):低位字节存储在低地址
整数 0x12345678 在内存中:78 56 34 12

// ===== 字节序转换代码示例 =====

// 将uint16_t转为大端序的两个字节(用于发送)
QByteArray uint16ToBigEndian(quint16 value)
{
    QByteArray bytes;
    bytes.append((value >> 8) & 0xFF);  // 高字节在前
    bytes.append(value & 0xFF);         // 低字节在后
    return bytes;
    // 例如 value=0x1234,返回 [0x12, 0x34]
}

// 将大端序的两个字节转为uint16_t(用于接收解析)
quint16 bigEndianToUint16(const QByteArray &bytes, int offset)
{
    quint16 value = 0;
    value = (quint8)bytes[offset] << 8;     // 高字节左移8位
    value |= (quint8)bytes[offset + 1];     // 低字节
    return value;
    // 例如 bytes=[0x12, 0x34],返回 0x1234
}

// 使用QT内置函数(更简单)
#include <QtEndian>
quint16 value = qFromBigEndian<quint16>(bytes.mid(offset, 2).data());
// qFromBigEndian: 大端序转主机字节序
// qToBigEndian:   主机字节序转大端序

12.4 结构体内存对齐

在 C/C++ 中,结构体的成员在内存中并非紧密排列,而是按照一定的规则对齐。这在串口通信中非常重要,因为如果收发双方的对齐方式不一致,会导致数据解析错误。

// ===== 结构体对齐示例 =====

// 默认对齐(通常按成员中最大的类型对齐)
struct SensorData_Default {
    quint8  cmd;       // 1字节
    // 编译器可能插入1字节填充
    quint16 temp;      // 2字节
    quint32 timestamp; // 4字节
};
// sizeof = 8字节(而非7字节),因为有1字节填充

// 使用#pragma pack(1)取消对齐(紧凑排列)
#pragma pack(push, 1)  // 保存当前对齐设置,设为1字节对齐
struct SensorData_Packed {
    quint8  cmd;       // 1字节
    quint16 temp;      // 2字节
    quint32 timestamp; // 4字节
};
#pragma pack(pop)       // 恢复之前的对齐设置
// sizeof = 7字节(无填充,紧凑排列)

// 在串口通信中,必须使用紧凑排列,确保收发双方内存布局一致

12.5 校验和计算

校验和用于检测数据在传输过程中是否发生错误。常见的校验算法有:

1. 累加和校验(最简单)

// 计算累加和校验
quint8 calcChecksum(const QByteArray &data)
{
    quint8 sum = 0;
    for(int i = 0; i < data.size(); i++){
        sum += (quint8)data[i];  // 所有字节相加
    }
    return sum;  // 取低8位
}

// 验证:接收方重新计算并比较
bool verifyChecksum(const QByteArray &data, quint8 receivedChecksum)
{
    return calcChecksum(data) == receivedChecksum;
}

2. CRC16 校验(最常用)

// CRC16-Modbus 校验算法
quint16 calcCRC16(const QByteArray &data)
{
    quint16 crc = 0xFFFF;  // CRC初始值

    for(int i = 0; i < data.size(); i++){
        crc ^= (quint8)data[i];  // 异或当前字节
        for(int j = 0; j < 8; j++){
            if(crc & 0x0001){
                crc = (crc >> 1) ^ 0xA001;  // 多项式
            }else{
                crc >>= 1;
            }
        }
    }
    return crc;
}

// 使用示例
QByteArray frameData = QByteArray::fromHex("010400010001");
quint16 crc = calcCRC16(frameData);
// crc的低字节在前(小端序),这是Modbus的规定
QByteArray crcBytes;
crcBytes.append(crc & 0xFF);         // 低字节
crcBytes.append((crc >> 8) & 0xFF);  // 高字节

12.6 协议包组包(发送方)

// 组装一个完整的协议帧
QByteArray buildFrame(quint8 cmd, const QByteArray &data)
{
    QByteArray frame;

    // 1. 帧头
    frame.append(0xAA);
    frame.append(0x55);

    // 2. 长度(数据区的字节数)
    frame.append((quint8)data.size());

    // 3. 命令字
    frame.append(cmd);

    // 4. 数据区
    frame.append(data);

    // 5. 校验和(对 命令+数据 计算CRC16)
    QByteArray crcData;
    crcData.append(cmd);
    crcData.append(data);
    quint16 crc = calcCRC16(crcData);
    frame.append(crc & 0xFF);         // CRC低字节
    frame.append((crc >> 8) & 0xFF);  // CRC高字节

    // 6. 帧尾
    frame.append(0x0D);
    frame.append(0x0A);

    return frame;
}

// 使用示例:发送温度查询指令
QByteArray tempData;  // 空数据区
QByteArray frame = buildFrame(0x01, tempData);
serialPort->write(frame);
// 发送的完整帧: AA 55 00 01 [CRC_L] [CRC_H] 0D 0A

12.7 协议包解包(接收方)

解包是组包的逆过程,需要处理粘包(多帧数据粘在一起)和半包(一帧数据被拆分)问题。

// 在Widget类中添加接收缓冲区
class Widget : public QWidget
{
    // ...
private:
    QByteArray rxBuffer;  // 接收缓冲区
};

// 修改接收槽函数,实现协议解析
void Widget::on_SerialData_readyToRead()
{
    // 1. 将新收到的数据追加到缓冲区
    rxBuffer.append(serialPort->readAll());

    // 2. 循环处理缓冲区中的完整帧
    while(rxBuffer.size() >= 8){  // 最小帧长: 帧头2+长度1+命令1+校验2+帧尾2 = 8

        // 2.1 查找帧头 0xAA 0x55
        int headerPos = rxBuffer.indexOf(QByteArray::fromHex("AA55"));
        if(headerPos < 0){
            // 没有找到帧头,清空缓冲区(丢弃无效数据)
            rxBuffer.clear();
            break;
        }

        // 2.2 移除帧头之前的无效数据
        if(headerPos > 0){
            rxBuffer = rxBuffer.mid(headerPos);
        }

        // 2.3 检查缓冲区是否有足够的字节
        if(rxBuffer.size() < 8){
            break;  // 数据不完整,等待下次接收
        }

        // 2.4 解析长度字段(第3个字节)
        quint8 dataLen = (quint8)rxBuffer[2];

        // 2.5 计算完整帧的长度
        // 帧头(2) + 长度(1) + 命令(1) + 数据(N) + 校验(2) + 帧尾(2) = N + 8
        int frameLen = dataLen + 8;

        // 2.6 检查是否收到完整帧
        if(rxBuffer.size() < frameLen){
            break;  // 帧不完整,等待更多数据
        }

        // 2.7 提取一帧完整数据
        QByteArray frame = rxBuffer.left(frameLen);

        // 2.8 验证帧尾
        if((quint8)frame[frameLen-2] != 0x0D ||
           (quint8)frame[frameLen-1] != 0x0A){
            // 帧尾错误,移除帧头后重新查找
            rxBuffer = rxBuffer.mid(2);
            continue;
        }

        // 2.9 提取各字段
        quint8 cmd = (quint8)frame[3];           // 命令字
        QByteArray data = frame.mid(4, dataLen); // 数据区
        quint16 recvCRC = ((quint8)frame[4+dataLen+1] << 8) |
                          (quint8)frame[4+dataLen];  // 接收到的CRC

        // 2.10 校验CRC
        QByteArray crcData;
        crcData.append(cmd);
        crcData.append(data);
        quint16 calcCRC = calcCRC16(crcData);

        if(calcCRC != recvCRC){
            // CRC校验失败
            qDebug() << "CRC Error! Expected:" << calcCRC
                     << "Received:" << recvCRC;
            rxBuffer = rxBuffer.mid(2);  // 移除帧头,继续查找
            continue;
        }

        // 2.11 校验通过,处理解析出的数据
        processFrame(cmd, data);

        // 2.12 从缓冲区移除已处理的数据
        rxBuffer = rxBuffer.mid(frameLen);
    }
}

// 处理解析出的协议帧
void Widget::processFrame(quint8 cmd, const QByteArray &data)
{
    switch(cmd){
    case 0x01:  // 温度数据
        if(data.size() >= 2){
            // 大端序转主机字节序
            quint16 tempRaw = (quint8)data[0] << 8 | (quint8)data[1];
            float temperature = tempRaw / 10.0f;  // 假设精度0.1℃
            ui->textEditRev->append(
                QString("温度: %1 ℃").arg(temperature, 0, 'f', 1));
        }
        break;

    case 0x02:  // 湿度数据
        if(data.size() >= 2){
            quint16 humRaw = (quint8)data[0] << 8 | (quint8)data[1];
            float humidity = humRaw / 10.0f;
            ui->textEditRev->append(
                QString("湿度: %1 %").arg(humidity, 0, 'f', 1));
        }
        break;

    case 0x03:  // 设备状态
        if(data.size() >= 1){
            quint8 status = (quint8)data[0];
            QString statusStr = status ? "正常" : "异常";
            ui->textEditRev->append("设备状态: " + statusStr);
        }
        break;

    default:
        qDebug() << "Unknown command:" << cmd;
        break;
    }
}

12.8 解包流程总结

完整的协议包解包流程分为以下步骤:

  1. 接收数据追加到缓冲区:解决半包问题
  2. 查找帧头:定位帧的起始位置
  3. 解析长度字段:计算完整帧的长度
  4. 判断数据完整性:解决粘包和半包问题
  5. 验证帧尾:确认帧结束位置正确
  6. 校验和验证:检测传输错误
  7. 提取有效数据:解析命令字和数据区
  8. 业务处理:根据命令字执行相应操作
  9. 移除已处理数据:从缓冲区中删除已解析的帧

12.9 调试技巧

在协议开发过程中,建议添加详细的调试日志:

// 在解包过程中添加调试输出
qDebug() << "=== 协议解析 ===";
qDebug() << "缓冲区大小:" << rxBuffer.size();
qDebug() << "缓冲区内容:" << rxBuffer.toHex(' ').toUpper();
qDebug() << "帧头位置:" << headerPos;
qDebug() << "数据长度:" << dataLen;
qDebug() << "完整帧长:" << frameLen;
qDebug() << "命令字: 0x" << QString::number(cmd, 16).toUpper();
qDebug() << "数据区:" << data.toHex(' ').toUpper();
qDebug() << "CRC校验:" << (calcCRC == recvCRC ? "通过" : "失败");

第十三章 调试技巧与错误排查

13.1 qDebug 调试输出

qDebug() 是 QT 最常用的调试工具,类似于 printf 但支持 QT 类型:

#include <QDebug>

// 基本用法
qDebug() << "Hello World";

// 输出多种类型
qDebug() << "字符串:" << "Hello"
         << "整数:" << 42
         << "浮点数:" << 3.14;

// 输出QByteArray的Hex格式
QByteArray data = QByteArray::fromHex("AABBCC");
qDebug() << "Hex:" << data.toHex(' ');  // "AA BB CC"

// 格式化输出
qDebug("发送了 %d 字节, 状态码: %d", writeCnt, status);

13.2 常见错误与解决方法

错误1:编译报错 “QSerialPort: No such file or directory”

原因:未在.pro文件中添加串口模块
解决:在.pro文件中添加 QT += serialport

错误2:串口打开失败

// 常见原因:
// 1. 串口被其他程序占用(如串口调试助手正在使用)
// 2. 串口名称错误(如COM3实际不存在)
// 3. 权限不足(Linux下需要sudo或添加用户到dialout组)

// 排查方法:
qDebug() << "可用串口列表:";
for(QSerialPortInfo info : QSerialPortInfo::availablePorts()){
    qDebug() << info.portName() << info.description();
}

// Linux权限解决:
// sudo usermod -aG dialout $USER  然后重新登录

错误3:中文乱码

// 原因:QString和char*之间的编码转换问题
// 解决:使用toLocal8Bit()而非toStdString().c_str()

// 错误写法(可能乱码):
const char* data = ui->lineEdit->text().toStdString().c_str();

// 正确写法:
const char* data = ui->lineEdit->text().toLocal8Bit().constData();

// 或者直接使用QByteArray:
QByteArray data = ui->lineEdit->text().toUtf8();
serialPort->write(data);

错误4:信号槽没有触发

// 排查清单:
// 1. 检查是否调用了connect
// 2. 检查信号和槽函数的参数是否匹配
// 3. 检查类是否包含Q_OBJECT宏
// 4. 检查自动关联的命名是否正确(on_控件名_信号名)
// 5. 运行qmake后重新编译(Q_OBJECT新增后需要)

// 调试方法:使用Qt5语法连接,编译时就能检查错误
connect(serialPort, &QSerialPort::readyRead, this, &Widget::on_readyRead);
// 如果函数名写错,编译时会报错

错误5:Hex 发送失败

// 常见原因:
// 1. 输入了奇数位Hex(如"ABC"应为"0ABC")
// 2. 输入了非Hex字符(如"GH"不是合法十六进制)

// 本项目的校验逻辑:
QByteArray tmpArray = tmp.toLocal8Bit();
if(tmpArray.size() % 2 != 0){
    // 奇数位错误
}
for(char c : tmpArray){
    if(!std::isxdigit(c)){
        // 非法字符错误
    }
}

13.3 虚拟串口调试

在没有物理串口设备时,可以使用虚拟串口软件模拟:

Windows 平台推荐:
VSPD(Virtual Serial Port Driver):成对创建虚拟串口,如 COM3↔COM4
串口调试助手:如 SSCOM、XCOM,用于辅助测试

调试流程:

  1. 用 VSPD 创建一对虚拟串口(COM3 和 COM4 互相连通)
  2. 本程序打开 COM3
  3. 串口调试助手打开 COM4
  4. 在调试助手中发送数据,本程序应能收到

13.4 性能优化建议

  1. 避免频繁刷新 UI:大量数据接收时,先缓存再批量更新
  2. 使用 Hex 模式分析协议:调试二进制协议时务必使用 Hex 显示
  3. 合理设置定时器间隔:定时发送间隔不宜过短(建议 ≥50ms)
  4. 及时清理缓冲区:长时间运行时定期清理接收缓冲区,防止内存增长

第十四章 最终版完整代码解析

14.1 项目文件结构

105-SerialPROFinal/
├── 105-SerialPROFinal.pro   # 工程配置(含serialport模块)
├── main.cpp                  # 程序入口
├── widget.h                  # 主窗口声明(含所有槽函数和成员变量)
├── widget.cpp                # 主窗口实现(核心业务逻辑)
├── widget.ui                 # 界面文件(Qt Designer设计)
├── mycombobox.h              # 自定义下拉框声明
├── mycombobox.cpp            # 自定义下拉框实现(点击刷新串口)
├── res.qrc                   # 资源文件
└── mianicon.png              # 应用图标

14.2 widget.h 完整解析

#ifndef WIDGET_H
#define WIDGET_H

#include <QCheckBox>
#include <QPushButton>
#include <QSerialPort>   // 串口类
#include <QTimer>        // 定时器类
#include <QWidget>
#include "mycombobox.h"  // 自定义下拉框

QT_BEGIN_NAMESPACE
namespace Ui { class Widget; }  // 前向声明UI类
QT_END_NAMESPACE

class Widget : public QWidget
{
    Q_OBJECT  // 信号槽支持

public:
    Widget(QWidget *parent = nullptr);
    ~Widget();

private slots:
    // ===== 串口控制 =====
    void on_btnCloseOrOpenSerial_clicked();           // 已作废(被带参数版本替代)
    void on_btnCloseOrOpenSerial_clicked(bool checked); // 打开/关闭串口

    // ===== 数据收发 =====
    void on_btnSendContext_clicked();                 // 发送数据
    void on_SerialData_readyToRead();                 // 接收数据

    // ===== 定时发送 =====
    void on_checkBSendInTime_clicked(bool checked);   // 定时发送开关
    void time_reflash();                              // 刷新时间显示

    // ===== 显示控制 =====
    void on_checkBHexDisplay_clicked(bool checked);   // Hex显示切换
    void on_btnhideTable_clicked(bool checked);       // 隐藏/显示面板
    void on_btnHideHistory_clicked(bool checked);     // 隐藏/显示历史

    // ===== 数据管理 =====
    void on_btnrevClear_clicked();                    // 清空接收区
    void on_btnrevSave_clicked();                     // 保存接收数据
    void on_btnInit_clicked();                        // 重置指令列表
    void on_btnSave_clicked();                        // 保存指令配置
    void on_btnLoad_clicked();                        // 加载指令配置

    // ===== 指令系统 =====
    void on_command_button_clicked();                 // 指令按钮点击
    void on_checkBox_send_clicked(bool checked);      // 批量发送开关
    void buttons_handler();                           // 批量发送处理器
    void refreshSerialName();                         // 刷新串口列表

private:
    Ui::Widget *ui;              // UI界面指针
    QSerialPort *serialPort;     // 串口对象

    // 统计计数
    int writeCntTotal;           // 累计发送字节数
    int readCntTotal;            // 累计接收字节数

    // 辅助变量
    QString sendBak;             // 上次发送内容(用于去重)
    QString myTime;              // 当前系统时间字符串
    bool serialStatus;           // 串口状态

    // 定时器
    QTimer *timer;               // 定时发送定时器
    QTimer *buttonsConTimer;     // 批量发送定时器
    int buttonIndex;             // 批量发送当前索引

    // 控件列表(指令系统)
    QList<QPushButton *> buttons;   // 9个指令按钮
    QList<QLineEdit *> lineEdits;   // 9个指令输入框
    QList<QCheckBox *> checkBoxs;   // 9个Hex复选框

    void getSysTime();           // 获取系统时间
};

#endif

14.3 核心功能模块关系

整个串口助手的功能模块可分为五大类:

模块一:串口通信核心
串口打开/关闭(on_btnCloseOrOpenSerial_clicked
数据发送(on_btnSendContext_clicked
数据接收(on_SerialData_readyToRead
串口列表刷新(refreshSerialName

模块二:定时与自动化
系统时间刷新(time_reflash + getSysTimeTimer
定时发送(timer + on_checkBSendInTime_clicked
批量指令发送(buttonsConTimer + buttons_handler

模块三:数据显示控制
Hex 显示切换(on_checkBHexDisplay_clicked
面板隐藏/显示(on_btnhideTable_clickedon_btnHideHistory_clicked
清空接收区(on_btnrevClear_clicked

模块四:指令管理系统
指令按钮处理(on_command_button_clicked
指令重置(on_btnInit_clicked
指令保存/加载(on_btnSave_clickedon_btnLoad_clicked

模块五:自定义控件
MyComboBox(点击刷新串口列表)

14.4 与早期版本的对比

功能 80-SerialPROJ 89-SerialPROJHex 105-SerialPROFinal
串口打开/关闭
数据收发
定时发送
Hex 收发
系统时间显示
自定义下拉框
指令按钮系统
批量自动发送
指令保存/加载
UI 面板隐藏
代码行数 ~210 ~290 ~520

14.5 扩展建议

基于现有的串口助手框架,可以扩展以下功能:

  1. 协议解析模块:集成第十二章的协议解包功能,支持自定义协议帧解析
  2. 数据可视化:使用 QCustomPlotQtCharts 绘制实时数据曲线
  3. 数据库存储:使用 QSqlite 将历史数据存入数据库,支持查询回放
  4. 多串口支持:支持同时打开多个串口,实现数据中转
  5. 脚本引擎:集成 QLuaJavaScript 引擎,支持脚本化控制
  6. 网络转发:将串口数据通过 TCP/UDP 转发到远程服务器

附录 学习路径总结

A.1 推荐学习顺序

第一步:环境搭建(第一章)
  ↓ 学习创建QT项目,理解.pro配置
第二步:信号槽机制(第二章)
  ↓ 理解connect的三种写法,掌握自动关联
第三步:UI 设计(第四章)
  ↓ 用Qt Designer搭建串口助手界面
第四步:串口基础(第三、五章)
  ↓ 实现打开/关闭/收发基本功能
第五步:定时器(第六章)
  ↓ 实现系统时间显示和定时发送
第六步:Hex 模式(第七章)
  ↓ 实现十六进制收发,理解编码转换
第七步:自定义控件(第八章)
  ↓ 创建MyComboBox,理解事件重写
第八步:指令系统(第九章)
  ↓ 实现多按钮快捷发送和批量发送
第九步:多线程(第十章)
  ↓ 理解QThread,实现非阻塞操作
第十步:文件操作(第十一章)
  ↓ 实现数据保存与加载
第十一步:协议解析(第十二章)
  ↓ 掌握协议帧设计、解包、校验

A.2 核心知识点清单

知识点 重要程度 对应章节
QT += serialport ★★★★★ 第一章
信号与槽 connect ★★★★★ 第二章
QSerialPort 配置 ★★★★★ 第三、五章
readyRead 信号 ★★★★★ 第五章
QTimer 定时器 ★★★★☆ 第六章
QByteArray Hex 转换 ★★★★☆ 第七章
自定义控件继承 ★★★☆☆ 第八章
动态属性 setProperty ★★★☆☆ 第九章
QThread 多线程 ★★★☆☆ 第十章
QFile 文件操作 ★★★☆☆ 第十一章
协议帧设计 ★★★★★ 第十二章
字节序转换 ★★★★☆ 第十二章
CRC 校验计算 ★★★★☆ 第十二章
粘包/半包处理 ★★★★★ 第十二章

A.3 常用 API 速查表

// ===== 串口配置 =====
serialPort->setPortName("COM3");
serialPort->setBaudRate(115200);
serialPort->setDataBits(QSerialPort::Data8);
serialPort->setParity(QSerialPort::NoParity);
serialPort->setStopBits(QSerialPort::OneStop);
serialPort->setFlowControl(QSerialPort::NoFlowControl);
serialPort->open(QIODevice::ReadWrite);
serialPort->close();

// ===== 数据收发 =====
serialPort->write(data);        // 返回写入字节数
serialPort->readAll();          // 返回QByteArray
serialPort->bytesAvailable();   // 返回可读字节数

// ===== 编码转换 =====
QString::toLocal8Bit();         // QString -> QByteArray(本地编码)
QString::toUtf8();              // QString -> QByteArray(UTF-8)
QByteArray::toHex();            // 转十六进制字符串
QByteArray::fromHex();          // 十六进制字符串转字节
QByteArray::fromHex("AABB");    // 返回2字节: 0xAA 0xBB

// ===== 定时器 =====
timer->start(1000);   // 启动,1000ms间隔
timer->stop();        // 停止
timer->isActive();    // 是否运行中

// ===== 文件操作 =====
QFile file("path");
file.open(QIODevice::ReadWrite | QIODevice::Text);
QTextStream stream(&file);
stream << "text";     // 写入
stream.readLine();    // 读取一行
file.close();

A.4 参考资源

QT 官方文档:https://doc.qt.io/
Qt Serial Port 文档:https://doc.qt.io/qt-5/qtserialport-index.html
QSerialPort API:https://doc.qt.io/qt-5/qserialport.html
QSerialPortInfo API:https://doc.qt.io/qt-5/qserialportinfo.html
QTimer API:https://doc.qt.io/qt-5/qtimer.html
QThread API:https://doc.qt.io/qt-5/qthread.html


文档说明: 本文档基于编号 72~105 的 QT 串口助手课程源码编写,代码片段均来源于实际项目文件并添加了详细注释。第十二章协议包解包部分为扩展内容,在基础串口助手框架上演示了协议解析的实现方法。建议读者按照附录的学习路径顺序实践,逐步掌握 QT 串口通信开发的各项技能。

适用读者: 有 C++ 基础、希望学习 QT 桌面开发和串口通信的初学者。

文档版本: v1.0 | 总字数: 约 12000 字 | 代码片段: 40+ 段


本文档为原创技术教程,代码示例基于开源 QT 框架,可自由用于学习和项目开发。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐