ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

嵌入式上位机开发:C++ Qt 与 SQLite 实现数据采集与本地存储

2026/9/2 22:28:49 拓冰建站 浏览量
嵌入式上位机开发:C++ Qt 与 SQLite 实现数据采集与本地存储 嵌入式上位机用 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_modeWAL; PRAGMA busy_timeout3000;开启 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 一次完整的验证流程刚接触这套组合时我建议按下面顺序验证创建一个带主窗口的 Qt 项目确保界面能启动。添加 SQLite 支持创建数据库文件和表。用一个 QTimer 模拟数据产生每 200 毫秒写入一条数据。用 DB Browser for SQLite 打开数据库文件确认数据真的落盘。再加串口或 TCP 真实数据重复步骤 3 和 4。最后加入批量写入、事务和界面刷新完成完整数据链路。这个流程的好处是每一步都有明确验证结果。如果哪一步失败问题范围很小排查起来不慌乱。很多人一上来就直接接真实设备结果串口数据不对、数据库路径不对、表结构不对叠在一起很难定位。7.2 需要长期维护时的额外配置发布上位机程序时Windows 下可以用windeployqt自动拷贝 Qt 运行库。如果用的是 MSVC 编译部署目标机器还要安装 Visual C Redistributable否则程序可能启动报错。数据库文件路径建议使用QStandardPaths::AppDataLocation里的目录不要硬编码到安装路径下。客户把程序装到C:\Program Files\后普通用户往往没有写入权限数据库打开会失败。这是很常见的部署坑。长时间运行的上位机还要考虑数据库文件膨胀问题。可以按天或按月分表定期清理过期数据。清理时用事务删除删除后执行VACUUM收缩文件但VACUUM会锁库尽量在设备停机或数据采集暂停时做。7.3 我怎么看待这个技术路线Qt SQLite 不是万能方案但很适合做嵌入式上位机。它把界面层和数据层拆开让软件更容易维护也让设备数据从“看一眼就没了”变成“随时能查”。真正落地时最该盯住的不是功能列表而是通信帧解析、数据库事务、线程同步和日志记录。踩过几次之后我发现很多问题不是 Qt 和 SQLite 能力不够而是环境、路径、事务和线程同步没有处理干净。先把单任务跑稳再谈批量生产是这套技术路线最省事的走法。