☰
嵌入式上位机开发:C++ Qt 与 SQLite 实现数据采集与本地存储
2026/9/30 6:41:58 网站建设 项目流程

嵌入式上位机用 C++ Qt 开发,是很多设备端项目里比较常用的路线。Qt 负责界面和交互,SQLite 负责本地数据存储,组合起来能解决一个很实际的问题:设备数据需要被采集、展示和回查,但你又不想维护一个重型数据库服务器。适合刚接触上位机的 C++ 开发者,也适合准备把手头工具型项目工程化的嵌入式工程师。下面按实际开发顺序拆一遍,重点讲清楚 SQLite 在 Qt 上位机里怎么用,以及哪些环节容易被忽略。

1. 嵌入式上位机选择 Qt + SQLite,先想清楚这几个问题

1.1 上位机到底解决什么问题

上位机通常运行在 PC、工控机或者性能稍强的嵌入式主板上,和下位机通过串口、网口或者 USB 通信。下位机可能是单片机、PLC、嵌入式 Linux 板,也可能是一个传感器网关。上位机的职责看起来很集中:接收数据、解析数据、展示数据、存储数据,必要的时候还要下发控制指令。

很多人一开始只做了界面,把下位机发过来的数据实时显示出来,就觉得项目完成了一半。真正跑起来才发现,历史数据没法处理。设备运行了一个晚上,第二天想查某一台设备在某个时段的数据,只能翻日志文件,或者干脆丢失。这个时候引入 SQLite 是最直接的办法。它不是用来替代界面,而是让数据有了去处,也让后续查询、统计、报表变得简单。

这也是为什么 Qt 和 SQLite 经常被放在一起讨论。Qt 本身跨平台,界面控件丰富,适合做工业现场桌面软件;SQLite 又是嵌入式数据库,单文件、零配置、事务能力强。两者配合,项目的体积和复杂度都能控制住。

1.2 为什么不用写文件,而用 SQLite

早期很多上位机项目喜欢把数据写到 CSV 或者 TXT 文件里。数据量小的时候问题不大,数据量一上来,问题就明显了:多线程写入同一个文件会冲突;想按时间范围查数据只能挨行遍历;文件编码不统一,用表格软件打开经常是乱码;程序中途崩溃,文件可能只写了一半,整份数据都不完整。

SQLite 解决的是这四类问题。它有事务机制,写入一批数据不会因为中途异常就产生半行记录;有 SQL 查询,按设备、按时间、按条件过滤非常直接;有统一的数据库文件格式,跨平台拷贝到 Windows、Linux 上都能读;还有不错的并发读取能力,程序写入的同时,可以让调试工具打开同一个数据库文件查看数据。

更重要的是 SQLite 不需要安装服务、不需要配置账号、不需要单独维护数据库进程。对上位机这种要部署到客户机器上、希望开箱即用的软件来说,这是很关键的优势。

1.3 哪些项目适合用这个组合

用 Qt 写上位机、用 SQLite 存数据,适合的场景是:设备数量几十台以内、每秒采集数据量在几百条以内、历史数据需要按设备或时间查找、软件需要离线运行。比如环境监控、设备测试台、小型产线数据管理、嵌入式 Linux 开发板上的数据看板,这些场景用这套组合非常顺手。

如果数据量到了每秒几万条,或者需要多台电脑同时写同一个数据库,SQLite 就不是最佳选择了。那种情况下要考虑 MySQL、PostgreSQL 或者时序数据库。我这里说的是常规上位机场景,没必要为了性能提前上重型数据库。

2. 环境准备:Qt 版本、编译器和 SQLite 接入方式

2.1 开发环境怎么搭

Qt 的安装路径很多,最稳妥的还是去官网下载开源版或者用在线安装器。Windows 下编译套件一般选 MinGW 或 MSVC。如果以后想和 Visual Studio 插件一起用,选 MSVC;如果不想装 Visual Studio,就选 MinGW。Linux 下可以通过 apt 或源码安装,注意 cmake 版本和 Qt 的二进制兼容。

我自己的常用环境是 Windows + Qt 6.x + MinGW,因为编译方便、发布包相对轻量。如果你的项目是老工程,继续用 Qt 5.12 也没有问题,QSqlDatabase 和 QSerialPort 的接口在 Qt 5 和 Qt 6 之间基本一致。只是落地之前先确认一次依赖版本,不要凭记忆直接迁移。

安装时记得勾选 Qt SQL 模块。如果你还需要串口通信,就勾选 SerialPort;需要网络通信,勾选 Network。这里的模块选择会影响最终的运行库大小,也影响后面代码能不能直接使用对应类。

2.2 SQLite 的三种接入方式选哪种

Qt 环境下接入 SQLite 有三种常见方式。

第一种是使用 Qt 自带的 QSqlDatabase 驱动,数据库类型填QSQLITE。这个方式最简单,不需要额外引入第三方 sqlite3 源码,跨平台也能编译。大部分上位机项目用这一种就足够了。

第二种是直接在 C++ 代码里引入 sqlite3 原生库,自己管理数据库连接和 SQL 执行。这种方式更底层,控制力更强,但代码量和错误处理成本都更高。除非你有特殊需求,比如要同时管理多个数据库文件、要做非常精细的数据库加密,否则不建议在入门阶段这么干。

第三种是使用第三方 ORM 封装库,比如 sqlite_orm。这类库能把 C++ 结构体直接映射成表,减少手写 SQL 的重复代码。但 ORM 也有缺点:复杂查询不直观,调试问题时要多一层抽象。我建议先把 QSqlDatabase 用熟,遇到真的需要大量建表、大量模型映射时再考虑 ORM。

2.3 最小项目骨架怎么组织

不要把所有代码都塞到 MainWindow 里。上位机里面的职责其实很清楚:

  • main.cpp:负责启动程序
  • MainWindow:负责界面布局、控件、信号槽连接
  • DeviceClient:负责串口或网络通信,解析协议
  • DatabaseManager:负责数据库连接、建表、插入、查询
  • Widgets:负责曲线、表格、仪表盘等自定义控件

给一个非常简单的 CMake 示例:

find_package(Qt6 COMPONENTS Core Gui Widgets Sql SerialPort REQUIRED) add_executable(UpperComputer main.cpp mainwindow.cpp mainwindow.h database_manager.cpp database_manager.h device_client.cpp device_client.h ) target_link_libraries(UpperComputer PRIVATE Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Sql Qt6::SerialPort )

数据库连接代码可以先放到 DatabaseManager 里:

#include <QSqlDatabase> #include <QSqlQuery> #include <QSqlError> #include <QDebug> #include <QDir> bool DatabaseManager::init(const QString &dbPath) { QDir().mkpath(QFileInfo(dbPath).absolutePath()); db_ = QSqlDatabase::addDatabase("QSQLITE", "upper_connection"); db_.setDatabaseName(dbPath); if (!db_.open()) { qDebug() << "open database failed:" << db_.lastError().text(); return false; } return createTables(); }

这里要给连接起一个名字,比如"upper_connection"。如果项目里只使用一个数据库,可以不加第二个参数;但一旦代码里同时出现多个 QSqlDatabase,不加连接名很容易互相干扰。

3. 上位机的核心数据链路:采集、显示、入库

3.1 串口和网络通信模块怎么设计

下位机数据要进入上位机,第一步是通信。常用两类:串口通信用 QSerialPort,网络通信用 QTcpSocket 或 QUdpSocket。

串口需要配置串口号、波特率、数据位、停止位、校验位。不同下位机协议不一样,有些用 9600,有些用 115200。上位机界面里最好把串口号、波特率做成可配置项,方便现场调试。

网络通信要注意字节序和分包。很多下位机是定长帧发送,但 TCP 是流式协议,一次 read 可能读到半帧,也可能读到多帧。不能用readAll()之后直接当作一帧处理。我一般会在接收缓冲区里累积数据,然后按帧头、帧尾或长度字段切包。

void DeviceClient::onReadyRead() { QByteArray data = socket_->readAll(); buffer_.append(data); QByteArray frame; while (extractFrame(buffer_, frame)) { emit frameReceived(frame); } }

判断通信模块做得是否稳,要看三点:连续运行不丢帧;断线重连后能继续接收;异常数据不会导致程序卡死。这三条都做到了,再进下一步。

3.2 实时曲线和仪表盘也离不开数据缓冲

数据解析之后,最常见的需求是实时曲线显示、仪表盘数值刷新、表格列表更新。如果每收到一条数据就去更新一次控件,数据量大的时候界面会频繁重绘,反而卡顿。

更稳妥的方式是维护一个数据缓冲。缓冲可以用 QQueue、QVector 或者自定义的环形缓冲。数据到达时先写入缓冲,界面用一个 QTimer 定时刷新,比如每 500 毫秒刷新一次曲线,既保证实时性,又不让界面陷入高频重绘。

这也解释了为什么 Qt 上位机不能只写“接收数据后直接 setText”这种代码。数据链路是连续的,显示要控制节奏,界面才不卡。

3.3 从采集线程到数据库写入,为什么必须加队列

采集、显示、入库这三件事不应该在同一个地方直接顺序执行。串口数据到达时,如果直接把这些数据写入数据库,数据库写入耗时会导致串口缓冲区堆积,下一帧数据就可能丢失。

正确做法是把数据放进队列,由一个专门的数据库线程或定时任务批量写入。这里有两层意思:第一,不要在串口回调里直接操作 UI;第二,不要在采集线程里直接写数据库。

Qt 里可以用信号槽完成跨线程投递。采集线程 emit 一个frameReceived信号,槽函数运行在数据库线程或主线程,再执行数据库写入。Qt 的队列连接会把信号当作事件投递到目标线程,相当于天然提供了一条消息队列,比我手写一堆锁要简单。

如果不想引入额外线程,也可以在主线程里用 QTimer 定时把缓冲区的数据批量写入数据库。这种方式适合数据量不大的场景,简单可靠,但要注意写入期间界面不能卡顿。

4. SQLite 在 Qt 项目里的基本操作

4.1 建库建表:字段类型和主键设计

数据库文件创建好之后,第一件事是建表。以传感器数据为例:

CREATE TABLE IF NOT EXISTS sensor_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, device_id INTEGER NOT NULL, timestamp TEXT NOT NULL, temperature REAL, humidity REAL, status INTEGER ); CREATE INDEX IF NOT EXISTS idx_device_time ON sensor_record(device_id, timestamp);

id是自增主键,适合普通流水记录。timestamp我用 ISO8601 的 TEXT 存储,因为这种格式在 Qt 里可以直接和 QDateTime 互转,排序也符合字典序。如果按设备和时间范围查询很频繁,就加一个组合索引。

不要一上来就把所有可能的字段都塞进一张表。上位机的数据表通常按任务拆开:设备配置表、原始采样表、报警记录表、操作日志表。分表之后,查询和维护都清晰很多。

4.2 插入和查询:参数绑定比拼字符串可靠

写入数据时,最不推荐的做法是把值直接拼到 SQL 字符串里。虽然看起来直观,但会遇到三个问题:一是字符串中出现单引号时容易拼错;二是输入内容不可控,存在注入风险;三是频繁拼字符串会影响可读性。

正确做法是参数绑定:

QSqlQuery query(db_); query.prepare("INSERT INTO sensor_record" "(device_id, timestamp, temperature, humidity, status) " "VALUES (?, ?, ?, ?, ?)"); query.addBindValue(deviceId); query.addBindValue(QDateTime::currentDateTime().toString(Qt::ISODate)); query.addBindValue(temperature); query.addBindValue(humidity); query.addBindValue(status); if (!query.exec()) { qDebug() << "insert failed:" << query.lastError().text(); }

参数绑定让驱动去处理类型和转义,既安全又省心。后面如果要扩展字段,改动也集中在 SQL 语句和绑定参数上。

4.3 批量写入:事务如何提高落盘速度

SQLite 默认每次exec都会自动提交事务,写入一条数据就要刷一次磁盘,速度会慢很多。解决办法是手动开启事务,多条数据共用一个事务。

db_.transaction(); QSqlQuery query(db_); query.prepare("INSERT INTO sensor_record(...) VALUES (...);"); for (const auto &item : records) { query.addBindValue(item.deviceId); query.addBindValue(item.timestamp); query.addBindValue(item.temperature); query.addBindValue(item.humidity); query.addBindValue(item.status); query.exec(); } db_.commit();

批量条数不是越多越好。我一般会先测试 100 条、500 条、1000 条三个档位。事务太大会占用内存,中途失败回滚代价也大。采集任务里如果每秒产生 100 条数据,可以 1 秒开一次事务写入,这样磁盘压力稳定,数据也不容易丢。

这里要记住:事务提交之后才能保证数据落盘。如果程序在事务未提交时崩溃,这部分数据可能不会写入数据库,这是 SQLite 的正常行为。

4.4 存在就更新,不存在就新增:一条语句解决

很多上位机不止存传感器流水,还要存设备配置、仪表快照、最新状态。这类数据往往需要“存在就更新,不存在就新增”。SQLite 从 3.24.0 版本开始支持ON CONFLICT DO UPDATE,也就是常说的 UPSERT。

先建表时给唯一键加约束:

CREATE TABLE IF NOT EXISTS device_status ( device_id INTEGER PRIMARY KEY, voltage REAL, temperature REAL, updated_time TEXT );

然后写入:

INSERT INTO device_status (device_id, voltage, temperature, updated_time) VALUES (?, ?, ?, ?) ON CONFLICT(device_id) DO UPDATE SET voltage = excluded.voltage, temperature = excluded.temperature, updated_time = excluded.updated_time;

这里的关键是表上必须有唯一约束或主键,否则ON CONFLICT没有目标可依。这个功能非常实用,解决了一个很高频的需求:配置表、状态表、快照表都不需要先查后写,一条 SQL 就能处理干净。

5. 把上位机项目做稳:架构、日志、备份和排查

5.1 常见坑:driver not loaded、database locked、中文路径

实际开发中,最常遇到的问题和排查顺序,可以用一张表说清楚:

现象常见原因排查顺序
QSQLITE driver not loadedQt 缺少 SQLite 驱动插件,或发布时没有打包插件检查程序目录里的 sqldrivers 文件夹,确认qsqlite.dll或libqsqlite.so存在
database locked多个连接同时写,或事务没有及时提交看代码里有没有未 commit 的事务,给数据库设置 busy_timeout,开启 WAL 模式
中文路径打不开数据库路径编码、权限、程序没有创建目录的权限先用英文绝对路径测试,再用 QDir 创建目录,避免程序安装目录不可写
数据写入成功但查询不到查询时没有提交事务,或条件写错用 DB Browser for SQLite 打开数据库文件,先确认表里有没有数据

DB Browser for SQLite 是一个免费的可视化工具,调试数据库时非常有用。运行程序后,如果怀疑数据没有写入,可以先关闭程序,用这个工具打开 db 文件看看表格内容。这能快速区分是写入问题还是查询问题。

5.2 数据库文件损坏和并发访问

SQLite 对单个文件的管理比较可靠,但并发写需要注意。多个线程同时写同一个数据库连接会有锁风险;多个进程同时写同一个数据库文件风险更大。

基本建议是:上位机里只保留一个数据库连接写数据,其他模块需要读数据时再打开短连接;开启 WAL 模式可以让读和写并发执行,减少读阻塞。

PRAGMA journal_mode=WAL; PRAGMA busy_timeout=3000;

开启 WAL 后,目录里会出现-wal和-shm文件,程序运行期间不要手工删掉它们。部署备份时,最好在程序停止后复制,或者使用 SQLite 的备份 API,避免直接拷贝正在写入的数据库文件。

5.3 日志与调试信息的记录方式

日志是排查问题的第一步。qDebug()在开发时很直观,但发布版程序不会一直带控制台。我建议用 QLoggingCategory 给每个模块分一个日志分类,再把日志写到一个独立的文本文件里。

日志文件不要写在 SQLite 数据库里。数据库日志和业务数据写同一个文件,锁冲突会放大,排查也麻烦。日志文件按日期滚动,保留最近一周或一个月的记录即可,避免磁盘被占满。

记录日志的内容要具体,至少包含时间、模块、级别、消息。比如“数据库打开失败”要带上路径和错误码,“写入失败”要带上设备 ID 和表名。日志越具体,问题定位越快。

6. 进阶方向:消息队列、事件驱动和组态化界面

6.1 从超级大循环到事件驱动

很多嵌入式工程师的常见写法是“超级大循环”:一个 while(1) 里不停地读串口、处理数据、刷新界面、写数据库。这种写法在单片机裸机上可能还行,放到上位机里就会暴露出问题:界面卡死、串口读取阻塞、数据库写入耗时导致整个循环变慢。

Qt 本身是事件驱动框架。应用启动后进入事件循环,串口数据、网络数据、定时器、鼠标操作都以事件形式进入消息队列,再由槽函数处理。这个机制比超级大循环更合理,也让代码更容易拆分。

如果你是从嵌入式单片机切到 Qt 上位机,最需要改掉的习惯就是“用循环驱动所有任务”。换成定时器、信号槽和事件循环后,你会发现代码结构清爽很多。

6.2 Qt 消息队列和信号槽怎么配合

Qt 的信号槽在跨线程时会以队列方式投递,相当于系统自带消息队列。这在上位机里非常重要。

比如采集线程收到数据后:

emit frameReceived(frame);

主窗口连接了这个信号,更新曲线或表格。如果连接方式设置为Qt::QueuedConnection,槽函数会回到目标线程的事件循环里执行,不会阻塞采集线程。这比自己创建 QQueue、加互斥锁要省事得多。

需要注意的是,信号槽跨线程投递时,发送方和接收方生命周期都要确认好。窗口关闭后,信号槽连接如果没断开,可能会导致回调到已销毁对象。Qt 5 之后的QObject::connect支持上下文对象参数,可以自动断开连接,推荐多用这种写法。

6.3 组态与可视化扩展

工业上位机里经常听到“组态”这个词,意思是用户在界面上拖拽设备、绑定数据点、配置报警规则。Qt 做组态可以采用 Graphics View 框架,自定义图元来表示电机、阀门、传感器,再把图元和数据库字段绑定起来。

但如果项目还处于起步阶段,不建议一上来就做组态。用 QChart 做实时曲线,用 QTableView 做历史数据表格,用几个 LCD 控件做数值显示,已经能覆盖大部分场景。

对比 C# 上位机和 MFC 上位机,Qt 的优势是跨平台 UI 统一、控件稳定、社区资源多,缺点是需要熟悉 C++ 的 RAII 和 Qt 的对象模型。MFC 老项目还很多,维护成本高;C# 开发效率高,却比较依赖 Windows 生态。你可以根据目标平台选择,不用被某一个框架绑死。

7. 最后:先跑通单任务,再考虑批量生产

7.1 一次完整的验证流程

刚接触这套组合时,我建议按下面顺序验证:

  1. 创建一个带主窗口的 Qt 项目,确保界面能启动。
  2. 添加 SQLite 支持,创建数据库文件和表。
  3. 用一个 QTimer 模拟数据产生,每 200 毫秒写入一条数据。
  4. 用 DB Browser for SQLite 打开数据库文件,确认数据真的落盘。
  5. 再加串口或 TCP 真实数据,重复步骤 3 和 4。
  6. 最后加入批量写入、事务和界面刷新,完成完整数据链路。

这个流程的好处是,每一步都有明确验证结果。如果哪一步失败,问题范围很小,排查起来不慌乱。很多人一上来就直接接真实设备,结果串口数据不对、数据库路径不对、表结构不对叠在一起,很难定位。

7.2 需要长期维护时的额外配置

发布上位机程序时,Windows 下可以用windeployqt自动拷贝 Qt 运行库。如果用的是 MSVC 编译,部署目标机器还要安装 Visual C++ Redistributable,否则程序可能启动报错。

数据库文件路径建议使用QStandardPaths::AppDataLocation里的目录,不要硬编码到安装路径下。客户把程序装到C:\Program Files\后,普通用户往往没有写入权限,数据库打开会失败。这是很常见的部署坑。

长时间运行的上位机还要考虑数据库文件膨胀问题。可以按天或按月分表,定期清理过期数据。清理时用事务删除,删除后执行VACUUM收缩文件,但VACUUM会锁库,尽量在设备停机或数据采集暂停时做。

7.3 我怎么看待这个技术路线

Qt + SQLite 不是万能方案,但很适合做嵌入式上位机。它把界面层和数据层拆开,让软件更容易维护,也让设备数据从“看一眼就没了”变成“随时能查”。真正落地时,最该盯住的不是功能列表,而是通信帧解析、数据库事务、线程同步和日志记录。

踩过几次之后我发现,很多问题不是 Qt 和 SQLite 能力不够,而是环境、路径、事务和线程同步没有处理干净。先把单任务跑稳,再谈批量生产,是这套技术路线最省事的走法。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询