STM32下NB-IoT模块AT指令状态机设计与优化
发布时间:2026/10/2 3:39:52来源:尧图网络
去年做野外数据采集项目用STM32L4挂NB-IoT模块上报传感器数据。设备在机房跑得好好的一到现场连续跑几天就开始掉线。后来我把串口调试器接上去看到模块返回的乱序数据才明白——问题不在NB-IoT信号而在MCU这边处理AT指令的方式。那段时间我把AT指令从阻塞式delay改成状态机驱动踩了一堆坑也总结出不少经验。这篇就聊聊STM32下NB-IoT模块AT指令状态机的设计与优化给同样在做窄带物联网设备的开发者做个参考。1. NB-IoT模块的AT指令为什么比普通WiFi模块更难伺候1.1 响应时间波动大阻塞式delay必然翻车先说阻塞方式的痛点。很多STM32工程写AT指令是这样的send_cmd(ATCSQ); delay_ms(500); wait_for_ok();这种写法在WiFi模块上勉强能用因为WiFi模块响应通常很快几十毫秒内就能回。NB-IoT模块情况完全不一样模块要搜网、注册、附着数据网络AT指令经常要1到3秒才有响应极端情况下上电后第一条AT等到5秒都算正常。代码里delay 500ms根本不够delay 5s又会阻塞其他任务。比如同时要读传感器、翻转LED、维护心跳一旦主循环卡在delay里整机逻辑就僵住了。有人会用HAL_UART_Receive阻塞接收但NB-IoT模块响应时间波动非常大依赖固定延时等待的代码调试时能跑通现场网络差一点就原形毕露。核心矛盾是“模块响应是异步的”而你却用同步的方式等待。实际在弱信号场景下一条ATCGATT?的响应从几十毫秒到三四秒都出现过任何固定延时都没法全覆盖。1.2 主动上报事件打乱“一问一答”节奏NB-IoT模块和SPI传感器不一样它不仅会回答你的问题还会“主动汇报”。典型场景是下行数据模块收到服务器下发数据时会上报NNMI: 6,010203...这样的URCUnsolicited Result Code主动结果码。这个上报可能发生在任何时刻比如你在等OK的时候突然插入一行URC。如果代码只按“发AT-等OK”的流程写这行URC会被当成垃圾数据丢掉下行指令就漏了。更严重的是某些模块在URC里还带状态变化比如CGEV: ME PDN DEACT表示PDN连接断开如果漏掉业务层还在傻傻地发数据而模块其实已经掉线了。我碰到过一次设备一直上报成功但服务器端已经几个小时没收到数据查来查去就是URC被吞了模块掉线后业务层还蒙在鼓里。1.3 状态机解决的核心矛盾其实是“异步”所以我在STM32工程里引入状态机的根本原因不是炫技而是为了把“AT请求-响应”这种异步流程拆解成一个个离散状态。状态机的思路很简单每个时刻我只关心“当前处于什么状态收到了什么事件迁移到哪个状态”而不是在一大段流程代码里等着串口数据。我把状态机放在主循环里轮询UART接收中断只负责往行缓冲里填字节解析到一行完整数据后投递一个事件状态机拿到事件后做状态迁移。这样MCU永远不会在等待AT响应时卡死该读传感器、该刷心跳照常做。这个方法不是最花哨的但对MCU这类资源有限的平台非常合适而且错误定位容易打印一下当前状态和收到的行就知道问题在哪。2. 把状态、事件、迁移表画明白再动键盘2.1 最小状态集别把业务逻辑塞进状态机状态机最容易犯的错误是贪多把业务逻辑全塞进去。我的做法是只保留与“AT交互流程”相关的状态状态含义IDLE空闲可以发送AT指令WAIT_ACK等待单行/标准响应OK/ERRORWAIT_MULTILINE等待多行响应直到出现OK/ERRORWAIT_URC等待模块主动上报BOOT_WAIT模块上电后等待READY/URC业务层不要直接操作这些状态。比如“查询信号强度后显示在OLED上”业务层只需要调用at_send_cmd(ATCSQ, callback)状态机内部负责迁移和超时回调里再写显示逻辑。这样状态机的改动就很克制测试也简单。如果一开始就把“低电量上报”“定时发送”这类业务状态混进来状态数会膨胀到十几个排查问题的时候很难分清是业务逻辑错还是AT交互错。2.2 事件定义串口数据怎么变成长度有限的event事件分两类一类是外部驱动的比如业务层要发命令、定时器超时、模块复位另一类是串口数据解析出来的比如收到一行以\r\n结尾的数据、解析到OK、解析到URC。我建议先统一成这样几个事件typedef enum { AT_EVT_SEND_REQ, AT_EVT_LINE, AT_EVT_OK, AT_EVT_ERROR, AT_EVT_URC, AT_EVT_TIMEOUT, AT_EVT_MODULE_RESET, } at_event_t;这里的AT_EVT_OK和AT_EVT_ERROR是解析层从AT_EVT_LINE里剥离出来的不是串口直接生成的。也就是说串口解析层负责“按行分帧”AT状态机负责“按语义判断”。两层分开的好处是换不同厂家的NB-IoT模块时只需要改解析层的匹配规则状态机主体不用动。2.3 迁移表先画纸再写代码我见过不少同事上来就写switch-case写了200行发现漏状态。正确流程是把迁移表画好再写代码。简单梳理一下核心迁移IDLE 收到 SEND_REQ - WAIT_ACK启动超时计时器WAIT_ACK 收到 OK - IDLE执行回调WAIT_ACK 收到 ERROR - IDLE执行错误回调WAIT_ACK 收到 URC - 处理URC状态不变停留在WAIT_ACKWAIT_ACK 收到 TIMEOUT - 记录错误进入IDLEWAIT_ACK 收到 LINE 但既不是OK也不是ERROR/URC - 继续等不迁移这些内容用表格列一列写代码的时候只是机械翻译。实际项目里我就是先画表把表和同事一起review确认再动手写后面几乎没有因为状态机逻辑本身返工过。3. STM32上的AT状态机骨架状态机怎么和串口、定时器配合3.1 状态枚举、事件结构和迁移函数给出代码骨架typedef enum { AT_STATE_BOOT_WAIT 0, AT_STATE_IDLE, AT_STATE_WAIT_ACK, AT_STATE_WAIT_MULTILINE, AT_STATE_SLEEP, } at_state_t; typedef struct { at_state_t state; uint32_t timeout_start; uint32_t timeout_ms; const char *expect_prefix; void (*on_done)(at_response_t *resp, void *user_ctx); void *user_ctx; } at_machine_t;迁移实现不一定是每个状态一个case我比较喜欢用“当前状态 事件”检索迁移表static at_state_t at_handle_ack(at_event_t ev); static at_state_t at_handle_multiline(at_event_t ev); at_state_t at_machine_process(at_machine_t *m, at_event_t ev) { switch (m-state) { case AT_STATE_IDLE: if (ev AT_EVT_SEND_REQ) { m-timeout_ms at_get_cmd_timeout(m-pending_cmd); m-timeout_start HAL_GetTick(); m-state AT_STATE_WAIT_ACK; } break; case AT_STATE_WAIT_ACK: m-state at_handle_ack(ev); break; default: break; } return m-state; }这里有个容易被忽略的细节timeout_start必须在迁移到WAIT_ACK时立刻记录而不是在事件循环里统一记录。两种做法看起来差不多但如果在事件循环里统一判断一旦状态机处理函数里有耗时操作超时时间就被缩短了。3.2 UART接收缓冲行缓冲与半包处理这是AT状态机能不能稳定运行的关键前置。我推荐STM32用HAL_UART_Receive_IT加中断逐字节收集或者用DMA加空闲中断按行收。简单工程用逐字节中断就够了。一个行缓冲实现#define AT_LINE_BUF_SIZE 256 static char at_line_buf[AT_LINE_BUF_SIZE]; static uint16_t at_line_len 0; static uint8_t at_line_ready 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart ! huart_at) return; char ch at_rx_byte; if (ch \n) { at_line_buf[at_line_len] \0; at_line_ready 1; at_line_len 0; } else if (ch ! \r) { if (at_line_len AT_LINE_BUF_SIZE - 1) { at_line_buf[at_line_len] ch; } } HAL_UART_Receive_IT(huart, (uint8_t *)at_rx_byte, 1); }主循环里每轮检查at_line_ready为1就解析并投递AT_EVT_LINE。最关键的是无论模块一次返回多少字节都不会因为中断里处理逻辑太重而丢字。如果主循环里还有LCD刷新、传感器读取之类的耗时任务行缓冲就能保证URC这种突发数据不丢。3.3 超时重试如何与状态机联动状态机必须有个超时机制不然模块没回包状态就永远锁死在WAIT_ACK。我在主循环里调用void at_machine_tick(at_machine_t *m) { if (m-state ! AT_STATE_IDLE m-state ! AT_STATE_BOOT_WAIT m-state ! AT_STATE_SLEEP) { if (HAL_GetTick() - m-timeout_start m-timeout_ms) { at_machine_process(m, AT_EVT_TIMEOUT); } } }超时后要做三件事把状态拉回IDLE、回调告诉业务层超时、计数重试。重试次数一般是2到3次超过就上报错误。不能无限重试否则低电量、信号差的情况下设备会一直卡在发包流程里。以我实际的使用来看普通AT指令超时设3秒网络注册类指令设8秒发数据指令设5秒比较稳妥。时间太短在现场弱网环境容易误判时间太长则会拖累整个业务循环。4. 调试NB-IoT状态机我踩过的四个真实坑4.1 坑一模块上电自检未完成首条AT“石沉大海”表现复位STM32后程序马上发ATCSQ等了几秒一直没有OK状态机超时重试两三次都无响应但手动触发一次重试又正常了。根因NB-IoT模块上电后要经过启动、搜网、附着等过程中间会主动输出一行URC比如有的模块输出RDY有的输出NBIOT:READY。主机在这行URC之前发过去的AT指令模块根本来不及处理直接丢弃。修改方案状态机增加BOOT_WAIT状态。系统上电后先等模块的启动URC到达收到后才进入IDLE并允许发AT。启动URC不同模块不一样需要查模块手册但原理相同。这样处理之后上电瞬间的丢指令问题就彻底解决了。我当时还顺手在BOOT_WAIT阶段加了3秒总超时如果3秒还没等到模块的启动URC就主动拉一次复位引脚把模块重启避免模块偶发启动异常。4.2 坑二响应粘包导致OK被拆成两行表现状态机有时会把OK误判成超时查日志发现收到的一行数据是OK但前面带着半截响应内容比如99\r\nOK。根因问题出在串口接收的时机上。如果模块返回CSQ: 22,99\r\nOK\r\n串口中断逐字节收集没问题我用行缓冲等\r\n分帧应该能把CSQ: 22,99和OK分开。但如果用DMA收固定长度或者某些低功耗模式下串口时钟不校准可能把99\r\nOK收成同一段。更常见的其实是中断处理里有耗时操作导致半包期间丢字节。排查过程我先在中断里只留了一个字节变量排除中断耗时然后确认行缓冲逻辑最后把“收到\r\n再置位”改成“收到\n后再忽略\r”问题解决。经验是NB-IoT模块的换行符通常是\r\n分帧一定要同时处理两个字节不能只认\n否则偶发粘包会顽强复现。调试时可以打开模块的回显关闭功能ATE0减少串口数据量也能让分帧更干净。4.3 坑三多行响应与URC的优先级冲突表现发送ATNMGS发数据时模块在下行到达时主动上报一行NNMI: 6,010203...状态机没处理这行URC导致后续OK行被误判成异常数据。根因状态机在高频收发时URC和命令响应可能交织在一起。如果解析层把“非当前命令预期前缀”的所有行都当成错误URC就被丢弃了。排查过程我通过日志看到ATNMGS发出后收到NNMI行接着收到OK。但业务层的回调里拿到的响应数据却是URC的内容明显是解析层把URC当成了命令响应。修改方案解析层对每一行先做URC匹配只要匹配到NNMI:、CGEV:这类开头就直接投递AT_EVT_URC状态机收到URC时只做数据缓存和回调通知不改变当前等待状态然后再把当前行与命令期望前缀做匹配。这个优先级顺序写在状态机进入WAIT_ACK前的解析函数里不要放在业务回调里。这样URC永远不会被误吃同时也不会打断正在等待的命令响应。4.4 坑四低功耗PSM模式下状态机“睡死”表现为了省电启用了PSMPower Saving Mode设备休眠一段时间后用AT指令查询信号时模块始终不回状态机超时后进入错误处理设备彻底与服务器断开。根因PSM模式下模块已经进入休眠串口不再接收指令。主控不知道模块已经休眠还按正常时序发AT并等待响应结果必然超时。状态机只考虑了通信交互没把电源状态纳入状态迁移。修改方案把休眠流程也纳入状态机。在进入PSM前先发ATCPSMS或ATPSM配置等模块确认后再让模块进入休眠主控侧把状态迁移到AT_STATE_SLEEP。唤醒时通过外部事件比如定时器或按键触发先给模块一个唤醒时序有的模块需要给串口发一个字节唤醒再迁移到IDLE。这一步是很多新手最容易忽视的状态机不只是“AT响应的状态机”它还得管“模块电源状态的同步”。实测下来休眠指令从发出到模块真正睡过去可能延迟几百毫秒主控不能发完配置就立刻切换状态要等模块回OK、确认已经attach好再睡。5. 状态机稳定运行之后值得做的三处优化5.1 命令层和业务层分离一开始状态机是为单条AT命令服务的但项目跑起来后业务上经常要轮巡查信号、注册状态、发数据命令多了状态机如果还只处理一条其他命令只能排队。建议把状态机封装成“一次只处理一条命令但命令可排队”的结构typedef struct { char cmd[64]; char expect_prefix[16]; uint32_t timeout_ms; void (*callback)(at_response_t *resp, void *ctx); void *ctx; } at_cmd_t;业务层调用at_cmd_send(cmd)状态机内部维护一个环形队列一次只pop一条命令处理处理完再pop下一条。这样业务代码不用关心当前是否有命令在执行只要往队列里塞。这个封装在FOTA升级场景特别好用升级流程里要连续查询版本号、切换激活区、重启模块每条命令的时序都由状态机控制业务层只需要按顺序塞队列。5.2 打印状态迁移日志状态机调试最大的优势就是可观测。我在工程里加了一个调试开关#define AT_DEBUG_ENABLE 1 #if AT_DEBUG_ENABLE #define AT_DEBUG(fmt, ...) printf([AT] fmt \r\n, ##__VA_ARGS__) #else #define AT_DEBUG(fmt, ...) #endif关键位置都打点状态发生迁移时AT_DEBUG(state %d - %d, line%s, old, new, at_line_buf);超时产生时AT_DEBUG(timeout state%d cmd%s, m-state, m-pending_cmd);解析到URC时AT_DEBUG(urc line%s, line);现场调试时串口打印量不要太大状态迁移日志按事件打不要逐字节打。我通常只看状态迁移和URC两行95%的问题都能定位。这里多说一句如果设备本身省电要求高调试日志影响功耗可以用一个编译宏把AT日志整体关掉发布版本只留错误码不用逐行删代码。5.3 多路AT命令排队与优先级带优先级排队是压榨状态机价值的最后一步。查询信号这类命令优先级低发数据命令优先级高。队列里插入时做一次优先级排序紧急命令插到队头普通命令追加队尾。实现不复杂static uint8_t at_queue_insert(at_cmd_t *cmd, uint8_t urgent) { if (urgent) { // 从队头开始找第一个非urgent的位置插到它前面 } else { // 追加到队尾 } }优先级设计对NB-IoT场景很重要因为NB-IoT无线链路建立很慢高优先级命令可以抢在队列前面发起减少链路空闲等待。我在项目里把“发送上行数据”设为最高优先级因为平台下发指令可能随时到达而普通的状态查询命令可以等当前命令栈放空后再执行。带优先级队列后整个状态机的吞吐能力明显提升靠一条串口能把上报、下行、心跳、信号轮巡全部跑顺。这些优化做完后状态机已经从最初“勉强能跑”变成“能支撑多业务并发”。我现在的NB-IoT项目里传感器上报、FOTA升级、平台下行指令都在同一套状态机上跑代码量虽然多了三分之一但稳定性和可维护性提高了一个档次。调试设备的时候看到状态日志基本不用猜模块到底干了什么。这套思路同样适配其他用AT指令控制的模块比如4G、蓝牙、WiFi模块只要你把URC前缀和启动流程换成对应模块的参数就行。
网站建设高端定制企业官网