1. 为啥要搞自定义AT指令
先说个场景。嵌入式工程师做物联网产品,十有八九都要跟通信模块打交道,4G模组、WiFi模组、蓝牙模组,几乎清一色用AT指令做控制。模块厂商给的那套固件自带AT指令,够用是够用,但总有对不上的时候——比如你想让设备上报一个私有协议的状态,或者想在本地模拟一个远程控制口,甚至想给产测工具开个后门通道,这些场景下厂商指令根本覆盖不了,这时候就得自己写一套AT指令服务。
更常见的情况是,产品里主控MCU自己就是“被控制端”,上位机、手机App、云平台通过串口WiFi模组中转,拿AT指令来操控MCU。那你总不能把MCU的每个动作都用GPIO模拟吧?最干净的做法就是让MCU直接跑一个AT指令解析器,把串口收到的字符串当成指令来处理。
RT-Thread作为国产开源RTOS,在国内物联网开发圈子里用得非常多,组件生态也比较全。它自带一个叫做finsh的命令行组件,能在控制台跑命令,但那个东西主要是给人调试用的,不是给设备间通信用。finish命令解析是基于控制台终端的,讲究可读性,不支持带参数回显、主动上报这套机制,而且它会跟日志输出混在一起。生产环境里设备间的AT指令交互,需要更严格的帧格式、超时机制和应答协议,所以不能拿finish凑合。
自定义AT指令组件,就是自己在RT-Thread上搭一套轻量级的AT指令收发框架,说白了就是做一个“串口指令解释器”。它能做三件事:第一,接收完整的AT指令行;第二,匹配注册好的命令表并调用对应的处理函数;第三,把处理结果按约定的格式回给对端。这套东西看着简单,真要做得稳、做得可维护,里面有不少门道。
这篇文章就是从零开始,捋一遍在RT-Thread上自定义AT指令的完整思路和关键代码,包括数据结构的组织、解析状态机的设计、串口DMA接收的配合、命令注册宏的封装,还有常见坑的排查方法。想给项目加个可靠的指令通道,或者想理解AT指令框架内部原理的,可以跟着走一遍。
2. 整体设计思路拆解
2.1 解析方案选型:轮询、事件驱动还是独立线程
写AT指令解析,第一步是决定指令怎么被“接住”。常见的做法有三种。
第一种是轮询。主循环里不断检查串口缓冲区里有没有完整的一行,有就解析,没有就跳过。好处是省资源、不用额外线程,坏处是会被其他任务拖累,如果主循环里有阻塞操作,指令响应就变得很不及时,调试起来很痛苦。
第二种是事件驱动。串口收到数据后触发回调,解析器在中断上下文里做部分解析,遇到完整指令再投递到消息队列,由业务线程去处理。好处是实时性好,坏处是中断里要做状态判断,如果处理逻辑复杂,容易把中断搞得很重。
第三种是独立线程。RT-Thread里创建一个专门负责AT指令处理的线程,线程阻塞在消息队列或信号量上,串口中断也好、DMA接收完成中断也好,只负责把数据塞进队列/缓冲区,然后唤醒处理线程。处理线程里做完整的行提取、命令匹配、参数解析和回调执行。
我个人推荐第三种,理由很直接:嵌入式项目里AT指令的处理往往涉及业务逻辑,比如设置参数要写Flash、查询状态要读传感器,这些操作不可能在中断里做。独立线程把“收数据”和“处理数据”彻底解耦,指令处理慢一点也不会影响串口接收,数据不丢。虽然多花一个线程的栈空间,但对现在动不动几十K RAM的MCU来说,完全值得。
2.2 命令表驱动的框架思想
做完线程模型,第二步就是把解析器核心设计成“命令表驱动”。
什么叫命令表驱动?就是预先定义一张命令结构体数组,每个元素描述一条命令的“名字”和“处理函数指针”。解析器拿到一条指令文本后,把命令名取出来,在这张表里线性查找(或者哈希查找),找到了就调用对应的handler,找不到就回一个“ERROR”之类的结果。
typedef struct at_cmd { const char *cmd_name; // 命令名,比如 "AT+LED" uint8_t cmd_len; // 命令名长度,省去strlen int32_t (*cmd_handler)(int argc, char *argv[]); // 回调函数 } at_cmd_t;
好处非常明显:你要加一条新指令,不需要动解析器核心代码,只需要在命令表里增加一项,然后实现对应的回调函数。维护成本低,代码结构清晰,而且命令表的存储可以用const放到Flash上,不占RAM。
这种设计在成熟的开源AT框架里都能见到影子。它的核心思想就是把“规则的描述”和“规则的执行”分离,命令表是数据,解析器是逻辑,回调和业务是最终动作。哪怕以后产品换了个主控,只要命令表协议不变,移动代码的成本很低。
2.3 解析状态机的四种状态
AT指令是一行一行的文本,但串口是字节一个接一个来的,所以解析器必须自己做“组帧”。这里需要一个简单的有限状态机,通常有四个状态:
- 等待起始状态:等待AT指令行的头两个字符“AT”,通常是回车换行之前不能乱收。这个状态用于过滤无关数据,防止噪声串扰。
- 收集命令状态:收到有效起始符之后,逐个字节收集命令正文,直到碰到结束符。
- 结束处理状态:收到
\r\n或者单独\n,判定一行指令完整。 - 空行/异常状态:收到奇奇怪怪的字节,比如数据超长、非ASCII字符,直接丢弃这一行并复位到等待起始状态。
状态机的好处是解析逻辑跟具体业务完全解耦,串口数据可以任意分段,不管对方一次发20个字节还是分三次发完,状态机都能正确拼出完整行,只要不超过缓冲上限。
2.4 为什么选行列式而不是字节流式接口
有些开发者图省事,直接把AT指令框架做成“字节流接口”,就是外面往里塞一个字节就解析一个字节,解析完一个命令回调一次。这种接口在简单的echo场景下没啥问题,但一旦指令带参数、带字符串,尤其是需要校验命令完整性,就会变得很难用。
我选择的是“行列式”接口,也就是“外部保证给到一行完整数据,解析器只处理完整行”。串口DMA接收或者中断接收负责攒行,攒够一行才调用at_parse_line()。这样解析器不需要维护跨调用的状态,纯函数式输入,特别容易做单元测试。
实测下来,行列式接口还有一个隐藏优势:多条串口同时接入,或者将来要对接网络Socket数据,都可以共用同一套行解析器,只要有一个“按行从流里提数据”的前置层即可,框架复用性极高。
3. 核心数据结构与命令注册机制
3.1 命令表结构体定义
下面直接贴一份我常用的命令表定义。这里用了固定大小数组,不用链表,原因后面说。
#define AT_CMD_NAME_MAX 16 #define AT_CMD_ARG_MAX 8 #define AT_CMD_LINE_MAX 128
typedef struct at_cmd_desc { const char *cmd_name; uint8_t cmd_name_len; int32_t (*handler)(int32_t argc, char *argv[]); } at_cmd_desc_t;
typedef struct at_cmd_table { const at_cmd_desc_t *cmds; // 指向命令描述数组 uint32_t count; // 命令数量 } at_cmd_table_t;
这里值得注意的小细节是cmd_name_len字段。很多人写命令匹配直接用strcmp,每次匹配都要从头扫描字符串,效率低是一回事,更重要的是容易受大小写和多余空格干扰。把命令名的长度预先存起来,匹配时先比长度,长度一致再比内容,能省掉不必要的strlen开销。
还有一个细节:argv的字符串存储问题。解析出来的参数是直接指向哪块内存?我建议解析器在解析一行时,把该行数据拷贝到一个专门的line_buffer里,然后在line_buffer上原地用\0切分,这样argv里的指针都指向line_buffer内的不同偏移。好处是参数修改不会影响原始缓冲区,坏处是回调返回后line_buffer会被覆盖,所以回调里如果需要保存参数,必须自己strcpy出来。
3.2 注册宏的封装
为了让加指令变得像写一行配置一样简单,我封装了注册宏。原理是利用RT-Thread的自动初始化机制,把命令表段放到指定的section,由系统启动时自动收集。
#include <rtthread.h>
#define AT_CMD_EXPORT(_name, _handler)
static const at_cmd_desc_tat_cmd##_name SECTION("at_cmd") =
{
#_name, sizeof(#_name) - 1, _handler
}
然后在初始化代码里,通过extern声明section的起止地址,把所有命令项聚合成一张表:
extern const int at_cmd_start; extern const int at_cmd_end;
static const at_cmd_desc_t *get_cmd_table(uint32_t *count) { const at_cmd_desc_t *start = (const at_cmd_desc_t *)&at_cmd_start; const at_cmd_desc_t *end = (const at_cmd_desc_t *)&at_cmd_end; *count = (uint32_t)(end - start); return start; }
这个做法是从RT-Thread的INIT_EXPORT抄来的思路,好处是新增命令模块时,连初始化函数都不用改,链接器自动把命令项归到一张表里,模块化程度很高。前提是链接脚本里要留出at_cmd段,不然符号找不到会直接链接报错。
3.3 参数缓冲区与命令行的生命周期管理
定义一个结构体来管理“正在处理的一行数据”:
typedef struct at_parse_context { char line_buf[AT_CMD_LINE_MAX]; // 缓存当前这一行 uint8_t line_len; char *argv[AT_CMD_ARG_MAX]; // 解析出的参数指针 int32_t argc; uint8_t parse_state; // 状态机状态(如果有) } at_parse_context_t;
上下文被设计成“一次性使用”的,也就是说解析器拿到一行,把数据拷贝进这个context,解析出argv,然后调用回调,回调里同步使用完后这次解析即结束,context可以被下一条复用。
这里有个很关键的取舍:line_buf的大小。AT_CMD_LINE_MAX我设的是128字节,对绝大多数物联网应用够了。但如果你要支持AT+CFUN=1这种简短指令,128绝对绰绰有余;如果你要支持AT+SMSCFG="+8613800100500",1,1这种长指令,128也够。万一将来遇到超长数据,我会加一个溢出标志,直接丢弃该行并恢复状态机,而不是动态扩容,因为嵌入式环境里动态内存是不受控的。
4. 实操过程与关键环节实现
4.1 串口设备层的接入
在RT-Thread上做串口接收,最推荐用DMA模式加空闲中断(如果需要精确组帧),或者用轮询加信号量的方式也可以接受。我实际用的方案是:串口以DMA接收模式挂在设备上,收到数据后由驱动回调通知一个rt_sem_release,处理线程阻塞在信号量上。
关键代码:
static struct rt_semaphore at_rx_sem;
static rt_err_t at_uart_rx_ind(rt_device_t dev, rt_size_t size) { rt_sem_release(&at_rx_sem); return RT_EOK; }
static void at_uart_thread_entry(void *param) { char ch; at_parse_context_t *ctx = &at_parse_ctx;
while (1) { rt_sem_take(&at_rx_sem, RT_WAITING_FOREVER); while (rt_device_read(at_uart_dev, 0, &ch, 1) == 1) { at_parse_char(ctx, ch); } }}
这里有个经验:rt_device_read一次读1字节效率不高,如果串口一帧数据有几十字节,建议一次读多个字节到一个临时buffer,再逐字节喂给解析函数。比如:
char tmp[32]; rt_size_t n = rt_device_read(at_uart_dev, 0, tmp, sizeof(tmp)); for (rt_size_t i = 0; i < n; i++) { at_parse_char(ctx, tmp[i]); }
这样能明显降低设备驱动层的调用次数,尤其在高波特率下效果明显。
4.2 AT指令解析主循环的实现
解析主循环用状态机来做按字节处理:
void at_parse_char(at_parse_context_t *ctx, char ch) { switch (ctx->parse_state) { case AT_STATE_WAIT_START: if (ch == 'A') { ctx->line_buf[0] = ch; ctx->line_len = 1; ctx->parse_state = AT_STATE_COLLECT_AT; } break;
case AT_STATE_COLLECT_AT: if (ch == 'T') { ctx->line_buf[1] = ch; ctx->line_len = 2; ctx->parse_state = AT_STATE_COLLECT_BODY; } else { ctx->parse_state = AT_STATE_WAIT_START; } break; case AT_STATE_COLLECT_BODY: if (ch == '\n' || ch == '\r') { ctx->line_buf[ctx->line_len] = '\0'; at_handle_line(ctx); ctx->parse_state = AT_STATE_WAIT_START; } else { if (ctx->line_len < AT_CMD_LINE_MAX - 1) { ctx->line_buf[ctx->line_len++] = ch; } else { // 超长,丢弃本行 ctx->parse_state = AT_STATE_WAIT_START; } } break; }}
这个状态机已经能处理绝大多数情况。要注意的是,如果对端发的指令结尾是\r\n,那在读到\r时就会触发处理,然后下一个\n会当作新一行起始,这就有问题了。所以我在AT_STATE_COLLECT_BODY里加了一个“跳过连续结束符”的处理,简单做法是遇到\r后,如果下一个字节是\n,就吃掉它不进入状态机。
4.3 命令名与参数解析
完整行提取出来后,先分离命令名,再切割参数:
static void at_parse_command(at_parse_context_t *ctx) { char *p = ctx->line_buf;
// 跳过行首空白 while (*p == ' ' || *p == '\t') p++; // 提取命令名:命令名是连续的非空格、非等号、非逗号字符 char *cmd_start = p; while (*p && *p != ' ' && *p != '=' && *p != '\t') p++; char *cmd_end = p; // 保存命令名(截断到buf里) uint8_t len = cmd_end - cmd_start; if (len >= AT_CMD_NAME_MAX) len = AT_CMD_NAME_MAX - 1; memcpy(ctx->cmd_buf, cmd_start, len); ctx->cmd_buf[len] = '\0'; // 剩余部分是参数,切分 ctx->argc = 0; while (*p && ctx->argc < AT_CMD_ARG_MAX - 1) { // 跳过空白 while (*p == ' ' || *p == '\t') p++; if (*p == '\0') break; ctx->argv[ctx->argc++] = p; // 跳到下一个空白或结束 while (*p && *p != ' ' && *p != '\t') p++; if (*p) { *p++ = '\0'; } } ctx->argv[ctx->argc] = NULL;}
这个解析器不处理逗号分隔的结构化参数,因为AT指令参数格式五花八门,有AT+CMD=1,2,3,也有AT+CMD="abc",1,如果在解析器里强行分逗号,引号内的逗号会被误切分,反而麻烦。我的建议是解析器只保证“把参数按空白分成几个token”,真正的结构化解析放回调里做,每个handler自己判断期望的格式。
4.4 查询指令与设置指令的统一处理
AT指令规范里,查询和设置通常用同一个命令名区分:AT+LED?是查询,AT+LED=1是设置。解析器里怎么做?
我采用的方法是:命令表里注册同一名字的两个handler,一个负责查询,一个负责设置。匹配逻辑先查命令名,再查参数个数和起始字符——如果参数是?,走查询handler;如果参数以=开头,走设置handler。
typedef struct at_cmd_item { const char *cmd_name; uint8_t cmd_name_len; void (*query_handler)(at_response_t *resp); void (*set_handler)(int32_t argc, char *argv[], at_response_t *resp); } at_cmd_item_t;
匹配时,先整体过一遍命令表找cmd_name,找到后看该行有没有?或者=,来决定调用哪个handler。这样两条命令逻辑可以分开写,查询和设置不互相干扰,代码可读性也好。
4.5 响应输出的实现
AT指令的响应格式虽然各厂略有不同,但核心就两句话:知道就说OK,不知道就说ERROR。我封装了一个简易的响应对象:
typedef struct at_response { char *buf; uint32_t size; uint32_t len; uint8_t is_error; } at_response_t;
void at_response_printf(at_response_t *resp, const char *fmt, ...) { va_list args; va_start(args, fmt); uint32_t n = vsnprintf(resp->buf + resp->len, resp->size - resp->len, fmt, args); va_end(args); resp->len += n; }
这样每个handler只需要往resp里写内容,最后由框架统一加\r\nOK\r\n或者\r\nERROR\r\n,保证格式一致。我还加了resp->is_error标志,回调如果执行失败可以手动置位,框架会输出ERROR而不是OK。
4.6 一个完整指令的功能演示
贴一个最简单的LED控制指令作为例子,你直接照着扩展即可。
static void at_led_query(at_response_t *resp) { at_response_printf(resp, "+LED:%d\r\n", led_get_state()); }
static void at_led_set(int32_t argc, char *argv[], at_response_t *resp) { if (argc < 2) { resp->is_error = 1; return; } int state = atoi(argv[1]); if (state != 0 && state != 1) { resp->is_error = 1; return; } led_set_state(state); }
AT_CMD_EXPORT("AT+LED", at_led_query, at_led_set);
串口里实际收发效果:
发送:AT+LED? 返回:+LED:0 OK
发送:AT+LED=1 返回:OK
发送:AT+LED=2 返回:ERROR
这种格式在调试时直观,上位机解析也简单,产测工具对接基本零成本。
5. 常见问题与排查技巧实录
5.1 数据半包导致指令被拆开
串口数据不可能保证一次发完一整行。DMA接收经常会遇到半包,比如整个指令20字节,第一次中断只到了12字节,第二次才补全。很多新手在这里踩坑,处理线程读一次数据就解析,结果指令不完整,解析器报ERROR,但其实数据没问题。
解决办法就是前面提的“行列式接口加状态机”的组合。处理线程把收到的字节全部喂给状态机,状态机自己攒行,攒完整才触发处理函数。只要状态机逻辑没写错,半包问题天然就被解决了。这也是我强调状态机重要性的原因。
5.2 串口LOG日志与AT响应互相污染
这是嵌入式调试中最头疼的问题之一。如果你用了RT-Thread的rt_kprintf输出日志,而同一串口又用作AT指令口,那么日志会和AT响应混在一起,上位机收到的数据乱七八糟。
我踩过这个坑之后学到的经验是:生产环境必须把日志口和AT口分开。如果硬件上只有一个串口,那宁可牺牲一些调试便利,也要把日志关了或者重定向到内存log里。还有一种做法是给AT响应加一个固定的帧头,比如统一用\r\n+AT_RESP:开头,上位机按这个前缀筛数据,但这只是补救,不是正解。
建议在初始化的时候把两个设备变量分开,用两个串口设备分别做调试口和业务口。RT-Thread很容易做到,创建两个设备实例即可。
5.3 指令处理函数阻塞导致解析线程卡死
如果业务handler里写了延时函数,比如rt_thread_mdelay(100),而且这个handler被放在AT解析线程里执行,那么整个解析线程会跟着阻塞100ms。这期间如果有新指令进来,虽然串口数据不丢,但响应会延迟。
解决方案有两种,取决于你的业务需求。一是把耗时的业务逻辑丢到额外的业务线程里,handler只做“投递任务”的动作。二是干脆在AT指令里就不要放耗时操作,把所有可能卡顿的动作通过消息队列异步执行。
我实际用的是第一种,在handler里把任务结构体塞进一个队列,让工作线程去消费。这样AT解析线程永远保持轻快,响应很快,用户体验也好。
5.4 缓冲区溢出和参数越界
命令解析里最容易出问题的是字符串处理。比如用atoi(argv[1])之前,没有确认argc是否大于1,这时候argv[1]可能指向NULL或者是未初始化内存。在C语言里,对NULL调用atoi是undefined behavior,运气好返回0,运气不好就hardfault了。
我习惯在解析器匹配命令、参数数量不足时直接返回ERROR,根本不给handler执行的机会。具体做法是在解析阶段,如果命令是设置类并且参数数量少于某个最小值,框架统一拒绝,只有参数数量合法的才进入handler。
再加一条:所有handler在访问argv之前,必须先判断argc个数,不能偷懒。
5.5 命令表在链接时丢失
用了SECTION("at_cmd")的自动收集方式后,一个坑是不能随意把命令表变量定义成局部变量。局部变量在栈上,不会进section,导致命令搜不到。我遇到过一次,排查了很久才发现是定义位置不对,后来全部改成全局静态变量,问题马上消失。
另外要注意链接脚本。有些MCU的链接脚本没有预留自定义section,直接使用会导致链接报错。解决方法是修改链接脚本,在.rodata附近加一个大小的段,具体语法取决于链接器,至少要在ld文件里加上类似:
. = ALIGN(4); _at_cmd_start = .; KEEP(*(at_cmd)) _at_cmd_end = .;
手动添加时务必确认段名与代码里的SECTION("at_cmd")一致,我之前因为大小写问题遇到过根本编译不过去的尴尬。
6. 性能优化与容量规划
6.1 匹配算法的时间复杂度
命令表线性查找,在命令数量很少(几十条以内)时完全够用。每条命令匹配只比较命令名长度和几个字节,主频哪怕只有72MHz的MCU,一次查找也在微秒级,完全无感。
如果将来命令数量膨胀到几百条,线性查找的耗时就不能忽视了。这时候可以改成哈希匹配,或者按命令名首字符建索引。RT-Thread的组件里有现成的哈希表实现可以直接引用,但大多数项目用不上,不必过度设计。
6.2 线程栈大小的估算
AT解析线程的栈大小,主要看最深的handler调用链。如果handler里调用了snprintf,那栈消耗会比较大,我一般给这个线程分配2048字节,调试模式再加512字节冗余。如果handler里还要调用外部模块,比如读写Flash,建议把栈调到3072字节。
估算方法很简单:在handler里放一个局部变量数组,比如char buf[256],然后递归调用几层,用rt_thread_self()拿线程控制块,打印stack_used,观察峰值。这是RT-Thread提供的标准手段,实测很准。
6.3 RAM占用总结
一个AT指令组件实例,主要消耗以下几块:
- 解析context:line_buf 128字节 + argv指针数组 32字节 + cmd_buf 16字节,约176字节。
- 串口接收缓冲:如果DMA接收,至少要一帧的缓冲,比如64字节或128字节。
- 线程栈:2048字节。
- 消息队列/信号量:几十字节。
合计大概2.5KB RAM,这在MCU选型时几乎可以忽略。相比功能强大的AT指令解析组件,这个成本非常划算。
7. 后续扩展与应用场景
自定义AT指令组件搭好之后,能干的事情其实远超“控制LED”这个层面。
一个典型的扩展是用它做固件升级通道。上位机通过AT指令把固件分包发过来,AT+OTA=0,1024表示偏移0长度1024,MCU收到后写入Flash的OTA分区,全部传完发AT+OTA_END,然后重启。这个方案在产测和现场维护时非常实用,不用开盖就能升级。
还可以做远程调试通道。如果设备上了网络模组,模组的TCP透传数据可以直接转发到本地AT指令解析器,这样远程服务器就能通过AT指令控制设备。这种模式在智能家居、工业网关里非常普遍,本质上就是把AT当成了应用层协议。
更远一点,还可以把AT指令解析器和RT-Thread的msh命令行对接,让同一个串口既能人工调试,又能跑设备协议。虽然我前面说生产环境不建议混用,但开发调试阶段很香——你可以用AT指令控制逻辑,用msh查系统状态,两不误。
我个人体会最深的一条:在RT-Thread上做AT指令,不要把希望全寄托在现成框架上,最好自己动手写一个轻量级的,哪怕只有几百行,这个过程中你对状态机、缓冲区管理、线程模型这些嵌入式基本功的理解都会上一个台阶。按这套思路做完之后,以后再遇到其他协议的解析,比如Modbus串口帧、JSON流解析、自定义二进制协议,基本就是举一反三,很快就能套上。
最后再说一个隐藏小技巧:AT指令解析器里的行列提取逻辑,其实可以单独提取成一个“行分割器”模块,喂给它的不止是串口数据,还可以是TCP socket收到的数据流。这样同一套AT组件既能服务串口,又能服务网络,一鱼两吃。我当时加了这层抽象之后,后面接入联网模组几乎没有再改过解析核心。