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

发布时间:2026/10/22 10:34:47
嵌入式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 为例典型流程是ATCFUN1 打开射频ATCGDCONT 设置 APNATCGATT1 附着网络然后等待 ATCEREG? 返回 1 或 5 表示注册成功。这里最常见的坑是 APN 设置不对不同运营商的 NB-IOT APN 不一样而且有些卡需要专门的 NB-IOT 套餐普通物联网卡可能不支持。频段方面国内主要用 B5 和 B8模组一般会自动搜网但如果你的模组是海外版本频段可能对不上表现就是一直注册不上。注册成功后要激活 PDP 上下文用 ATQIACT1 激活然后用 ATQIACT? 查询分配的 IP 地址。没有 IP 地址说明 PDP 激活失败检查 APN 和卡状态。这一步过了才能开始建 TCP 或者 MQTT 连接。下面是一个用 AT 指令走完注册到 MQTT 连接的流程# NB-IOT模组初始化与MQTT连接AT指令序列 # 波特率115200每条指令后等待OK响应 ATCFUN1 # 打开射频功能 ATCGDCONT1,IP,ctnb # 设置APN电信NB-IOT通常为ctnb ATCGATT1 # 附着网络 ATCEREG? # 查询注册状态返回1或5表示成功 ATQIACT1 # 激活PDP上下文 ATQIACT? # 查询IP地址确认已分配 ATQMTOPEN0,mqtt.server.com,1883 # 打开MQTT连接替换为你的服务器 ATQMTCONN0,client_id,username,password # 连接MQTT ATQMTPUB0,0,0,0,topic/data,{\ph\:7.2,\do\:8.1} # 发布数据这段指令序列的关键在于每一步都要等模组返回 OK 再发下一条不能连续发否则模组会丢指令。ATCGDCONT的 APN 参数要根据你的卡来改移动的可能是 cmnbiot联通的可能不同。ATQMTOPEN的服务器地址和端口要换成你自己的 MQTT Broker 地址。ATQMTPUB的 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, ATQMTPUB0,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但ATCEREG?一直返回 0 或 2注册不上网络。原因通常是三种APN 配置错误、卡没有 NB-IOT 权限、天线没接好或者频段不匹配。解决方法是先用ATCGMR查模组固件版本确认支持你所在地区的频段然后用ATCSQ查信号强度如果 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用ATCPSMS和ATCEDRXS配置传感器不用时用 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 就先校准再下水。这个习惯帮我省了很多事后扯皮的时间因为数据一旦传上去再想证明是传感器漂移而不是水质变化成本就高了。希望帮到你。本文还有配套的精品资源点击获取