1. 项目全景:为什么用LoRaWAN传GPS数据
1.1 这套方案到底解决什么问题
GPS数据传输这名字听起来不复杂,但真正落地的时候你会发现核心矛盾不是“能不能收到GPS信号”,而是“定位数据怎么低成本、低功耗地传回服务器”。去年我做一个户外车辆追踪项目,现场在偏远的矿区,4G信号断断续续,公网卡年费还不便宜,设备装在一百多台车上,光流量费就是一笔不小的支出。当时我第一反应是换NB-IoT,但山区基站覆盖同样不理想。
后来决定采用LoRaWAN方案:节点用ST官方的B-L072Z-LRWAN1评估板,外接一个串口GPS模块,GPS数据在节点上解析好,封装成紧凑的上行帧,通过LoRaWAN网关转发到网络服务器,最终落到应用服务器。这套方案没有公网流量费,网关覆盖范围内所有节点共用一套基础设施,单节点功耗也能压到很低。
这篇文章适合正在做GPS采集、低功耗广域网传输、户外资产追踪的嵌入式工程师,也适合刚接触LoRaWAN想快速跑通一个端到端demo的学生。我会把方案选型、硬件接线、NMEA协议解析、LoRaWAN入网、数据帧设计、信号质量评估这些环节全部拆开讲,最后附上我在实际调试中踩过的坑。
1.2 为什么选了B-L072Z-LRWAN1而不是自研板
板子的选择直接影响开发周期。B-L072Z-LRWAN1是意法半导体官方的LoRa评估套件,板载一颗STM32L072CZ微控制器(Cortex-M0+,192KB Flash、20KB RAM)和一颗SX1276 LoRa收发器,板上已经做好了射频匹配电路和SMA天线接口。换句话说,硬件链路里最容易出问题的部分——射频匹配、天线阻抗、晶振校准——ST已经帮你验证过了,我拿到手只写了应用层代码就完成了入网和数据传输。
对比一下几种常见方案:
| 方案 | 成本 | 开发周期 | 功耗 | 坑点 |
|---|---|---|---|---|
| B-L072Z-LRWAN1评估板 | 中等 | 短 | 低 | 引脚不够自由 |
| 自制STM32L0+SX1276小板 | 低 | 长 | 低 | 射频匹配难调 |
| ESP32+LoRa模块 | 低 | 中 | 较高 | 不支持官方LoRaWAN协议栈 |
| 成品LoRa DTU | 高 | 最短 | 中等 | 无法自定义协议 |
自研板虽然物料成本低,但SX1276的收发链路要对阻抗、调频偏,第一次打样很容易出现灵敏度差几dB的情况,排查起来非常折腾。B-L072Z-LRWAN1内置的ST-LINK调试器还能直接当串口用,开发阶段省了一个USB转TTL工具。
1.3 GPS模块选型与对比
GPS模块我最初测试了三款:Ublox NEO-M8N、中科微ATGM336H、以及一款蓝牙GPS模块(做方案对比用)。最终项目选的是ATGM336H,理由很直接:价格便宜、功耗不高、支持3.3V供电,串口输出标准NMEA 0183协议,和B-L072Z-LRWAN1直接电平兼容,不需要额外做电平转换。
蓝牙GPS虽然也可以用,但它多了一层蓝牙协议,功耗更大,而且B-L072Z-LRWAN1没有板载蓝牙,还得外挂BLE模块,完全没有必要。如果预算充足,NEO-M8N的灵敏度会好一些,冷启动时间短一点,但ATGM336H在开阔环境下的表现已经够用,卫星数基本在10颗以上。
接线很简单,GPS模块的TXD接到板子USART2的RX脚,RXD接到USART2的TX脚,VCC接3V3或5V(看模块版本),GND接GND。需要注意GPS模块的串口电平,市面上很多GPS模块是TTL电平,标称3.3V,直接接没问题;如果模块是5V电平,就需要加电阻分压或者用电平转换芯片,否则长期运行可能烧坏STM32的IO。
2. GPS数据采集与NMEA协议解析
2.1 串口配置:DMA接收是必选项
GPS模块上电后默认以9600波特率连续输出NMEA语句,一秒钟大概输出5到10帧,每帧几十字节。如果我用中断一字节一字节收,对STM32L072这种M0+核来说虽然也不至于崩,但CPU占用率偏高,而且程序逻辑很容易被串口中断打断。
我的做法是启用USART2的DMA接收加空闲中断(IDLE Line Interrupt)。DMA负责把数据搬运到内存缓冲区,空闲中断负责在一整帧数据接收完成后通知CPU处理。这样CPU大部分时间都可以睡在低功耗模式,GPS数据来了才被唤醒。
CubeMX里的配置要点:
- USART2模式设为Asynchronous,波特率9600,8位数据,无校验,1位停止位
- 开启USART2的全局中断
- 在DMA Settings里添加USART2_RX通道,模式Circular
- 开启USART2的IDLE中断(在NVIC里使能USART2 global interrupt就可以,代码里再操作IDLE相关寄存器)
初始化代码大致这样:
#define GPS_BUF_SIZE 512 uint8_t gps_dma_buf[GPS_BUF_SIZE]; void GPS_UART_Init(void) { __HAL_UART_ENABLE_IT(&huart2, UART_IT_IDLE); // 使能IDLE中断 HAL_UART_Receive_DMA(&huart2, gps_dma_buf, GPS_BUF_SIZE); }然后在串口中断回调里判断IDLE标志:
void USART2_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart2, UART_FLAG_IDLE) != RESET) { __HAL_UART_CLEAR_IDLEFLAG(&huart2); uint16_t len = GPS_BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart2_rx); GPS_ProcessDMAFrame(gps_dma_buf, len); } HAL_UART_IRQHandler(&huart2); }DMA用Circular模式的好处是硬件自动回绕,我不需要手动重置缓冲区指针,只需要在收到一帧后记录当前有效数据长度。注意每次进入IDLE中断时DMA计数器已经在减少,所以当前接收长度是缓冲区总大小减去剩余计数。
2.2 NMEA协议最常见的几条语句
GPS模块输出的NMEA语句里,我用得最多的是GPGGA和GPRMC。GPGGA包含定位状态、经纬度、卫星数、HDOP值和海拔,信息很完整,比较适合给数据质检用。GPRMC则带日期和地面速度、航向角,适合需要速度方向的场合。
GPGGA的一帧典型数据长这样:
$GPGGA,091253.000,3108.5585,N,12122.7893,E,1,09,1.2,18.5,M,-3.2,M,,*5D我实际只关心其中几个字段:
- 字段0:语句名,$GPGGA
- 字段1:UTC时间,格式hhmmss.sss
- 字段2:纬度,格式ddmm.mmmmm
- 字段3:N/S,北纬或南纬
- 字段4:经度,格式dddmm.mmmmm
- 字段5:E/W,东经或西经
- 字段6:定位状态,0=无定位,1=GPS定位,2=差分定位
- 字段7:跟踪到的卫星数
- 字段8:HDOP值,越小越好
- 字段9:海拔高度,单位米
这里有个非常容易踩的坑:NMEA输出的经纬度是“度分”格式,不是纯小数。比如3108.5585代表31度08.5585分,换算成纯小数度数是31 + 08.5585 / 60 = 31.142641度。如果直接当小数用,定位会偏出几十公里。
2.3 从DMA缓冲里切出完整NMEA帧
DMA缓冲里是连续的数据流,我需要在里面找到一帧的起始和结束位置。NMEA帧以$开头,以\r\n结尾,所以切帧的逻辑不复杂:在缓冲里找$,然后找下一对\r\n,中间就是完整的一帧。
我这里轮询处理DMA回调里的数据,但要注意DMA是Circular模式,可能出现数据回绕,也就是一帧被分成两段存在缓冲区头尾。处理方式是把缓冲当环形队列用,先计算有效数据区间,如果一帧跨过了缓冲区末尾,就手动拼一次。
void GPS_ProcessDMAFrame(uint8_t *buf, uint16_t len) { static uint8_t line[128]; static uint16_t line_len = 0; for (uint16_t i = 0; i < len; i++) { uint8_t ch = buf[i]; if (ch == '$') { line_len = 0; } if (ch == '\n' && line_len > 0) { line[line_len - 1] = '\0'; GPS_ParseNMEA((char *)line); line_len = 0; } else if (line_len < sizeof(line) - 1) { line[line_len++] = ch; } } }这只是一个简化版示例,实际项目里我建议把line缓冲加校验和保护,防止怪异字符导致越界。NMEA帧末尾的*5D是校验和,可以把$和*之间的字符逐字节异或,再与*后的两位十六进制比较,校验不过直接丢弃。GPS数据偶尔会出坏帧,加上校验能防止错误定位数据通过LoRaWAN上传。
2.4 解析GPGGA并提取核心定位信息
核心解析逻辑我放在一个固定数组的字段拆分函数里,避免用strtok(它是不可重入的,而且会修改原字符串,在单片机上是隐患)。NMEA语句用逗号分隔,我直接按逗号切字符串就行。
typedef struct { uint8_t fix_status; // 0=无定位,1=GPS,2=差分 uint8_t sat_num; // 卫星数 float hdop; // HDOP float latitude; // 纬度, 单位度, 北正南负 float longitude; // 经度, 单位度, 东正西负 float altitude; // 海拔, 单位米 uint16_t utc_hhmmss; // UTC时间 } GPS_Fix_t; GPS_Fix_t gps_fix; uint8_t GPS_ParseGGA(char *line) { char *p[16]; uint8_t idx = 0; p[idx++] = line; while (idx < 16) { char *comma = strchr(p[idx - 1], ','); if (comma == NULL) break; *comma = '\0'; p[idx++] = comma + 1; } if (idx < 10) return 0; if (strncmp(p[0] + 1, "GPGGA", 5) != 0) return 0; gps_fix.fix_status = (uint8_t)atoi(p[6]); gps_fix.sat_num = (uint8_t)atoi(p[7]); gps_fix.hdop = atof(p[8]); gps_fix.altitude = atof(p[9]); // 经纬度: ddmm.mmmmm -> dd + mm.mmmmm/60 double lat = atof(p[2]); int lat_deg = (int)(lat / 100); double lat_min = lat - lat_deg * 100; gps_fix.latitude = lat_deg + lat_min / 60.0; if (p[3][0] == 'S') gps_fix.latitude = -gps_fix.latitude; double lon = atof(p[4]); int lon_deg = (int)(lon / 100); double lon_min = lon - lon_deg * 100; gps_fix.longitude = lon_deg + lon_min / 60.0; if (p[5][0] == 'W') gps_fix.longitude = -gps_fix.longitude; return 1; }我一般只用GPGGA,不用GPRMC。GPGGA带HDOP和卫星数,评估数据质量更方便。而且LoRaWAN上行帧长度有限,没必要同时传两套定位数据。
3. LoRaWAN入网与GPS数据上行
3.1 基于STM32CubeMX搭建LoRaWAN工程
LoRaWAN这部分我直接用了ST官方扩展包I-CUBE-LRWAN,不要自己写MAC层协议,LoRaWAN的入网流程、帧同步、重传机制、加密认证全都在协议栈里处理好了,自己写的话工作量太大而且很难通过认证。
步骤大致是:
- 在STM32CubeMX里选择MCU型号STM32L072CZ,或者直接导入B-L072Z-LRWAN1的板级支持包
- 使能SPI1(接SX1276),配置好Radio芯片的复位引脚、DIO1中断引脚
- 使能USART2用于调试输出(如果被GPS占了就换一路串口)
- 安装I-CUBE-LRWAN扩展包,在Middleware里勾选LoRaWAN
- 配置LoRaWAN参数:频段选CN470或者EU868(看你的网关所在地区),OTAA模式,Class A
- 生成代码后,把Middlewares目录下的LoRaWAN库加入编译路径
需要特别说明的是,B-L072Z-LRWAN1上SX1276的SPI引脚在CubeMX的板载例程里已经映射好了,如果你换用自研板,需要根据原理图自己改Radio的SPI句柄和引脚定义,通常在radio_board.c或hw.c文件里。
3.2 OTAA入网参数怎么填
我用的OTAA入网方式,需要三样东西:DevEUI、JoinEUI(以前叫AppEUI)、AppKey。这三个值在LoRaWAN服务器端生成,服务器我用的是通用的LoRaWAN网络服务器,节点注册后分配一组密钥。
在LoRaMac.h或commissioning.h里填入参数,格式如下:
#define LORAWAN_DEVICE_EUI { 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77 } #define LORAWAN_JOIN_EUI { 0x00, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88 } #define LORAWAN_APP_KEY { 0x2B, 0x7E, 0x15, 0x16, 0x28, 0xAE, 0xD2, 0xA6, 0xAB, 0xF7, 0x15, 0x88, 0x09, 0xCF, 0x4F, 0x3C }DevEUI相当于节点的设备唯一标识,JoinEUI标识你加入的是哪个应用,AppKey是入网凭证。这三个值如果填错,入网请求会被服务器拒绝。调试的时候我在串口日志里能看到JOINED字样,如果一直返回NO_JOIN_ACCEPT,优先检查大小端和密钥字节顺序。
入网后LoRaWAN协议栈自动处理后续的加解密,应用层不需要关心。上行数据到达网络服务器后是一个纯十六进制payload,如何解析完全由应用服务器决定。
3.3 GPS数据帧格式设计:能压缩就压缩
LoRaWAN低速率的传输能力有限,在SF12、带宽125kHz的情况下,一个包最多带几十上百字节,而且信道占用时间越长,冲突概率越高。GPS数据不能一帧塞一大段字符串,必须压缩成紧凑的二进制结构。
我设计的帧格式如下,总共12字节:
| 偏移 | 长度 | 内容 | 说明 |
|---|---|---|---|
| 0 | 1 | 帧类型 | 0x01=定位数据 |
| 1 | 1 | 定位标志 | bit0=Fix状态,bit1=运动状态 |
| 2 | 4 | 纬度 | 有符号int32,放大了1e7,单位度 |
| 6 | 4 | 经度 | 有符号int32,放大了1e7,单位度 |
| 10 | 1 | 卫星数 | 范围0~255 |
| 11 | 1 | HDOP | 放大了10倍,0~255 |
经纬度放大1e7后,分辨率约1.1厘米,远高于GPS本身的定位精度。HDOP放大10倍后,1.5的HDOP会变成15,一个字节就能表达,接收端除以10还原即可。如果还需要海拔和速度,可以再扩展一帧类型,不要硬塞进同一个包。
发送函数用LoRaWAN中间件提供的数据结构:
static void GPS_SendLoRaFrame(void) { AppData.Port = 2; // FPort,应用服务器按端口区分数据用途 AppData.BufferSize = 12; AppData.Buffer[0] = 0x01; AppData.Buffer[1] = gps_fix.fix_status | (moving ? 0x02 : 0x00); int32_t lat = (int32_t)(gps_fix.latitude * 10000000); int32_t lon = (int32_t)(gps_fix.longitude * 10000000); memcpy(&AppData.Buffer[2], &lat, 4); memcpy(&AppData.Buffer[6], &lon, 4); AppData.Buffer[10] = gps_fix.sat_num; AppData.Buffer[11] = (uint8_t)(gps_fix.hdop * 10); LoRaMacStatus_t status = LoRaMacSend(&AppData, CONFIRMED); if (status != LORAMAC_STATUS_OK) { printf("LoRa send failed: %d\r\n", status); } }发送时间点上我做了个简单策略:在定位有效且GPS数据发生变化时才发送,连续上报间隔最短30秒,最长10分钟。这样既保证了移动物品的位置实时性,又不会把信道占满。
3.4 网关与服务器侧如何还原数据
网关收到LoRaWAN数据后,会通过UDP Packet Forwarder协议转发给网络服务器。网络服务器完成MAC层处理和应用payload分离,然后把应用数据通过HTTP回调推到我的应用服务器,或者通过MQTT订阅获取。
应用服务器收到12字节数据后,按帧格式反向解析:
import struct def parse_gps_frame(payload): if payload[0] != 0x01: raise ValueError("unknown frame type") fix_status = payload[1] & 0x01 moving = (payload[1] & 0x02) >> 1 lat = struct.unpack('<i', payload[2:6])[0] / 1e7 lon = struct.unpack('<i', payload[6:10])[0] / 1e7 sat_num = payload[10] hdop = payload[11] / 10.0 return { 'fix_status': fix_status, 'moving': bool(moving), 'latitude': lat, 'longitude': lon, 'sat_num': sat_num, 'hdop': hdop }注意我这里用了小端传输。STM32L072是Cortex-M0+内核,默认小端,所以发送时直接memcpy,服务器端用<i解析就能对上。如果服务器端用了大端解析,经纬度会完全错乱。
4. GPS数据质量评估与信号优化
4.1 怎么判断GPS数据是否可信
我最早做这个项目的时候以为GPS定位出来就能用,结果发现基站给的数据质量参差不齐,有的轨迹点跳了几百米。后来我参考了camera、lidar、imu这些传感器领域对数据质量的定义方式,给GPS也建立了一套简单的质量评估规则。
GPS这边我可以观测的指标主要有四类:
| 指标 | 说明 | 阈值建议 |
|---|---|---|
| Fix状态 | 0=无效,1=GPS定位,2=差分定位 | 必须>=1 |
| 卫星数 | 参与定位的卫星数量 | >=4才可用 |
| HDOP | 水平精度因子,反映几何分布优劣 | <2良好,2~5一般,>5差 |
| SNR | 卫星信号信噪比,通常看GSV里的单星SNR | >30 dBHz可用 |
卫星数少不代表定位一定不准,但HDOP大了定位误差一定增大。我实测到HDOP在1.2左右时,固定点漂移大概2到3米;HDOP超过5时,漂移能到10米以上。所以我在节点端做了过滤:只有Fix状态有效、卫星数>=4、HDOP<=5的数据才允许通过LoRaWAN上传,不满足条件就缓存起来等下一次GPS修正。
4.2 室内无定位和SNR过低的常见原因
在实际测试里最容易遇到的问题是“GPS在室内没有定位”。这其实不是GPS模块坏了,而是GPS信号本身是来自卫星的微波信号,经过混凝土楼板和金属窗框后衰减极大,SNR会掉到20 dBHz以下甚至完全搜不到星。我把GNSS天线贴在窗边能勉强定位,但SNR也在25 dBHz边缘闪烁,定位精度很差。
要验证是不是信号问题,一个很实用的办法是用串口工具直接看模块输出的GSV原始语句,里面带每颗卫星的SNR。如果在室外开阔地SNR都在35~45 dBHz,进室内直接掉到20以下,那就不是模块的问题,是天线位置的问题。SNR还要区分L1和L5频段,普通单频模块只有L1,不要拿双频模块的阈值来套。
4.3 天线摆放和去噪策略
GPS天线是所有环节里最值得投资的地方。我一开始用的是模块自带的陶瓷贴片天线,藏在塑料壳里,固定点漂移在3~5米。后来换成外置有源天线,加了一个低噪声放大器,漂移降到2米以内,而且冷启动时间从40秒缩短到25秒左右。
天线摆放口诀是“朝天、见光、远离金属”:陶瓷天线正面要朝向天空,不能被金属外壳完全遮挡,还要远离主控板上的大面积覆铜和电池。金属会形成屏蔽和反射,导致多径效应,定位点会在真实位置附近来回跳。
软件层面的去噪我做了两个动作。一是做固定点半径判断,如果两次定位距离小于3米,认为车子没动,不重复上报;二是用一个简单的一阶低通滤波平滑位置:
filtered_lat = filtered_lat + (raw_lat - filtered_lat) * 0.3; filtered_lon = filtered_lon + (raw_lon - filtered_lon) * 0.3;系数0.3要按上报周期调。周期短可以取更小,周期长就取大一点。注意这个低通滤波只能减小静止时的抖动,不能解决移动时的真实轨迹平滑,运动状态下用了反而会引入滞后。
4.4 低功耗和高上报频率的平衡
B-L072Z-LRWAN1主打低功耗,但GPS模块才是真正的耗电大头。普通的UART GPS模块在连续定位时功耗在20~40mA,LoRaWAN发送瞬间电流反而只有几十mA但持续时间很短。如果想用电池供电,不能让GPS一直开着。
我的低功耗策略是按需供电:用STM32的一个GPIO控制GPS模块的电源,默认断电,只有需要定位时才提前30秒开GPS,等GGA里的Fix状态变成1之后再采集数据,然后发送LoRaWAN,最后断电。这样可以把平均电流从几十mA降到几mA,具体取决于上报频率。
LoRaWAN的Class A模式本身就是下行接收最少、功耗最低的模式,节点只在发送后短暂开两个接收窗口,其余时间全部休眠,这非常适合电池供电的GPS追踪器。
5. 常见问题与调试心得
5.1 故障排查速查表
我把调试过程中遇到的典型问题整理成一张表,按现象、原因、解决方案三个维度写,方便你定位:
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| GPS串口无数据 | 模块没供电或TXD/RXD接反 | 先量VCC,再用逻辑分析仪看TXD是否有波形 |
| 串口有数据但无法解析 | NMEA语句不完整或波特率不对 | 用串口助手看原始输出,确认是否可见完整$GPGGA |
| 一直无Fix | 天线位置遮挡或模块没搜到星 | 到室外开阔地测试,检查天线是否朝上 |
| LoRaWAN发送失败 | 入网未成功或发送间隔太短 | 查看协议栈返回状态,确认是否JOINED,增大上报间隔 |
| 服务器收不到数据 | 密钥不匹配或payload长度超限 | 核对DevEUI/JoinEUI/AppKey,检查数据长度 |
| 经度纬度严重偏差 | 度分格式未换算 | 用地图软件对比,确认换算公式 |
| 轨迹点跳变 | 多径效应或HDOP过高 | 看GSV的SNR,换天线位置,增加HDOP过滤 |
5.2 调试时必须留一手的几个位置
LoRaWAN协议栈调试起来比普通串口麻烦,因为空中包看不见摸不着。我的经验是三个阶段分开验证:
第一阶段先不接LoRaWAN,只调试GPS。用板载USART2接GPS,电脑串口打印原始NMEA和解析结果,确认经纬度正确。第二阶段再用ST官方的端到端例程发固定payload,验证网关和服务器链路通。第三阶段才把GPS解析数据填进LoRaWAN帧里,这样出了任何问题都能二分定位。
还有一个细节:B-L072Z-LRWAN1的SX1276和主控之间通过SPI通信,SPI时钟频率不要超过SX1276的最大规格,配置太高会导致收发不稳定。LoRaWAN发送失败时协议栈一般会返回一个错误码,比如LORAMAC_STATUS_BUSY表示上次发送还没完成,LORAMAC_STATUS_NO_CHANNEL_FOUND表示没有可用信道,先把这些错误码打印出来再怀疑射频硬件。
5.3 后续功能扩展思路
如果你不满足于只传GPS数据,这个板子还有不少扩展空间。我后期在项目里加了几个功能:通过LoRaWAN下行命令远程修改上报频率,实现了免拆机的参数调整;在节点端加了一路ADC采集电池电压,电量低于阈值时主动上报告警;此外还在同一颗STM32L072上接了温湿度传感器,一个节点同时采集环境和位置数据。
LoRaWAN本身协议栈里就带了掉线重入网、自适应数据速率(ADR)这些功能。ADR开启后,网络会根据节点的接收情况自动调整速率和发射功率,设备离网关近的时候自动用更高数据率,在省电的同时减小信道占用,这个功能在初期不需要人工干预,建议直接打开。
老实说,GPS数据经LoRaWAN传输这个组合,真正的技术难点不在LoRaWAN,而在GPS数据的采集质量和节电策略。协议栈是现成的,出问题最多的地方永远是天线位置、串口波特率和经纬度换算。先把这几块吃透,这个项目就成功了一大半。