尧图精选

STM32+NB-IoT模组AT指令状态机设计:从阻塞到事件驱动

🕒 发布时间:2026/10/2 1:23:34 📁 来源:尧图网络
忘了从哪一年开始NB-IoT 项目的调试时间有一半以上不是花在业务逻辑上而是耗在“AT 指令怎么还没回”“这条消息怎么又断了”“模组怎么又挂死了”这些破事上。尤其用 STM32 做主控、外挂 NB-IoT 模组时串口上的 AT 指令交互看起来只是“发一句收一句”真正上了产品却各种花式翻车。今天这篇就集中聊聊我落地过的一整套 AT 指令状态机设计与优化思路把踩过的坑和最终沉淀下来的方案一次说透。这个方案适合谁如果你正在做 STM32 NB-IoT 模组BC26/BC35/M5310 这类的项目或者打算把 AT 指令交互从“阻塞式一问一答”升级成“事件驱动状态机”这篇文章可以直接当设计参考。我会把架构拆开、代码结构和关键参数一并摆出来附带大量实测遇到的坑保证和你在工位上折腾时碰到的问题对得上号。1. 先聊清楚阻塞式 AT 指令到底坑在哪1.1 你以为的“发一条回一条”全是假象很多第一次做 NB-IoT 开发的工程师对 AT 指令的印象停留在“发一句、等一句、收一句”。串口助手里敲ATCSQ模组回一个CSQ: 15,99一切完美。但一接上 STM32 主控事情就开始不按剧本走了。NB-IoT 模组本质上是一部微型通信终端它内部有协议栈、有网络注册流程、有数据缓存。你发出去一条指令它可能隔 100 毫秒回也可能隔 20 秒才回。尤其是ATCGATT1附着网络、ATCEREG?查询注册状态、ATCOAPTX发送 CoAP 数据这类和网络侧交互的指令动不动就要等很久而且延时完全不可预测跟模组当前所在的网络环境、信号强度、核心网状态都有关系。用HAL_UART_Receive阻塞接收、或者串口中断里收到一个OK再放行这种方式在实验室里好像能跑通但到现场就崩。网络一波动模组回包晚了 500 毫秒你的代码还在 while 循环里死死等着看门狗先不干了直接把 MCU 复位然后模组没收到完整下发指令双方就这样僵住了。1.2 串口乱序和被动上报才是压死骆驼的最后一根稻草比响应慢更头疼的是模组不会老老实实“你问什么它就答什么”。它随时可能主动上报一堆数据比如网络注册状态变化时上报CEREG: 1信号强度周期性上报CSQ: 12,99下行业务数据到达时上报NNMI: 5,1234567890或者COAPRX模组自己重启、进 PSM、醒来时打印各种提示符这些被动上报的消息不受你的控制它们可能在两条 AT 指令响应之间突然插进来。如果你用“发一条、等一条”的阻塞流程就会遇到以下状况明明在等COAPTX的发送结果结果模组先吐出一串下行业务数据你的一串代码直接判定“超时异常”然后重发指令下行业务数据却丢了后面用户投诉收不到数据你排查到半夜也找不到原因。所以我说AT 指令交互的底层逻辑根本不是一个“请求—响应”模型而是一个“多路异步事件流”。谁把它当同步请求做谁就要做好在现场丢数据的心理准备。1.3 状态机就是给混乱的串口世界立规矩状态机的价值不是说它让代码变得“高级”而是它把“不可预测的异步事件流”硬生生掰成了“一组有限的、可穷举的状态迁移”。你的代码不再是跟着模组的节奏走而是自己想清楚当前我在等什么、来了一个消息该去哪、来了一个超时该干嘛。对我个人而言在 STM32 这种资源受限的 MCU 上状态机最大的好处有三个主循环可以一直跑其他任务不必卡在某一条 AT 指令上干等无论模组什么时候回包、先回哪个包都能正确归位到对应状态状态可枚举、可打日志现场出问题时能够精确还原交互过程而不是靠猜。这一篇要讲的状态机就是针对 AT 指令交互场景把串口接收、指令流程、超时重试、异步上报全部统一进一套可维护的框架里。2. 状态机整体架构双状态机各管一摊2.1 接收侧状态机先把字节流变成“一行行数据”这里我强烈建议把串口接收的数据解析单独做成一个状态机不要和指令流程状态机混在一起。串口来的是一堆裸字节它们会被 TCP/IP 协议栈打包、又经过串口 DMA 搬运、零零散散地到达 MCU。你首先要做的是把这些字节拼接成一行一行的完整 AT 响应。接收状态机的状态可以这样定义RX_IDLE: 空闲等待行首RX_HEADER: 收到\r下一步判断是不是\nRX_BODY: 正在累积一行内容直到碰到\r\nRX_LINE_DONE: 一行完整数据已经进入缓冲区触发事件很多人不理解为什么要把这么简单的“分割行”做成状态机。真实原因是串口 DMA 中断可能一次搬来 1 个字节、可能搬来 100 个字节也可能一个字节被拆成两次中断数据随时会被截断。你如果只在收到\n时才去处理就会漏掉被截断的半行。正确做法是把收到每个字节都喂给接收状态机由它负责拼行、校验行尾再把完整的一行交给上层。这里还要处理一个细节有的模组行尾是\r\n有的是单独\n还有的会在OK后面加空格。建议统一用“遇到\n就认为一行结束”然后自动剔除行尾的\r和空白再往上层抛。2.2 指令流程状态机负责“发什么、等什么、超时怎么办”经过接收侧状态机清洗之后上层拿到的是一行行干净数据。这时指令流程状态机就有活干了。它管的是“当前命令的执行阶段”比如CMD_IDLE: 无指令执行中CMD_SYNC_SENT: 已发送 AT 同步指令CMD_CSQ_SENT: 已发送信号查询CMD_ATTACHING: 正在等待网络附着结果CMD_SENDING: 正在发送业务数据CMD_WAIT_CONFIRM: 已经收到数据等待模组确认这套状态机的本质是一个“状态迁移表”每个状态收到特定事件比如“收到 OK”“收到 ERROR”“超时”迁移到下一个状态。在 STM32 这种 MCU 上我不推荐用复杂的框架比如 QP 状态机直接用switch-case或者函数指针表就够了轻量、直观、容易调试。2.3 为什么拆成两个状态机而不是一个可能有人会想串口接收不就一个状态机吗指令流程再套一个不是更复杂吗依我的实践拆开反而更清晰。接收状态机很“机械”它只处理字节不关心业务指令流程状态机很“业务”它只处理完整行不关心字节怎么拼。二者通过一个环形缓冲区解耦——接收状态机往缓冲区里塞“行”指令流程状态机从缓冲区里取“行”做决策。这样做的好处是两个模块可以独立测试、独立修改而且将来换一个模组型号、换一套 AT 指令只需要改指令流程状态机接收和解析完全不用动。串口链路层的问题断包、粘包、乱码统统在接收状态机里解决业务指令的问题超时、重试、并发统统在指令流程状态机里解决。这就是我在项目里一直推荐“双状态机”架构的根本原因。3. 核心实现可直接落地的代码框架3.1 环形缓冲区与串口接收解析层先看接收侧这是整个状态机的“入口”。我用一个环形缓冲区rx_ring接收串口 DMA 中断的数据再让接收状态机一字节一字节地消费拼成一行后存入rx_line_queue。之所以不用“直接在 DMA 中断里拼行”是因为中断里不宜做太多逻辑且行解析如果出错会影响中断实时性。下面是参考实现采用 STM32 HAL 库 串口空闲中断方式数据到达后进入中断回调#define RX_RING_SIZE 512 #define RX_LINE_MAX_LEN 256 typedef enum { RX_IDLE 0, RX_HEADER, RX_BODY, RX_LINE_DONE } rx_state_t; typedef struct { uint8_t buf[RX_RING_SIZE]; uint16_t head; uint16_t tail; } ring_buffer_t; static ring_buffer_t rx_ring; static uint8_t rx_line[RX_LINE_MAX_LEN]; static uint16_t rx_line_len; static rx_state_t rx_state; void UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { uint8_t byte 0; while (HAL_UART_Receive(huart, byte, 1, 0) HAL_OK) { ring_write(rx_ring, byte); } // 这里关闭中断或交给 DMA 空闲中断再开启下次接收 } } void rx_state_machine_run(void) { while (!ring_is_empty(rx_ring)) { uint8_t byte ring_read(rx_ring); switch (rx_state) { case RX_IDLE: rx_line_len 0; rx_state RX_HEADER; /* 注意这里需要再处理一次当前字节所以不 break而是 fallthrough 到 HEADER */ if (byte \n) { /* 空行继续等待 */ rx_state RX_IDLE; } else if (byte ! \r) { rx_line[rx_line_len] byte; rx_state RX_BODY; } break; case RX_BODY: if (byte \n) { rx_line[rx_line_len] \0; rx_state RX_LINE_DONE; rx_line_queue_push((char *)rx_line, rx_line_len); rx_state RX_IDLE; } else if (byte ! \r) { if (rx_line_len RX_LINE_MAX_LEN - 1) { rx_line[rx_line_len] byte; } else { /* 超长行丢弃并重新开始 */ rx_line_len 0; rx_state RX_IDLE; } } break; default: rx_state RX_IDLE; break; } } }代码里有几个细节值得特别说明首先是RX_HEADER状态的引入。如果第一个字节是\r后面通常跟\n这是一个空行应当忽略如果第一个字节就是普通字符那就直接进入RX_BODY。处理\r\n和单独\n混用的情况都靠这层“软化”逻辑来兜底。其次是“超长行保护”。有的模组异常时会吐出一大串缓存数据如果没有长度保护缓冲区溢出就会踩到 STM32 的栈或全局变量导致不可名状的死机。我在项目里遇到过一次模组升级固件后疯狂打印调试信息直接把 MCU 内存打爆后来加了保护才稳住。再次环形缓冲区一定要在中断里写、在主循环里读读写双方靠head和tail判断空满注意不要在中断里做重逻辑尽量保持精简。3.2 指令流程状态机的骨架这一层的核心是“事件驱动”。所谓事件就是“收到了一行完整数据”或者“超时了”。指令流程状态机根据当前状态 事件类型决定要做什么、切到哪个状态。下面是一个简化但完整的框架以“查询信号强度并上报一次数据”为例typedef enum { CMD_IDLE 0, CMD_AT_SENT, CMD_CSQ_SENT, CMD_ATTACH_WAIT, CMD_COAP_SENT } cmd_state_t; typedef enum { EV_LINE_RECEIVED 0, EV_TIMEOUT, EV_URC } event_type_t; typedef struct { event_type_t type; const char *line; } event_t; static cmd_state_t cmd_state; int at_cmd_line_handler(const char *line) { if (strstr(line, CSQ:) ! NULL) { /* 解析信号值比如 CSQ: 15,99 */ int rssi 0; sscanf(line, CSQ: %d, rssi); if (rssi 99) { /* 无信号重试或进入低功耗 */ } cmd_state CMD_ATTACH_WAIT; at_send(ATCGATT1\r\n); return 0; } if (strstr(line, OK) ! NULL) { switch (cmd_state) { case CMD_AT_SENT: at_send(ATCSQ\r\n); cmd_state CMD_CSQ_SENT; break; case CMD_ATTACH_WAIT: at_send(ATNSOCRDGRAM,53,5683,1\r\n); cmd_state CMD_COAP_SENT; break; default: break; } return 0; } if (strstr(line, ERROR) ! NULL) { /* 指令出错统一做重试或恢复处理 */ cmd_state CMD_IDLE; return -1; } return 0; } void at_cmd_event_process(event_t *ev) { if (ev-type EV_LINE_RECEIVED) { if (is_urc_line(ev-line)) { /* 异步上报单独处理不干扰主流程 */ urc_handle(ev-line); } else { at_cmd_line_handler(ev-line); } } else if (ev-type EV_TIMEOUT) { /* 超时处理重发当前指令或者复位模组 */ at_cmd_timeout_retry(); } }这段代码真正关键的是对OK的响应与当前状态挂钩。如果没有状态只有一句if (strstr(line, OK))那你根本不知道该往哪儿走。配合cmd_state之后每个“OK”都有了上下文流程才是一环扣一环的。超时处理我单独拉出来说因为这是最容易写崩的地方。我的经验是每发送一条指令就记录一个时间戳主循环里检查当前时间与时间戳的差值超过预设超时值就触发EV_TIMEOUT。超时的默认动作是重发但重发次数要有限制比如同一个指令最多重发 3 次超过就进入错误恢复分支比如ATCFUN0再ATCFUN1或者干脆拉低 PWRKEY 硬复位模组。3.3 关于“一个状态机流程”和“多条指令并发”有些场景下你以为你是“一条指令发完再发下一条”但模组有自己的节奏。比如在入网阶段你要依次发AT同步ATCSQ查信号ATCGATT1附着网络ATCEREG?查注册ATNSOCR创建 socketATNSOST发送数据如果把这些写成“发一条、阻塞等一条”代码是简单了但一来会卡死主循环二来万一某一条没返回OK整个流程就得从头再来。有人因此改成“发完所有指令再一起等”结果模组根本不按顺序回状态全乱。正确做法是老老实实把每个步骤都定义成一个状态每一步只做一件事收到对应的“成功”响应才进入下一步。我见过比较常见的一个坑是ATCGATT1发出去后模组会先回一个OK然后过很久才回CGATT: 1。这时候你不能在收到OK后立刻就发ATCEREG?否则模组内部还没完成附着注册查询永远是 0。正确答案是收到OK后继续等CGATT: 1再往下走。这类“先 OK 后异步结果”的情况在 NB-IoT 模组里很普遍ATCOAPTX、ATNSOST也类似。尤其发送数据类指令模组先回OK表示“已收到命令”再回COAPTX: 0表示“发送成功”。如果你把“OK”当成发送成功那恭喜你统计上报成功率的时候会自欺欺人。4. 优化手段响应多样性、超时与内存调优4.1 指令匹配策略从“关键字包含”升级为“正则可配”在实际项目里模组返回的内容五花八门。同一个CSQ指令可能返回CSQ: 15,99 CSQ: 99,99 CSQ: 0,0也可能因为网络差返回CSQ: 99,99 ERROR更头痛的是有些模组在信号差时不返回ERROR而是返回CME ERROR: 50这种扩展错误码。如果在状态机里只匹配简单的ERROR就会漏掉CME ERROR导致状态机永远卡在“等待结果”的状态。所以优化匹配策略很重要。我建议把事件匹配规则抽出来做成一张表每条规则包含“关键字前缀”和“回调函数”然后按优先级逐条匹配typedef struct { const char *pattern; int (*handler)(const char *line); int priority; } at_event_rule_t; static const at_event_rule_t rules[] { { COAPTX:, at_coaptx_result_handler, 1 }, { CSQ:, at_csq_handler, 2 }, { CGATT:, at_cgatt_handler, 2 }, { CEREG:, at_cereg_handler, 2 }, { OK, at_ok_handler, 9 }, { ERROR, at_error_handler, 9 }, { CME ERROR, at_cme_error_handler, 9 }, };匹配规则按优先级排序特殊业务关键字如COAPTX:要放在通用关键字如OK之前。为什么因为模组可能返回一行COAPTX: 0\r\nOK如果先把OK匹配了就会错过“发送成功”这个更重要的信息。把特殊规则优先级调高先匹配到COAPTX:的业务结果下一行OK再走通用流程。4.2 超时参数要“因指令而异”这是很多项目优化不到位的重灾区。超时参数如果统一设成一个值要么太短导致频繁重发要么太长导致故障恢复慢。拿我实测过的一组典型指令来说指令含义典型响应时间建议超时AT同步测试5-50ms500msATCSQ信号查询10-200ms1sATCGATT1附着网络1-15s20sATCEREG?注册状态查询100ms-3s5sATNSOCR创建socket10-500ms2sATNSOST发送UDP数据100ms-3s5sATCOAPTXCoAP发送500ms-10s15s网络附着和 CoAP 发送是最容易超时的两个点因为网络侧要做鉴权、核心网交互。把超时时间设置得太短比如统一 3 秒会导致“明明能成功但因为回得慢被误判失败”。我的做法是给每条命令单独定义超时值和重试次数状态机里有一个at_config_entry表里面保存了“指令内容、成功关键字、超时值、重试次数”。有人可能担心超时太长会拖低实时性其实不会。状态机是异步的超时等待期间 MCU 照样可以处理其他任务比如按键、显示、传感器采样。你只是设了一个“最后期限”并不是真的占着 CPU 空转。4.3 URC 异步上报的隔离处理URCUnsolicited Result Code主动上报是 AT 状态机里最容易翻车的地方但处理思路也比较成熟。核心原则就一句话URC 永远不要干扰主命令流程单独开一条绿灯通道。举个例子模组注册成功后会主动上报CEREG: 1这时候如果你的状态机正停在“等待 ATCSQ 返回”收到CEREG: 1应该怎么做正确做法是识别出它是 URC 行执行urc_handle()更新网络状态标志然后继续等待接下来真正需要的CSQ响应。如果错误地把CEREG: 1当成当前指令的响应那状态机就会跑偏等不到真正的信号值最后超时。我提供一个简单实用的 URC 判定方案把所有 URC 关键词枚举出来比如CEREG:、CSQ:有的模组周期性上报、NNMI:、COAPRX:、NORMAL POWER DOWN收到一行数据后先跑一个is_urc_line()判断命中则直接走 URC 通道不进入指令响应的解析函数。但这里有个边界要注意像CSQ:这种既是查询响应、又可能周期性上报的指令你不能简单判定为 URC。我的做法是如果当前状态机在等ATCSQ且收到CSQ:先走“指令响应解析”完成后再看有没有额外数据如果状态机并不在等它那才当成 URC 处理。这里可以通过“当前是否处于等待某条指令”的状态来判断语义非常清楚。4.4 内存与性能优化环形缓冲区大小与行缓冲管理STM32 的资源不像 PC 那样随便挥霍。我见过的典型 NB-IoT 应用主控内存可能只有 20KB-60KB 可用跑完协议栈、业务逻辑后留给 AT 指令框架的区域其实很紧张。我在项目中使用的参数如下串口接收环形缓冲区256-512 字节够用除非你要同时接收大块下行数据否则无需更大指令行缓冲128-256 字节按模组最长一条上报线估算。像NNMI: 5,1234567890这种最多也就几十字节256 字节是安全的发送缓冲区结合你一次最多下发的指令长度一般为 128 字节不推荐使用动态内存malloc做行缓冲。在 MCU 上我统一用静态全局变量配合互斥访问。动态内存容易产生碎片而且一旦内存分配失败整个系统状态难以预测。还有一个小优化串口接收尽量用 DMA 空闲中断。STM32 的 HAL 库有HAL_UARTEx_ReceiveToIdle_DMA这类接口接收一整段数据后触发一次空闲中断可以极大减少 CPU 中断次数。配合前面说的接收状态机每次空闲中断后把新数据搬进环形缓冲区主循环持续消费解析即可。4.5 提升状态机可测试性代码写到后面你会发现真正难的不是“写功能”而是“复现问题”。NB-IoT 信号差、网络抖动很多问题可遇不可求。所以我强烈建议从一开始就给状态机留好“输入模拟口”。也就是把“从串口来的数据”抽象成一个at_feed_line(const char *line)函数调试时可以从调试串口、命令行、上位机直接注入一行 AT 响应完全模拟模组回包。我自己在调试时经常这么干用 USB 转串口工具把 STM32 的调试串口接出来在电脑上写一个小脚本随机延迟发送各种响应和 URC专门用来打压状态机。比如让脚本在收到某个指令后故意延迟 5 秒再回或者先发一个 URC 再回OK或者回ERROR之后又补一行OK这些异常场景用脚本自动化跑一遍状态机的健壮性就很快暴露出来。5. 典型的坑与排查技巧汇总5.1 经典问题速查表很多问题不是个例几乎每个项目都会踩到。我挑几个高频的列成表方便你对照排查现象根因解决方案发送 AT 后无任何响应模组休眠/死机/串口波特率不匹配先发AT再拉低 PWRKEY 硬复位检查串口波特率状态机卡在“等待响应”但串口数据明明来了收到了 URC 被误当成业务响应或匹配规则优先级错误检查is_urc_line()判定逻辑确认匹配规则优先级数据上报成功率低把“OK”误当发送成功模组实际返回COAPTX: 错误码必须等COAPTX:等业务结果单独匹配扩展错误码偶发死机或看门狗复位阻塞式等响应拖死主循环或缓冲区溢出改为状态机异步模型加行缓冲长度保护命令重发后出现多条响应模组已收到但回包延迟超时误判后重发超时时间要按指令单独配置不能一刀切掉线后长时间无法恢复只做指令重试没有做模组级复位规定重试上限超限后执行CFUN0/1或机械复位大量下行数据时串口数据错乱接收缓冲区太小或没有处理粘包/断包加大环形缓冲区接收状态机统一解析行5.2 日志系统怎么打才有效状态机项目最怕“状态不可见”。我建议从第一天就开始打带状态的日志每次状态切换、每次收到一行数据、每次超时重发都要打。调试时可以通过串口直接看状态迁移序列问题一目了然。一个效率很高的做法是把“状态迁移事件”编码成两个字符比如从CMD_AT_SENT到CMD_CSQ_SENT打作A-C这样整个运行日志看起来就是一条简短的事件链定位异常状态停留位置非常快。日志别打太多尤其是主循环里不要每毫秒都打印否则会影响时序。只在“状态变化、收发关键帧、异常分支”这三个点打就足够。5.3 关于模组选型与指令差异的提醒最后提一嘴市面上 NB-IoT 模组虽然大体都是 3GPP 标准 AT 指令但厂家实现细节总有些差异。移远 BC26 的ATNSOCR、中移 M5310 的ATMIPLCREATE、华为方案的ATCOAPTX它们之间绝对不是“改改波特率就能通”的。我在不同项目里换过三次模组每次都要重新核对指令集和 URC 关键字表。所以设计状态机的时候最好把“模组适配层”单独抽出来把所有指令字符串和关键字集中放到一个配置文件里不要散落在业务代码中。将来换模组只需要改适配层业务逻辑完全不用动。这也是状态机架构带来的边界收益之一。就着我个人经验写 AT 指令状态机最忌讳的是一开始就追求“终极通用框架”那样很容易陷入过度设计。最好的路线是先按你当前项目的指令流程写出第一版状态机跑通之后再把通用的匹配规则、URC 处理提炼出来。等到第二个项目复用的时候你会发现这套东西比自己想得还要省心。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →