嵌入式开发中,让 AI 辅助时,只要需求里出现状态两个字——通信协议解析、传感器采集流程、低功耗模式切换、BLE 连接管理、bootloader 流程——AI 就开始表演自由发挥。
生成的代码可能不太符合我们的预期:状态漏了、转移漏了、该报错的地方默默忽略。它跑起来像那么回事,出事在三个月后。
这次分享我们摸出来的四步做法:先表后码 → 穷举事件 → 表驱动 → 从表生成测试。文末有一段可以直接复制的提示词模板。
1. 先表后码:让 AI 先整理思路
不要一上来就让 AI 写代码。第一步永远是:
先不要写代码。把这个状态机的所有状态、所有事件、所有转移列成一张表,包括错误转移和异常处理。我确认之后你再实现。
为什么要这一步?
因为表格比代码好审 10 倍。审代码时,我们的注意力被语法、寄存器操作、边界条件分走了大半;审一张状态转移表时,我们只需要确认四个问题:
- 有没有漏掉的状态?有没有漏掉的事件?有没有漏掉的转移?每个转移的目标状态和动作,对不对?
举个例子。一个低功耗传感器采集的状态机,需求里只说了"待机、采集、上传",AI 交上来的表大概长这样(→ 表示转移,— 表示显式忽略,! 表示非法事件,应记录并断言):
| 状态\事件 | 启动采集 | 采集完成 | 上传完成 | 总线错误 | 超时 |
|---|---|---|---|---|---|
| IDLE | → SAMP | — | — | ! | — |
| SAMP | — | → UPLD | — | → ERROR | → ERROR |
| UPLD | — | — | → IDLE | → ERROR | → ERROR |
| ERROR | → SAMP | — | — | ! | — |
表格确认了,代码只是表格的机械翻译。翻译这一步,AI 很擅长。
但先别急着确认。 这张表至少少了三样东西:
动作:→ SAMP 的时候,要不要给传感器上电、清 FIFO?表驱动里,动作才是业务逻辑的落点。
低功耗态和终态:掉电唤醒、低电量休眠走哪条路?错误一直重试,有没有尽头?
错误恢复策略:ERROR 收到"启动采集"就直接回 SAMP,是自动重试还是等外部命令?重试几次放弃?
补齐之后就顺眼了:
| 状态\事件 | 启动采集 | 采集完成 | 上传完成 | 总线错误 | 超时 | 低电量 |
|---|---|---|---|---|---|---|
| IDLE | → SAMP | — | — | ! | — | → SLEEP |
| SAMP | — | → UPLD | — | → ERROR | → ERROR | → SLEEP |
| UPLD | — | — | → IDLE | → ERROR | → ERROR | → SLEEP |
| ERROR | → SAMP * | — | — | ! | — | → SLEEP |
| SLEEP | → IDLE | — | — | — | — | — |
2. 穷举,不要举例
给 AI 举例子,它可能只会处理我们举的例子。
"采集流程有待机、采集、上传这几个状态"
如果我们这样描述,AI 大概率不会主动问我们:
掉电唤醒算不算一个事件?
I2C NACK 之后是重试还是进错误态?
低电量时是直接休眠还是先把手头的数据存进 Flash?
上一节那张表,最后就是被"低电量 → SLEEP"这种谁都没提过的事件补完整的。嵌入式场景下,要特别把这几类容易被 AI 遗忘的硬件事件写进提示词:
列出所有状态和所有事件。特别注意,事件来源包括但不限于:
命令类:上位机命令、按键(含机械抖动、长按短按)、通信协议包;
硬件类:总线错误(NACK、超时、CRC 错)、传感器异常、掉电/欠压检测(PVD)、复位(含看门狗复位);
时间类:软件定时器超时、协议要求的应答超时、重试间隔;
异步类:中断到达(数据就绪、DMA 完成)、其他任务/线程发来的消息。 每个状态遇到每个事件时的行为(转移 / 显式忽略 / 报错)必须定义,不允许留默认。
输出表格后自查一遍,把你拿不准、需要我确认的格子单独列出来。
最后那句"把拿不准的格子列出来"很关键:AI 的沉默不等于它没有疑问,它只是默认替你做了决定。
3. 表驱动
确认完表格,接下来怎么写代码?
很多人的直觉是让 AI 生成一个大 switch (state),里面嵌套 if (event == ...)。AI 写出来大概是这个样子:
能用,但这是状态机腐烂的开始——switch 分支散落在几个函数里,三个月后需求一变,改了东忘了西。更要命的是 AI 生成的 switch 经常只在部分 case 里更新状态,其余路径的状态保持旧值,这种 bug 审代码时极难发现。
正确姿势是表驱动(table-driven):转移表是数据,执行器是代码。
(三张图依次是转移表定义、动作函数、只查表的执行器。)
这套结构对嵌入式的额外好处:
const 转移表落进 .rodata,只要链接脚本把它指向 Flash,就不占 RAM(函数指针数组、状态变量、事件队列仍然占 RAM,别把这句话理解成"整套机制零开销")。
加状态、加事件,只改表,不动执行器。
表可以独立审查,甚至可以当作接口文档发给同事。
4. 从表生成测试,再让 AI 反向对表
第一,从表生成穷举测试。
好处是联动:表改了,测试跟着变,漏掉的格子立刻变红。
第二,让 AI 反向输出一张表来对。
这是最便宜、也最有效的一招:
读一遍你刚才实现的执行器和转移表,反向输出一张"状态 × 事件"表,格式与第一步完全一致。然后把它和第一步的表逐格 diff,逐条列出不一致的格子,先不要修改代码。
为什么有效?因为正向生成时,AI 顺着表格机械翻译;反向生成时,它必须真的去读代码。
5. 提示词模板
把上面几条串起来,就是这样一个完整的工作流提示词:
环境:____(裸机 / RTOS / 嵌入式 Linux)
我要在 MCU 上实现一个状态机,
请严格按以下步骤来,每一步完成后
停下来等我确认,不要跳步。
【第一步】只输出一张状态转移表,
不写代码。表头:状态 × 事件 →
目标状态 + 动作。要求穷举:
1. 所有状态,含错误态、低功耗态、终态;
2. 所有事件,至少包括:
- 命令类:上位机命令、按键(含抖动、
长按短按)、通信协议包;
- 硬件类:总线错误(NACK / 超时 /
CRC 错)、传感器异常、掉电欠压(PVD)、
复位(含看门狗复位);
- 时间类:软件定时器超时、协议应答
超时、重试间隔;
- 异步类:中断就绪(数据就绪 / DMA
完成)、其他任务或线程发来的消息。
3. 每个 (state, event) 组合的行为:
转移、显式忽略、或报错,不留默认。
4. 输出后自查一遍,把拿不准、
需要我确认的格子单独列出来。
【第二步】等我确认表格后,
用 C 表驱动风格实现:
- 转移表用 const 二维数组(进 Flash),
动作用函数指针;
- 执行器只查表,无业务判断;
- ISR 只往事件队列入队,状态修改
只发生在唯一执行上下文,共享数据
用临界区 / 原子操作保护;
- 禁止 malloc、禁止 C++ 异常,
栈和队列全部静态分配。
【第三步】生成 PC 端可跑的穷举测试
(Unity / CppUTest):
- 每个状态 × 每个事件,
断言结果与表一致;
- mock 所有 action,验证副作用;
- 覆盖事件队列的满 / 空边界。
【第四步】反向校验:
读一遍你写的实现,反向输出同样
格式的状态 × 事件表,与第一步的
表逐格 diff,列出所有不一致的格子,
先不要改代码。
(____ 处换成自己的运行环境;粘贴给 AI 时不用管这里的折行。)
6. 总结
AI 不会替我们把需求想清楚,但它非常擅长把想清楚的东西机械地翻译成代码和测试。我们要做的,是先把那张表逼出来。
186