做嵌入式上位机开发,C++、Qt、SQLite 这三个关键词几乎绕不开。很多从单片机转过来的同学,第一个上位机项目往往是“打开串口 -> 收数据 -> 显示波形 -> 存数据”,等真正动手才发现:界面怎么搭?数据来了怎么存?程序关了之后数据怎么再查出来?这篇文章不聊大而全的工业平台,只围绕一条最常用的技术链路展开:用 Qt 写界面,用串口或网络接收设备数据,用 SQLite 做本地持久化,再把工程结构、关键代码、测试流程和常见坑位拆开讲清楚。这也是嵌入式上位机开发中最实在、最容易落地的一套技术组合。

这套组合最核心的优点是“轻”。Qt 负责跨平台界面和信号槽通信,SQLite 负责零配置的单文件数据库,不需要单独安装数据库服务,拷一个文件就能带走整个数据目录。对于产线工具、实验室台架、设备调试软件这种场景,比 C# + SQL Server、Qt + MySQL 都要省事很多。本文会带你完成环境准备、工程创建、数据库建表、串口数据入库、曲线展示、批量导出和问题排查,最后还会给出几段可以直接拿到项目里改的示例代码,适合正在做毕业设计、入门 Qt 上位机开发或者想把本地数据存储做规范的工程师。

更具体地说,这套技术方案真正值得关注的几个点包括:SQLite 支持事务批量写入,适合高频串口采集;Qt 的 QSqlTableModel 可以直接把数据库表绑定到表格控件;QChart 可以在曲线图上实时刷新数据;数据文件独立存放在本地,后期交付时不需要给用户额外装数据库环境。硬件门槛方面,普通办公电脑就能跑,不需要独立显卡,也不需要超大内存。文章后面所有示例都以 Qt Widgets + QSerialPort + QSqlDatabase 为核心,读者可以根据自己的实际 Qt 版本微调。

这篇文章的内容顺序是:核心能力速览、技术选型与边界、环境准备、项目模块划分、SQLite 集成代码、串口通信代码、功能测试、接口与批量任务、资源占用观测、常见问题排查、最佳实践。如果你正准备做一个嵌入式上位机项目,或者已经在写但数据库部分比较薄弱,建议收藏备用,按章节对照着改自己的项目。

1. 核心能力速览

能力项 说明
开发语言 C++(C++11 及以上,现代 Qt 工程建议至少 C++17)
界面框架 Qt Widgets 或 Qt Quick,本文以 Widgets 为例
通信方式 QSerialPort 串口、QTcpSocket/QTcpServer 网络、后续可扩展 Modbus 等协议
数据库 SQLite,通过 Qt SQL 模块接入
数据存储形式 单文件数据库,默认本地持久化,无需安装服务
主要功能 串口数据采集、协议解析、实时曲线显示、历史数据查询、CSV 导出、设备配置管理
启动方式 编译生成可执行文件后直接运行
支持平台 Windows / Linux / 部分嵌入式 Linux 板卡
硬件要求 普通办公电脑即可,无独立显卡要求
是否支持批量任务 支持,事务批量入库、批量导出 CSV、批量导入配置
是否支持接口 API 可通过 QTcpServer 提供局域网数据服务,或通过共享内存/文件接口供第三方读取
适合场景 设备调试工具、实验室数据采集、产线数据记录、小型监控上位机

这里要特别说明一点:SQLite 不是用来扛高并发服务的。它是嵌入式数据库,理想场景就是“一个程序、一个数据文件、本地读写”。上位机采集数据天然符合这个模型:设备数据到了上位机,主程序负责写入,查询窗口负责读取,偶尔需要导出。把 SQLite 用在这个位置,性能和复杂度都刚刚好。

2. 嵌入式上位机技术选型与适用边界

2.1 为什么选 Qt 而不是其他框架

嵌入式上位机的需求通常集中在几个方面:跨平台部署、控件丰富、图表展示、串口网络支持、开发效率。Qt 在这几项上表现得比较均衡。C++ 负责底层数据结构和通信逻辑,Qt 的信号槽机制可以很好地解决串口数据到达后如何通知界面刷新、如何通知数据库写入的问题,代码结构比纯 Win32 或 MFC 清晰很多。对于产线设备调试、实验室仪器控制这类软件,Qt Widgets 基本够用,控件响应速度也直观可控。

2.2 为什么选 SQLite 而不是 MySQL / SQL Server

很多人在做上位机时习惯性想到 MySQL,但如果这一步停下来想一下部署成本:用户机器上要装数据库服务、要配置账号、要处理端口冲突,仅这一条就足以让小工具类上位机失去便携性。SQLite 直接以单文件形式工作,Qt 通过 QSQLITE 驱动连接,不需要安装任何服务,程序启动时打开数据库文件,关闭时释放,整体体验和读配置文件没有太大区别。

2.3 什么场景适合这套组合

适合的场景包括:设备调试软件(采集温度、压力、电流等参数)、实验室数据记录、产线质检记录、小型设备配置管理工具、学习 Qt 和 C++ 的练手项目。这类项目的共同特点是数据量不大、单机访问、以本地存储和查询为主,对数据库事务要求不复杂。

2.4 什么场景不要硬用 SQLite

如果上位机后面要支持多个客户端同时写库、每秒写入几千条记录并且需要长期保留、需要分布式部署,或者数据要跨平台被多个进程同时频繁读写,SQLite 就不太合适。更稳妥的判断是:多进程高并发写入改用真正的数据库服务;超大数据分析交给专业工具;如果只是单机采集存储,SQLite 是很省心的选择。

2.5 数据安全与使用边界

上位机采集的数据可能涉及设备参数、生产信息甚至人员操作记录。开发时要确认设备数据是否有授权,不要采集和保存未授权数据。界面中的“权限”“日志”“用户管理”模块要保留审计记录。代码发布前要检查数据库文件是否包含敏感测试数据,交付时做好隐私清理。这一点在涉及第三方设备的项目里尤其重要,否则后期容易引发合规问题。

3. 环境准备与开发环境搭建

3.1 安装 Qt

在开始写代码之前,需要先准备 Qt 开发环境。官方提供了在线安装程序,安装时选择自己需要的模块,建议至少勾选:

  • Qt Widgets
  • Qt Charts(用于实时曲线)
  • Qt Serial Port(串口通信)
  • Qt SQL(数据库)
  • Qt Network(网络通信)

如果网络下载源访问不畅,可以使用国内镜像站,但下载完成后最好核对文件签名和完整性。安装器会自动完成编译环境的配置,但仍然需要单独准备编译器。Windows 上通常选择 MSVC 或 MinGW;Linux 上使用系统 gcc 即可。

一个容易忽略的点:Qt 版本与编译器的位数要一致。如果你的 Qt 是 64 位的 MinGW 版本,编译器也得是 64 位 MinGW,不能混用 MSVC 的 .lib 库。第一次搭环境时,如果报一堆链接错误,优先检查位数和编译器类型。

3.2 创建项目并添加模块

如果使用 qmake 创建工程,需要在 .pro 文件中添加模块:

QT += core gui widgets sql charts serialport network

CONFIG += c++17

TARGET = embedded_host
TEMPLATE = app

SOURCES += \
    main.cpp \
    MainWindow.cpp \
    DataStorage.cpp \
    SerialWorker.cpp

HEADERS += \
    MainWindow.h \
    DataStorage.h \
    SerialWorker.h

如果使用 CMake,则在 CMakeLists.txt 中通过 find_package 查找 Qt 组件,并链接到目标:

cmake_minimum_required(VERSION 3.16)
project(embedded_host)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON)

find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets Sql Charts SerialPort Network)

add_executable(embedded_host
    main.cpp
    MainWindow.cpp
    DataStorage.cpp
    SerialWorker.cpp
)

target_link_libraries(embedded_host PRIVATE
    Qt6::Core
    Qt6::Gui
    Qt6::Widgets
    Qt6::Sql
    Qt6::Charts
    Qt6::SerialPort
    Qt6::Network
)

这里有一点需要提醒:Qt6 的 CMake 模块名和 Qt5 略有差异,如果在老项目上迁移,需要按照实际版本调整组件名称。添加模块后先编译一次空项目,确认环境没问题再往里面填业务代码。

4. 上位机项目功能设计与模块划分

4.1 整体数据链路

一个典型的上位机数据链路可以拆成下面几个环节:

  1. 设备数据源:传感器、控制器、仪器仪表等,通过串口或网口输出。
  2. 通信模块:QSerialPort 负责收发串口字节流;QTcpSocket 负责网络字节流。
  3. 协议解析模块:把收到的原始字节流按帧格式拆包、校验、解析成结构化数据。
  4. 内存缓冲模块:解析后的数据先进入队列或缓存列表,避免高频串口数据直接打满界面线程。
  5. 数据存储模块:使用 SQLite 事务批量写入。
  6. 界面显示模块:表格、曲线、状态面板,通过主线程刷新。

如果采集频率不高,比如每秒 1 到 10 帧,那么收到一帧就界面刷新一次问题不大。如果设备 10ms 发一帧,就必须把“数据接收—协议解析”和“UI 刷新”拆开处理,否则界面很容易卡死。

4.2 线程模型设计

串口通信、数据库写入和 UI 刷新分属不同频度的任务。最简单的线程模型是:

  • UI 主线程:负责 QWidget 界面、图表刷新、用户操作响应。
  • 采集线程:负责串口接收和协议解析。
  • 数据库线程:负责把缓冲区里的数据批量写入 SQLite。

线程之间通过 Qt 信号槽传递数据,信号槽会自动完成跨线程队列调用。需要注意,数据库连接不能在一个线程里创建后在另一个线程直接使用,推荐每个线程各自打开自己的数据库连接,或者把数据库操作统一封装到专门的数据存储类中。

4.3 工程目录结构建议

embedded_host/
├── CMakeLists.txt
├── config/
│   └── app.ini
├── src/
│   ├── main.cpp
│   ├── MainWindow.h
│   ├── MainWindow.cpp
│   ├── serial/
│   │   ├── SerialWorker.h
│   │   └── SerialWorker.cpp
│   ├── protocol/
│   │   ├── ProtocolParser.h
│   │   └── ProtocolParser.cpp
│   ├── storage/
│   │   ├── DataStorage.h
│   │   └── DataStorage.cpp
│   └── ui/
│       ├── ChartWidget.h
│       └── ChartWidget.cpp
├── data/
│   └── records.db
└── output/
    └── export/

从项目的第一个版本开始就按模块分目录,后面加功能时不会把代码堆成一团。尤其是协议解析和存储层,它们最容易被单文件版本的 MainWindow 绑架,导致维护成本快速上升。

5. SQLite 在 Qt 中的集成与关键代码实现

5.1 初始化数据库连接和建表

在 Qt 中使用 SQLite 首先需要建立数据库连接。 QSqlDatabase::addDatabase 创建连接时,数据库类型写 QSQLITE ,数据库名填具体的文件路径。建表语句建议在程序初始化时执行一次,使用 CREATE TABLE IF NOT EXISTS 可以保证多次启动不会重复建表。

#include <QSqlDatabase>
#include <QSqlQuery>
#include <QSqlError>
#include <QVariant>
#include <QDebug>

bool initDatabase(const QString &dbPath)
{
    // 每个线程独立打开自己的连接
    QSqlDatabase db = QSqlDatabase::contains("main_conn")
            ? QSqlDatabase::database("main_conn")
            : QSqlDatabase::addDatabase("QSQLITE", "main_conn");

    db.setDatabaseName(dbPath);
    if (!db.open()) {
        qCritical() << "打开数据库失败:" << db.lastError().text();
        return false;
    }

    QSqlQuery query(db);

    // 设备信息表
    bool ok = query.exec(
        "CREATE TABLE IF NOT EXISTS device_info ("
        "  device_id   TEXT PRIMARY KEY,"
        "  device_name TEXT NOT NULL,"
        "  model       TEXT,"
        "  created_at  TEXT DEFAULT (datetime('now', 'localtime'))"
        ")");
    if (!ok) {
        qCritical() << "创建设备信息表失败:" << query.lastError().text();
        return false;
    }

    // 采集数据表
    ok = query.exec(
        "CREATE TABLE IF NOT EXISTS sample_data ("
        "  id         INTEGER PRIMARY KEY AUTOINCREMENT,"
        "  device_id  TEXT NOT NULL,"
        "  timestamp  TEXT NOT NULL,"
        "  value      REAL NOT NULL,"
        "  status     INTEGER DEFAULT 0"
        ")");
    if (!ok) {
        qCritical() << "创建采样数据表失败:" << query.lastError().text();
        return false;
    }

    // 数据量上来后需要建索引
    ok = query.exec(
        "CREATE INDEX IF NOT EXISTS idx_sample_device_time "
        "ON sample_data(device_id, timestamp)");
    if (!ok) {
        qCritical() << "创建索引失败:" << query.lastError().text();
        return false;
    }

    return true;
}

建表语句中, timestamp 字段使用 TEXT 类型保存形如 2025-01-01 10:00:00 的字符串。这样做的原因是跨平台兼容性好,排序也能满足字符串时间序。如果要按时间范围精确查询,可以在应用层再做时间转换。

5.2 单条插入与事务批量插入

数据库写入最忌讳的是每收到一帧数据就执行一次插入并自动提交。高频采集时,这样会导致 SQLite 不停地做磁盘同步操作,性能迅速恶化。更稳妥的做法是先把解析结果放进一个缓冲区,每攒够一批或者每 500ms 批量插入一次。

struct SampleRecord {
    QString deviceId;
    QString timestamp;
    double  value;
    int     status;
};

bool batchInsertSamples(const QVector<SampleRecord> &records)
{
    if (records.isEmpty()) {
        return true;
    }

    QSqlDatabase db = QSqlDatabase::database("main_conn");
    if (!db.isOpen()) {
        qWarning() << "数据库未打开";
        return false;
    }

    db.transaction();
    QSqlQuery query(db);
    query.prepare(
        "INSERT INTO sample_data (device_id, timestamp, value, status) "
        "VALUES (?, ?, ?, ?)");

    for (const auto &rec : records) {
        query.addBindValue(rec.deviceId);
        query.addBindValue(rec.timestamp);
        query.addBindValue(rec.value);
        query.addBindValue(rec.status);
        if (!query.exec()) {
            qWarning() << "批量插入失败:" << query.lastError().text();
            db.rollback();
            return false;
        }
    }

    bool ok = db.commit();
    qInfo() << "批量写入完成,记录数:" << records.size() << "提交结果:" << ok;
    return ok;
}

批量插入时,应该把 records 的大小控制在一个合理范围内,比如 100 到 500 条,避免单次事务持锁时间过长。数据缓冲区可以放在数据存储类内部,用 QVector 或 QList 暂存。实际项目中,还需要加一个互斥锁,保证采集线程调用 appendToBuffer 时,数据库线程不会同时读取同一个容器。

5.3 按条件查询数据

历史数据查询是上位机的基本功能。常见查询包括按设备查询、按时间范围查询、查询最新一条数据。下面的函数按时间范围读取数据,并把结果转换成适合 QChart 使用的点集:

QVector<QPair<qint64, double>> querySamples(
        const QString &deviceId,
        const QString &startTime,
        const QString &endTime)
{
    QVector<QPair<qint64, double>> result;

    QSqlDatabase db = QSqlDatabase::database("main_conn");
    QSqlQuery query(db);
    query.prepare(
        "SELECT timestamp, value FROM sample_data "
        "WHERE device_id = ? AND timestamp >= ? AND timestamp <= ? "
        "ORDER BY timestamp ASC");
    query.addBindValue(deviceId);
    query.addBindValue(startTime);
    query.addBindValue(endTime);

    if (!query.exec()) {
        qWarning() << "查询失败:" << query.lastError().text();
        return result;
    }

    while (query.next()) {
        QString ts = query.value(0).toString();
        double value = query.value(1).toDouble();
        QDateTime dt = QDateTime::fromString(ts, "yyyy-MM-dd HH:mm:ss");
        result.append({dt.toMSecsSinceEpoch(), value});
    }

    return result;
}

如果数据量较大,查询条件一定要走索引。上面建表语句中的 idx_sample_device_time 索引就是为了让 device_id + timestamp 的组合查询不进行全表扫描。

5.4 存在就更新,不存在就插入

嵌入式上位机里经常需要保存“设备最新状态”。比如每台设备的最新温度、最新连接时间、当前在线状态。这种场景不需要追加历史记录,而是希望“存在就更新,不存在就新增”。SQLite 3.24 以上版本支持 UPSERT 语法,Qt 的 QSQLITE 驱动在较新的 SQLite 版本上可以直接执行。

bool upsertDeviceStatus(const QString &deviceId,
                        double lastValue,
                        const QString &updateTime)
{
    QSqlQuery query(QSqlDatabase::database("main_conn"));
    query.prepare(
        "INSERT INTO device_status (device_id, last_value, update_time) "
        "VALUES (?, ?, ?) "
        "ON CONFLICT(device_id) DO UPDATE SET "
        "last_value = excluded.last_value, "
        "update_time = excluded.update_time");

    query.addBindValue(deviceId);
    query.addBindValue(lastValue);
    query.addBindValue(updateTime);

    if (!query.exec()) {
        qWarning() << "UPSERT 失败:" << query.lastError().text();
        return false;
    }
    return true;
}

使用 excluded 关键字,可以在冲突时引用本次想要插入的新值,从而避免“先查再改”的两步操作。这个技巧在做设备在线状态、参数快照时非常实用,也是 SQLite 高频运维操作之一。

5.5 数据库升级与新增表

上位机软件迭代过程中,经常要新增字段或新增表。直接在旧数据库上执行 ALTER TABLE ADD COLUMN 是比较常见的方式。为了管理版本,推荐使用 PRAGMA user_version 保存数据库结构版本号。

bool upgradeDatabase(QSqlDatabase &db)
{
    QSqlQuery query(db);

    // 当前结构版本
    int version = query.exec("PRAGMA user_version")
            ? query.value(0).toInt() : 0;

    if (version < 1) {
        query.exec(
            "ALTER TABLE sample_data "
            "ADD COLUMN remark TEXT DEFAULT ''");
        query.exec("PRAGMA user_version = 1");
    }

    if (version < 2) {
        query.exec(
            "CREATE TABLE IF NOT EXISTS alarm_record ("
            "  id INTEGER PRIMARY KEY AUTOINCREMENT,"
            "  device_id TEXT NOT NULL,"
            "  alarm_msg TEXT NOT NULL,"
            "  create_time TEXT NOT NULL"
            ")");
        query.exec("PRAGMA user_version = 2");
    }

    return true;
}

数据库升级的要点是增量执行。每个版本只做当前版本应该做的事,已经升上去的用户不能重复执行旧语句,降级或覆盖安装时也不会因为旧语句报错导致程序启动失败。

6. 串口通信与数据采集代码实现

6.1 打开串口

串口通信是嵌入式上位机最常用的数据入口。Qt 的 QSerialPort 使用方式比较简单,但有几个细节需要注意:波特率、数据位、停止位、校验位要和设备端保持一致;串口名称在 Windows 上是 COM3 这样的编号,在 Linux 上是 /dev/ttyUSB0 这样的设备节点。

#include <QSerialPort>
#include <QSerialPortInfo>

bool openSerialPort(QSerialPort *serial,
                    const QString &portName,
                    qint32 baudRate)
{
    if (!serial) {
        return false;
    }

    serial->setPortName(portName);
    serial->setBaudRate(baudRate);
    serial->setDataBits(QSerialPort::Data8);
    serial->setParity(QSerialPort::NoParity);
    serial->setStopBits(QSerialPort::OneStop);
    serial->setFlowControl(QSerialPort::NoFlowControl);

    if (!serial->open(QIODevice::ReadWrite)) {
        qCritical() << "打开串口失败:" << serial->errorString();
        return false;
    }

    qInfo() << "串口打开成功:" << portName << "波特率:" << baudRate;
    return true;
}

串口打开失败的原因很多,端口被其他软件占用是最常见的。排查时先用系统设备管理器确认串口编号,再关掉可能占用串口的调试工具。

6.2 数据接收与协议解析

QSerialPort 的 readyRead 信号说明有数据到达。收到数据后不要直接在槽函数里做复杂解析,因为串口接收频率可能很高。更稳妥的做法是把原始字节先暂存,再按帧格式解析。

下面是一个简单的帧接收示例,假设协议格式为 AA 55 长度 数据 校验 :

void SerialWorker::onReadyRead()
{
    QByteArray data = m_serial->readAll();
    m_buffer.append(data);

    // 查找帧头 0xAA 0x55
    while (true) {
        int startIndex = m_buffer.indexOf(QByteArray::fromHex("AA55"));
        if (startIndex < 0) {
            // 没有完整帧头,丢弃前面无用的字节
            if (m_buffer.size() > 2) {
                m_buffer.remove(0, m_buffer.size() - 2);
            }
            return;
        }
        if (startIndex > 0) {
            m_buffer.remove(0, startIndex);
        }

        // 校验帧长度:AA 55 LEN ... CRC
        if (m_buffer.size() < 4) {
            return;
        }
        quint8 len = static_cast<quint8>(m_buffer.at(2));
        int frameLen = 3 + len + 1; // 帧头2 + 长度1 + 数据len + CRC1
        if (m_buffer.size() < frameLen) {
            return;
        }

        // 提取一帧完整数据
        QByteArray frame = m_buffer.left(frameLen);
        m_buffer.remove(0, frameLen);

        // 解析帧内容
        quint8 crc = static_cast<quint8>(frame.at(frameLen - 1));
        // 具体 CRC 校验逻辑按设备协议实现
        // ...

        // 解析有效数据
        QByteArray payload = frame.mid(3, len);
        // 把 payload 中的设备 ID、数值、状态解析出来
        // emit parsedFrame(deviceId, value, status);

        // 发送到主界面线程
        emit frameReady(deviceId, value, status);
    }
}

帧解析逻辑写成独立类的好处是,后续如果要支持 Modbus、自定义 TCP 协议,只需要替换协议解析层,界面和数据库层不需要动。数据解析完成后,通过信号把结果抛给主线程或数据存储线程,信号槽会根据接收者所在线程自动切换执行上下文。

7. 功能测试与效果验证

7.1 测试环境准备

没有真实设备时,可以用虚拟串口工具创建一对互相连接的虚拟串口,比如一个程序往 COM1 发数据,上位机从 COM2 收数据。也可以直接写一个简单的模拟数据发生器,按固定频率向串口写入帧数据。如果是网络通信,可以用本机回环地址模拟。

模拟数据发生器不需要复杂,几行代码就够了:

QTimer *timer = new QTimer(this);
timer->setInterval(100); // 每 100ms 发一帧
connect(timer, &QTimer::timeout, this, [=]() {
    QByteArray frame;
    frame.append(0xAA);
    frame.append(0x55);
    frame.append(0x04);
    frame.append(0x00);
    frame.append(0x01);
    float value = 20.0f + (qrand() % 100) / 10.0f;
    frame.append(reinterpret_cast<const char*>(&value), 4);
    frame.append(0x00); // CRC 占位
    serial->write(frame);
});
timer->start();

7.2 测试清单

测试项 操作步骤 预期结果 判断标准
数据库初始化 启动程序 数据目录下生成 records.db 文件存在且表结构正确
串口收发 打开模拟串口,发送测试帧 界面日志显示接收到 N 帧数据 无丢帧、无解析错误
数据入库 连续发送数据 1 分钟 sample_data 表记录数增长 记录数与发送帧数一致
实时曲线 曲线界面选择设备 ID 曲线随数据实时滚动 无明显卡顿、不崩溃
历史查询 选择时间范围查询 图表显示历史数据 查询时间可接受
导出 CSV 点击导出按钮选择文件 生成 CSV 文件 可用 Excel/WPS 打开
异常恢复 发送中途关闭模拟串口 程序提示串口断开 不崩溃、可重连

7.3 常见失败原因分析

如果测试过程中发现数据入库数量少于发送数量,优先检查串口缓冲区是否有粘包和半包问题;如果曲线刷新卡顿,可能是每帧数据都触发了图表重绘;如果中文乱码,需要检查源文件编码、串口设备端编码和 Qt 字符串编码是否一致。整个链路从串口到数据库,每一层都可能引入 bug,测试时建议先做最小单测,比如先用硬编码数据验证数据库层,再接入串口数据源。

8. 接口 API 与批量任务扩展

8.1 提供局域网 TCP 数据服务

上位机软件除了操作界面,还经常需要向外部系统提供数据。一个常见做法是内置一个 QTcpServer,监听某个端口,第三方程序可以连接后拉取最新数据。下面的代码展示了最简单的 TCP 服务框架:

#include <QTcpServer>
#include <QTcpSocket>

void startDataServer(quint16 port)
{
    QTcpServer *server = new QTcpServer();
    if (!server->listen(QHostAddress::Any, port)) {
        qWarning() << "监听失败:" << server->errorString();
        return;
    }

    QObject::connect(server, &QTcpServer::newConnection, [=]() {
        QTcpSocket *socket = server->nextPendingConnection();
        QObject::connect(socket, &QTcpSocket::readyRead, [=]() {
            QByteArray request = socket->readAll();
            if (request == "get_latest") {
                // 查询数据库最新一条数据,返回 JSON 或自定义协议
                QByteArray resp = "value=25.6\n";
                socket->write(resp);
            } else {
                socket->write("unknown command\n");
            }
            socket->flush();
        });
        QObject::connect(socket, &QTcpSocket::disconnected, socket, &QTcpSocket::deleteLater);
    });

    qInfo() << "数据服务已启动,端口:" << port;
}

这个服务可以放在后台线程里,用信号槽把外部查询请求转到数据库层执行。实际部署时需要注意端口不能和系统服务冲突,同时要限制访问来源,避免局域网内任意设备都能读取数据。生产环境建议增加简单的 token 或 IP 白名单机制。

8.2 批量导出 CSV

批量导出是上位机很实用的功能。用户选定设备和时间范围,一键导出 CSV 文件。Qt 通过 QFileDialog::getSaveFileName 弹出保存对话框,用 QTextStream 写文件,注意编码选择。

bool exportCsv(const QString &filePath,
               const QString &deviceId,
               const QString &startTime,
               const QString &endTime)
{
    QFile file(filePath);
    if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
        qWarning() << "打开文件失败:" << file.errorString();
        return false;
    }

    QTextStream out(&file);
    out.setEncoding(QStringConverter::Utf8);
    out << "device_id,timestamp,value,status\n";

    auto records = querySamples(deviceId, startTime, endTime);
    for (const auto &rec : records) {
        out << deviceId << ","
            << QDateTime::fromMSecsSinceEpoch(rec.first)
                   .toString("yyyy-MM-dd HH:mm:ss") << ","
            << rec.second << ",0\n";
    }

    file.close();
    return true;
}

8.3 批量任务与失败重试

采集任务可以设计成任务队列:每个任务包含设备 ID、采集时长、采样周期、保存路径。程序按顺序执行任务,每个任务启动时重新打开串口或网络连接,结束后断开连接。失败的任务记录到日志表,下一次启动时可以选择重试。

struct CollectTask {
    QString deviceId;
    int durationSec;
    int intervalMs;
    QString outputDb;
};

void runBatchTasks(const QVector<CollectTask> &tasks)
{
    for (const auto &task : tasks) {
        bool ok = runSingleTask(task);
        if (!ok) {
            qWarning() << "任务失败:" << task.deviceId;
            // 记录到失败队列,等待重试
        }
        QThread::msleep(200); // 任务间隔
    }
}

批量任务的核心是“每个任务独立打开连接、独立写库、独立记录状态”,这样单个任务失败不会影响后续任务。对嵌入式采集工具来说,这种设计比一个长连接跑到底要稳定得多。

9. 资源占用与性能观察方法

9.1 内存占用观察

Qt Widgets 上位机程序在启动后,内存占用通常比纯 C 命令行程序高,这是 Qt 框架本身带来的基础开销。空窗口程序的内存占用取决于 Qt 版本、编译器、操作系统的具体实现,因此不建议拿固定数值去衡量。从实际开发经验看,只要界面控件数量可控、串口缓冲区不无限增长、数据库查询结果不一次性加载上万条到表格,普通办公电脑的内存压力就不会太大。

更值得关注的是内存泄漏问题。串口报文解析时,如果 QByteArray 在循环里一直 append 却不清理,缓冲区可能随着时间无限增长。推荐在接收函数里限制缓冲区最大长度,超出后主动清理最前面的旧数据。

9.2 CPU 占用优化

高频数据采集时,CPU 高通常不是因为 Qt 本身,而是因为界面刷新策略或者数据库 SQL 语句。实时曲线更新时,建议使用 QTimer 定时刷新,而不是每一帧数据到达都立即重绘。比如每 200ms 刷新一次曲线,数据到达后只往缓存里追加,这样 CPU 占用会明显下降。

数据库写入对 CPU 的影响主要集中在 SQL 执行和事务提交。高频采集时,不要逐条提交,而是攒批后统一提交。对 QChart 曲线图,使用 setAnimationOptions(QChart::NoAnimation) 可以避免动画重绘带来的额外 CPU 开销。

9.3 SQLite 数据量增长对性能的影响

SQLite 在数据量达到几百万条后,查询性能会开始下降。更好的做法是定期归档。常见方案有两种:按月分表,将每个月的数据放入独立表;定期把历史数据导出到 CSV 后从主表删除。对那些需要长期保存数据的场景,可以在程序设置里增加“数据保留天数”选项,超过保留天数的数据自动清理,避免数据库文件无限膨胀。

9.4 硬件不是瓶颈,设计才是

嵌入式上位机大多数跑在普通 PC 或工控机上,CPU 和内存本身不是瓶颈。真正影响体验的往往是代码设计:高频刷新、无节制的数据追加、线程卡死、SQL 没走索引。开发者在测试阶段就要关注程序运行 24 小时后的状态,而不是只看刚启动几分钟的表现。

10. 常见问题与排查方法

问题现象 可能原因 排查方式 解决方案
编译报错找不到 QSqlDatabase 未添加 sql 模块 检查 .pro 或 CMakeLists 添加 QT += sql 或链接 Qt6::Sql
运行报 driver not loaded SQLite 驱动插件缺失 查看 plugins/sqldrivers 目录 部署时复制 qsqlite 插件
数据库文件没有生成 路径目录不存在 打印 dbPath 和错误信息 先创建目录再打开数据库
中文乱码 源文件编码或串口编码不一致 检查文件编码和显示字体 统一使用 UTF-8,必要时转换编码
Linux 下启动报 qxcbconnection 错误 缺少 xcb、xrandr 等运行库 检查依赖库 安装对应的 xcb 相关包
串口打开失败 端口被占用或权限不足 查看 QSerialPort errorString 关闭占用程序、检查权限
串口收不到完整数据 半包或粘包 打印收到的 hex 数据 增加缓冲区暂存,按帧头帧尾拆包
曲线卡顿 数据点过多或动画开启 观察 CPU 占用 限制点数、定时刷新、关闭动画
SQLite 写入慢 逐条提交、无索引 查看写入耗时 事务批量写入、建索引
程序退出崩溃 子线程仍在访问 UI 检查跨线程调用 用信号槽代替直接操作控件
Qt 无法加载中文文件路径 编码或路径格式问题 打印 QFileInfo 使用 QDir::toNativeSeparators 规范化

在这些常见问题里,有四个高频细节值得重点展开。

第一个是 SQLite“存在就更新,不存在就新增”的需求。在 Qt 中可以先用 SELECT 查询,再决定是 UPDATE 还是 INSERT,但更推荐直接使用 UPSERT 语法,效率更高,代码也更简洁。

第二个是数据库升级新增表。上位机升级版本后,用户机器上的旧数据库可能缺少新表或新字段。建议在程序启动时执行 PRAGMA user_version 版本检查,按版本号依次执行增量升级 SQL。

第三个是 Qt 模拟鼠标点击事件。做自动化测试时,可以使用 QTest::mouseClick 或直接调用按钮的 click() 方法。不要在主线程里用阻塞式延时等待,否则界面会卡住。

第四个是 QCoreApplication::exec() 之后无法捕获异常的问题。Qt 事件循环内的异常如果不处理,可能导致程序直接退出。更稳妥的做法是避免在槽函数里抛出异常,业务逻辑中主动检查返回值并进行容错处理。

另外,Qt 弹出对话框选择文件也是新手经常找代码的需求。保存文件用 QFileDialog::getSaveFileName ,打开文件用 QFileDialog::getOpenFileName ,选择目录用 QFileDialog::getExistingDirectory 。这些静态方法会阻塞当前线程并进入模态循环,不要在非主线程中调用。

11. 最佳实践与工程化建议

11.1 目录与数据分离

把程序、数据库文件、导出文件放在不同目录。程序目录只存放可执行文件和依赖库,数据目录保存 records.db 和配置文件,导出目录保存用户生成的 CSV。这样升级程序时不会覆盖用户数据,也不会把数据库文件带到安装包里。首次启动时,如果数据目录不存在,程序自动创建。

11.2 日志系统要尽早接入

上位机部署在用户现场后,最常见的排查手段就是看日志。在 Qt 中可以通过 qInstallMessageHandler 把 qDebug、qInfo、qWarning 输出到日志文件。日志要带时间戳,按天滚动。串口接收异常、数据库错误、任务失败都要记录,不能只打印到控制台,因为交付后的程序通常没有控制台窗口。

11.3 配置管理规范化

串口号、波特率、数据库路径、自动保存间隔等参数,建议统一放在 INI 或 JSON 配置文件中。每次启动时读取配置,用户修改后写回配置文件。不要把这些参数硬编码在 MainWindow 中,否则换一台电脑部署又要改代码重新编译。

11.4 发布部署时检查依赖插件

Qt 程序在开发机上运行正常,拷到其他机器可能提示缺少 Qt 插件。发布时需要在可执行文件目录下带上 platforms、sqldrivers、imageformats 等目录,并使用 windeployqt 或 linuxdeployqt 自动收集依赖。sqlite 驱动插件尤其容易遗漏,因为它是动态加载的,编译期不报错,运行期才报 driver not loaded。

11.5 数据备份与合规提示

数据库文件建议在程序退出或空闲时自动备份一份到 backup 目录,防止数据库文件损坏导致历史数据丢失。涉及设备数据、人员信息的上位机项目,要明确数据使用边界,不要采集未授权数据,不要在日志中记录敏感明文信息。如果需要远程传输数据,必须确认接口协议和数据访问权限。

12. 总结与下一步

这套“C++ + Qt + SQLite 嵌入式上位机”技术组合,最值得尝试的点在于它几乎覆盖了上位机开发的完整链路:界面、通信、存储、展示、导出。如果是从零开始做一个数据采集工具,最先应该验证的是数据库初始化和串口收发这两条主线,因为它们决定了程序能不能把数据稳定写入本地文件。

最容易踩的坑有三个:SQLite 驱动插件在部署时丢失,串口高频数据导致界面卡顿,数据库连接在多线程中被错误共享。第一个坑需要提前检查部署目录,第二个坑要靠事件驱动和批量刷新解决,第三个坑要严格遵守“每个线程独立建连接”的规则。

后续可以继续扩展的方向还有不少:Modbus RTU/TCP 协议接入、WebSocket 远程查看曲线、SQLite 数据加密、自动生成 PDF 报表、设备 OTA 升级管理。如果做毕业设计或实际项目,可以先从本文的代码框架起步,先跑通基础链路,再逐项叠加业务功能,整个开发过程会顺畅很多。

更多推荐