Qt C++ 串口通信+数据可视化:工业设备数据实时采集与界面显示

一、技术背景与应用场景

工业现场中,PLC、传感器、智能仪表等设备常通过串口(RS232/RS485)输出实时运行数据(如温度、压力、转速、电压等)。Qt作为跨平台的C++应用开发框架,兼具串口通信API与强大的界面/绘图能力,是开发工业数据采集与可视化系统的理想选择。本文将完整实现一套工业设备数据实时采集系统,涵盖串口参数配置、数据解析、实时绘图、数据存储与异常报警等核心功能,满足工业场景下的高可靠性与实时性要求。

二、系统整体设计

2.1 核心功能模块

系统分为5个核心模块,各模块解耦设计,便于维护与扩展:

  1. 串口通信模块:负责串口参数配置、数据收发、异常处理(如断连重连);
  2. 数据解析模块:对串口接收的二进制/ASCII数据进行解析,提取有效工业参数;
  3. 可视化模块:基于Qt Charts实现实时曲线绘制、数值仪表盘、数据表格展示;
  4. 数据存储模块:将采集数据存入本地文件(CSV),支持历史数据回溯;
  5. 报警模块:对超阈值数据进行界面提示与声音报警。

2.2 技术选型

  • 开发框架:Qt 6.5(兼容Qt 5.x),Qt Creator 12.0;
  • 串口通信:Qt SerialPort模块(跨平台串口操作);
  • 数据可视化:Qt Charts模块(QLineSeries、QValueAxis、QChartView);
  • 数据解析:自定义协议解析(适配工业常用的Modbus RTU/自定义ASCII协议);
  • 开发语言:C++17(兼容C++11/14);
  • 编译环境:MSVC 2019(Windows)/GCC(Linux)。

三、开发环境搭建

3.1 环境配置

  1. 安装Qt时需勾选"SerialPort"和"Charts"模块(Qt 6中Charts属于Add-ons);
  2. 在项目.pro文件中添加模块依赖:
cpp 复制代码
QT += core gui serialport charts widgets

CONFIG += c++17
SOURCES += main.cpp \
           mainwindow.cpp \
           serialmanager.cpp \
           dataparser.cpp \
           datavisualizer.cpp
HEADERS += mainwindow.h \
           serialmanager.h \
           dataparser.h \
           datavisualizer.h

四、核心模块实现

4.1 串口通信模块(SerialManager)

串口模块是数据采集的基础,需实现串口枚举、参数配置、异步收发、异常处理等功能,采用单例模式设计,避免多实例冲突。

4.1.1 头文件(serialmanager.h)
cpp 复制代码
#ifndef SERIALMANAGER_H
#define SERIALMANAGER_H

#include <QObject>
#include <QSerialPort>
#include <QSerialPortInfo>
#include <QTimer>
#include <QMutex>

class SerialManager : public QObject
{
    Q_OBJECT
public:
    // 单例获取
    static SerialManager* getInstance();

    // 串口参数结构体
    struct SerialParams {
        QString portName;      // 串口名(如COM3、/dev/ttyUSB0)
        qint32 baudRate = 9600;// 波特率
        QSerialPort::DataBits dataBits = QSerialPort::Data8; // 数据位
        QSerialPort::Parity parity = QSerialPort::NoParity; // 校验位
        QSerialPort::StopBits stopBits = QSerialPort::OneStop; // 停止位
        QSerialPort::FlowControl flowControl = QSerialPort::NoFlowControl; // 流控
    };

    // 枚举可用串口
    QStringList getAvailablePorts();
    // 打开/关闭串口
    bool openSerial(const SerialParams& params);
    void closeSerial();
    // 发送数据
    bool sendData(const QByteArray& data);

signals:
    // 接收数据信号
    void dataReceived(const QByteArray& data);
    // 串口状态信号
    void serialStateChanged(bool isOpen);
    // 错误信号
    void errorOccurred(const QString& error);

private:
    explicit SerialManager(QObject *parent = nullptr);
    ~SerialManager() override;

    // 禁用拷贝
    SerialManager(const SerialManager&) = delete;
    SerialManager& operator=(const SerialManager&) = delete;

    // 串口对象
    QSerialPort* m_serialPort;
    // 重连定时器(断连后自动重连)
    QTimer* m_reconnectTimer;
    // 串口参数缓存
    SerialParams m_lastParams;
    // 线程安全锁
    QMutex m_mutex;
    // 重连间隔(ms)
    const int RECONNECT_INTERVAL = 3000;

private slots:
    // 读取串口数据
    void readSerialData();
    // 处理串口错误
    void handleSerialError(QSerialPort::SerialPortError error);
    // 自动重连
    void tryReconnect();
};

#endif // SERIALMANAGER_H
4.1.2 源文件(serialmanager.cpp)
cpp 复制代码
#include "serialmanager.h"
#include <QMutexLocker>

SerialManager* SerialManager::getInstance()
{
    static SerialManager instance;
    return &instance;
}

SerialManager::SerialManager(QObject *parent)
    : QObject(parent)
    , m_serialPort(new QSerialPort(this))
    , m_reconnectTimer(new QTimer(this))
{
    // 连接串口信号
    connect(m_serialPort, &QSerialPort::readyRead, this, &SerialManager::readSerialData);
    connect(m_serialPort, &QSerialPort::errorOccurred, this, &SerialManager::handleSerialError);
    // 重连定时器配置
    m_reconnectTimer->setInterval(RECONNECT_INTERVAL);
    m_reconnectTimer->setSingleShot(true);
    connect(m_reconnectTimer, &QTimer::timeout, this, &SerialManager::tryReconnect);
}

SerialManager::~SerialManager()
{
    closeSerial();
}

QStringList SerialManager::getAvailablePorts()
{
    QMutexLocker locker(&m_mutex);
    QStringList ports;
    // 枚举系统可用串口
    for (const QSerialPortInfo& info : QSerialPortInfo::availablePorts()) {
        ports.append(info.portName());
    }
    return ports;
}

bool SerialManager::openSerial(const SerialParams& params)
{
    QMutexLocker locker(&m_mutex);
    // 先关闭已打开的串口
    if (m_serialPort->isOpen()) {
        m_serialPort->close();
    }

    // 配置串口参数
    m_serialPort->setPortName(params.portName);
    m_serialPort->setBaudRate(params.baudRate);
    m_serialPort->setDataBits(params.dataBits);
    m_serialPort->setParity(params.parity);
    m_serialPort->setStopBits(params.stopBits);
    m_serialPort->setFlowControl(params.flowControl);

    // 打开串口(读写模式)
    bool isOpen = m_serialPort->open(QIODevice::ReadWrite);
    if (isOpen) {
        m_lastParams = params;
        m_reconnectTimer->stop(); // 关闭重连定时器
        emit serialStateChanged(true);
        qInfo() << "串口打开成功:" << params.portName;
    } else {
        QString error = "串口打开失败:" + m_serialPort->errorString();
        emit errorOccurred(error);
        qWarning() << error;
    }
    return isOpen;
}

void SerialManager::closeSerial()
{
    QMutexLocker locker(&m_mutex);
    if (m_serialPort->isOpen()) {
        m_serialPort->close();
        m_reconnectTimer->stop();
        emit serialStateChanged(false);
        qInfo() << "串口已关闭";
    }
}

bool SerialManager::sendData(const QByteArray& data)
{
    QMutexLocker locker(&m_mutex);
    if (!m_serialPort->isOpen()) {
        emit errorOccurred("串口未打开,发送失败");
        return false;
    }
    qint64 bytesWritten = m_serialPort->write(data);
    if (bytesWritten == -1) {
        QString error = "数据发送失败:" + m_serialPort->errorString();
        emit errorOccurred(error);
        return false;
    }
    return true;
}

void SerialManager::readSerialData()
{
    QMutexLocker locker(&m_mutex);
    if (!m_serialPort->isOpen()) return;

    QByteArray data = m_serialPort->readAll();
    if (!data.isEmpty()) {
        emit dataReceived(data);
        qDebug() << "接收数据:" << data.toHex() << "(原始:" << data << ")";
    }
}

void SerialManager::handleSerialError(QSerialPort::SerialPortError error)
{
    if (error == QSerialPort::NoError) return;

    QString errorMsg = "串口错误:" + m_serialPort->errorString();
    emit errorOccurred(errorMsg);
    qCritical() << errorMsg;

    // 断连后启动重连
    if (error != QSerialPort::PermissionError && error != QSerialPort::NotFoundError) {
        closeSerial();
        m_reconnectTimer->start();
    }
}

void SerialManager::tryReconnect()
{
    qInfo() << "尝试重新连接串口:" << m_lastParams.portName;
    openSerial(m_lastParams);
}

4.2 数据解析模块(DataParser)

工业设备串口输出的数据格式多样,本文以"温度(℃)+压力(MPa)"的自定义ASCII协议为例(格式:T:25.5,P:1.23\r\n),实现通用解析框架,可扩展支持Modbus RTU等二进制协议。

4.2.1 头文件(dataparser.h)
cpp 复制代码
#ifndef DATAPARSER_H
#define DATAPARSER_H

#include <QObject>
#include <QByteArray>
#include <QVariantMap>

class DataParser : public QObject
{
    Q_OBJECT
public:
    explicit DataParser(QObject *parent = nullptr);

    // 解析模式枚举
    enum ParseMode {
        AsciiMode,    // ASCII协议
        ModbusRTUMode // Modbus RTU协议
    };
    Q_ENUM(ParseMode)

    // 设置解析模式
    void setParseMode(ParseMode mode);
    // 解析原始数据
    QVariantMap parseData(const QByteArray& rawData);

signals:
    // 解析完成信号(key:参数名,value:数值)
    void dataParsed(const QVariantMap& data);
    // 解析错误信号
    void parseError(const QString& error);

private:
    // ASCII协议解析
    QVariantMap parseAsciiData(const QByteArray& data);
    // Modbus RTU协议解析(示例框架)
    QVariantMap parseModbusRTUData(const QByteArray& data);

    // 缓存未解析的残数据(如半包数据)
    QByteArray m_buffer;
    // 当前解析模式
    ParseMode m_parseMode = AsciiMode;
    // 数据帧结束符
    const QByteArray FRAME_END = "\r\n";
};

#endif // DATAPARSER_H
4.2.2 源文件(dataparser.cpp)
cpp 复制代码
#include "dataparser.h"
#include <QRegularExpression>
#include <QDebug>

DataParser::DataParser(QObject *parent)
    : QObject(parent)
{}

void DataParser::setParseMode(ParseMode mode)
{
    m_parseMode = mode;
    m_buffer.clear(); // 切换模式清空缓存
}

QVariantMap DataParser::parseData(const QByteArray& rawData)
{
    // 拼接缓存数据(处理半包)
    m_buffer.append(rawData);
    QVariantMap result;

    // 按帧结束符分割数据
    int endIndex = m_buffer.indexOf(FRAME_END);
    if (endIndex == -1) {
        // 无完整帧,返回空
        return result;
    }

    // 提取完整帧并清空对应缓存
    QByteArray frame = m_buffer.left(endIndex);
    m_buffer = m_buffer.mid(endIndex + FRAME_END.length());

    // 根据模式解析
    switch (m_parseMode) {
    case AsciiMode:
        result = parseAsciiData(frame);
        break;
    case ModbusRTUMode:
        result = parseModbusRTUData(frame);
        break;
    default:
        emit parseError("未知解析模式");
        break;
    }

    if (!result.isEmpty()) {
        emit dataParsed(result);
    }
    return result;
}

QVariantMap DataParser::parseAsciiData(const QByteArray& data)
{
    QVariantMap parsedData;
    // 正则表达式匹配 T:数值,P:数值
    QRegularExpression regex(R"(T:([\d\.]+),P:([\d\.]+))");
    QRegularExpressionMatch match = regex.match(data);

    if (match.hasMatch()) {
        // 提取温度和压力数值
        double temp = match.captured(1).toDouble();
        double pressure = match.captured(2).toDouble();
        parsedData.insert("Temperature", temp);
        parsedData.insert("Pressure", pressure);
        qDebug() << "解析结果:温度=" << temp << "℃,压力=" << pressure << "MPa";
    } else {
        QString error = "ASCII数据解析失败:" + QString(data);
        emit parseError(error);
        qWarning() << error;
    }
    return parsedData;
}

QVariantMap DataParser::parseModbusRTUData(const QByteArray& data)
{
    QVariantMap parsedData;
    // 此处实现Modbus RTU解析逻辑(示例)
    // Modbus RTU帧结构:地址(1B)+功能码(1B)+数据(NB)+CRC(2B)
    if (data.length() < 4) { // 最小帧长度
        emit parseError("Modbus RTU帧长度不足");
        return parsedData;
    }

    // 示例:解析功能码03(读保持寄存器)返回的温度数据(寄存器地址0x0000)
    quint8 devAddr = static_cast<quint8>(data.at(0));
    quint8 funcCode = static_cast<quint8>(data.at(1));
    if (devAddr == 0x01 && funcCode == 0x03) {
        // 温度数据为2字节,高字节+低字节,缩放系数0.1
        quint16 tempRaw = (static_cast<quint8>(data.at(3)) << 8) | static_cast<quint8>(data.at(4));
        double temp = tempRaw * 0.1;
        parsedData.insert("Temperature", temp);
    }

    return parsedData;
}

4.3 数据可视化模块(DataVisualizer)

基于Qt Charts实现实时曲线绘制,支持动态更新数据、坐标轴自适应、多曲线叠加,同时实现数值仪表盘和数据表格展示。

4.3.1 头文件(datavisualizer.h)
cpp 复制代码
#ifndef DATAVISUALIZER_H
#define DATAVISUALIZER_H

#include <QObject>
#include <QChart>
#include <QLineSeries>
#include <QValueAxis>
#include <QChartView>
#include <QTimer>
#include <QTableWidget>
#include <QLabel>

class DataVisualizer : public QObject
{
    Q_OBJECT
public:
    explicit DataVisualizer(QObject *parent = nullptr);

    // 初始化图表
    void initChart(QChartView* chartView);
    // 初始化数据表格
    void initTable(QTableWidget* table);
    // 初始化数值仪表盘
    void initDashboard(QLabel* tempLabel, QLabel* pressureLabel);
    // 设置数据缓存长度(曲线显示点数)
    void setDataCacheSize(int size);

public slots:
    // 更新可视化数据
    void updateData(const QVariantMap& data);
    // 清空图表数据
    void clearChart();

private:
    // 图表组件
    QChart* m_chart;
    QLineSeries* m_tempSeries;  // 温度曲线
    QLineSeries* m_pressureSeries; // 压力曲线
    QValueAxis* m_xAxis;        // X轴(时间/点数)
    QValueAxis* m_yAxis;        // Y轴(数值)
    // 数据缓存
    QList<double> m_tempData;
    QList<double> m_pressureData;
    int m_dataCacheSize = 100;  // 默认显示100个点
    int m_currentX = 0;         // 当前X轴坐标
    // UI组件
    QTableWidget* m_dataTable = nullptr;
    QLabel* m_tempLabel = nullptr;
    QLabel* m_pressureLabel = nullptr;
    // 数据更新锁
    QMutex m_mutex;

    // 自适应Y轴范围
    void adjustYAxisRange();
    // 添加数据到表格
    void addDataToTable(double temp, double pressure);
};

#endif // DATAVISUALIZER_H
4.3.2 源文件(datavisualizer.cpp)
cpp 复制代码
#include "datavisualizer.h"
#include <QDateTime>
#include <QMutexLocker>
#include <QVXYModelMapper>
#include <QFont>

DataVisualizer::DataVisualizer(QObject *parent)
    : QObject(parent)
    , m_chart(new QChart())
    , m_tempSeries(new QLineSeries())
    , m_pressureSeries(new QLineSeries())
    , m_xAxis(new QValueAxis())
    , m_yAxis(new QValueAxis())
{
    // 初始化曲线样式
    m_tempSeries->setName("温度 (℃)");
    m_tempSeries->setColor(Qt::red);
    m_pressureSeries->setName("压力 (MPa)");
    m_pressureSeries->setColor(Qt::blue);

    // 初始化坐标轴
    m_xAxis->setTitleText("采样点");
    m_xAxis->setRange(0, m_dataCacheSize);
    m_yAxis->setTitleText("数值");
    m_yAxis->setRange(0, 10); // 初始范围,后续自适应

    // 配置图表
    m_chart->addSeries(m_tempSeries);
    m_chart->addSeries(m_pressureSeries);
    m_chart->setTitle("工业设备实时数据曲线");
    m_chart->setAxisX(m_xAxis, m_tempSeries);
    m_chart->setAxisX(m_xAxis, m_pressureSeries);
    m_chart->setAxisY(m_yAxis, m_tempSeries);
    m_chart->setAxisY(m_yAxis, m_pressureSeries);
    m_chart->legend()->setVisible(true);
    m_chart->legend()->setAlignment(Qt::AlignBottom);
}

void DataVisualizer::initChart(QChartView* chartView)
{
    chartView->setChart(m_chart);
    chartView->setRenderHint(QPainter::Antialiasing); // 抗锯齿
}

void DataVisualizer::initTable(QTableWidget* table)
{
    m_dataTable = table;
    // 配置表格列
    table->setColumnCount(3);
    table->setHorizontalHeaderLabels({"时间", "温度(℃)", "压力(MPa)"});
    table->horizontalHeader()->setStretchLastSection(true);
    // 设置列宽
    table->setColumnWidth(0, 150);
    table->setColumnWidth(1, 100);
    table->setColumnWidth(2, 100);
}

void DataVisualizer::initDashboard(QLabel* tempLabel, QLabel* pressureLabel)
{
    m_tempLabel = tempLabel;
    m_pressureLabel = pressureLabel;
    // 设置仪表盘字体样式
    QFont font = tempLabel->font();
    font.setPointSize(16);
    font.setBold(true);
    tempLabel->setFont(font);
    pressureLabel->setFont(font);
}

void DataVisualizer::setDataCacheSize(int size)
{
    QMutexLocker locker(&m_mutex);
    m_dataCacheSize = size;
    m_xAxis->setRange(0, size);
    // 裁剪现有数据
    if (m_tempData.size() > size) {
        m_tempData = m_tempData.mid(m_tempData.size() - size);
        m_pressureData = m_pressureData.mid(m_pressureData.size() - size);
        m_currentX = size;
    }
}

void DataVisualizer::updateData(const QVariantMap& data)
{
    QMutexLocker locker(&m_mutex);
    if (!data.contains("Temperature") || !data.contains("Pressure")) {
        return;
    }

    // 提取数值
    double temp = data["Temperature"].toDouble();
    double pressure = data["Pressure"].toDouble();

    // 更新仪表盘
    if (m_tempLabel) {
        m_tempLabel->setText(QString::asprintf("%.1f ℃", temp));
        // 超阈值(如50℃)标红
        m_tempLabel->setStyleSheet(temp > 50 ? "color: red;" : "color: black;");
    }
    if (m_pressureLabel) {
        m_pressureLabel->setText(QString::asprintf("%.2f MPa", pressure));
        m_pressureLabel->setStyleSheet(pressure > 2.0 ? "color: red;" : "color: black;");
    }

    // 添加数据到缓存
    m_tempData.append(temp);
    m_pressureData.append(pressure);

    // 超出缓存长度时移除最早数据
    if (m_tempData.size() > m_dataCacheSize) {
        m_tempData.removeFirst();
        m_pressureData.removeFirst();
    }

    // 更新曲线数据
    m_tempSeries->clear();
    m_pressureSeries->clear();
    for (int i = 0; i < m_tempData.size(); ++i) {
        m_tempSeries->append(i, m_tempData[i]);
        m_pressureSeries->append(i, m_pressureData[i]);
    }

    // 自适应Y轴范围
    adjustYAxisRange();

    // 添加数据到表格
    addDataToTable(temp, pressure);
}

void DataVisualizer::clearChart()
{
    QMutexLocker locker(&m_mutex);
    m_tempData.clear();
    m_pressureData.clear();
    m_tempSeries->clear();
    m_pressureSeries->clear();
    m_currentX = 0;
    if (m_dataTable) {
        m_dataTable->setRowCount(0);
    }
}

void DataVisualizer::adjustYAxisRange()
{
    // 计算Y轴最大/最小值(留10%余量)
    double maxTemp = m_tempData.isEmpty() ? 0 : *std::max_element(m_tempData.begin(), m_tempData.end());
    double maxPressure = m_pressureData.isEmpty() ? 0 : *std::max_element(m_pressureData.begin(), m_pressureData.end());
    double maxY = qMax(maxTemp, maxPressure) * 1.1;

    double minTemp = m_tempData.isEmpty() ? 0 : *std::min_element(m_tempData.begin(), m_tempData.end());
    double minPressure = m_pressureData.isEmpty() ? 0 : *std::min_element(m_pressureData.begin(), m_pressureData.end());
    double minY = qMin(minTemp, minPressure) * 0.9;
    minY = qMax(minY, 0.0); // 确保最小值不小于0

    m_yAxis->setRange(minY, maxY);
}

void DataVisualizer::addDataToTable(double temp, double pressure)
{
    if (!m_dataTable) return;

    // 插入行到表格首行
    int row = 0;
    m_dataTable->insertRow(row);
    // 设置数据:时间、温度、压力
    QString timeStr = QDateTime::currentDateTime().toString("yyyy-MM-dd hh:mm:ss.zzz");
    m_dataTable->setItem(row, 0, new QTableWidgetItem(timeStr));
    m_dataTable->setItem(row, 1, new QTableWidgetItem(QString::asprintf("%.1f", temp)));
    m_dataTable->setItem(row, 2, new QTableWidgetItem(QString::asprintf("%.2f", pressure)));

    // 限制表格行数(最多1000行)
    if (m_dataTable->rowCount() > 1000) {
        m_dataTable->removeRow(m_dataTable->rowCount() - 1);
    }
}

4.4 主界面整合(MainWindow)

将上述模块整合到主界面,实现串口配置、数据采集、可视化展示的一体化操作。

4.4.1 头文件(mainwindow.h)
cpp 复制代码
#ifndef MAINWINDOW_H
#define MAINWINDOW_H

#include <QMainWindow>
#include <QSerialPort>
#include <QVariantMap>
#include <QSoundEffect>

QT_BEGIN_NAMESPACE
namespace Ui { class MainWindow; }
QT_END_NAMESPACE

class SerialManager;
class DataParser;
class DataVisualizer;

class MainWindow : public QMainWindow
{
    Q_OBJECT

public:
    MainWindow(QWidget *parent = nullptr);
    ~MainWindow() override;

private slots:
    // 串口配置相关槽函数
    void on_btnRefreshPorts_clicked();
    void on_btnOpenSerial_clicked();
    void on_btnSendData_clicked();
    // 串口状态更新
    void updateSerialState(bool isOpen);
    // 数据接收与解析
    void handleRawData(const QByteArray& data);
    void handleParsedData(const QVariantMap& data);
    // 错误处理
    void handleSerialError(const QString& error);
    void handleParseError(const QString& error);
    // 报警处理
    void checkAlarm(const QVariantMap& data);
    // 数据存储
    void saveDataToCsv(const QVariantMap& data);

private:
    Ui::MainWindow *ui;
    // 核心模块实例
    SerialManager* m_serialManager;
    DataParser* m_dataParser;
    DataVisualizer* m_dataVisualizer;
    // 报警音效
    QSoundEffect* m_alarmSound;
    // CSV文件句柄
    QFile* m_csvFile;

    // 初始化UI
    void initUI();
    // 初始化信号连接
    void initConnections();
    // 初始化CSV文件
    bool initCsvFile();
};

#endif // MAINWINDOW_H
4.4.2 源文件(mainwindow.cpp)
cpp 复制代码
#include "mainwindow.h"
#include "ui_mainwindow.h"
#include "serialmanager.h"
#include "dataparser.h"
#include "datavisualizer.h"
#include <QFileDialog>
#include <QDateTime>
#include <QTextStream>
#include <QMessageBox>
#include <QDir>

MainWindow::MainWindow(QWidget *parent)
    : QMainWindow(parent)
    , ui(new Ui::MainWindow)
    , m_serialManager(SerialManager::getInstance())
    , m_dataParser(new DataParser(this))
    , m_dataVisualizer(new DataVisualizer(this))
    , m_alarmSound(new QSoundEffect(this))
    , m_csvFile(nullptr)
{
    ui->setupUi(this);
    initUI();
    initConnections();
    initCsvFile();

    // 初始化可视化组件
    m_dataVisualizer->initChart(ui->chartView);
    m_dataVisualizer->initTable(ui->tableWidget);
    m_dataVisualizer->initDashboard(ui->lblTemp, ui->lblPressure);
    m_dataVisualizer->setDataCacheSize(200); // 曲线显示200个点

    // 加载报警音效
    m_alarmSound->setSource(QUrl::fromLocalFile(":/sounds/alarm.wav"));
    m_alarmSound->setVolume(1.0);
}

MainWindow::~MainWindow()
{
    // 关闭CSV文件
    if (m_csvFile && m_csvFile->isOpen()) {
        m_csvFile->close();
        delete m_csvFile;
    }
    delete ui;
}

void MainWindow::initUI()
{
    setWindowTitle("工业设备数据采集与可视化系统");
    // 初始化串口参数下拉框
    ui->cbxBaudRate->addItems({"9600", "19200", "38400", "115200"});
    ui->cbxBaudRate->setCurrentText("9600");
    ui->cbxDataBits->addItems({"8", "7", "6", "5"});
    ui->cbxDataBits->setCurrentText("8");
    ui->cbxParity->addItems({"无", "奇校验", "偶校验"});
    ui->cbxStopBits->addItems({"1", "1.5", "2"});
    // 刷新串口列表
    on_btnRefreshPorts_clicked();
    // 初始状态:关闭串口相关按钮
    ui->btnOpenSerial->setText("打开串口");
    ui->btnSendData->setEnabled(false);
}

void MainWindow::initConnections()
{
    // 串口模块信号连接
    connect(m_serialManager, &SerialManager::dataReceived, this, &MainWindow::handleRawData);
    connect(m_serialManager, &SerialManager::serialStateChanged, this, &MainWindow::updateSerialState);
    connect(m_serialManager, &SerialManager::errorOccurred, this, &MainWindow::handleSerialError);
    // 解析模块信号连接
    connect(m_dataParser, &DataParser::dataParsed, this, &MainWindow::handleParsedData);
    connect(m_dataParser, &DataParser::dataParsed, this, &MainWindow::checkAlarm);
    connect(m_dataParser, &DataParser::dataParsed, this, &MainWindow::saveDataToCsv);
    connect(m_dataParser, &DataParser::parseError, this, &MainWindow::handleParseError);
}

bool MainWindow::initCsvFile()
{
    // 创建数据存储目录
    QDir dataDir("data");
    if (!dataDir.exists()) {
        dataDir.mkdir(".");
    }
    // 生成CSV文件名(按时间戳)
    QString fileName = QString("data/采集数据_%1.csv").arg(QDateTime::currentDateTime().toString("yyyyMMdd_hhmmss"));
    m_csvFile = new QFile(fileName);
    if (!m_csvFile->open(QIODevice::WriteOnly | QIODevice::Text)) {
        QMessageBox::warning(this, "错误", "CSV文件创建失败:" + m_csvFile->errorString());
        return false;
    }
    // 写入CSV表头
    QTextStream stream(m_csvFile);
    stream << "时间,温度(℃),压力(MPa)\n";
    return true;
}

void MainWindow::on_btnRefreshPorts_clicked()
{
    ui->cbxPortName->clear();
    QStringList ports = m_serialManager->getAvailablePorts();
    ui->cbxPortName->addItems(ports);
}

void MainWindow::on_btnOpenSerial_clicked()
{
    if (ui->cbxPortName->currentText().isEmpty()) {
        QMessageBox::warning(this, "警告", "请选择串口!");
        return;
    }

    if (m_serialManager->openSerial({
        ui->cbxPortName->currentText(),
        ui->cbxBaudRate->currentText().toInt(),
        static_cast<QSerialPort::DataBits>(ui->cbxDataBits->currentText().toInt()),
        (ui->cbxParity->currentIndex() == 0) ? QSerialPort::NoParity : (ui->cbxParity->currentIndex() == 1 ? QSerialPort::OddParity : QSerialPort::EvenParity),
        (ui->cbxStopBits->currentIndex() == 0) ? QSerialPort::OneStop : (ui->cbxStopBits->currentIndex() == 1 ? QSerialPort::OneAndHalfStop : QSerialPort::TwoStop),
        QSerialPort::NoFlowControl
    })) {
        ui->btnOpenSerial->setText("关闭串口");
        ui->btnSendData->setEnabled(true);
        ui->cbxPortName->setEnabled(false);
        ui->cbxBaudRate->setEnabled(false);
        ui->cbxDataBits->setEnabled(false);
        ui->cbxParity->setEnabled(false);
        ui->cbxStopBits->setEnabled(false);
    }
}

void MainWindow::on_btnSendData_clicked()
{
    // 发送测试指令(如读取数据指令)
    QByteArray cmd = "READ_DATA\r\n";
    m_serialManager->sendData(cmd);
}

void MainWindow::updateSerialState(bool isOpen)
{
    if (!isOpen) {
        ui->btnOpenSerial->setText("打开串口");
        ui->btnSendData->setEnabled(false);
        ui->cbxPortName->setEnabled(true);
        ui->cbxBaudRate->setEnabled(true);
        ui->cbxDataBits->setEnabled(true);
        ui->cbxParity->setEnabled(true);
        ui->cbxStopBits->setEnabled(true);
        ui->statusbar->showMessage("串口已断开", 3000);
    } else {
        ui->statusbar->showMessage("串口已连接:" + ui->cbxPortName->currentText(), 3000);
    }
}

void MainWindow::handleRawData(const QByteArray& data)
{
    // 显示原始数据
    ui->txtRawData->appendPlainText(QString("[%1] 原始数据:%2").arg(QDateTime::currentDateTime().toString("hh:mm:ss.zzz")).arg(data.toHex(' ')));
    // 解析数据
    m_dataParser->parseData(data);
}

void MainWindow::handleParsedData(const QVariantMap& data)
{
    // 更新可视化数据
    m_dataVisualizer->updateData(data);
}

void MainWindow::handleSerialError(const QString& error)
{
    ui->statusbar->showMessage(error, 5000);
    QMessageBox::critical(this, "串口错误", error);
}

void MainWindow::handleParseError(const QString& error)
{
    ui->statusbar->showMessage(error, 5000);
    ui->txtRawData->appendPlainText("[解析错误] " + error);
}

void MainWindow::checkAlarm(const QVariantMap& data)
{
    double temp = data["Temperature"].toDouble();
    double pressure = data["Pressure"].toDouble();
    // 超阈值报警
    if (temp > 50 || pressure > 2.0) {
        ui->lblAlarm->setText("⚠ 数据超阈值!");
        ui->lblAlarm->setStyleSheet("color: red; font-weight: bold;");
        m_alarmSound->play();
        // 5秒后清除报警提示
        QTimer::singleShot(5000, [this]() {
            ui->lblAlarm->setText("");
            ui->lblAlarm->setStyleSheet("");
        });
    }
}

void MainWindow::saveDataToCsv(const QVariantMap& data)
{
    if (!m_csvFile || !m_csvFile->isOpen()) return;

    double temp = data["Temperature"].toDouble();
    double pressure = data["Pressure"].toDouble();
    QString timeStr = QDateTime::currentDateTime().toString("yyyy-MM-dd hh:mm:ss.zzz");

    QTextStream stream(m_csvFile);
    stream << timeStr << "," << QString::asprintf("%.1f", temp) << "," << QString::asprintf("%.2f", pressure) << "\n";
    stream.flush(); // 立即写入文件
}

4.5 主函数(main.cpp)

cpp 复制代码
#include "mainwindow.h"
#include <QApplication>
#include <QStyleFactory>

int main(int argc, char *argv[])
{
    QApplication a(argc, argv);
    // 设置系统样式,提升界面美观度
    a.setStyle(QStyleFactory::create("Fusion"));

    MainWindow w;
    w.resize(1200, 800); // 设置主窗口大小
    w.show();

    return a.exec();
}

五、界面设计(UI文件关键部分)

在Qt Designer中设计主界面,核心组件布局如下:

xml 复制代码
<!-- 关键组件示例 -->
<widget class="QWidget" name="centralwidget">
  <layout class="QVBoxLayout" name="verticalLayout">
    <!-- 串口配置区域 -->
    <widget class="QGroupBox" name="groupBox">
      <property name="title">
        <string>串口配置</string>
      </property>
      <layout class="QGridLayout" name="gridLayout">
        <item row="0" column="0">
          <widget class="QLabel" name="label">
            <property name="text">
              <string>串口:</string>
            </property>
          </widget>
        </item>
        <item row="0" column="1">
          <widget class="QComboBox" name="cbxPortName"/>
        </item>
        <item row="0" column="2">
          <widget class="QPushButton" name="btnRefreshPorts">
            <property name="text">
              <string>刷新</string>
            </property>
          </widget>
        </item>
        <item row="0" column="3">
          <widget class="QPushButton" name="btnOpenSerial">
            <property name="text">
              <string>打开串口</string>
            </property>
          </widget>
        </item>
        <!-- 波特率、数据位等参数配置 -->
        <item row="1" column="0">
          <widget class="QLabel" name="label_2">
            <property name="text">
              <string>波特率:</string>
            </property>
          </widget>
        </item>
        <item row="1" column="1">
          <widget class="QComboBox" name="cbxBaudRate"/>
        </item>
        <item row="1" column="2">
          <widget class="QLabel" name="label_3">
            <property name="text">
              <string>数据位:</string>
            </property>
          </widget>
        </item>
        <item row="1" column="3">
          <widget class="QComboBox" name="cbxDataBits"/>
        </item>
        <!-- 校验位、停止位 -->
        <item row="2" column="0">
          <widget class="QLabel" name="label_4">
            <property name="text">
              <string>校验位:</string>
            </property>
          </widget>
        </item>
        <item row="2" column="1">
          <widget class="QComboBox" name="cbxParity"/>
        </item>
        <item row="2" column="2">
          <widget class="QLabel" name="label_5">
            <property name="text">
              <string>停止位:</string>
            </property>
          </widget>
        </item>
        <item row="2" column="3">
          <widget class="QComboBox" name="cbxStopBits"/>
        </item>
        <item row="3" column="0" colspan="4">
          <widget class="QPushButton" name="btnSendData">
            <property name="text">
              <string>发送读取指令</string>
            </property>
          </widget>
        </item>
      </layout>
    </widget>

    <!-- 实时数据仪表盘 -->
    <widget class="QGroupBox" name="groupBox_2">
      <property name="title">
        <string>实时数据</string>
      </property>
      <layout class="QHBoxLayout" name="horizontalLayout">
        <item>
          <widget class="QLabel" name="label_6">
            <property name="text">
              <string>温度:</string>
            </property>
          </widget>
        </item>
        <item>
          <widget class="QLabel" name="lblTemp">
            <property name="text">
              <string>0.0 ℃</string>
            </property>
          </widget>
        </item>
        <item>
          <widget class="QLabel" name="label_7">
            <property name="text">
              <string>压力:</string>
            </property>
          </widget>
        </item>
        <item>
          <widget class="QLabel" name="lblPressure">
            <property name="text">
              <string>0.00 MPa</string>
            </property>
          </widget>
        </item>
        <item>
          <widget class="QLabel" name="lblAlarm">
            <property name="text">
              <string/>
            </property>
          </widget>
        </item>
      </layout>
    </widget>

    <!-- 数据可视化区域 -->
    <widget class="QSplitter" name="splitter">
      <property name="orientation">
        <enum>Qt::Horizontal</enum>
      </property>
      <widget class="QGroupBox" name="groupBox_3">
        <property name="title">
          <string>实时曲线</string>
        </property>
        <layout class="QVBoxLayout" name="verticalLayout_2">
          <item>
            <widget class="QChartView" name="chartView"/>
          </item>
        </layout>
      </widget>
      <widget class="QGroupBox" name="groupBox_4">
        <property name="title">
          <string>数据列表</string>
        </property>
        <layout class="QVBoxLayout" name="verticalLayout_3">
          <item>
            <widget class="QTableWidget" name="tableWidget"/>
          </item>
        </layout>
      </widget>
    </widget>

    <!-- 原始数据显示 -->
    <widget class="QGroupBox" name="groupBox_5">
      <property name="title">
        <string>原始数据</string>
      </property>
      <layout class="QVBoxLayout" name="verticalLayout_4">
        <item>
          <widget class="QPlainTextEdit" name="txtRawData"/>
        </item>
      </layout>
    </widget>
  </layout>
</widget>
<widget class="QStatusBar" name="statusbar"/>

六、功能测试与优化

6.1 测试步骤

  1. 串口模拟 :使用串口调试工具(如SSCOM)模拟工业设备,发送格式为T:25.5,P:1.23\r\n的ASCII数据;
  2. 功能验证
    • 串口枚举、参数配置、打开/关闭功能正常;
    • 数据接收后能正确解析,仪表盘实时更新;
    • 曲线随数据动态绘制,坐标轴自适应;
    • 超阈值(温度>50℃/压力>2.0MPa)触发声音报警;
    • 数据自动写入CSV文件,表格显示历史数据;
    • 断连后自动重连,重连成功后恢复数据采集。

6.2 性能优化

  1. 数据更新频率:限制曲线更新频率(如50ms/次),避免UI卡顿;
  2. 内存管理:表格数据限制最大行数(1000行),曲线缓存长度可配置;
  3. 线程优化:将串口数据接收与解析放到子线程,避免阻塞UI线程(可通过Qt的moveToThread实现);
  4. 绘图优化:使用QChart的OpenGL加速(setRenderHint(QPainter::Antialiasing)),提升曲线绘制效率。

七、扩展功能建议

  1. Modbus协议支持:完善Modbus RTU/TCP解析,适配工业标准协议;
  2. 历史数据回放:读取CSV文件,还原历史曲线;
  3. 多设备采集:支持多串口同时采集,多曲线对比显示;
  4. 网络传输:增加TCP/UDP模块,将数据上传至服务器;
  5. 自定义报警:支持用户配置阈值、报警方式(声音/邮件/短信);
  6. 导出报表:支持将历史数据导出为Excel/PDF报表。

八、总结

本文基于Qt C++实现了一套完整的工业设备串口数据采集与可视化系统,涵盖串口通信、数据解析、实时绘图、数据存储、异常报警等核心功能。系统采用模块化设计,代码结构清晰,可扩展性强,适配多种工业串口协议,满足工业现场的高可靠性与实时性要求。通过Qt Charts实现的可视化界面直观展示设备运行数据,帮助运维人员快速掌握设备状态,降低故障排查成本。

该系统可广泛应用于智能制造、工业监控、设备运维等场景,通过简单的协议适配即可对接不同类型的工业设备,具备较高的工程实用价值。

相关推荐
oioihoii3 小时前
在MFC桌面应用中嵌入现代浏览器内核:原理、选型与实践全解析
c++·mfc
_OP_CHEN3 小时前
【从零开始的Qt开发指南】(九)Qt 常用控件之显示类控件(下):ProgressBar 与 CalendarWidget 实战进阶
开发语言·c++·qt·gui·前端开发·图形化界面开发·qt常用控件
兵哥工控3 小时前
MFC模拟量转工程量换应用程序实例
c++·mfc·工程量·模拟量
不会代码的小猴3 小时前
C++的第十三天笔记
c++·笔记·算法
OliverH-yishuihan3 小时前
Windows上VScode编译C++
c++·vscode
世转神风-3 小时前
qt-弹框提示-界面提醒
开发语言·qt·策略模式
~patience~3 小时前
简单易懂的计数器(理解Qt的信号和槽机制)
开发语言·qt
木心爱编程4 小时前
Qt C++ Excel 文件解析与导出实战:QAxObject 封装工具类
c++·qt·数据库开发
爱装代码的小瓶子4 小时前
【c++知识铺子】map和set的底层-红黑树
java·开发语言·c++