最近用Qt6开发了一套工业设备的上位机监控程序,涉及串口通信、多线程、动态仪表盘、数据驱动UI等典型场景。本文将完整复盘整个开发过程,从架构设计到核心模块实现,最后再总结那些让人抓狂的坑以及最终的优雅解决方案。

一、项目背景与功能概览

本项目是一套液压设备监控终端,需要实时采集下位机(单片机)传来的温度、转速、压力仓压力、油量、机械臂压力等数据,并以仪表盘、进度条、数值标签等形式展示。同时支持通过按钮发送加速/减速指令,实现对执行机构的速度控制。

核心模块

  • 串口通信(QSerialPort)
  • 后台轮询线程(QThread)
  • 自定义仪表指针控件
  • 数据驱动的UI映射机制
  • 协议帧解析(含奇偶校验、滑窗解包)
  • 动态告警(数值超限变红、进度条变色、全局警告图标)

二、整体架构设计

2.1 分层思想

层级 模块 职责
UI层 MainWindow + UI文件 显示数据、响应用户点击
业务逻辑层 数据映射、告警判断、仪表盘计算 将原始数据转换为UI可展示的形式
通信层 send_receive_pack_thread 串口读写、协议组包解包、轮询调度
协议层 protocol.h 定义帧格式、校验算法、枚举映射

2.2 线程模型

在这里插入图片描述

为什么要用子线程?
串口读写是阻塞操作(waitForReadyRead),放在主线程会卡界面。因此将所有串口操作放到独立的QThread中,只通过信号槽传递数据,保证UI流畅。


三、核心模块实现详解

3.1 协议设计(protocol.h)

3.1.1 解决ID冲突的高低位编码

下位机传来的参数ID只有1字节(0x01~0x0F),但多个不同的物理量(比如1号压力仓和1号油缸)都可能是0x01,导致上层无法区分。

解决方案:定义MotorParam枚举为16位,高8位用于UI分类,低8位才是物理地址。

enum class MotorParam : uint16_t {
    HostTemp = 0x1001,   // 主机温度
    MainAxis = 0x2001,   // 主轴转速
    SubAxis  = 0x2002,   // 副轴转速
    Press1_1 = 0x3001,   // 1号压力仓
    Mass_1   = 0x4001,   // 1号油量
    Mechanical = 0x5001, // 机械臂压力
    // ...
};

组包时自动提取低8位:

uint8_t physicalParam = static_cast<uint8_t>(param & 0xFF);
3.1.2 协议帧结构(内存对齐坑点)
#pragma pack(push, 1)  // 强制1字节对齐,否则结构体大小会是12字节
struct ProtocolFrame {
    uint8_t header = 0xEF;
    uint8_t cmd;
    uint8_t param;
    uint32_t data;
    uint8_t checkSum;
    uint8_t tail = 0xFE;
};
#pragma pack(pop)
static_assert(sizeof(ProtocolFrame) == 9, "帧大小必须为9字节");
3.1.3 偶校验算法
inline uint8_t calculateEvenParity(const ProtocolFrame &frame) {
    uint8_t bytes[6] = { frame.cmd, frame.param,
        (uint8_t)(frame.data & 0xFF),
        (uint8_t)((frame.data >> 8) & 0xFF),
        (uint8_t)((frame.data >> 16) & 0xFF),
        (uint8_t)((frame.data >> 24) & 0xFF) };
    uint8_t count = 0;
    for (int i = 0; i < 6; i++) {
        uint8_t val = bytes[i];
        while (val) {
            if (val & 1) count++;
            val >>= 1;
        }
    }
    return (count % 2 == 0) ? 0 : 1;
}

3.2 数据驱动UI —— 消灭Switch-Case

传统写法会在onDataReceived中写几十行switch(type)分别更新不同控件。维护噩梦。

进化方案:建立控件映射表,O(1)查找更新。

// 头文件定义
QMap<MotorParam, QLabel*> m_uiMap;
QMap<MotorParam, QProgressBar*> m_barMap;
QMap<MotorParam, QString> m_unitMap;

// 构造函数中初始化
m_uiMap.insert(MotorParam::HostTemp, ui->label_wen_du);
m_uiMap.insert(MotorParam::Press1_1, ui->label_cang_1);
m_barMap.insert(MotorParam::Press1_1, ui->progressBar);
m_unitMap.insert(MotorParam::Press1_1, " Pa");

// 槽函数中一行代码搞定
void MainWindow::onDataReceived(MotorParam type, double value) {
    if (m_uiMap.contains(type)) {
        QString text = QString::number(value, 'f', 2) + m_unitMap[type];
        m_uiMap[type]->setText(text);
    }
    if (m_barMap.contains(type)) {
        m_barMap[type]->setValue(static_cast<int>(value));
    }
    checkAndHandleWarning(type, value);
}

3.3 后台轮询线程设计

3.3.1 静态轮询表
const QList<PollTask> MOTOR_POLL_LIST = {
    {MotorQueryCmd::Temp,     MotorParam::HostTemp, "主机温度"},
    {MotorQueryCmd::Speed,    MotorParam::MainAxis, "主轴转速"},
    {MotorQueryCmd::Speed,    MotorParam::SubAxis,  "副轴转速"},
    {MotorQueryCmd::Pressure, MotorParam::Press1_1, "1号压力仓"},
    // ... 共12个节点
};
3.3.2 线程主循环 + 命令队列
void send_receive_pack_thread::run() {
    initSerial();  // 打开串口
    while (1) {
        // 阶段1:优先处理主线程插队的控制指令
        {
            QMutexLocker locker(&m_mutex);
            while (!m_cmdQueue.isEmpty()) {
                auto cmd = m_cmdQueue.takeFirst();
                burstification(cmd.first, cmd.second);
                serialprot->write((const char*)&Prot, sizeof(Prot));
                QThread::msleep(50);
            }
        }
        // 阶段2:常规轮询
        for (const auto &task : MOTOR_POLL_LIST) {
            burstification((uint8_t)task.cmd, (uint16_t)task.param);
            serialprot->write((const char*)&Prot, sizeof(Prot));
            if (serialprot->waitForReadyRead(100)) {
                m_buffer.append(serialprot->readAll());
                processBuffer(task);
            }
            QThread::msleep(10);
        }
        QThread::msleep(2000);
    }
}
3.3.3 滑动窗口解包引擎(解决粘包/半包)
void send_receive_pack_thread::processBuffer(const PollTask &task) {
    while (m_buffer.size() >= sizeof(ProtocolFrame)) {
        // 1. 找帧头0xEF
        if ((uint8_t)m_buffer.at(0) != 0xEF) {
            m_buffer.remove(0, 1);
            continue;
        }
        // 2. 找帧尾0xFE
        if ((uint8_t)m_buffer.at(8) != 0xFE) {
            m_buffer.remove(0, 1);
            continue;
        }
        // 3. 截取一帧
        QByteArray packet = m_buffer.left(sizeof(ProtocolFrame));
        const ProtocolFrame *frame = (const ProtocolFrame *)packet.data();
        // 4. 校验
        if (calculateEvenParity(*frame) == frame->checkSum) {
            double value = frame->data;
            emit dataReceived(task.param, value);
        }
        // 5. 移除已处理的9字节
        m_buffer.remove(0, sizeof(ProtocolFrame));
    }
}

3.4 仪表盘指针实现(QGraphicsScene + QGraphicsProxyWidget)

自定义指针控件needle继承QWidget,在paintEvent中绘制图片。

// needle.cpp
void needle::paintEvent(QPaintEvent *) {
    QPainter painter(this);
    painter.setRenderHint(QPainter::SmoothPixmapTransform);
    painter.drawPixmap(rect(), m_pix);
}

添加到场景并支持旋转:

void MainWindow::initPointer() {
    QGraphicsScene *scene = new QGraphicsScene(this);
    scene->setSceneRect(ui->graphicsView->rect());
    m_needle = scene->addWidget(new needle);
    m_needle->setTransformOriginPoint(4, 60);  // 旋转中心
    m_needle->setPos(101, 45);
    ui->graphicsView->setScene(scene);
    ui->graphicsView->setBackgroundBrush(Qt::NoBrush);
}

void MainWindow::setNeedleValue(double value) {
    // 值域0~120 → 角度-120°~120°
    double angle = 2.0 * value - 120.0;
    m_needle->setRotation(angle);
}

3.5 告警联动系统

当任何参数超过阈值时:

  • 对应Label文字变红
  • 对应ProgressBar样式改为红色渐变
  • 界面顶部的WARNING图片切换为警告图标
void MainWindow::checkAndHandleWarning(MotorParam type, double value) {
    bool isWarning = false;
    switch (type) {
    case MotorParam::HostTemp: if(value > 100) isWarning = true; break;
    case MotorParam::Press1_1: if(value > 3200) isWarning = true; break;
    case MotorParam::Mass_1:   if(value < 10) isWarning = true; break;
    // ...
    }
    m_warningStates[type] = isWarning;
    
    QLabel *label = m_uiMap[type];
    label->setStyleSheet(isWarning ? "color: red;" : "color: white;");
    
    QProgressBar *bar = m_barMap[type];
    if (bar) {
        QString style = isWarning ? 
            "QProgressBar::chunk { background: qlineargradient(x1:0,y1:0,x2:1,y2:0,stop:0 #cb2d3e,stop:1 #ef473a); }" :
            "QProgressBar::chunk { background: qlineargradient(x1:0,y1:0,x2:1,y2:0,stop:0 #11998e,stop:1 #38ef7d); }";
        bar->setStyleSheet(style);
    }
    updateGlobalWarningImage();
}

四、UI细节与用户体验优化

4.1 窗口秒开技巧

main.cpp中使用QTimer::singleShot(0, ...)将耗时的初始化推迟到事件循环之后:

int main(int argc, char *argv[]) {
    QApplication a(argc, argv);
    MainWindow w;
    w.show();   // 先让窗口出现
    QTimer::singleShot(0, &w, &MainWindow::monitor); // 再启动串口线程
    return a.exec();
}

4.2 按钮按下效果

使用border-image配合:pressed伪状态:

ui->pushButton_up->setStyleSheet(
    "QPushButton { border-image: url(:/res/speed_up.png); }"
    "QPushButton:pressed { border-image: url(:/res/speed_up_2.png); }"
);

4.3 实时时钟显示

void MainWindow::initTime() {
    m_timer = new QTimer(this);
    connect(m_timer, &QTimer::timeout, this, [=](){
        ui->label_time->setText(QDateTime::currentDateTime().toString("yyyy-MM-dd HH:mm:ss"));
    });
    m_timer->start(1000);
}

五、踩坑记录与解决方案

以下问题是我在实际开发中真实遇到的,每个都花了不少时间排查,希望能帮你少走弯路。

5.1 图片资源加载失败(明明加了qrc却不显示)

现象:所有图片(背景、按钮图标、指针)都不显示,控制台无报错。
原因

  • 忘记在.pro文件中添加RESOURCES += res.qrc
  • 资源路径大小写敏感(Windows下不敏感但Linux/Android严格,养成好习惯)
  • 某些情况下需要手动调用Q_INIT_RESOURCE(res)

解决方案

# .pro文件
RESOURCES += res.qrc
// main.cpp 顶部
#include <QResource>
// 在main函数最开始
Q_INIT_RESOURCE(res);

调试技巧

QDirIterator it(":", QDirIterator::Subdirectories);
while (it.hasNext()) qDebug() << it.next();

5.2 串口线程导致窗口启动卡顿2~3秒

现象:双击exe后,界面要等好几秒才弹出来。
原因:在MainWindow构造函数中直接创建并启动了串口线程,而串口打开、轮询循环会占用主线程时间。
解决方案:见4.1节,使用QTimer::singleShot延迟启动。

5.3 跨线程信号槽传递自定义类型收不到

现象:后台线程发射dataReceived(MotorParam, double),主线程槽函数始终不触发。
原因:Qt元对象系统默认不支持自定义枚举类型跨线程排队传递。
解决方案:在使用前注册:

// mainwindow.cpp 构造函数中
qRegisterMetaType<MotorParam>("MotorParam");

5.4 减速按钮变成了“加速”

现象:点击减速按钮,速度值反而增加。
原因:复制粘贴时忘记把+=改成-=
解决方案

void MainWindow::on_pushButton_cut_clicked() {
    m_currentSpeed -= 10.0;   // ✅ 减
    if (m_currentSpeed < 0) m_currentSpeed = 0;
    setNeedleValue(m_currentSpeed);
}

5.5 串口粘包/半包导致解析错位

现象:某些参数偶尔跳动巨大或者长时间不更新。
原因:下位机连续发送多帧时可能合并(粘包),或读取时机导致只读了半帧。
解决方案:实现滑动窗口解包引擎(见3.3.3节),按帧头帧尾定位,逐字节滑动。

5.6 进度条数值显示不对(永远满格或0)

现象:压力3000Pa,进度条却满格或始终0。
原因:未设置setRange(),默认0-100,而实际最大值5000。
解决方案:初始化时统一设置bar->setRange(0, 5000);

5.7 自定义指针控件无法旋转

现象:调用setRotation()无效。
原因QWidget本身不支持旋转变换,需借助QGraphicsScene + QGraphicsProxyWidget
解决方案:见3.4节。

5.8 协议结构体大小不是9字节

现象:发送9字节,下位机收不到正确数据。
原因:编译器默认4字节对齐,结构体实际占用12字节。
解决方案:使用#pragma pack(push, 1)强制1字节对齐,并用static_assert验证。

5.9 Qt6中QSerialPort的某些行为变化

现象:Qt5下正常的代码,Qt6中串口读取有时会丢数据。
原因:Qt6对串口模块进行了重构,waitForReadyRead的默认行为略有不同。
解决方案

  • 调用serialprot->clear()发送前清空缓冲区
  • 增大waitForReadyRead超时时间(从50ms提高到100ms)
  • 使用readAll()而非read(size)

5.10 Windows 11下COM口权限问题

现象serialprot->open()返回false,errorString()显示“拒绝访问”。
原因:Windows 11对串口权限管理更严格,或者串口号被其他程序占用。
解决方案

  • 以管理员身份运行程序
  • 在设备管理器中确认串口号未被占用
  • 关闭串口助手等其他占用程序

六、性能优化总结

问题 优化手段 效果
窗口启动慢 QTimer::singleShot延迟启动线程 秒开
界面刷新卡顿 串口操作全部放入子线程 UI不掉帧
大量switch-case 数据驱动 + QMap映射表 代码量减少70%
轮询频率过高CPU飙升 轮询节点间延时10ms,整轮延时2s CPU占用<5%
资源加载失败 qrc编译 + Q_INIT_RESOURCE + 路径规范 稳定加载

七、项目结构一览

Upper_Computer_demo_plus/
├── main.cpp                   # 入口,延时启动监控
├── mainwindow.h/cpp           # 主界面,UI映射,告警逻辑
├── mainwindow.ui              # 设计的界面文件
├── protocol.h                 # 协议定义,枚举,校验算法
├── send_receive_pack_thread.h/cpp  # 串口线程,轮询,解包
├── needle.h/cpp               # 自定义指针控件
├── res.qrc                    # 图片资源
│   └── res/
│       ├── bg3.png
│       ├── needle.png
│       ├── speed_up.png
│       ├── warning.png
│       └── ...
└── CMakeLists.txt

八、常见FAQ

Q1: 为什么不用Qt Charts做仪表盘?
A: 自定义指针控件更轻量,可以完全按照UI设计师给的图片素材还原样式,且性能更好。

Q2: 如果下位机返回的数据不是9字节怎么办?
A: 滑动窗口引擎会自动丢弃不完整包,继续等待后续数据拼装。

Q3: 串口线程如何安全退出?
A: 可以在run()中检查一个volatile bool标志位,主线程quit()前设置标志并调用wait()

Q4: 能否支持多个串口同时监控?
A: 可以,为每个串口创建独立的send_receive_pack_thread实例即可。

Q5: Qt6相比Qt5在串口方面有什么需要注意的?
A: Qt6的QSerialPort在Windows上默认使用重叠I/O,超时处理略有不同,建议增加超时时间并适当加入clear()调用。


九、结语

通过这个项目,我深刻体会到Qt作为工业级GUI框架的强大——资源系统、信号槽、多线程、绘图框架等模块配合得天衣无缝。同时也踩了不少坑,尤其是资源加载、跨线程通信和协议解析,希望本文的总结能帮你少走弯路。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐