Skip to content

LV020-命令系统设计 ​

一、 模块定位与数据结构 ​

1. 职责边界 ​

串口命令模块只做"把一行文本翻译成一次函数调用",它不认识任何具体命令名:

  • 驱动层(usart.c)负责:拼行、匹配命令表、记录待执行命令、在主循环里调用处理函数;
  • 业务层负责:提供处理函数,并调用 usart1_cmd_register() 把自己的命令挂上去。

这样切分的好处是"谁的命令谁注册":参数模块注册 paramset/paramget,SPI Flash 模块注册 spiflash,IAP 业务层注册 go/reboot,命令表为空时整段逻辑不产生任何开销。要加一条新命令,只需在业务模块里多写一个处理函数、多调一行注册。

命令系统的识别与执行分离

2. 状态结构体 ​

全部可变状态收在一个结构体里,调试时看一个对象就能掌握全貌:

c
// 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:

c
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. 匹配规则 ​

匹配只针对"行内第一个单词",且必须整词相等——命令名之后要么是行尾,要么是一个空格(空格之后的部分整体作为参数交给处理函数):

c
// 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_NUMusart.h12命令表槽位数(现用 9 条)
USART1_CMD_NAME_MAXusart.h16命令名最大长度(含结束符)
USART1_CMD_LINE_MAXusart.h80单行最大长度(含结束符)

注册必须在 usart1_rx_start() 之前完成。这不是风格问题:接收中断一开,字节就可能随时抵达并开始匹配,此时再往表里塞新条目,等于让中断读一张正在被写的表。Bootloader 里的注册顺序就是按这条规则排的——spiflash_init()、param_init()、crash/deadloop 全部排在 iap_service_init() 之前,因为后者内部会触发 usart1_rx_start()。

三、 识别与执行分离 ​

1. 中断侧只记录 ​

usart1_cmd_feed() 由字节来源方(串口中断、TCP 回调)逐字节调用,行为限定在"拼行 + 比对 + 记下标":

c
// 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 一起取走,再在临界区外执行处理函数:

c
// 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():

c
// 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 技能辅助生成