ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

单片机AT指令非阻塞配置模块模板:状态机与超时重试机制

单片机AT指令非阻塞配置模块模板:状态机与超时重试机制 搞单片机开发的朋友十有八九都跟AT指令打过交道。WiFi模块要配ESP8266蓝牙模块要配HC-054G模块要配EC200GPS模块要配ATGM336H甚至不少传感器模块都爱用AT指令来设置参数。AT指令本身逻辑很简单就是“发一条命令、等一个回复”但你要是真把它嵌到项目里尤其是放进一个带实时性要求的嵌入式系统中问题立马就出来了系统卡住、按键失灵、界面刷新冻结、看门狗复位轻则体验差重则直接跑飞。这篇文章就来分享一套我整理过很多次、最后沉淀下来的“单片机AT指令配置模块程序模板”而且是非阻塞版。它不依赖RTOS不占用独立任务只要一个常驻轮询函数加一个简单的状态机就能把一串AT指令按顺序发出、逐条等待应答、自动处理超时和重试整个过程CPU全程不空转主循环照跑按键扫描照常屏幕刷新不卡。适合正在做单片机项目、需要给模块写配置程序的开发者参考也适合想从阻塞式代码往非阻塞状态机架构过渡的朋友。1. 为什么需要一套AT指令配置模块模板1.1 先说说AT指令这几个字背后的事AT指令最早是给调制解调器用的后来因为单片机通信模块遍地开花几乎所有透传模块都顺手继承了这个指令体系。它的交互模型极其简单主机发一条以“AT”开头的ASCII命令从机回一条或者多条响应最后回一个结果码一般是OK或者ERROR。听起来很干净但实际用起来有几个麻烦。第一个麻烦是响应不固定。有些命令不回OK直接返回数据比如查询类命令有些命令会先回一堆内容再回OK比如ESP8266的ATCWMODE3之后如果直接连路由可能会先回WIFI CONNECTED再回OK还有像ATCIFSR这种查询IP的指令回的内容本身就是一行接一行的数据。所以做模板的时候不能只判断“收到了OK”得允许匹配“关键子串”。第二个麻烦是时序依赖。模块上电之后要等一段时间才能响应AT指令有些模块波特率还会变化比如ESP8266默认115200部分老模块默认9600。发完一条指令后响应有时几十毫秒就回来了有的要等几百毫秒甚至更久。如果脚本写得太快指令直接就没被模块接住那才是噩梦的开始。第三个麻烦是重试要讲策略。一条指令发一次没回OK直接再发大概率还是失败。合理做法是发送→等待响应→超时→再发重试N次全部失败才上报错误。这套逻辑听起来不复杂但每一处“等待”和“超时”在裸机代码里都很容易写成一堆嵌套的while和delay最后代码烂成一锅粥。1.2 阻塞式配置的痛做过的都懂说实话早期我写的AT配置代码也是阻塞式的大概长这样uart_send_string(ATCWMODE3\r\n); delay_ms(200); uart_send_string(ATCWJAP\MyWiFi\,\12345678\\r\n); delay_ms(2000);这种写法在纯上电初始化阶段勉强能跑但问题非常明显。delay_ms阻塞期间CPU在空转定时器中断以外的任务全部停摆。如果项目里还有按键扫描、OLED刷新、传感器采集、看门狗喂狗那就麻烦了。特别是看门狗很多单片机在看门狗开启后要求周期性喂狗一旦你在delay_ms(5000)里卡住狗先饿死了系统直接复位反复重启。更难受的是你根本不知道模块到底配成功没有。延时到了就往下执行万一模块上电慢了或者信号不好连接没建立代码还会继续往下走后面业务逻辑全建立在“假成功”的基础上。排查起来只能加一堆串口打印挨个延时试想想都头大。所以后来我把配置逻辑改成状态机驱动的非阻塞方式不再用任何阻塞延时所有等待都是“超时判断”所有流程推进都由一个轮询函数驱动。项目里同时跑按键、显示、其他通信互不干扰。1.3 模块化模板到底在解决什么问题我把这套模板的核心目标定为三件事。第一把配置流程和业务代码解耦。配置一个模块的指令序列应该是数据而不是写死在业务逻辑里的代码。你在模板里填一张指令表程序按表执行需要调整指令顺序、增加参数、改超时时间都只改表不用动框架代码。第二让配置过程变成可观测的。任何时候都能知道当前在发哪条指令、已经重试了几次、是成功还是失败、失败在哪一步。这在现场调试和产品量产环节尤其救命。曾经有个项目在客户现场老连不上网我远程把配置日志打开一看日志立刻定位到是SIM卡欠费导致的注册失败不是代码问题。第三给所有AT模块提供同一套处理框架。ESP8266、HC-05、4G模块、GPS模块指令集完全不同但“按序发送、等待应答、超时重试”这个骨架是一致的。把这套骨架抽出来以后接任何新的AT模块我只需要写一个新的指令配置表再稍微处理一下特殊响应大部分代码直接复用。这套模板就是从这个思路里长出来的。2. 整体设计思路与关键数据结构2.1 非阻塞的本质把“等待”变成“轮询”非阻塞的底层逻辑其实就一句话没有任何地方用死等所有状态迁移都由时间条件触发。裸机程序本质是一个大循环外加中断。非阻塞程序要做的是把“我要等200ms”翻译成“我先记录当前时间每次循环检查时间差达到200ms就继续没到就返回去干别的事”。翻译不准那就改造响应的获取方式把“等串口返回OK”变成“每次循环检查串口接收缓冲里有没有出现OK这个字符串”没出现就继续跑别的代码。这就是状态机轮询的模型。每个时刻程序都处于某个明确状态比如“正在发送指令”“等待应答”“超时处理中”。一个轮询函数每次被执行时只检查当前状态是否满足跳转条件满足就跳到下一个状态不满足就返回。由于每次执行的时间极短主循环里其他任务的实时性完全不受影响。这种模型还有个好外号叫**“协作式多任务”**每个任务主动让出CPU大家分时复用。它的好处是代码简单、无锁、不需要RTOS坏处是你要保证轮询函数被频繁调用尤其在响应等待阶段不能让主循环卡在别的地方太久。2.2 三个核心数据结构好概念说完了直接上干货。这个模板的骨架我用三个C语言结构体撑起来。第一个是配置项结构体描述一条AT指令及其处理策略typedef struct { const char *cmd; // 要发送的AT指令内容须包含结尾的\r\n const char *expect_str; // 期望在响应中匹配的子串比如OK uint16_t timeout_ms; // 单次等待超时时间单位毫秒 uint8_t retry_cnt; // 最大重试次数0表示不重试 void (*ok_cb)(void); // 成功回调可为NULL void (*fail_cb)(void); // 失败回调可为NULL } at_cfg_item_t;第二个是运行状态结构体承载当前执行进度typedef struct { const at_cfg_item_t *table; // 指令表指针 uint16_t table_size; // 指令条数 uint16_t cur_idx; // 当前执行到第几条 uint8_t cur_retry; // 当前重试计数 uint32_t tick_start; // 本条指令发送的时间戳 uint8_t state; // 当前状态机状态 uint8_t result; // 总体配置结果 } at_run_env_t;第三个是缓冲区结构体用于存放串口收到的响应数据typedef struct { char buf[128]; // 环形缓冲区大小视模块响应量调整 volatile uint16_t head; volatile uint16_t tail; } at_ringbuf_t;这三个结构体分工很清晰at_cfg_item_t描述“做什么”at_run_env_t记录“做到哪了”at_ringbuf_t保存“模块回了什么”。模块化设计里最怕的就是东一榔头西一棒子地放全局变量把数据收拢到结构体里后面不管是单实例还是多实例使用都从容得多。2.3 对外接口设计怎么用才顺手接口设计决定一个模板好不好用。我最后定了四个接口分别管“开始配置”“周期轮询”“喂入串口数据”“查询状态”。void at_config_start(const at_cfg_item_t *table, uint16_t size); void at_config_poll(void); void at_config_uart_rx(uint8_t byte); uint8_t at_config_get_result(void);at_config_start负责初始化运行环境把指令表和表长度灌进去状态机切到起始状态。at_config_poll是核心驱动函数建议在主循环里每几毫秒到几十毫秒调用一次。at_config_uart_rx是串口中断里调用的接收喂入函数只负责往环形缓冲写一个字节不做任何字符串判断保证中断服务程序短小精悍。at_config_get_result是给外部查询配置最终结果的比如返回1表示全部执行成功返回0表示中间某一步重试后仍然失败。这四个接口再配上一张指令配置表就能覆盖绝大多数AT模块配置场景。有个项目我同时要配置一个ESP8266和一个GPS模块因为所有状态都存在结构体里我定义两个实例变量、两张配置表各自调用同一套接口就行互不干扰。3. 模块化模板的核心代码实现3.1 状态机主体一次只做一件事状态机是这套模板的心脏。我把整个AT配置过程切成了四个状态。#define AT_STATE_IDLE 0u #define AT_STATE_SENDING 1u #define AT_STATE_WAITING 2u #define AT_STATE_RETRY_DELAY 3uIDLE表示空闲没有任务执行。SENDING表示正在发送下一条指令。WAITING表示指令已发出正在等待模块响应。RETRY_DELAY表示上一条等待超时了在重试前先歇一口气避免连着猛发。轮询函数的主体长这样void at_config_poll(void) { switch (g_env.state) { case AT_STATE_IDLE: break; case AT_STATE_SENDING: uart_send_string(g_env.table[g_env.cur_idx].cmd); g_env.tick_start get_tick_ms(); g_env.state AT_STATE_WAITING; break; case AT_STATE_WAITING: if (at_match_response(g_env.table[g_env.cur_idx].expect_str)) { // 本条成功执行回调推进到下一条 if (g_env.table[g_env.cur_idx].ok_cb) g_env.table[g_env.cur_idx].ok_cb(); if (g_env.cur_idx 1 g_env.table_size) { g_env.result 1; // 全部完成 g_env.state AT_STATE_IDLE; } else { g_env.cur_idx; g_env.cur_retry 0; g_env.state AT_STATE_SENDING; } } else if (is_timeout(g_env.tick_start, g_env.table[g_env.cur_idx].timeout_ms)) { // 超时进入重试逻辑 if (g_env.cur_retry g_env.table[g_env.cur_idx].retry_cnt) { g_env.cur_retry; g_env.state AT_STATE_RETRY_DELAY; } else { if (g_env.table[g_env.cur_idx].fail_cb) g_env.table[g_env.cur_idx].fail_cb(); g_env.result 0; g_env.state AT_STATE_IDLE; } } break; case AT_STATE_RETRY_DELAY: if (is_timeout(g_env.tick_start, 50)) { // 短暂的50ms静默让模块缓过来 at_ringbuf_clear(); g_env.state AT_STATE_SENDING; } break; default: g_env.state AT_STATE_IDLE; break; } }之所以把“发送”和“等待”拆成两个状态是因为串口发送在低速波特率下可能耗时很长。比如一条指令三十多个字节9600波特率下大约要30毫秒才能发完。如果在主循环里直接把整串指令发给阻塞式串口发送函数还是会卡住。拆成状态后发送动作可以通过发送中断逐字节处理或者用一个非阻塞发送缓冲一次只放一个字节到发送寄存器效率高得多。我的模板里用的是中断发送方式代码里简化为uart_send_string实际移植时替换成自己工程里的发送函数即可。3.2 响应匹配与串口接收缓冲AT指令返回的内容可能分好几帧到达如果不做缓冲只判断“最近一个字节”或者“最近一行”很容易丢响应。我用的方案是环形缓冲加子串匹配。串口中断里每次收到一个字节就往环形缓冲里写void at_config_uart_rx(uint8_t byte) { uint16_t next_head (g_rb.head 1) (AT_RB_SIZE - 1); if (next_head ! g_rb.tail) // 不满才写 { g_rb.buf[g_rb.head] byte; g_rb.head next_head; } }匹配函数则在轮询里调用。它把环形缓冲里的数据看作是线性字节流只要里面出现过目标子串就当作匹配成功static uint8_t at_match_response(const char *expect) { uint16_t len (uint16_t)((g_rb.head - g_rb.tail) (AT_RB_SIZE - 1)); if (len 0 || expect NULL || expect[0] \0) return 1; // 不检查响应直接视为成功 // 简单从tail位置开始向后找子串 // 实际项目里可以做循环缓冲拼接查找这里逻辑示意 uint16_t i; for (i 0; i len; i) { uint16_t pos (g_rb.tail i) (AT_RB_SIZE - 1); const char *p expect; if (g_rb.buf[pos] *p) { uint16_t j pos; while (*p ! \0) { if (g_rb.buf[j] ! *p) break; p; j (j 1) (AT_RB_SIZE - 1); } if (*p \0) return 1; } } return 0; }有两个细节值得说。一是匹配前要不要清空缓冲。我的策略是每次发送新指令前清空缓冲确保匹配的是最新一轮响应。否则上一条指令的残留响应可能被误当成下一条的成功信号尤其有些模块多条指令的响应都以OK结尾不清空就会把上一条的OK拿来顶包看起来全流程秒过其实指令根本没生效。二是匹配失败并不等于要清缓冲。等待期间缓冲里的数据会持续累积每次轮询重新从尾部开始扫描都是针对当前这一轮的完整数据做判断而不是只判断最新一次新增的部分。否则很有可能模块回复OK的那一帧被拆成两批到达第一次扫描只看到OK还没到齐误判失败。3.3 超时、重试与错误上报超时机制是整个非阻塞设计里最考验细节的地方。我的做法是维护一个毫秒级tick计数在SENDING状态里记录当前tick在WAITING状态里不断比较当前tick与记录值的差值。核心函数就一句话static uint8_t is_timeout(uint32_t start_tick, uint32_t timeout_ms) { return (uint32_t)(get_tick_ms() - start_tick) timeout_ms; }这里用无符号减法做时间差天然处理了tick回绕的问题。只要get_tick_ms()返回的计数是用uint32累加且及时溢出回绕不管溢出发生在什么时候减法得到的结果都是正确的时间差。很多新手写超时判断喜欢直接比较tick timeouttick一溢就出bug用差值判断就没这个烦恼。重试策略我设计成“次数上限短暂静默”。为什么超时后要插一段静默时间有一次我做4G模块注册网络的调试模块开机后信号弱ATCSQ返回的信号强度指令偶尔会超时。如果超时后立刻重发模块还在忙着处理上一次请求大概率再次超时连续失败三次直接判负。后来在重试前加了50~100ms的静默等待同时把接收缓冲清空重试成功率一下就上来了。这个细节看起来不起眼实际效果立竿见影。错误上报分两级。单条指令重试耗尽后调用该条的fail_cb整套配置失败后外部通过at_config_get_result拿到总体结果。这样做的好处是业务代码不用关心配置流程内部细节只要最终检查一下结果即可。但调试阶段我建议在fail_cb里把失败的指令索引和指令内容打印出来能省下大把排查时间。3.4 把配置序列变成一张表模块化设计的精髓就在这个表上。比如要配置ESP8266连接路由器我定义一张配置表static void cb_wifi_ok(void) { // 可以在这里亮个灯表示WiFi已连接 } const at_cfg_item_t wifi_cfg_table[] { {AT\r\n, OK, 500, 3, NULL, NULL}, {ATE0\r\n, OK, 500, 3, NULL, NULL}, // 关回显 {ATCWMODE1\r\n, OK, 500, 3, NULL, NULL}, // Station模式 {ATCWJAP\MyWiFi\,\12345678\\r\n, OK, 8000, 3, cb_wifi_ok, NULL}, {ATCIFSR\r\n, OK, 1000, 2, NULL, NULL}, };启动配置时一句at_config_start(wifi_cfg_table, sizeof(wifi_cfg_table) / sizeof(wifi_cfg_table[0]));然后在主循环里持续调用at_config_poll()就行。指令顺序、参数、超时、重试次数、回调全都在表里一目了然。哪条要加个验证就在表里插一行哪条不需要直接删一行。框架代码一行都不用动。这就是“数据驱动”的思路在工作里的落地把程序的处理逻辑与具体配置数据分开让代码更通用让数据更直观。这种表驱动写法同样适用于I2C设备寄存器初始化、LCD初始化序列、通道校准系数表等场景你可以把这个思路复制到项目的任意初始化场景里。4. 实操演示以ESP8266配置为例完整跑一遍4.1 先规划配置流程拿到一个新模块配之前先别急着写代码我习惯先做一遍“配置流程规划”。以ESP8266连接WiFi为例完整流程大致分五步第一步发AT验证模块通信正常看是否回OK。第二步发ATE0关闭回显减少后续不必要的数据干扰。第三步设置工作模式为Station模式即ATCWMODE1。第四步发ATCWJAP连接路由器这一步耗时长需要把超时设置得宽一些比如6~10秒。第五步可以发ATCIFSR获取分配到的IP方便后续调试。规划时有几个要点。每条指令的timeout_ms要结合模块手册和实测数据来定不要拍脑袋。比如连接WiFi的指令在信号正常的环境下大约2秒回OK信号弱的时候可能8秒才回我把超时设到10秒重试2次保证成功率。而AT这种基础指令模块一般100ms内就回500ms超时足够设太长反而会让整体流程卡很久。顺序也很重要。ATE0必须在验证通信成功之后再发因为如果模块根本没起来发了也白发。ATCWMODE1必须在连接WiFi之前设置否则工作模式不对连接指令会报错。这些依赖关系提前理清楚配置表才能一次写对。4.2 接入项目主循环的细节模板代码本身写好后接入工程就很简单。我在主循环里这样用while (1) { at_config_poll(); // 驱动AT配置状态机 key_scan(); // 按键扫描照常 oled_refresh(); // 屏幕刷新照常 sensor_read(); // 传感器采集照常 feed_watchdog(); // 看门狗喂狗照常 }由于at_config_poll()内部没有任何阻塞延时每次执行时间只有几微秒到几十微秒主循环里其他任务完全感觉不到它的存在。这也是非阻塞版相比阻塞版最大的体验提升初始化配置模块的时候系统依然是“活”的。实际测试时我习惯单独加一个调试串口打印当前状态。方便起见我在模板里加了个调试钩子每切换一次状态就打印一行例如[AT] send: ATCWMODE1 [AT] recv: OK [AT] step 2 success现场看不到模块回什么的时候这个日志就是找问题的最好线索。4.3 移植到其他平台的关键点模板用纯C写的只要平台上有串口发送和毫秒tick就能移植。换到新平台通常只需要做三件事。第一实现uart_send_string把指令字符串真正发出去。如果你用的串口库是非阻塞发送直接调用即可如果是阻塞发送也没关系只要单条指令长度不太长发送耗时可控主循环卡顿不明显即可。更讲究的做法是加一个发送环形缓冲在串口中断里逐字节发送。第二实现get_tick_ms返回毫秒级tick。STM32可以用SysTick51单片机可以用定时器0中断累加计数ESP32可以用esp_timer_get_time()换算。注意这个函数必须快速返回不能在里边做复杂运算。第三确认串口中断服务程序里调用了at_config_uart_rx保证每个字节都不丢。波特率越高中断触发越频繁中断处理函数里除了往环形缓冲写一个字节以外最好什么都别干。如果项目用的MCU内存很小比如只有1KB RAM的51单片机128字节的环形缓冲也完全够用。模板里的at_ringbuf_t占128字节加上at_run_env_t和配置表指针总共开销不超过200字节对绝大多数单片机而言毫无压力。5. 常见问题与排查技巧实录5.1 高频问题速查表这模板我用过很多次也帮同事排查过不少问题整理了一张速查表遇到异常先对着查一遍。现象可能原因排查方法流程一开始就超时失败波特率不匹配先用串口助手手动发AT看是否有OK第一条AT成功后面全失败模块没进入命令模式回显干扰匹配确认ATE0已生效检查期望串是否被回显的AT原文误匹配匹配成功但流程卡住不动环形缓冲满导致新数据进不来增大缓冲区或确认有无大容量响应数据排空偶发超时重试后成功模块忙响应延迟大调整超时时间超时后增加静默期长度没有获取到最终结果漏调用at_config_poll确认主循环里周期调用不要在某个任务里死循环配置结果正确但模块没工作某条指令发送了但未真正执行在回调里加打印逐条确认OK来源高波特率下乱码中断响应不及时丢数据降低波特率或改用DMA接收前三个问题在我实际项目中遇到过后四个是帮别人看代码时总结出来的。5.2 我踩过的几个坑第一个坑期望串太短误匹配。有次配GPS模块我要等待OK但模块在处理一条查询指令时会先回$GNGGA,...OK之类的混合数据而这条数据里任何一段都可能包含字母K和O连在一起的巧合。后来我把期望串统一改为\r\nOK\r\n这种带边界的形式误匹配率大幅下降。当然前提是模块确实会回完整的OK如果模块回的是OK后面直接跟别的数据那就要灵活处理。第二个坑环形缓冲没做清空导致上一条响应污染下一条判断。这个我前面提过最典型的场景是ESP8266连接路由器成功后模块会主动上报WIFI CONNECTED紧接着又来一个OK。如果不清空缓冲下一条ATCIFSR还没发呢缓冲里残留的OK就会被当成这一条的成功响应导致CIFSR的实际返回数据根本没有被处理。每次发送新指令前清空缓冲可以根治这个问题。第三个坑超时时间设置不当被模块“软卡死”。有次配4G模块ATCGREG?查询注册状态理论上2秒内必回。但模块在开机初期处于找网状态偶尔这条指令会等到10秒以上才回而我把超时设成了5秒于是它总是超时重试又超时最后判定失败。改法是把查询类指令的超时调到15秒同时减少重试次数整体流程反而更快了。第四个坑调试打印里用了阻塞延时。有段时间我在调试钩子里加了delay_ms(50)看起来无伤大雅但主循环里其他任务的周期直接被打乱按键响应出现明显抖动。非阻塞程序里做调试输出要确保打印函数也是非阻塞的或者用缓冲日志稍后统一输出千万不要一时顺手加阻塞延时。第五个建议给串口接收加个“尾巴”提示。我在接收缓冲里加了一个简单的统计变量每次轮询时如果发现缓冲里新增了数据就打印一个[RX]标志。这样即使匹配逻辑误判也能从日志里看到模块到底回了什么排查起来方便得多。模板代码我放在自己的嵌入式工具库里每次开新项目需要配置AT模块直接复制粘贴改表就能用。从ESP8266、HC-05、4G模块到GPS模块这套非阻塞框架基本是同一套代码。你有类似需求的话建议也不要直接堆代码先把状态图和数据表理清楚再按这个模板搭建自己的版本。纸上得来终觉浅自己把状态机跑通一次对嵌入式里的“非阻塞编程”会有更深的理解。
返回列表