ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

嵌入式NB-IOT无人水质监测船:从硬件选型到MQTT上云与低功耗避坑

嵌入式NB-IOT无人水质监测船:从硬件选型到MQTT上云与低功耗避坑

简介:这份资源是面向嵌入式开发者与物联网方向学习者的完整项目资料,围绕基于NB-IoT的无人水质监测船展开,适合希望掌握STM32L4平台、FreeRTOS实时操作系统与低功耗广域网通信的进阶实践者。项目以无人船为载体,集成温度、PH值、溶解氧、TDS、浑浊度等水质传感器,通过NB-IoT模块将数据上传至物联网云平台,并支持自主航行、远程控制与自动返航,供电采用太阳能锂电池方案,兼顾节能与续航。压缩包为zip格式,整体约348.61MB,文件总数与类型明细上游暂未提供,但内容应涵盖工程源码、硬件驱动、通信协议与云平台对接等模块。目前已有117人浏览学习,可作为课程设计、毕业设计或竞赛项目的参考蓝本,帮助读者理解从传感器采集、RTOS任务调度到NB-IoT数据传输的完整链路,并借鉴其低功耗与运动控制设计思路。

1. 从一条船到一张网:NB-IOT 水质监测船到底在解决什么

去年夏天,某实验室的 A 同学拿着一个「嵌入式基于 NB-IOT 的无人水质监测船」的题目来找我,说想做一个能自动巡航、定点采样、把数据传回云端的无人船。我第一反应不是问他用什么主控,而是问他:你的数据往哪传?他愣了一下说「WiFi 或者 4G 吧」。这就是典型的翻车起点——水面环境没有固定网络,4G 模块功耗高、资费贵,WiFi 覆盖半径只有几十米,船一开出去就失联。NB-IOT 的价值恰恰在这里:它用极窄的带宽换取超强穿透和超低功耗,一个 2000mAh 的电池包能让通信模块待机几个月,这对需要长时间漂在水面上的监测船来说是刚需。

这个标题拆开看,核心是三个技术栈的交叉:嵌入式主控负责船体运动控制和传感器采集,NB-IOT 负责把数据送到云端,无人船平台负责承载这一切在水面上跑起来。它适合两类人:一类是做环境监测、水产养殖、河道巡检的工程人员,想用低成本方案替代人工采样;另一类是嵌入式学习者,想找一个能把传感器、电机控制、无线通信、云平台串起来的综合项目。但我要先说清楚边界:NB-IOT 不是实时图传方案,它的上行速率典型值只有几十 kbps,传温湿度、pH、溶解氧这类小包数据没问题,想传视频或者做实时遥控,趁早换方案。

2. 硬件选型与船体搭建:主控、传感器、动力怎么配

2.1 主控与通信模组的搭配逻辑

主控选型上,我一般会推荐 STM32F103 系列或者 GD32 作为飞控主控,原因不是性能多强,而是生态成熟、资料多、踩坑少。NB-IOT 模组常见的是 BC26、BC35-G、M5311 这几款,它们都支持 CoAP 和 MQTT 协议,通过 AT 指令或者串口透传和主控通信。这里有个关键点:NB-IOT 模组的工作电压通常是 3.3V 到 4.2V,峰值电流能到 200mA 以上,如果你的主控板载 LDO 只有 100mA 输出能力,模组注册网络时会直接把你主控拉复位。我见过太多人卡在这里,以为是固件问题,其实是供电塌陷。

船体方面,常见做法是用双体船或者单体船加两侧浮筒,长度控制在 60cm 到 80cm 之间。太小了抗风浪差,太大了搬运和成本都上去了。动力用两个 370 有刷电机配螺旋桨做差速转向,比舵机方案简单可靠,而且原地转向灵活。电机驱动用 TB6612 或者 L298N,前者效率高发热小,后者便宜但压降大。电池用 3S 锂电(11.1V)经过 DC-DC 降到 5V 给主控和传感器,再单独一路 LDO 给 NB-IOT 模组,保证通信瞬间大电流不会影响主控。

2.2 水质传感器的接口与校准

水质监测常用的传感器有 pH、溶解氧、电导率、浊度、温度这几类。pH 传感器输出的是高阻抗毫伏信号,不能直接接 STM32 的 ADC,中间必须加运放做阻抗匹配和信号调理,常见的是用 CA3140 或者 INA116 这类高输入阻抗运放。溶解氧传感器分极谱式和原电池式,极谱式需要 0.7V 左右的极化电压,原电池式自发电但寿命短。电导率传感器本质是测两个电极之间的电阻,但必须用交流激励,直流激励会导致电极极化,读数越测越偏。

校准这块是血泪经验:pH 传感器必须用标准缓冲液做两点校准(pH4.00 和 pH6.86),而且每次换水样前要重新校准,因为电极老化漂移很快。溶解氧传感器需要做零氧校准(用亚硫酸钠溶液)和满氧校准(空气中饱和水),电导率用标准 KCl 溶液校准。这些校准系数存在主控的 Flash 里,每次上电读取。下面是一个传感器读取和校准系数应用的代码片段:

// sensor.c - 水质传感器读取与校准 #include "sensor.h" #include "adc.h" #include "flash.h" #define PH_SLOPE_ADDR 0x0800F000 // pH斜率存储地址 #define PH_OFFSET_ADDR 0x0800F004 // pH偏移存储地址 #define DO_SLOPE_ADDR 0x0800F008 // 溶解氧斜率 #define DO_OFFSET_ADDR 0x0800F00C // 溶解氧偏移 static float ph_slope = 1.0f, ph_offset = 0.0f; static float do_slope = 1.0f, do_offset = 0.0f; void sensor_calib_load(void) { ph_slope = *(float*)PH_SLOPE_ADDR; ph_offset = *(float*)PH_OFFSET_ADDR; do_slope = *(float*)DO_SLOPE_ADDR; do_offset = *(float*)DO_OFFSET_ADDR; // 如果Flash未写入过,使用默认值 if (ph_slope < 0.1f || ph_slope > 10.0f) ph_slope = 1.0f; if (do_slope < 0.1f || do_slope > 10.0f) do_slope = 1.0f; } float sensor_read_ph(void) { uint16_t adc_val = adc_read_channel(ADC_CH_PH); float voltage = adc_val * 3.3f / 4096.0f; // 两点校准公式: pH = slope * voltage + offset float ph = ph_slope * voltage + ph_offset; if (ph < 0.0f) ph = 0.0f; if (ph > 14.0f) ph = 14.0f; return ph; } float sensor_read_do(void) { uint16_t adc_val = adc_read_channel(ADC_CH_DO); float voltage = adc_val * 3.3f / 4096.0f; float do_val = do_slope * voltage + do_offset; if (do_val < 0.0f) do_val = 0.0f; return do_val; // 单位 mg/L }

这段代码的逻辑很直接:上电时从 Flash 读取校准系数,读取传感器时把 ADC 原始值转成电压,再用线性公式算出物理量。参数说明:PH_SLOPE_ADDR这些地址要根据你主控的 Flash 页大小调整,STM32F103 的 Flash 页是 1KB,别写到程序区去了。adc_read_channel需要你自己实现,注意 ADC 参考电压要稳定,最好用外部基准而不是 VDDA。校准系数的写入函数我没贴,但逻辑就是先擦除页再写入,注意 Flash 写入前必须解锁。

2.3 动力与船体装配的实操步骤

装配顺序我建议从下往上:先把两个电机固定在船尾支架上,螺旋桨要左右对称,一个正桨一个反桨,这样差速转向时推力才平衡。然后装电池仓,电池位置尽量靠船体中心偏低,降低重心防翻。主控板和传感器板放在防水盒里,防水盒用硅胶密封圈,所有出线孔用防水接头。传感器探头固定在船底或者侧面,pH 和溶解氧探头不能离电机太近,否则水流扰动和电磁干扰会让读数跳变。

调试时先不装 NB-IOT,用串口打印传感器数据,确认船能直线走、能转向、传感器读数合理。然后再装通信模组,单独测试模组能否注册网络、能否连上云平台。最后联调,把数据打包上传。这个顺序能帮你快速定位问题,不然通信和传感器问题混在一起,排查起来很痛苦。

3. NB-IOT 通信链路打通:从 AT 指令到 MQTT 上云

3.1 模组注册网络与频段配置

NB-IOT 模组上电后第一件事是注册网络。以 BC26 为例,典型流程是:AT+CFUN=1 打开射频,AT+CGDCONT 设置 APN,AT+CGATT=1 附着网络,然后等待 AT+CEREG? 返回 1 或 5 表示注册成功。这里最常见的坑是 APN 设置不对,不同运营商的 NB-IOT APN 不一样,而且有些卡需要专门的 NB-IOT 套餐,普通物联网卡可能不支持。频段方面,国内主要用 B5 和 B8,模组一般会自动搜网,但如果你的模组是海外版本,频段可能对不上,表现就是一直注册不上。

注册成功后要激活 PDP 上下文,用 AT+QIACT=1 激活,然后用 AT+QIACT? 查询分配的 IP 地址。没有 IP 地址说明 PDP 激活失败,检查 APN 和卡状态。这一步过了,才能开始建 TCP 或者 MQTT 连接。下面是一个用 AT 指令走完注册到 MQTT 连接的流程:

# NB-IOT模组初始化与MQTT连接AT指令序列 # 波特率115200,每条指令后等待OK响应 AT+CFUN=1 # 打开射频功能 AT+CGDCONT=1,"IP","ctnb" # 设置APN,电信NB-IOT通常为ctnb AT+CGATT=1 # 附着网络 AT+CEREG? # 查询注册状态,返回1或5表示成功 AT+QIACT=1 # 激活PDP上下文 AT+QIACT? # 查询IP地址,确认已分配 AT+QMTOPEN=0,"mqtt.server.com",1883 # 打开MQTT连接,替换为你的服务器 AT+QMTCONN=0,"client_id","username","password" # 连接MQTT AT+QMTPUB=0,0,0,0,"topic/data","{\"ph\":7.2,\"do\":8.1}" # 发布数据

这段指令序列的关键在于每一步都要等模组返回 OK 再发下一条,不能连续发,否则模组会丢指令。AT+CGDCONT的 APN 参数要根据你的卡来改,移动的可能是 cmnbiot,联通的可能不同。AT+QMTOPEN的服务器地址和端口要换成你自己的 MQTT Broker 地址。AT+QMTPUB的 topic 和 payload 格式要和云端订阅的一致,payload 是 JSON 字符串,注意转义引号。

3.2 MQTT 主题设计与数据打包

MQTT 主题设计有个原则:层级清晰、可扩展。我一般用device/{device_id}/data作为数据上报主题,device/{device_id}/cmd作为下行控制主题。device_id 用模组的 IMEI 后 8 位,保证唯一性。数据打包用 JSON,字段名尽量短,因为 NB-IOT 带宽有限,每个字节都要省。比如{"t":25.3,"ph":7.2,"do":8.1,"ec":450,"tb":12.5},t 是温度,ph 是酸碱度,do 是溶解氧,ec 是电导率,tb 是浊度。

QoS 等级选 0 还是 1 要看场景。QoS0 是最多发一次,不保证到达,但省流量省电;QoS1 是至少发一次,有确认重传,但会增加功耗和流量。水质监测数据不是每秒都发,一般 30 秒到 5 分钟发一次,丢一两个包影响不大,我建议用 QoS0。如果做报警数据,比如 pH 超标,那就用 QoS1 保证送达。下面是一个数据打包和发布的函数:

// nbiot_mqtt.c - 数据打包与MQTT发布 #include "nbiot_mqtt.h" #include "usart.h" #include <stdio.h> #include <string.h> #define MQTT_BUF_SIZE 256 static char mqtt_buf[MQTT_BUF_SIZE]; int nbiot_publish_data(const char *device_id, float temp, float ph, float do_val, float ec, float turbidity) { // 打包JSON,字段名尽量短以节省带宽 int len = snprintf(mqtt_buf, MQTT_BUF_SIZE, "AT+QMTPUB=0,0,0,0,\"device/%s/data\"," "\"{\\\"t\\\":%.1f,\\\"ph\\\":%.1f,\\\"do\\\":%.1f," "\\\"ec\\\":%.0f,\\\"tb\\\":%.1f}\"", device_id, temp, ph, do_val, ec, turbidity); if (len <= 0 || len >= MQTT_BUF_SIZE) { return -1; // 缓冲区不足 } uart_send_string(NBIOT_UART, mqtt_buf); // 等待模组返回 +QMTPUB: 0,0,0 表示发送成功 return nbiot_wait_response("+QMTPUB: 0,0,0", 5000); }

这段代码的核心是snprintf拼接 AT 指令和 JSON payload。参数说明:device_id是设备唯一标识,temp到turbidity是传感器读数。注意 JSON 里的引号需要转义,在 C 字符串里用\\\"表示。nbiot_wait_response是等待模组响应的函数,超时时间 5000ms 是因为 NB-IOT 网络延迟可能到几秒。如果返回 -1 说明缓冲区不够,需要增大MQTT_BUF_SIZE或者减少字段。

3.3 低功耗策略与心跳间隔

NB-IOT 的低功耗模式有 PSM 和 eDRX 两种。PSM 是深度休眠,模组完全断开,唤醒后需要重新注册,适合对实时性要求极低的场景。eDRX 是扩展不连续接收,模组周期性醒来听一下网络,延迟比 PSM 小但功耗比 PSM 高。水质监测船一般用 eDRX,周期设 20 秒到 60 秒,既能省电又能保证数据及时上报。

心跳间隔要结合 MQTT 的 keepalive 和 eDRX 周期。如果 keepalive 设 60 秒,eDRX 周期设 20 秒,那模组每 20 秒醒来一次,但只有到 60 秒才发心跳,中间醒来如果没数据就继续睡。这样平均功耗能控制在几毫安。我实测过一个 2000mAh 电池包,30 秒上报一次数据,eDRX 周期 20 秒,续航大概 5 到 7 天。想再长就得加大电池或者降低上报频率。

4. 避坑与排查:那些让船失联、数据跳变的真实原因

4.1 模组注册失败但串口有响应

现象:AT 指令能返回 OK,但AT+CEREG?一直返回 0 或 2,注册不上网络。原因通常是三种:APN 配置错误、卡没有 NB-IOT 权限、天线没接好或者频段不匹配。解决方法是先用AT+CGMR查模组固件版本,确认支持你所在地区的频段;然后用AT+CSQ查信号强度,如果 RSSI 低于 10 说明信号太差,需要换位置或者加天线;最后确认 APN 和卡状态,有些物联网卡需要先激活才能用。

4.2 数据上报成功但云端收不到

现象:模组返回+QMTPUB: 0,0,0表示发送成功,但云端 MQTT Broker 没有收到数据。原因可能是 topic 不匹配、payload 格式错误、或者 QoS 等级导致的消息被丢弃。排查方法是先用 MQTT 客户端工具订阅相同的 topic,看能不能收到;然后检查 payload 是不是合法 JSON,有些 Broker 对格式要求严格;最后确认 QoS 等级,QoS0 在弱网下可能丢包,换成 QoS1 试试。

4.3 传感器读数跳变或恒定不变

现象:pH 读数在 6.8 到 7.5 之间乱跳,或者一直显示 7.0 不变。原因分两种:跳变通常是电源干扰或者运放自激,检查传感器供电是否干净、运放反馈电阻是否合理、ADC 参考电压是否稳定;恒定不变通常是传感器没接好、ADC 通道配置错误、或者校准系数写成了默认值。解决方法是先用万用表测传感器输出端电压,看是否随水质变化;然后检查 ADC 配置和校准系数存储地址是否正确。

4.4 船体跑偏或者原地打转

现象:船不走直线,或者一个电机转一个不转。原因可能是电机接线反了、PWM 占空比不对称、或者螺旋桨装反了。排查时先单独测试两个电机,确认都能正反转;然后检查 PWM 输出,用示波器看两路占空比是否一致;最后检查螺旋桨方向,正桨和反桨装错会导致推力不平衡。差速转向的代码里,左右电机的基础速度要一致,转向时一个加速一个减速,不能一个正转一个反转,那样会原地打转。

4.5 电池续航远低于预期

现象:标称 7 天续航,实际 1 天就没电。原因通常是模组没有进入低功耗模式、传感器一直供电、或者 LDO 静态电流太大。解决方法是确认模组已开启 eDRX 或 PSM,用AT+CPSMS和AT+CEDRXS配置;传感器不用时用 MOS 管切断供电,不要一直通电;LDO 选静态电流低于 10uA 的型号,比如 HT7333 或者 TPS7A02。另外,NB-IOT 模组在注册网络时峰值电流很大,如果电池内阻高,电压会瞬间跌落导致模组复位,表现就是反复注册,耗电飞快。

5. 进阶技巧:用差分定位和边缘滤波把数据质量提上去

船跑起来之后,你会发现两个问题比通信更棘手:一是船的位置漂移,明明想让它在一个点采样,结果被水流推出去十几米;二是传感器数据噪声大,pH 和溶解氧的原始读数波动能到 ±0.3 和 ±0.5,直接上传根本没法用。这两个问题不解决,数据再多也是垃圾。

定位方面,普通 GPS 的定位精度在 2.5 米到 5 米,对水质监测来说勉强够用,但如果你想做网格化采样,每个点间隔 10 米,那 GPS 误差就太大了。常见做法是上 RTK 差分定位,但 RTK 模块成本高、还需要基站。折中方案是用 GPS 加 IMU 做航位推算,船在短时间内的运动可以用陀螺仪和加速度计积分出来,和 GPS 位置做卡尔曼滤波融合,能把定位精度提到 1 米以内。代码上就是在主控里跑一个简化版的卡尔曼滤波,状态量是位置和速度,观测量是 GPS 位置,过程噪声和观测噪声根据实测调。

数据滤波方面,我一般用滑动平均加中值滤波的组合。滑动平均窗口取 10 个点,能平滑随机噪声;中值滤波窗口取 5 个点,能去掉脉冲干扰。但要注意,滑动平均会引入滞后,如果水质变化很快,滞后可能让你错过峰值。所以我的做法是:正常监测用滑动平均,报警判断用原始值加阈值,这样既不丢报警又不传噪声。下面是一个组合滤波的实现:

// filter.c - 滑动平均与中值滤波组合 #include "filter.h" #include <string.h> #define MA_WINDOW 10 #define MED_WINDOW 5 static float ma_buf[MA_WINDOW]; static float med_buf[MED_WINDOW]; static uint8_t ma_idx = 0, med_idx = 0; static uint8_t ma_cnt = 0, med_cnt = 0; float filter_combined(float new_val) { // 滑动平均 ma_buf[ma_idx] = new_val; ma_idx = (ma_idx + 1) % MA_WINDOW; if (ma_cnt < MA_WINDOW) ma_cnt++; float ma_sum = 0.0f; for (uint8_t i = 0; i < ma_cnt; i++) { ma_sum += ma_buf[i]; } float ma_val = ma_sum / ma_cnt; // 中值滤波,对滑动平均结果再做一次 med_buf[med_idx] = ma_val; med_idx = (med_idx + 1) % MED_WINDOW; if (med_cnt < MED_WINDOW) med_cnt++; // 复制到临时数组排序取中值 float tmp[MED_WINDOW]; memcpy(tmp, med_buf, med_cnt * sizeof(float)); for (uint8_t i = 0; i < med_cnt - 1; i++) { for (uint8_t j = i + 1; j < med_cnt; j++) { if (tmp[i] > tmp[j]) { float t = tmp[i]; tmp[i] = tmp[j]; tmp[j] = t; } } } return tmp[med_cnt / 2]; }

这段代码先做滑动平均,再对平均值做中值滤波。参数说明:MA_WINDOW和MED_WINDOW根据你的采样频率和数据波动程度调,采样越快窗口可以越大。注意ma_cnt和med_cnt是实际填充数量,刚开始数据不够时不能除以窗口大小,否则会偏小。这个组合滤波的代价是每次调用要做一次排序,MED_WINDOW 为 5 时计算量很小,STM32F103 完全跑得动。

还有一个进阶玩法是自适应阈值报警。固定阈值比如 pH 低于 6.5 报警,在干净水域没问题,但在某些矿区或者工业区,背景值本身就低,固定阈值会一直误报。我的做法是用前 24 小时的数据算均值和标准差,报警阈值设为均值加减 3 倍标准差,这样能适应不同水域的背景值。这个逻辑放在云端做更合适,主控只负责上传原始数据,云端做统计和报警判断,主控的负担小,也方便后期调整算法。

最后说一个我自己的习惯:每次下水前,先用手持仪器测一遍水样的 pH 和溶解氧,和船上的传感器读数对比,偏差超过 0.3 就先校准再下水。这个习惯帮我省了很多事后扯皮的时间,因为数据一旦传上去,再想证明是传感器漂移而不是水质变化,成本就高了。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进