单片机AT指令非阻塞处理:事件驱动与状态机模板
发布时间:2026/10/5 12:13:23 锦皓数字建站

做单片机开发的人迟早要碰AT指令WiFi模块要配网、蓝牙模块要发数据、4G模块要打电话发短信全是通过“ATxxxxx”这种字符串命令来驱动的。早期我写AT指令交互时非常粗暴发一条命令Delay几百毫秒再读串口缓冲看到OK就继续下一步。简单项目没问题可一旦程序里还有按键扫描、屏幕刷新、多个外设驱动整个系统就会被这些阻塞式等待拖得卡顿无比。后来我把这块重构成一套非阻塞的单片机AT指令配置模块程序模板主循环永远不被串口等待阻塞命令收发全靠事件驱动和状态机推进实测下来响应稳定代码也能在不同型号单片机上直接复用。这篇博文会把模板的设计思路、核心代码、实测问题和延展方向都讲清楚。如果你正在做STM32、51或者其他主流MCU的项目需要接ESP8266、蓝牙模块、4G模块这类AT指令设备又不想再被那种“发送一条命令等半天”的写法折磨那这套思路应该能帮上大忙。1. AT指令为什么越写越难受从阻塞式等待到事件驱动1.1 大多数人第一次写的“伪实时”AT读取流程很多初学者第一次接触AT模块时代码大概是这样的void wifi_config(void) { uart_send_str(ATCWMODE1\r\n); delay_ms(200); if (strstr(rx_buf, OK) NULL) { return; } uart_send_str(ATCWJAP\test\,\12345678\\r\n); while (1) { if (strstr(rx_buf, OK)) break; if (strstr(rx_buf, FAIL)) return; delay_ms(10); } }这套写法在只有单个任务的例程里确实能跑问题会出现在“rx_buf到底缓存到什么时候”这个点上。模块应答不是一次性完整送到的它可能先回一部分过一会儿再回剩下的。如果你在第一条命令之后只等200ms模块处理速度稍慢你还没看到OK就提前判断失败了。于是有人不断拉长Delay从200ms改到500ms再到1秒程序倒是能跑了但整个系统在等待应答期间什么都干不了。更麻烦的是如果程序里还存在按键扫描、屏幕刷新、电机控制之类需要实时响应的任务这些任务会被AT指令的Delay彻底拖住。按键按下去没反应屏幕刷新卡住某些项目里连看门狗都被饿到复位。这时候你就会明白这种“伪实时”的写法本质上是把外部设备的响应时间强行塞进主线程的时间线里自然越写越难受。1.2 真正的问题AT应答是不可预测到达的异步事件AT指令的应答和普通函数返回值有本质区别。你调用一个加法函数结果当场就能拿到AT指令是发过去一串字符模块处理完以后往串口上“踢”出来一串字符。这个处理时间受模块内部固件逻辑、网络协议栈、云端交互的影响短则几十毫秒长则十几秒。你根本没法预测它什么时候会回来。还有一个阻塞式写法完全无法处理的情况模块会主动上报数据。WiFi模块网络断开时会发“WIFI DISCONNECT”TCP收到数据时会发“IPD”4G模块有来电时会上报“RING”。这些消息不是你主动询问触发的它们属于不请自来的异步上报。阻塞式代码在等待某一条命令应答的时候这些主动上报的数据要么被当成干扰丢掉要么被错误地匹配给当前正在等待的命令。可以用一个生活类比来理解你去银行办业务取完号不是一直站在柜台窗口面前干等而是找个座位做别的事广播里喊到你的号再过去。AT模块的串口数据就是那个广播事件驱动才是处理它的正确姿势。你不需要为了“等待”这件事一直占住CPU只需要把“我在等什么”登记好然后该干嘛干嘛消息到了自然会触发对应动作。1.3 非阻塞版的本质把“等待”拆成“发送-记录-匹配-回调”非阻塞版模板的核心思想是把一次完整的AT指令交互拆成四件事发送直接把命令字符串推给串口不等结果。记录在程序里登记“我在等什么应答、等多久来了以后通知谁”。匹配后台循环不断读取串口字节把一行一行文本和登记信息比对。回调比对上了调用对应的函数处理结果然后继续下一件事。举个例子你要发送“ATCWMODE1”这条命令。非阻塞版的做法是在主循环里调用一次at_send_command把命令字符串、成功标志、超时时间和回调函数打包成一个结构体塞进队列。函数立刻返回主循环继续跑按键、刷屏幕。几毫秒之后模块回了“OK”串口中断把字节放进环形缓冲下一次主循环执行at_poll时解析出行文本发现它与当前等待的“OK”匹配于是执行你注册的回调函数整个过程没有任何一处阻塞。下面是阻塞式和非阻塞式的对比对比维度阻塞式非阻塞式串口占用方式等待期间必须死盯串口缓冲中断收字节主循环批量处理CPU利用率等待应答的时间全部浪费等待时可运行其他任务多任务并存难度很高容易互相拖垮独立模块不影响其他逻辑模块主动上报没有统一处理位置空闲状态自动匹配URC表状态恢复掉线、超时后很难从中间恢复状态机可随时重置和恢复代码复杂度简单但脆弱稍复杂但稳定这套机制就是非阻塞模块化程序的核心。你把“等待”这个动作从主循环的时间线里拆了出去变成随时可以查询、可以超时、可以取消的记录项系统的实时性自然就回来了。2. 模板的核心架构发送队列、接收解析器和命令状态机2.1 发送队列不能让两条命令前后脚挤在一起多数AT模块是半双工对话机制你发一条它回一条然后你再发下一条。如果在没有收到前一条命令应答的情况下连续发两条模块的应答会交错甚至丢失。比如你发完“ATCWMODE1”紧接着发“ATCWJAP...”模块可能只处理了前一条后一条进缓冲区排队但你已经无法判断哪些回应对应哪条命令。因此模板里必须维护一个发送队列。所有待发送命令按顺序排队同一时间只允许一条命令处于执行状态。命令执行完成收到最终应答或超时后再从队列头部取出下一条。这个机制不仅避免了应答交错也让应用层的调用方式变得很简单不需要自己去维护发送节奏只管往队列里扔命令。队列里的每个条目代表一条命令请求至少要包含命令字符串、成功标志、失败标志、超时时间和结束回调。我在实际项目里通常这样定义typedef void (*at_callback_t)(uint8_t result); #define AT_OK 0 #define AT_ERROR 1 #define AT_TIMEOUT 2 typedef struct { const char *cmd; // 要发送的AT命令例如 ATCWMODE1\r\n const char *expect_ok; // 成功应答特征串例如 OK const char *expect_err; // 失败应答特征串例如 ERROR uint16_t timeout_ms; // 超时时间 uint8_t retry; // 失败自动重试次数 at_callback_t on_done; // 命令结束回调成功、失败、超时都会触发 } at_cmd_t;这里有个容易忽略的点expect_ok不一定要写死成“OK”。有些命令的最终成功标志不是OK比如ATRST的成功标志是“ready”发短信时的等待提示是“”。把这些特征串抽象成结构体成员模板的可复用性会大幅提升。2.2 接收侧环形缓冲区加按行解析串口中断的职责只有一个把收到的字节写进环形缓冲区。不要在中断里做字符串匹配、回调调用这类耗时操作否则高波特率数据流下很容易把CPU时间耗尽反而丢数据。单片机上实现单生产者单消费者的环形缓冲很简单中断是生产者主循环是消费者。一个经典的实现如下#define RING_SIZE 512 static volatile uint8_t ring[RING_SIZE]; static volatile uint16_t r_wr, r_rd; void uart_rx_isr(uint8_t byte) { uint16_t next (r_wr 1) % RING_SIZE; if (next ! r_rd) { ring[r_wr] byte; r_wr next; } /* 缓冲区满了就丢弃避免影响中断实时性 */ }主循环通过at_poll批量消费环形缓冲区里的字节并拼装成行。之所以按行处理是因为AT指令和应答基本都以换行符作为结束标记行是天然的消息单位。#define AT_LINE_BUF_MAX 128 static char line[AT_LINE_BUF_MAX]; static uint16_t line_len 0; void at_poll(void) { while (r_rd ! r_wr) { uint8_t c ring[r_rd]; r_rd (r_rd 1) % RING_SIZE; if (c \n) { line[line_len] \0; at_handle_line(line); line_len 0; } else if (c ! \r) { if (line_len AT_LINE_BUF_MAX - 1) { line[line_len] c; } /* 超过最大行长说明数据异常直接丢弃这段重新同步 */ } } at_check_timeout(); }这里“超过最大行长就丢弃”是一个容易被漏掉的保护逻辑。AT模块偶尔会吐出一大段异常数据如果不对行长度做限制缓冲区会被撑破整个解析器会失去同步。砍掉超长行的代价很小但能避免很多诡异问题。2.3 命令状态机会话级的“期待应答”表有了发送队列和接收解析器之后还需要一个状态机把它们串起来。这个状态机描述的是“当前AT通道正在干什么”我通常定义成下面四个状态typedef enum { AT_STATE_IDLE, /* 通道空闲等待取新的命令 */ AT_STATE_WAIT_RESP, /* 命令已发出等待最终应答 */ AT_STATE_WAIT_PROMPT, /* 命令已发出等待 这类输入提示符 */ AT_STATE_DATA /* 已进入透传/业务数据模式 */ } at_state_t;状态迁移的完整过程是这样的应用层调用at_send_command把命令塞进队列。at_poll检测到通道空闲且队列非空就取出队首命令通过串口发送出去同时记录超时时间把状态切到AT_STATE_WAIT_RESP。后续每收到一行文本解析器拿它与当前命令的expect_ok和expect_err做子串匹配。匹配成功就停掉当前命令触发回调状态回到AT_STATE_IDLE然后从队列取下一条命令继续执行。如果在超时时间内没有匹配到任何特征串就重试重试机会用完则触发超时回调状态回到AT_STATE_IDLE。有人会问为什么这里要用子串匹配而不是整行相等因为模块返回的行往往包含额外信息。比如ATCIFSR查询IP地址返回的行可能是192.168.1.100整行内容每次都不一样再比如版本查询返回AT version:1.2.0.0整行匹配就没法用了。子串匹配结合行首尾清理在实际项目中稳定得多。3. 关键代码与落地细节从AT_Init到AT_Poll3.1 初始化与平台解耦这个模板要能在不同单片机上复用就必须把串口发送和毫秒计时这两个底层依赖抽象出来。我习惯用一个平台接口结构体来做解耦typedef struct { void (*send)(const uint8_t *data, uint16_t len); uint32_t (*get_tick)(void); } at_platform_t; static at_platform_t plat; void at_init(const at_platform_t *platform) { plat *platform; q_head q_tail 0; state AT_STATE_IDLE; current NULL; }这样做的好处是模板本身完全不关心具体单片机型号。你在STM32上把发送函数指向串口DMA发送在51上指向SBUF发送在GD32上指向另一组寄存器其他逻辑一概不用动。3.2 命令发送接口与队列推进命令发送接口的逻辑很简单如果队列没满就加入队尾如果当前通道正好空闲立刻触发一次实际发送。int at_send_command(at_cmd_t *cmd) { uint16_t next (q_tail 1) % AT_QUEUE_SIZE; if (next q_head) { return -1; /* 队列满 */ } queue[q_tail] cmd; q_tail next; if (state AT_STATE_IDLE) { at_start_next(); } return 0; } static void at_start_next(void) { if (q_head q_tail) { return; } current queue[q_head]; plat.send((const uint8_t *)current-cmd, strlen(current-cmd)); start_tick plat.get_tick(); timeout start_tick current-timeout_ms; state AT_STATE_WAIT_RESP; }注意队列里的at_cmd_t *指向的是应用层定义的结构体所以使用模板的命令结构体必须保持生存期有效。如果你打算在多任务里频繁创建和销毁命令对象需要改成值拷贝或者使用内存池不过裸机前后台系统里静态定义加复用就非常合适。3.3 应答匹配与超时处理的完整实现at_handle_line是模板里最核心的函数。它接收一个已经去掉换行符的行文本然后根据当前状态决定如何处理static void at_handle_line(const char *line_in) { char buf[AT_LINE_BUF_MAX]; at_trim_copy(buf, line_in); if (state AT_STATE_WAIT_RESP || state AT_STATE_WAIT_PROMPT) { if (current-expect_ok strstr(buf, current-expect_ok)) { at_complete(AT_OK); } else if (current-expect_err strstr(buf, current-expect_err)) { at_complete(AT_ERROR); } else if (state AT_STATE_WAIT_PROMPT strstr(buf, )) { if (current-on_prompt) { current-on_prompt(); } } /* 其余中间状态行例如 WIFI CONNECTED不影响当前等待 */ } else { at_handle_urc(buf); } }超时检查比较简单放在at_poll末尾调用static void at_check_timeout(void) { if (state AT_STATE_WAIT_RESP || state AT_STATE_WAIT_PROMPT) { if ((plat.get_tick() - start_tick) current-timeout_ms) { if (current-retry 0) { current-retry--; plat.send((const uint8_t *)current-cmd, strlen(current-cmd)); start_tick plat.get_tick(); } else { at_complete(AT_TIMEOUT); } } } }at_complete负责收尾调用回调、清空当前命令、弹出队列里的该项、启动下一条命令static void at_complete(uint8_t result) { if (current current-on_done) { current-on_done(result); } current NULL; q_head (q_head 1) % AT_QUEUE_SIZE; state AT_STATE_IDLE; at_start_next(); }这里有一个我踩过好几次的细节回调函数里虽然可以做“投递下一条命令”的操作但你必须在at_complete弹出队列后再调用回调还是先调用回调再弹出队列如果先调用回调再弹出队列回调里调用at_send_command时队列还是满的新命令进不来链路就断了。所以我统一采用“先弹出队列项再调用回调”的顺序这样回调里想继续发下一条命令队列也是有空间的。3.4 回调函数里面能干什么不能干什么回调函数是应用层和模板交互的主要入口但很多人容易在这里失控。回调里可以做的事情置位业务标志位修改状态机的业务状态通过at_send_command投递下一条命令把收到的数据拷贝到业务缓冲回调里绝对不能做的事情调用delay_ms、HAL_Delay这类阻塞延时做耗时超过几毫秒的浮点运算或者长字符串扫描直接操作发送队列的内部结构原因在于回调是在主循环的at_poll上下文中被调用的如果它本身耗时过长串口缓冲区里的数据就会积压轻则导致后续行解析延迟重则环形缓冲溢出丢数据。我见过有人在配网成功回调里直接跑了一个HTTP请求的延时循环结果串口缓冲区被塞满模块连接状态紧接着就报异常看起来像是模块不稳定实际是回调阻塞了收发。4. 接入典型设备ESP8266配网流程和SIM800C发短信的实战4.1 用模板执行ESP8266的连续配网流程ESP8266的AT固件是目前最常见的AT设备之一。一次完整的配网并建立TCP连接需要依次执行多条命令步骤命令期望成功标志超时建议1ATRSTready5s2ATCWMODE1OK1s3ATCWJAPssid,pwdOK10s4ATCIFSR任意IP地址行1s5ATCIPSTARTTCP,192.168.1.10,8080OK5s6ATCIPSEND52s如果沿用阻塞思维你得写一个超长的状态机来处理这些步骤。而模板化的做法是把流程拆成多个at_cmd_t每步的回调里决定是否继续下一步static void on_reset_done(uint8_t result) { if (result ! AT_OK) return; static at_cmd_t c; c.cmd ATCWMODE1\r\n; c.expect_ok OK; c.expect_err ERROR; c.timeout_ms 1000; c.retry 1; c.on_done on_wifi_mode_done; at_send_command(c); }每个命令结束后的回调里再投递下一条命令链条自然往前推进。如果某一步失败就在对应回调里决定是中止流程还是回到上一步重来。这里有一个值得注意的点ATCWJAP的应答并不是一上来就回OK它中间可能先发WIFI CONNECTED、WIFI GOT IP最后才回OK也可能回FAIL。这时候直接用expect_ok OK就行中间状态那一堆行模板会自动忽略。如果你想及时知道“已经连上路由器”这个中间状态可以在命令结构体里增加一个可选的expect_tip字段先匹配到WIFI CONNECTED时调用一个提示回调但此时不结束命令继续等待最终OK。这是一次状态机设计上的灵活扩展。4.2 处理模块主动上报的数据URC统一注册表AT指令体系里模块主动上报的数据统称URC。这些数据可能出现在任何时候阻塞式代码没有统一处理位置。在非阻塞模板里我单独维护一个URC处理表typedef struct { const char *prefix; void (*handler)(const char *line); } at_urc_t; static const at_urc_t urc_table[] { {WIFI DISCONNECT, on_wifi_disconnect}, {IPD, on_ipd}, {RING, on_ring}, }; static void at_handle_urc(const char *line) { for (uint8_t i 0; i sizeof(urc_table)/sizeof(urc_table[0]); i) { if (strstr(line, urc_table[i].prefix)) { urc_table[i].handler(line); return; } } }URC处理一定发生在AT_STATE_IDLE状态下。如果当前正在等待某条命令的应答那么收到的行会优先和当前命令的期望特征串匹配只有在空闲状态行文本才会进入URC表。这个顺序很关键否则模块在你发查询命令时主动上报的数据会被错误消费掉。举个例子ESP8266收到TCP数据时会上报一行类似IPD,12:hello world!的内容。URC的handler拿到这一行后要自己截取长度和数据部分static void on_ipd(const char *line) { const char *colon strchr(line, :); if (colon NULL) return; int len atoi(line 5); /* 跳过 IPD, */ if (len 0) { memcpy(tcp_rx_buf, colon 1, len); tcp_rx_len len; tcp_data_ready 1; } }这个方法简单直接足以应对大部分AT模块的数据接收场景。4.3 进入透传模式后如何处理业务数据与AT解析共存把AT模块切到透传模式后模块不再对每一行回OK、ERROR而是把串口收到的数据直接通过通信链路转发出去。此时AT解析器如果还把每一行都拿去匹配命令应答就会出现严重误判。我的做法是在模板里增加一个显式的data_mode标志。进入透传模式的流程本身也是AT命令交互先发ATCIPMODE1再发ATCIPSEND收到提示后应用层开始直接把业务数据写入串口此时状态机切到AT_STATE_DATA。在这个状态下at_handle_line不再做命令匹配而是把原始字节通过一个数据回调交给业务代码。退出透传更加讲究。ESP8266的AT固件要求发送之前必须保持串口静默至少1秒收到模块回OK后才算退出透传。如果在阻塞式代码里用delay_ms(1000)去等这1秒主循环又卡住了。非阻塞模板里处理这个场景是加一个延时状态发送后不立刻做任何事而是注册一个1秒定时事件等待期间不往串口发任何字节时间到后再继续后续AT交互。这个“延时也是状态机的一部分”的思路避免了为满足时序要求再次退回阻塞写法。5. 实测中踩过的坑缓冲区爆掉、粘包、超时误判5.1 串口中断里的压栈和拷贝开销在高波特率下串口中断触发频率很高。比如115200波特率理论上每秒约11.5KB数据折算成中断频率就是每秒一万多次。如果中断里还做了大量处理比如拷贝数组、调用strstr、执行复杂函数CPU时间很容易被吃掉一大块。我实际测试发现把模板从普通单字节中断改成“中断只负责写环形缓冲、主循环批量消费”之后同样数据流量下CPU占用下降非常明显。后来又进一步把串口改成DMA加空闲中断接收环形缓冲直接由DMA硬件搬运主循环只需要处理DMA半满或全满标志高数据量下依然顺畅。这个升级建议在主频较低的MCU上尤其值得做。5.2 CRLF不一致导致的匹配失败AT模块的行结束符并不统一。有的回\r\nOK\r\n有的回\nOK\n有的还在行首行尾加了空格或者不可见字符。匹配时如果直接拿原始行和strstr(OK)比较一般问题不大但如果你把expect_ok设成纯OK遇到OK: 1这种带参数的行会有一定的容错空间但遇到ERROR: 01之类带编号的字符串时strstr(buf, ERROR)也能匹配到容易误判。稳妥的做法是在进入匹配之前统一做一次行首尾清理。我写了一个简单的at_trim_copy函数去掉行首尾的空白和\r然后再进行子串匹配。匹配时优先用带边界的特征串比如\r\nOK\r\n但大多数情况下配合去空白已经足够稳定了。5.3 回调函数中的耗时操作拖垮主循环前文提到过回调函数里的耗时操作会拖垮整个AT轮询链路。这里说一个具体的排查经历我负责的一个中控面板项目用的WiFi模块每次连接服务器成功后要在回调里解析云端下发的配置JSON包含一个长度不小的字符串处理。第一次联调时一切正常后来云端配置内容增大面板开始间歇性掉线。定位过程很折磨人最后通过日志发现回调执行时间已经达到几十毫秒。在这几十毫秒里串口数据源源不断进入环形缓冲等退出回调再处理时缓冲早就满了新到的数据被丢弃。因为丢的是状态下发消息模块侧判断连接异常主动断开了连接于是看起来就是“读卡顿导致掉线”。后来把回调改成只拷贝数据到业务缓冲区并置位标志真正耗时的JSON解析放到主循环后续阶段处理问题立刻消失。整个过程让我深刻认识到回调就是“短平快”的接口任何长耗时操作都不应该出现在这里。5.4 一个定位了两小时的隐藏Bug重试导致重复发信模板加入自动重试机制之后大多数场景确实更省心但个别场景会出大问题。最典型的就是SIM800C发短信。发短信的AT指令流程比较特殊先发ATCMGS10086模块不会直接回OK而是回一个提示符此时你需要再发送短信内容最后发0x1A表示结束。如果模板把整条命令的超时时间和自动重试配置不当事情就变复杂了。我踩过这样一个坑发送短信命令设置了retry 3第一次发送后模块其实已经进入状态但由于某种原因模板在主循环里没及时处理导致超时误判于是模板自动重发了一次ATCMGS10086。模块此时还停在等待短信内容的阶段收到新命令后状态发生错乱短信内容被重复发送用户收到了两条一模一样的短信。查这个Bug查了两个小时最后在SIM卡账单里发现多出一条短信记录才定位到根因。从那以后凡是带“中间提示符且需要后续输入”的命令我都强制retry 0或者在做重试之前先检查当前是否已经收到过提示符如果收到过就只补发后续内容不再重发整条命令。6. 给初学者的模板使用建议和扩展方向6.1 直接套用时的参数配置参考如果你打算直接把这套模板用到项目里下面这些参数是实践下来的推荐值参数建议值说明RING_SIZE512~1024高波特率下建议至少512字节AT_LINE_BUF_MAX128绝大多数AT行内容不超过128字节AT_QUEUE_SIZE8连续配置流程一般不超过8条命令查询类命令超时50~500ms如ATGMR、ATCWMODE配网类命令超时5~15s如ATCWJAP具体取决于路由器响应建立连接类超时1~5s如ATCIPSTART发短信类超时1~3s如ATCMGS需要处理 提示超时时间并不是越长越好。太短容易误判太长会让系统在模块真正卡死时迟迟得不到恢复。建议所有超时时间都做成可配置的宏或者结构体成员放到项目调试阶段根据实测数据微调。6.2 移植到RTOS时AT_Poll应该放在哪里在裸机前后台系统里at_poll放在主循环里自然流转。如果项目使用了FreeRTOS、RT-Thread这类RTOS建议专门建一个AT处理任务把at_poll放进循环里或者在串口中断里只写环形缓冲并发送一个信号量让AT任务被唤醒后再执行解析。信号量方案要注意粒度问题。如果每个字节都发送一次信号量频繁的任务切换会带来不小的开销。我通常是在串口中断里做计数每收到16个字节或收到一个换行符时才发送一次信号量这样能把任务唤醒次数降低一个数量级。在没有信号量机制的环境里保持主循环调用也是完全可行的只要保证AT轮询任务的优先级高于UI刷新这类低时效任务即可。6.3 还能怎么扩展到其他协议和业务场景这套“串口字节流 环形缓冲 行解析 状态机”的思路并不只适用于AT指令。它本质上是所有串口类协议的通用骨架。我后来把同样的代码结构用在了Modbus RTU帧接收、GPS NMEA报文解析、私有二进制分包协议上改动量都很小。如果你同时使用多个AT模块比如WiFi和蓝牙各占一个串口只需要把模板里的全局状态机封装成结构体每个串口实例化一份独立上下文发送队列、接收缓冲、状态机全都变成实例成员。这就是模块化程序设计的好处整个模块可以像库一样被多个实例复用。另外调试阶段建议给模板加一个日志开关。所有发出的命令和收到的行都通过调试串口打出来用两个不同前缀区分“发”和“收”。这个看似简单的功能在联调AT流程时能省下大量时间。很多看起来是模块不稳定的问题其实只要把收发日志拉出来一眼就能定位到是命令格式、超时设置还是匹配串写错了。这套非阻塞模板是我做过最值的重构之一。它让我意识到单片机程序面对不确定到达的外部事件时最好的办法不是死等而是把期望和状态记录下来让主循环自己去对答案。后来我把这种思路应用到Modbus帧处理、按键消抖和界面上几乎都能套用。如果你也正被AT指令卡得焦头烂额建议先把发送队列和行解析跑通再逐步加状态机。别急着一次到位但方向一定别回头走阻塞老路。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。