LV020-命令系统设计
一、 模块定位与数据结构
1. 职责边界
串口命令模块只做"把一行文本翻译成一次函数调用",它不认识任何具体命令名:
- 驱动层(
usart.c)负责:拼行、匹配命令表、记录待执行命令、在主循环里调用处理函数; - 业务层负责:提供处理函数,并调用
usart1_cmd_register()把自己的命令挂上去。
这样切分的好处是"谁的命令谁注册":参数模块注册 paramset/paramget,SPI Flash 模块注册 spiflash,IAP 业务层注册 go/reboot,命令表为空时整段逻辑不产生任何开销。要加一条新命令,只需在业务模块里多写一个处理函数、多调一行注册。
2. 状态结构体
全部可变状态收在一个结构体里,调试时看一个对象就能掌握全貌:
// atk-f103/Core/Src/usart.c
typedef struct
{
usart1_cmd_item_t tab[USART1_CMD_MAX_NUM]; /* 命令表 */
uint8_t num; /* 已注册的命令数 */
char line[USART1_CMD_LINE_MAX]; /* 正在拼的一行(不含 CR/LF) */
uint8_t len; /* 当前行长度 */
char matched[USART1_CMD_LINE_MAX]; /* 命中行的快照 */
volatile int8_t pending; /* 待执行命令的下标, -1 = 无 */
volatile uint8_t enabled; /* 0 = 收帧中, 挂起命令识别 */
} usart1_cmd_state_t;两个字段值得单独说明:
pending是"识别"与"执行"之间唯一的交接点,-1表示没有待执行命令;matched是命中行的整行快照。拼行缓冲下一行就会复用,不留快照的话,主循环取行时参数早已被后续字节冲掉。
结构体的两个非零初值显式写出,不依赖"零值恰好等于初始态"这种巧合:pending = -1 表示无待执行命令,enabled = 1 表示识别开箱即开。
二、 命令的注册与匹配
1. usart1_cmd_register()
该函数在 atk-f103/Core/Src/usart.c 中定义,声明位于 atk-f103/Core/Inc/usart.h:
uint8_t usart1_cmd_register(const char *name, usart1_cmd_handler_t handler);【函数作用】
把一条命令挂进命令表:命令名统一转小写存储,处理函数原样保存。注册顺序决定了同名命令的命中优先级——先注册的先被匹配到。
【参数含义】
name:命令名,不区分大小写,长度需小于USART1_CMD_NAME_MAX(16 字节)handler:命中后执行的函数,原型为void (*)(const char *line),收到整行原文
【返回值】
返回 1 表示注册成功;返回 0 表示参数为空、名字超长或命令表已满。
2. 匹配规则
匹配只针对"行内第一个单词",且必须整词相等——命令名之后要么是行尾,要么是一个空格(空格之后的部分整体作为参数交给处理函数):
// atk-f103/Core/Src/usart.c
static uint8_t usart1_cmd_match(const char *line, uint8_t len, const char *name)
{
uint8_t i;
for (i = 0; i < len; i++)
{
if (name[i] == '\0')
{
return (line[i] == ' ') ? 1 : 0; /* 名字结束: 行内也结束, 或后跟参数 */
}
if (name[i] != usart1_cmd_to_lower(line[i]))
{
return 0;
}
}
return (name[len] == '\0') ? 1 : 0; /* 整行就是命令名, 无参数 */
}由此得到三条可直接依赖的结论:
GO、Go、go等价,大小写不敏感;gosram不会命中go,必须是go sram(有空格分隔);- 参数不解析、不校验,原样给处理函数,
paramset name=xxx serial=yyy这种写法才能成立。
3. 容量与注册时机
| 宏 | 位置 | 取值 | 含义 |
|---|---|---|---|
USART1_CMD_MAX_NUM | usart.h | 12 | 命令表槽位数(现用 9 条) |
USART1_CMD_NAME_MAX | usart.h | 16 | 命令名最大长度(含结束符) |
USART1_CMD_LINE_MAX | usart.h | 80 | 单行最大长度(含结束符) |
注册必须在 usart1_rx_start() 之前完成。这不是风格问题:接收中断一开,字节就可能随时抵达并开始匹配,此时再往表里塞新条目,等于让中断读一张正在被写的表。Bootloader 里的注册顺序就是按这条规则排的——spiflash_init()、param_init()、crash/deadloop 全部排在 iap_service_init() 之前,因为后者内部会触发 usart1_rx_start()。
三、 识别与执行分离
1. 中断侧只记录
usart1_cmd_feed() 由字节来源方(串口中断、TCP 回调)逐字节调用,行为限定在"拼行 + 比对 + 记下标":
// atk-f103/Core/Src/usart.c
if ((byte == (uint8_t)'\r') || (byte == (uint8_t)'\n')) /* 行尾: 与命令表比对 */
{
g_cmd.line[g_cmd.len] = '\0';
for (i = 0; i < g_cmd.num; i++)
{
if (usart1_cmd_match(g_cmd.line, g_cmd.len, g_cmd.tab[i].name) != 0)
{
memcpy(g_cmd.matched, g_cmd.line, (uint32_t)g_cmd.len + 1U);
g_cmd.pending = (int8_t)i; /* 记下标, 执行留给主循环 */
break;
}
}
g_cmd.len = 0; /* 一行结束, 无论命中与否都从头来 */
return;
}行长度达到 80 字节仍未收尾时,整行丢弃而不是截断——超长的东西肯定不是命令,截断反而会让半条命令变成另一条合法命令。
2. 主循环侧执行
usart1_cmd_poll() 在主循环里周期调用,先用临界区把 pending 与 matched 一起取走,再在临界区外执行处理函数:
// atk-f103/Core/Src/usart.c
uint32_t primask = __get_PRIMASK();
__disable_irq(); /* 与中断里的写操作互斥, 免得丢命令 */
idx = g_cmd.pending;
if (idx >= 0)
{
memcpy(line, g_cmd.matched, USART1_CMD_LINE_MAX);
g_cmd.pending = -1;
}
__set_PRIMASK(primask);
if (idx >= 0)
{
g_cmd.tab[idx].handler(line); /* 主循环上下文: 可打印/跳转/复位 */
}临界区只包住"取值"这两行,处理函数在中断已放开的状态下运行。处理函数里可以 printf、写 Flash、跳转、甚至复位——这些动作放在中断里任何一个都会出事(打印阻塞接收、写 Flash 期间取指被暂停、跳转后中断现场不成立)。
用 PRIMASK 存取而不是裸的 __disable_irq()/__enable_irq(),是为了不破坏调用者原来的中断屏蔽状态:调用 usart1_cmd_poll() 之前若已经关中断,返回后仍应保持关中断。
3. 二进制帧期间挂起
IAP 收帧期间必须把命令识别关掉,否则载荷里的随机字节既会冲刷命令行缓冲,也可能凑出一条命令。开关就是 usart1_cmd_set_enabled():
- IAP 状态机匹配到帧头时调用
usart1_cmd_set_enabled(0); - 回到扫描态时调用
usart1_cmd_set_enabled(1)恢复; - 挂起时会把已拼了一半的缓冲清掉,不跨帧残留半条命令。
挂起状态下 usart1_cmd_feed() 直接把 len 清零返回,不做任何匹配,成本是几次赋值。
四、 跨通道复用与扩展
1. TCP 通道复用同一张表
命令识别被设计成"喂字节"的接口,因此与字节从哪来无关。TCP 会话建立后,iap_tcp_recv() 对每个字节同时调用 usart1_cmd_feed() 和 iap_rx_byte():
// atk-f103/Components/lwip-2.1.3/port/iap_tcp.c
for (i = 0; i < q->len; i++)
{
usart1_cmd_feed(d[i]); /* 文本命令: 与串口通道共用同一张命令表 */
iap_rx_byte(d[i]);
}于是 go sram、go flash、reboot 这些命令从网口发也能生效,命令表只维护一份。注意这里的回调运行在主循环上下文(lwIP 是 NO_SYS 模式,由 lwip_comm_poll() 驱动),所以 usart1_cmd_feed() 在这一路虽然是"主循环调用",互斥关系仍然成立:它只写状态,不执行处理函数。
2. 新增一条命令的步骤
(1)在业务模块里写处理函数,签名为 void xxx_cmd(const char *line),参数是整行原文(含命令名)。
(2)在 usart1_rx_start() 之前调用 usart1_cmd_register("xxx", xxx_cmd),并检查返回值。
(3)若命令需要参数,自己从 line 里跳过命令名再解析;驱动层不做任何参数拆分。
(4)若命令会长时间阻塞(写 Flash、等 PHY、批量打印),评估对串口接收与看门狗的影响。
3. 已知限制
- 只有一行、只有一个挂起槽位:
pending是单下标,连续命中两条命令时后一条会覆盖前一条,中间那条被丢弃。对"人敲命令"的场景足够,但不能当消息队列用。 - 不做重复注册检查:同名命令注册两次时两条都在表里,永远是先注册的那条生效。
- 命令名不含空格:匹配只认第一个单词,带空格的命令名无法命中。
- 执行是同步的:
usart1_cmd_poll()一次最多执行一条命令,命令耗时就直接反映为主循环延时,所以写 Flash 这类命令执行期间看门狗裕量要单独核算。
本文档由 markdowncli 技能辅助生成