
第 6 讲 | AT 指令模组的可靠驱动设计【本讲要点】一次阻塞的printf就能让 WiFi 模组掉线重连。这一讲讲透 AT 驱动必须解决的四个问题超时、重试、状态同步、非阻塞。一、一个离谱的现象项目背景16KB RAM 的控制器通过串口挂了 WiFi 模组用 AT 指令连 MQTT。现象平时通信正常但只要一加日志打印模组就开始反复掉线、重连上报全部中断。开发同学的直觉反应是网络不稳于是去改天线、改路由器、改服务器。折腾了两天毫无进展。真相是他加的日志用了阻塞式打印printf/debug_printf把 CPU 卡住了导致模组来不及喂指令超时掉线。这个坑之所以隐蔽是因为它看起来完全不相关。下面拆解。二、根因AT 通信是一条实时喂食链路AT 模组的本质它是一个独立的小系统主控通过串口喂指令给它它回响应。这条链路对时序有硬要求主控 WiFi 模组 │ ── ATCWJAPssid ──► │ │ │ 内部开始连接耗时数秒 │ ◄── OK 或 WIFI CONNECTED │ │ │ │ ── ATMQTTCONN ──► │ │ ◄── OK │ │ │ │ ... 模组内部有心跳/保活定时器 ... │ │ │ 如果主控长时间不响应模组可能 │ - 认为链路异常主动断开 │ - 或缓冲溢出丢失响应关键点模组不是发一条等一条的傻瓜设备它内部有状态机、有超时、有心跳。如果主控因为执行了一段耗时几千毫秒的代码比如阻塞打印一大段日志期间模组发来的响应无人接收→ 缓冲溢出响应丢失主控错过了模组的保活窗口 → 模组判超时断开连接。这就是加日志导致掉线的完整因果链。三、四个必须解决的问题问题 1非阻塞——绝对不能在通信路径上做耗时操作反面教材/* 危险每一步都可能阻塞 */voidwifi_connect(void){at_send(ATCWJAP\ssid\,\pwd\\r\n);debug_printf(connect cmd sent\n);/* - 阻塞可能是几十毫秒 */delay_ms(5000);/* - 直接卡死 5 秒 */if(strstr(rx_buf,OK)){...}}正确姿势非阻塞状态机 超时计时。typedefenum{WIFI_ST_IDLE,WIFI_ST_SEND_JOIN,WIFI_ST_WAIT_JOIN,WIFI_ST_CONNECTED,WIFI_ST_RETRY,}wifi_state_t;staticwifi_state_ts_stateWIFI_ST_IDLE;staticuint32_ts_deadline0;voidwifi_tick(void)/* 每次 10ms 调用一次由任务循环驱动 */{switch(s_state){caseWIFI_ST_IDLE:at_send_raw(ATCWJAP\ssid\,\pwd\\r\n);s_deadlinenow_ms()15000;/* 15 秒不返回就算超时 */s_stateWIFI_ST_WAIT_JOIN;break;caseWIFI_ST_WAIT_JOIN:if(at_rx_contains(WIFI GOT IP)){s_stateWIFI_ST_CONNECTED;}elseif(now_ms()s_deadline){s_stateWIFI_ST_RETRY;/* 超时进入重试 */}break;caseWIFI_ST_RETRY:/* 退避重试避免疯狂重连 */at_send_raw(ATCWJAP\ssid\,\pwd\\r\n);s_deadlinenow_ms()15000;s_stateWIFI_ST_WAIT_JOIN;break;caseWIFI_ST_CONNECTED:/* 定期检查心跳 */break;}}核心整个连接过程不阻塞任何一次循环。wifi_tick每次只花几十微秒模组始终被及时服务。问题 2超时——每条指令都必须有最后期限没有超时的 AT 驱动等于没有驱动的 AT 驱动。/* 每条指令发出时记录一个 deadline */staticuint32_ts_cmd_deadline;voidat_send_cmd(constchar*cmd,uint32_ttimeout_ms){at_send_raw(cmd);s_cmd_deadlinenow_ms()timeout_ms;}boolat_wait_ok(uint32_ttimeout_ms){uint32_tstartnow_ms();while(now_ms()-starttimeout_ms){if(at_rx_contains(OK)){returntrue;}if(at_rx_contains(ERROR)){returnfalse;}vTaskDelay(1);/* 让出 CPU绝不空转死等 */}returnfalse;/* 超时 */}注意vTaskDelay(1)即使是等待响应这种看似必须同步的操作也要主动让出 CPU否则会饿死其他任务包括处理接收数据的任务那就死锁了。问题 3重试——失败是常态必须可控重试#defineAT_RETRY_MAX3boolat_cmd_with_retry(constchar*cmd,uint32_ttimeout_ms){for(inti0;iAT_RETRY_MAX;i){at_flush_rx();/* 清空上一轮的残留响应 */at_send_cmd(cmd,timeout_ms);if(at_wait_ok(timeout_ms)){returntrue;}log_warn(at retry %d/%d: %s,i1,AT_RETRY_MAX,cmd);}returnfalse;}两个细节重试前必须flush_rx否则上一轮的残留响应会让本轮误判成功这叫响应串台是 AT 驱动的经典 bug重试要有上限无限重试会卡死状态机。超过上限应该上报错误、复位模组。问题 4状态同步——主控和模组的认知必须一致模组会主动发消息比如IPD,... 收到数据 WIFI DISCONNECT 连接断开 CLOSED 连接关闭主控不能只在自己发指令时才处理响应。必须有一个常驻的模组事件处理任务持续解析来自模组的异步消息voidat_event_task(void*arg){uint8_tline[128];for(;;){uint16_tlenuart_rx_read_frame(line,sizeof(line));if(len0){vTaskDelay(1);continue;}/* 异步事件优先处理不管主控有没有在等响应 */if(strstr((char*)line,WIFI DISCONNECT)){s_stateWIFI_ST_RETRY;/* 主动同步状态 */log_warn(wifi disconnected, retry);}elseif(strstr((char*)line,IPD)){iot_on_downlink(line,len);}elseif(strstr((char*)line,CLOSED)){s_mqtt_connectedfalse;}/* 同时投递给等待响应的同步调用方 */at_rx_feed(line,len);}}状态同步是 AT 驱动最容易被忽视、又最容易导致主控以为连着、其实早断了的地方。四、非阻塞日志解决打印即掉线既然阻塞打印是元凶就要给它一条不占 CPU、不阻塞的出路。三个方案方案 1日志缓冲 后台任务推荐/* 日志先进环形缓冲专门的低优先级任务慢慢往串口吐 */staticuint8_ts_log_ring[512];staticvolatileuint16_ts_log_head,s_log_tail;/* 任何地方调用只做 push绝不阻塞 */voidlog_async(constchar*msg){while(*msg){uint16_tnext(uint16_t)((s_log_head1)511);if(nexts_log_tail)return;/* 满了就丢绝不阻塞 */s_log_ring[s_log_head]*msg;s_log_headnext;}}/* 低优先级任务负责真正输出 */voidlog_task(void*arg){for(;;){while(s_log_tail!s_log_head){uart_tx_byte(s_log_ring[s_log_tail]);s_log_tail(uint16_t)((s_log_tail1)511);}vTaskDelay(pdMS_TO_TICKS(10));}}方案 2RTT调试期首选J-Link 的 RTTReal Time Transfer通过调试口输出几乎不占 CPU、不影响时序是调试期的最佳选择#includeSEGGER_RTT.hSEGGER_RTT_printf(0,heap free%u\n,xPortGetFreeHeapSize());但量产设备没有调试器所以方案 3 仍然必要。方案 3分级编译开关/* 量产固件默认关掉 verbose 日志 */#defineLOG_LEVELLOG_WARN#ifLOG_LEVELLOG_DEBUG#definelog_debug(...)do{/* 输出 */}while(0)#else#definelog_debug(...)do{}while(0)/* 编译期消除零开销 */#endif发布版本把日志等级调高从源头消除打印阻塞的可能。五、一个隐蔽的延伸坑变量遮蔽在 AT 驱动里还有一个经典陷阱同名变量在不同文件里的作用域冲突。/* iot.c */uint8_tiot_temp[300];/* 全局300 字节 *//* wifi_app.c */voidbuild_payload(void){uint8_tiot_temp[256];/* 局部遮蔽了全局的 iot_temp */...}后果wifi_app.c里的iot_temp是局部变量占用的是栈空间不是那 300 字节全局更糟的是同事看到全局定义了iot_temp以为两处是同一块内存改了一处却发现另一处没变——调试时极其迷惑栈上多出 256 字节在 16KB RAM 的系统里可能直接压垮小栈任务。规避方法全局变量统一加前缀如g_iot_temp局部变量不加——一眼区分编译时打开-WshadowGCC或 Keil 的同类警告让编译器帮你抓定期用 map 文件核对全局变量的实际占用确认没有以为共享、其实各异的情况。六、AT 驱动的检查清单投产后逐条核对[ ]所有 AT 操作都是非阻塞的没有delay_ms大延时、没有阻塞打印[ ]每条指令都有超时[ ]重试有上限且重试前会 flush 接收缓冲[ ]有常驻任务处理模组的异步事件[ ]日志走异步缓冲或 RTT或量产版本关闭[ ]全局变量加了g_前缀无变量遮蔽[ ]掉线能自动重连且带退避避免疯狂重连打爆服务器七、小结四句话1. AT 通信是实时喂食链路任何阻塞都会让模组掉线。2. 非阻塞状态机 超时 可控重试 常驻事件任务 可靠 AT 驱动。3. 日志必须异步化环形缓冲/RTT绝不能阻塞通信路径。4. 变量遮蔽是隐蔽杀手用命名规范和编译警告防住。至此Part 3 通信链路讲完。下一讲第 7 讲是本专栏的核心案例当收包、模组、上报全部就位后为什么上报还是会延迟 3 倍我们要把整条链路串起来找出那个把心跳挂错地方的状态机设计问题。