简介:一套面向物联网与嵌入式方向学习者的室内环境检测系统上位机代码,聚焦 Qt 人机交互部分的实现,可作为课程设计、毕业设计或 Zigbee 项目联调的参考工程。工程围绕串口通信展开,完成上位机与下位机的数据交互,实时接收并显示温度、湿度与甲烷浓度,配合数据库存储与查询历史数据,并具备越限报警与用户注册登录机制,界面由 Qt Designer 搭建、QSS 美化。压缩包共 92 个文件、约 49.14MB,13 个 cpp 源文件与 11 个头文件承载业务逻辑,6 个 ui 界面文件与样式表构成交互层,另有 qrc 资源、mp3 提示音、图标素材、数据库文件与 pro 工程配置,附带已编译的 exe 可直接运行。目前已有 2803 人学习下载。读者可获得一套结构完整、可直接编译运行的 Qt 串口通信与数据库联动范例,理解多界面切换、报警处理与资源打包方式,并借助现成工程排查自己项目中的通信与界面问题。
1. 四路节点、一条曲线:这套系统到底解决什么问题
去年帮朋友改造一间六十平米的工作室,四个角落各放一个巴掌大的采集节点,结果空调出风口那个位置的温度,常年比对角高两度多。这事拿一支温度计根本看不出来,得同时收四路数据、还要能翻历史曲线才行。这套室内环境检测系统做的就是这件事:底层用 Zigbee 把分散的传感节点自组网连起来,协调器通过串口把数据送进电脑,上层是一个 Qt 写的桌面端,负责收包解析、实时画曲线、落库存档、越线报警。它没有云平台那套大而全的架子,链路短、能离线跑、每一层的代码都摊开摆着。适合正在做课程设计或毕设的人,也适合那些模块已经买回来了、却卡在无线数据和上位机对接这一步的开发者。
2. 架构与工程结构:Zigbee 传感网加 Qt 上位机怎么分工
这个项目最容易劝退人的地方,不是某一行代码难写,而是它横跨了两个技术栈:一半在单片机里跑 C,一半在电脑上跑 C++。很多人拿到源码包第一反应是“先打开主界面看看”,结果改了界面发现数据不动,因为数据根本没进到界面这一层。所以先把分层和目录讲清楚,后面的代码才有地方放。
2.1 三层结构:感知层、网络层、应用层
整个系统按职责切成三段,每段只关心自己那点事,接口就一条自定义的串口帧协议。
| 分层 | 承担硬件 | 职责 | 代码位置 |
|---|---|---|---|
| 感知层 | 若干 CC2530 终端节点 | 采集温度、湿度、光照、PM2.5,按周期上报 | firmware/sensor_node/ |
| 网络层 | CC2530 协调器 | 组建 Zigbee 网络、允许节点入网、把收到的数据从串口吐出去 | firmware/coordinator/ |
| 应用层 | 一台电脑 | 收串口、解析帧、画实时曲线、存 SQLite、阈值报警 | host/src/ |
这种切法的好处是,调传感器的时候不用管 Qt,改界面的时候也不用碰单片机。协调器在这里的角色很纯粹,就是一个“协议转换器”:Zigbee 空中包进来,串口字节流出去,它不做业务判断,也不存储数据。很多同学喜欢把阈值判断塞进协调器里,觉得报警快一点,实际上后面改个阈值就得重新烧固件,调试成本高得离谱。常见做法是让协调器只做透传,所有业务规则放在 Qt 端,改一行配置就能生效。
终端节点这边,一个节点配一路 DHT11 测温湿度、一个 BH1750 测光照、一个光电式灰尘传感器测 PM2.5,加起来四路数据、十五个字节的帧。节点数量按房间面积给,六十平米四个点足够,多房间就多加节点,Zigbee 网络里挂二三十个终端节点在这个数据量下毫无压力。
2.2 工程目录与文件职责
源码包解压之后大致是这么个结构,两边分开看:
indoor-env-monitor/ ├── firmware/ # 单片机侧,8051 工具链工程 │ ├── sensor_node/ # 终端节点:采集 + 组帧 + 周期上报 │ │ ├── sensor.c # DHT11 / BH1750 / PM2.5 驱动 │ │ ├── report.c # 组帧与 AF_DataRequest │ │ └── net.c # 入网、心跳、掉线重连 │ ├── coordinator/ # 协调器:建网 + 串口转发 │ │ ├── pan.c # 网络参数、允许入网 │ │ └── uart_forward.c # 收到空中包后写串口 │ └── common/ # 帧结构定义、公共宏 ├── host/ # Qt 上位机,qmake / CMake 均可 │ ├── src/ │ │ ├── main.cpp │ │ ├── mainwindow.cpp # 界面组装、定时刷新 │ │ ├── serialworker.cpp # 串口打开、readyRead │ │ ├── frameparser.cpp # 滑窗解析、异或校验 │ │ ├── datastore.cpp # SQLite 建表、批量入库 │ │ └── alarmrule.cpp # 阈值配置与报警 │ ├── ui/ # .ui 界面文件 │ └── resource/ # 图标、默认阈值配置 ├── tools/ # 无硬件时用的模拟发包脚本 └── docs/ # 帧协议说明、接线表frameparser.cpp单独抽出来是有意为之的。它不依赖任何界面类,输入是QByteArray,输出是一个结构体,这样写单元测试或者拿模拟脚本灌数据的时候,可以完全脱离 GUI 跑。datastore.cpp同理。剩下mainwindow.cpp只干三件事:拿数据、刷控件、响应用户操作。
2.3 一次数据上报的完整时序
把这条链路走一遍,后面看代码就不会迷失位置。节点上电之后先扫描信道、找协调器、发入网请求,拿到一个 16 位短地址(协调器自己是固定的 0x0000)。入网成功后启动一个 2 秒的软件定时器,每 2 秒读一次传感器、组一帧、调一次AF_DataRequest,把帧丢给协调器。
协调器收到空中包,在 AF 回调里把负载原样写进串口,波特率 115200、8 位数据位、无校验、1 位停止位。电脑侧 Qt 的QSerialPort触发readyRead,把字节追加进缓冲区,交给解析器滑窗找帧头、验长度、验校验,通过后拆出四个物理量,一路送去刷新曲线,一路写进数据库。
带宽算一下就安心了:一帧 15 字节,四个节点每 2 秒各一帧,也就 30 字节每秒,115200 的串口连百分之一的占用都不到。真正容易出问题的从来不是带宽,而是帧边界——串口没有“包”的概念,你收到的可能是一帧半、也可能是两帧粘一起,解析器必须自己扛住这件事。
2.4 选型对比:为什么是 Qt 加 Zigbee,而不是 WiFi 加 MQTT
| 维度 | Zigbee + 串口 + Qt | WiFi + MQTT + Web |
|---|---|---|
| 功耗 | 终端节点电池能撑数月 | 常供电,电池扛不住 |
| 断网可用 | 完全离线,本地组网 | 依赖路由器和 broker |
| 部署成本 | 一个协调器 + 若干节点 | 每个节点都要配网 |
| 带宽 | 低,适合小包周期上报 | 高,能传图片视频 |
| 调试难度 | 需要抓包工具看空中信令 | 浏览器即可 |
室内环境这类场景,数据量小、变化慢、要求长期在线,功耗和离线可用性比带宽重要得多,Zigbee 的匹配度很高。代价是调试链路长:数据没上来,你可能要依次怀疑传感器、节点固件、空中链路、协调器串口、上位机解析五层。所以这个项目里我特意留了tools/下的模拟脚本,它就是这条长链路上的“后悔药”——先把上位机这一端验干净,再回头查硬件。
3. 节点固件拆解:采集、组帧与入网重连怎么落地
固件这部分的坑主要集中在两个地方:传感器读数不稳,以及帧格式在两端对不上。前者靠滤波和失败保留上次值解决,后者靠“全部按字节偏移写”这条纪律解决。下面按采集、组帧、入网三个动作拆。
3.1 协调器与终端节点的角色划分
协调器固件里其实没几行业务代码,核心是三件事:设定 PAN ID 和信道、周期性调用NLME_PermitJoiningRequest允许新节点入网、注册一个 AF 回调把收到的数据写串口。信道选择上有个血泪经验,2.4G 频段里 WiFi 的路由器基本占着 1、6、11 信道,Zigbee 的信道 11 到 26 跟它们有重叠,实测把协调器固定在 15、20 或 25 这几个信道上,丢包率会明显下降。这个参数建网之后就固定下来,终端节点入网时会自动跟随,不需要单独配。
终端节点则是“干活的那个”,它要维护三样状态:自己的短地址、协调器的地址结构体、上一次采到的有效数据。第三项经常被忽略,但很关键——DHT11 这类单总线器件偶尔会读失败,如果读失败就上报 0,曲线会出现一根根垂直的毛刺,看起来像系统在抽风。正确处理是沿用上一次的有效值,同时在上位机侧记录一个“本次为陈旧值”的标记。
3.2 四路传感器的采集实现
采集代码单独放sensor.c,对外只暴露三个返回整型工程量的函数。全部转成整数是刻意的:8051 上跑浮点又慢又占空间,精度到 0.01 已经完全够用。
/* sensor.c —— 三路传感器读取,输出整型工程量,避免浮点运算 */ #include "sensor.h" #include "hal_i2c.h" #include "hal_adc.h" /* DHT11:单总线,主机拉低 18ms 起始,随后读 40bit(湿度整数/小数、温度整数/小数、校验) */ uint8 DHT11_Read(uint16 *temp100, uint16 *humi100) { uint8 buf[5] = {0}; if (!DHT11_Start()) return 0; /* 器件无响应,交给上层沿用旧值 */ for (uint8 i = 0; i < 5; i++) buf[i] = DHT11_ReadByte(); /* 校验和:前四个字节相加取低八位 */ if ((uint8)(buf[0] + buf[1] + buf[2] + buf[3]) != buf[4]) return 0; *humi100 = buf[0] * 100 + buf[1]; /* 整数部分 *100 + 小数部分 */ *temp100 = buf[2] * 100 + buf[3]; return 1; } /* BH1750:I2C 连续高分辨率模式,原始值除以 1.2 即勒克斯 */ uint16 BH1750_ReadLux(void) { uint8 cmd = 0x10, buf[2]; I2C_Write(BH1750_ADDR, &cmd, 1); Delay_ms(120); /* 高分辨率模式转换时间约 120ms */ I2C_Read(BH1750_ADDR, buf, 2); return (uint16)((((uint16)buf[0] << 8) | buf[1]) / 1.2); } /* PM2.5:光电式灰尘传感器,读模拟量后按手册曲线做分段线性拟合 */ uint16 PM25_Read(void) { uint16 adc = HAL_ADC_Read(ADC_CH_PM25); /* 12 位,0~4095 */ uint32 mv = (uint32)adc * 3300 / 4095; /* 换算成毫伏 */ if (mv < 600) return 0; /* 低于阈值视为洁净空气 */ return (uint16)((mv - 600) * 17 / 100); /* 系数按手册曲线取,现场需标定 */ }三个函数都做了同一件事:把物理量归一成整型。湿度*100之后,45.5%RH 变成 4550,两个字节放得下;光照直接是勒克斯;PM2.5 单位微克每立方米。这里要注意DHT11_Read返回 0 时的处理方式是“不更新”,而不是“写 0”,调用方必须配合。
PM2.5 那个系数是整个项目里最玄学的一段。手册给的拟合曲线是实验室条件下的,实际装到盒子里,风扇转速、进气口朝向、灰尘沉积都会让读数偏移。我的做法是拿一台靠谱的商用检测仪摆在旁边,同时跑两小时,记下几组对应点,把系数手动校一遍。这件事没有捷径,源码包里给的17/100只是一个初值。
3.3 上报帧结构与组帧代码
帧格式是两端唯一的契约,先把它钉死。
| 偏移 | 字段 | 长度 | 示例 | 说明 |
|---|---|---|---|---|
| 0 | HEAD | 2 | AA 55 | 固定帧头 |
| 2 | LEN | 1 | 0C | 从本字节到数据末尾共 12 字节 |
| 3 | ADDR | 2 | 34 12 | 节点短地址,小端,示例为 0x1234 |
| 5 | TYPE | 1 | 01 | 01 环境数据、02 心跳、03 报警 |
| 6 | TEMP | 2 | 9A 08 | int16,单位 0.01℃,示例 22.02℃ |
| 8 | HUMI | 2 | 2C 1A | uint16,单位 0.01%RH,示例 67.00% |
| 10 | LUX | 2 | F4 01 | uint16,单位 lx,示例 500 |
| 12 | PM25 | 2 | 1E 00 | uint16,单位 μg/m³,示例 30 |
| 14 | XOR | 1 | — | 偏移 2~13 逐字节异或 |
组帧代码我坚持一个写法:不用结构体,全部按下标往uint8数组里写。
/* report.c —— 组帧并发送,全程按下标写字节,杜绝结构体对齐差异 */ static void EnvReport_Send(void) { uint8 buf[15]; uint16 temp100 = 0, humi100 = 0, lux = 0, pm25 = 0; uint8 xor = 0; if (!DHT11_Read(&temp100, &humi100)) { /* 读失败沿用上次值,曲线不断点 */ temp100 = g_lastTemp; humi100 = g_lastHumi; } else { g_lastTemp = temp100; g_lastHumi = humi100; } lux = BH1750_ReadLux(); pm25 = PM25_Read(); buf[0] = 0xAA; buf[1] = 0x55; buf[2] = 12; /* 长度:偏移 2~13 共 12 字节 */ buf[3] = LO_UINT16(myAddr); /* 小端:低字节在前 */ buf[4] = HI_UINT16(myAddr); buf[5] = 0x01; /* 类型:环境数据 */ buf[6] = LO_UINT16(temp100); buf[7] = HI_UINT16(temp100); buf[8] = LO_UINT16(humi100); buf[9] = HI_UINT16(humi100); buf[10] = LO_UINT16(lux); buf[11] = HI_UINT16(lux); buf[12] = LO_UINT16(pm25); buf[13] = HI_UINT16(pm25); for (uint8 i = 2; i <= 13; i++) xor ^= buf[i]; buf[14] = xor; AF_DataRequest(&gwAddr, &SensorApp_epDesc, APP_CLUSTERID, 15, buf, &transId, AF_DISCV_ROUTE, AF_DEFAULT_RADIUS); }AF_DataRequest的参数逐个说:gwAddr是目标地址结构体,addrMode设成 16 位短地址、shortAddr固定 0x0000 指向协调器;APP_CLUSTERID是簇号,两端对得上就行,自己定;15 是负载长度;buf是负载指针;transId是事务号,由协议栈回填;最后的AF_DISCV_ROUTE表示走路由发现,半径用默认值。
字节偏移写法的好处,等你在第 5 章看到那个“温度显示 3000 度”的坑就明白了。AF_DataRequest里的负载长度必须是 15,别用sizeof(buf)——数组传参会退化成指针,sizeof只会返回 2,这个错误编译器不报,运行时数据全乱。
3.4 入网、心跳与掉线重连
节点入网成功不等于一直在线。协调器重启、路由器断电、房间格局变动导致路由失效,都可能让某个节点变成“孤儿”。这套代码里用两个机制兜底:
一是节点侧定时发心跳帧,类型字段0x02,负载复用同样的 15 字节结构,数据段填 0。上位机收到心跳只更新“最后在线时间”,不写数据库,也不上曲线。
二是两侧都有超时判断。协调器维护一张短地址表,15 秒没收到某个地址的任何帧就标记为离线;上位机也维护一份同样的表,界面上对应节点的指示灯从绿变灰。节点侧则是连续 3 次AF_DataRequest返回失败就调用一次重新入网流程,重新拿短地址。这个“3 次”不是拍脑袋,是让偶发的单次发送失败不触发无谓的重连风暴。
重连有个容易忽略的细节:节点重新入网之后短地址会变。如果上位机只按短地址索引曲线,这个节点会凭空多出一条新曲线,旧曲线永远停在历史里。解决办法是给每个节点烧一个唯一的 MAC 或者出厂编号,帧里虽然只发短地址,但协调器可以在入网回调里把“短地址 → MAC”的映射通过一条特殊的类型帧同步给上位机,显示时按 MAC 归并。源码包里这段做了,但默认关着,配置文件里把use_mac_identity打开即可。
4. Qt 上位机核心链路:收包、解析、画曲线、落库
上位机是整个项目里代码量最大的一块,也是最容易被写成“能跑就行”的一块。串口读取、帧解析、界面刷新、数据库写入这四件事如果全挤在一个槽函数里,四个节点跑起来界面就会一顿一顿的。所以这一章按数据流的顺序拆开讲,每一段都对应一个独立文件。
4.1 串口初始化:参数、DTR 与打开失败处理
串口参数的配置本身没什么好说的,115200、8N1、无流控,跟协调器固件对齐就行。真正的麻烦在打开失败和“打开了但收不到数据”。
// serialworker.cpp —— 打开串口,参数与协调器固件严格对齐 bool SerialWorker::open(const QString &portName) { m_serial.setPortName(portName); m_serial.setBaudRate(QSerialPort::Baud115200); m_serial.setDataBits(QSerialPort::Data8); m_serial.setParity(QSerialPort::NoParity); m_serial.setStopBits(QSerialPort::OneStop); m_serial.setFlowControl(QSerialPort::NoFlowControl); if (!m_serial.open(QIODevice::ReadWrite)) { emit errorOccurred(m_serial.errorString()); // 交给界面弹提示,不在这里弹窗 return false; } m_serial.setDataTerminalReady(true); // 部分 USB 转串口模块靠 DTR 使能 m_serial.clear(QSerialPort::AllDirections); // 清掉上电瞬间的垃圾字节 connect(&m_serial, &QSerialPort::readyRead, this, &SerialWorker::onReadyRead); return true; }setDataTerminalReady(true)这行看着多余,实际上救过不少次。有些 USB 转串口模块在 DTR 未拉高时不往外吐数据,串口明明打开成功,readyRead就是不来,排查半天以为是协调器没发。clear(QSerialPort::AllDirections)同理:插拔瞬间缓冲区里可能残留半个字节,不清掉的话第一帧解析必然错位,然后滑窗要滑好几个字节才能重新对上帧头。
SerialWorker这个类被设计成跑在独立线程里,通过信号把整块QByteArray发给主线程的解析器,而不是让解析器去直接读串口。串口读和界面刷新分属两个线程,这是避免界面卡顿的第一道保险。
还有一个实操细节:USB 转串口模块换一个 USB 口,Windows 下的 COM 号就变了,配置文件里存死的端口名会直接失效。我的做法是启动时枚举QSerialPortInfo::availablePorts(),按 VID/PID 或者描述字符串去匹配,匹配不上再退回手动选择。
4.2 帧解析:滑窗加校验的双保险
解析器的目标很明确:从一条没有边界的字节流里,稳定地切出 15 字节的合法帧。做法是维护一个缓冲区,循环判断“够不够长、帧头对不对、长度对不对、校验对不对”,任何一步不通过就丢掉一个字节重新滑。
// frameparser.cpp —— 滑窗找帧头,逐帧校验,失败只丢一个字节 void FrameParser::feed(const QByteArray &chunk) { m_buf.append(chunk); static const int FRAME_LEN = 15; while (m_buf.size() >= FRAME_LEN) { const char *p = m_buf.constData(); /* 帧头不对,丢一个字节继续找 */ if (quint8(p[0]) != 0xAA || quint8(p[1]) != 0x55) { m_buf.remove(0, 1); continue; } /* 长度字段必须等于 12,否则说明这是伪帧头 */ if (quint8(p[2]) != 12) { m_buf.remove(0, 1); continue; } /* 偏移 2~13 逐字节异或,与第 14 字节比对 */ quint8 xorSum = 0; for (int i = 2; i <= 13; ++i) xorSum ^= quint8(p[i]); if (xorSum != quint8(p[14])) { m_buf.remove(0, 1); continue; } DataPoint dp; dp.addr = quint16(quint8(p[3])) | (quint16(quint8(p[4])) << 8); dp.type = quint8(p[5]); dp.temp = qint16(quint16(quint8(p[6])) | (quint16(quint8(p[7])) << 8)) / 100.0; dp.humi = quint16(quint8(p[8])) | (quint16(quint8(p[9])) << 8); dp.humi /= 100.0; dp.lux = quint16(quint8(p[10])) | (quint16(quint8(p[11])) << 8); dp.pm25 = quint16(quint8(p[12])) | (quint16(quint8(p[13])) << 8); dp.ts = QDateTime::currentMSecsSinceEpoch(); emit frameReady(dp); m_buf.remove(0, FRAME_LEN); // 整帧消费掉 } if (m_buf.size() > 4096) m_buf.remove(0, m_buf.size() - FRAME_LEN); // 防脏数据撑爆内存 }几个关键点值得展开。第一,失败时只remove(0, 1)而不是清空整个缓冲区,因为伪帧头后面很可能紧跟着一帧真数据,全清掉就把好数据也扔了。第二,长度字段的校验是必要的一层,0xAA 0x55这两个字节在数据段里完全可能出现,光靠帧头会有误判。第三,温度用了qint16强转,因为零下的时候高字节是0xFF,按quint16解析会变成六万多度。
最后那个防撑爆的判断是后加的。有次传感器故障,节点一直往外吐固定字节,帧头永远对不上,缓冲区几秒钟涨到几十兆,界面直接冻住。加上这行之后最多保留一帧的长度,问题消失。
4.3 曲线刷新节流与环形缓冲
串口线程把帧丢过来之后,最省事的写法是在槽函数里直接往QLineSeries上append一个点。四个节点、每 2 秒一帧,看起来频率很低,但 Qt Charts 每次append都会触发一次重绘请求,如果此时用户正在拖动窗口或者切标签页,重绘会叠加,表现出来就是鼠标一顿一顿的。
正确的做法是把“收数据”和“刷界面”彻底解耦:收数据只入队,界面用一个 200 毫秒的定时器统一刷。
// mainwindow.cpp —— 收到数据只入环形缓冲,定时器统一刷图表 void MainWindow::onFrameReady(const DataPoint &dp) { auto &ring = m_ring[dp.addr]; // 每个节点一条独立缓冲 ring.push_back(dp); if (ring.size() > 600) ring.pop_front(); // 约 20 分钟的 2 秒周期数据 m_alarm.check(dp); // 阈值判断,轻量操作,可以同步做 } void MainWindow::onRefreshTimer() // 200ms 触发一次 { for (auto it = m_ring.begin(); it != m_ring.end(); ++it) { QLineSeries *series = m_series.value(it.key(), nullptr); if (!series) continue; QVector<QPointF> points; points.reserve(it->size()); for (const auto &dp : *it) { double x = (dp.ts - m_startTs) / 1000.0; // 以启动时间为零点,单位秒 points.append(QPointF(x, dp.temp)); } series->replace(points); // 一次性替换,只触发一次重绘 } }replace相比clear加逐个append,重绘次数从 N 次降到 1 次,这是刷新流畅与否的分水岭。600 个点约 20 分钟,超过就从头丢,属于典型的环形缓冲思路。真要做长时间历史回看,别指望曲线控件扛,直接查数据库按时间范围抽稀,这个功能在datastore.cpp里。
温度、湿度、PM2.5 各画一条,光照单独一个柱状图。四条曲线的 Y 轴量纲差太远,共用一个轴会把湿度压成一条直线,所以代码里拆成了左右两个 Y 轴。
4.4 SQLite 落库与历史回看
存储这块最省事也最容易被写坏。表结构很简单,四列数据加两列索引列:
CREATE TABLE IF NOT EXISTS env_data ( id INTEGER PRIMARY KEY AUTOINCREMENT, addr INTEGER NOT NULL, -- 节点短地址 ts INTEGER NOT NULL, -- 毫秒时间戳 temp REAL, -- 摄氏度 humi REAL, -- 百分比 lux INTEGER, -- 勒克斯 pm25 INTEGER -- 微克每立方米 ); CREATE INDEX IF NOT EXISTS idx_env_ts ON env_data(ts); CREATE INDEX IF NOT EXISTS idx_env_addr_ts ON env_data(addr, ts);写入用一个专门的方法,关键是“事务”两个字:
// datastore.cpp —— 攒够 50 条或超过 1 秒就提交一次事务 void DataStore::enqueue(const DataPoint &dp) { m_pending.append(dp); if (m_pending.size() >= 50 || m_lastFlush.elapsed() > 1000) { flush(); } } void DataStore::flush() { if (m_pending.isEmpty()) return; m_db.transaction(); // 关键:批量提交,否则磁盘 IO 会拖垮界面 QSqlQuery q(m_db); q.prepare("INSERT INTO env_data(addr, ts, temp, humi, lux, pm25) " "VALUES(?, ?, ?, ?, ?, ?)"); for (const auto &dp : m_pending) { q.addBindValue(dp.addr); q.addBindValue(dp.ts); q.addBindValue(dp.temp); q.addBindValue(dp.humi); q.addBindValue(dp.lux); q.addBindValue(dp.pm25); q.exec(); } m_db.commit(); m_pending.clear(); m_lastFlush.restart(); }不裹事务逐条插入,SQLite 每一条都要 fsync 一次磁盘,四个节点跑上一小时,界面就会开始周期性卡顿——这个现象特别有迷惑性,因为刚启动的时候一切正常。加上事务之后,写一万条也就几百毫秒。
历史回看按时间范围查,用idx_env_addr_ts这个复合索引,查询时把addr放前面能用上索引:
SELECT ts, temp, humi, lux, pm25 FROM env_data WHERE addr = :addr AND ts BETWEEN :from AND :to ORDER BY ts ASC;数据量大了之后,一个月的连续记录可能几百万行,直接全查出来画图必然卡死。常见做法是查询时按目标点数抽稀,比如要画 1000 个点,就用LIMIT配合步长,或者干脆在 SQL 里用id % N = 0做采样。源码包里给的是按小时聚合的版本,保留了平均值和极值。
5. 避坑与排查:串口、组网、界面卡顿的五条血泪记录
这一章记的都是真实踩过的坑,每条的排查路径比结论更值钱。现象、原因、解决,按这个顺序看。
5.1 界面一卡一卡,曲线数据还会跳变
现象:节点一多,界面就开始周期性顿挫,鼠标拖动窗口有明显的粘滞感,切到别的标签页再切回来,曲线会有断层。
原因:readyRead槽里直接调用series->append()。四路数据在不同时刻到达,每次append都触发一次重绘请求,这些请求在事件循环里排队,界面线程被重绘任务占满。
解决:改成“入队 + 定时批量刷新”,也就是 4.3 节那套。刷新周期定在 200 毫秒,肉眼完全看不出延迟,重绘次数降到每秒 5 次。顺序上要先关掉图表的setAnimationOptions,默认的动画效果在大数据量下是纯粹的负担。
5.2 温度显示成 3000 度,湿度是天文数字
现象:串口数据能收到,帧也能对上,但解析出来的温度动辄几千度,湿度上万。
原因:用结构体直接memcpy造帧。编译器会在结构体成员之间插填充字节,uint8后面跟uint16就会补一个字节,两端如果编译选项不同,sizeof都不一样,偏移全部错位。另外 8051 是小端,如果上位机按大端解析,高低字节也会颠倒。
解决:造帧和解析都按字节下标写,一个字段一个字段地拼,彻底不碰结构体对齐。真要保留结构体,就在两边都加#pragma pack(1),并且写一个静态断言检查sizeof是否等于 15。我在common/目录里放了一份帧格式说明,改协议的时候两边对着这张表改,比看代码快。
5.3 某个节点掉线之后再也不回来
现象:房间门关一下,或者协调器断电重启,某个节点就永久变成灰色,重新上电也不行,必须把节点断电重启。
原因有两个。一是协调器重启后 PAN ID 可能变化,老节点还在找原来的网络;二是节点侧没有超时重连逻辑,AF_DataRequest一直返回失败但没人处理。
解决:协调器把 PAN ID 和信道写死在固件里,重启后网络参数不变;节点侧加发送失败计数,连续 3 次失败就主动触发一次重新入网;上位机侧 15 秒收不到任何帧就标记离线,收到心跳帧自动恢复。顺带一提,2.4G 干扰也是掉线的常见诱因,把信道从默认的 11 挪到 25,办公室里丢包率能降一大截。
5.4 串口打不开,或者打开了收不到任何数据
现象:QSerialPort::open返回 false,提示 Permission denied 或者 Device busy;或者打开成功了,readyRead永远不触发。
原因:前者通常是上一次的进程没退干净还占着句柄,或者 Linux 下当前用户不在dialout组里。后者多半是 DTR 没拉高,部分 USB 转串口模块需要这个信号才往外吐数据;也有可能是协调器的 TX/RX 接反了,这个在用杜邦线搭台子的时候特别常见。
解决:打开失败时把errorString()原样打到日志里,不要吞掉;Linux 下把用户加进串口组重新登录;代码里补上setDataTerminalReady(true);硬件上先用回环测试(把 TX 短接 RX)确认串口本身没问题,再怀疑协调器。
5.5 数据库越跑越大,查询越来越慢
现象:连续跑一周之后,历史查询要转好几秒,数据库文件涨到几百兆甚至几个 G。
原因:逐条INSERT没有事务包裹,每次都要 fsync;再加上没有归档策略,数据无限增长;查询语句没用上索引,全表扫描。
解决:写入按 4.4 节的方式批量提交事务;表上建(addr, ts)复合索引;再加一个每天凌晨跑的归档任务,把 30 天以前的数据聚合成小时级平均值存到另一张表,原始记录删掉。归档这一步别嫌麻烦,等到磁盘满了再处理,代价大得多。
6. 二次开发改造:加传感器、换协议、上云的三条路径
拿到这套代码之后,最常见的三个改造诉求是:多接一路传感器、把数据推到远端、把告警规则做得更细。这三件事的改动量差别很大,先把边界说清楚,能省下很多返工。
加一路 CO2 传感器,需要动的位置是固定的几处,照着改不会漏:
| 改动位置 | 具体动作 | 备注 |
|---|---|---|
sensor.c | 加一个CO2_Read(),返回 ppm 整数 | 串口型传感器注意波特率 |
| 帧结构 | 数据段从 8 字节扩到 10 字节,LEN 从 12 改 14 | 两端必须同步改 |
report.c | 补两个字节的组帧 | 下标顺延,别复用旧偏移 |
frameparser.cpp | FRAME_LEN改 17,补两行解析 | 校验范围跟着改 |
env_data表 |
本文还有配套的精品资源,点击获取