如果你正在用 AI 辅助开发嵌入式固件,让 AI 写过 bootloader hook、烧录流程、验签逻辑,然后把代码推到线上后发现设备行为完全不对、测试全绿但真机 FAIL、或者线上还在跑旧版本。这篇文章就是为你写的。
接下来六天,我把 meta-pass v1.0 的十三次弯路重新梳理了一遍。大多数坑不是技术问题,是同一个原因:任务描述没写清楚什么是「完成」。
文章先给出问题的明确判断,然后用这六天里踩过的真实事例作为证据,最后给出行动建议。
问题的判断
AI 能写出正确的代码,但不会自己判断任务是否结束。
它会编译通过、会跑通测试、会提交代码,但不会去验证线上部署是不是滞后、不会检查二进制里有没有真的把功能链接进去、不会确认测试用的是不是真实数据。
这些判断必须由人来写。写在哪里?写在任务描述里。
这篇文章把十三次弯路提炼成六条原则,作为 meta-pass 项目后续所有需求开发的前置约定。
原则一:验收标准必须有,且必须具体到命令
这个原则回答什么问题:你怎么知道任务做完了?
AI 没有「验收标准」这个概念。它看到「实现 bootloader hook」,就会写代码、跑编译、提交 PR。它不会去想「我怎么知道这事做完了」。
具体 bug
bootloader hook 第一次编译全绿,但检查二进制发现 hook 根本没被链接进去。新建组件目录不触发 cmake 重新配置,组件缺 CMakeLists.txt1。
原始任务描述
实现 bootloader hook 功能,让设备每次开机都回到启动器。验证配置修改已生效。
缺失分析
这条描述没有回答「怎么知道完成了」。AI 收到后会自动执行:写代码 → 编译 → 提交。但它不会主动验证 hook 是否真的进了二进制,也不会检查 sdkconfig 里的 BT 优化配置是否真的生效。
「验证配置修改已生效」是一个模糊的目标描述,不是验收条件。AI 不知道什么叫「生效」,也不知道该查什么。
模板骨架
任务:一句话描述要做什么
验收标准:
1. 命令级别验证(nm / grep / ls / 串口日志等)
2. 具体输出预期(=n / =y / 有输出 / 无输出)
3. 边界条件(文件大小上限、版本号范围等)
4. 最后一步:跑一次完整流程,确认端到端正常
重写后
我要让设备每次开机都回到启动器,不让它直接进入上次跑的固件。bootloader hook 已经实现了,但要确认两件事。
第一,hook 得真的进了二进制。我在 Mac 上跑 nm 命令看符号表,确认 bootloader_after_init 这个符号存在;然后在 map 文件里搜 hook called,看到记录就对了;最后 QEMU 跑一次,串口日志里出现 hook executed 才算过关。
第二,sdkconfig 里的 BT 和优化配置得真的生效了。跑 grep 命令查 sdkconfig,BT 和编译器优化这两个配置项的值跟我期望的不一样,那就是没生效。另外看一眼固件二进制的大小,不要超过 1.5MB 就行。
以上都过了,这个任务就算完成。
原则二:必须声明环境约束
这个原则回答什么问题:你的代码可以在什么环境下运行?
AI 不知道「环境假设」这个概念。它不会主动检查当前环境是否有残留文件、是否依赖本地状态。它会在任何它能写到的地方放代码,不会自动选择唯一正确的目录。
具体 bug
改完 sdkconfig.defaults 后 idf.py build,IDF 直接复用旧的 sdkconfig 而不重新生成2。有一个测试直接读本地 build/ 目录的构建产物做比对,本地全绿、CI 裸 checkout 后崩3。
原始任务描述
修复这个测试失败的问题。
缺失分析
这条描述没有说明问题发生的场景,也没有说明期望的验证环境。AI 收到后可能在本地 build/ 目录上找原因,而这个问题本质是测试依赖了本地状态,CI 没有这个状态所以崩。
更严重的是,任务描述没有排除错误的环境假设。AI 不知道哪些环境状态是「允许的」、哪些是「不允许的」。
模板骨架
任务:一句话描述要做什么
环境约束:
- 禁止依赖本地状态(build/、sdkconfig 缓存等)
- 必须在干净 checkout 环境下运行
- 测试数据必须能版本化
- 改完 XXX 之后必须先做 YYY 再运行 ZZZ
重写后
这个测试在本地能过,但在 CI 上崩。原因是测试直接读了
build/目录里的东西,CI 没有这个目录。你要改的是让测试从仓库里的固定数据读取,不要依赖本地构建产物。改完之后在干净的 checkout 环境里跑一次确认,别再用本地的build/目录了。
原则三:验证必须覆盖部署状态和真实数据
这个原则回答什么问题:你怎么知道上线的东西是对的?
AI 没有「部署状态」和「真实数据」这两个概念。它修完代码、跑完测试、提交 PR,就会认为任务完成。它不会去检查线上版本对不对,也不会去确认测试用的是不是真实固件镜像。
具体 bug
BUG-03 排查三天4:仓库代码修复为「单写尾扇区」流程,但线上 https://meta-pass.pages.dev/ 还是旧版(pre-dbbd091),执行三写流程把签名扇区擦掉了。真机表现为「签名固件显示未签名」。
宿主集成测试对合成镜像验签全 PASS,但真机行为与预期不符5。合成 fixture 只能验证逻辑正确性,不能验证真实二进制格式、真实密钥链、真实设备行为。
原始任务描述
修复这个签名验证失败的 bug。
缺失分析
这条描述没有要求验证部署状态,也没有指定测试数据的要求。AI 收到后可能只做两件事:改代码 → 跑测试。但它不会检查线上版本对不对,也不会用真实固件做验证。
「修复签名验证失败的 bug」是一个结果描述,不是验收标准。AI 不知道「修复」意味着什么。
模板骨架
任务:一句话描述要做什么
验收标准:
1. 代码修复
2. 部署验证(curl 比对线上文件与仓库源文件)
3. 真机复现(在实体设备上跑一遍完整流程)
4. 数据要求(必须用真实数据,不能用合成数据)
重写后
这个 bug 是在真机上发现的:用了新版本的安装脚本之后,签名固件在设备上显示为未签名。你先修代码,然后做两件事。
第一,重新部署到线上环境,部署完用 curl 抓取线上的 JS 文件,跟仓库里的源文件对比一下,确认部署上去的是最新代码。
第二,在真机上跑一遍完整流程,串口日志里能看到签名校验通过才算修完。
另外,验证的时候用真实的固件镜像,不要用合成的测试数据。合成数据结构和真机不一样,合成数据能通过不代表真机能过。
原则四:协议修改必须先读对端源码
这个原则回答什么问题:改客户端之前,你需要知道什么?
AI 不知道协议的对端长什么样。它只看自己负责的代码,不会去想对端可能期望什么。它不会主动去读服务端源码,也不会主动去对照参照实现。
具体 bug
esptool-js 不读 MD5 digest 帧,备份必败。前三轮补丁(分块大小、重试次数、超时设置)全无效6。后来逐行读 stub 源码才发现:handle_flash_read 在数据帧发完后无条件追加 16 字节 MD5 digest,但 esptool-js 从来不读这一帧6。残帧留在传输缓冲里,每次 readFlash 调用都毒化下一次响应,协议错位累积导致备份必败。
原始任务描述
修一下备份功能,现在经常失败。
缺失分析
这条描述只说了现象,没说根因。AI 收到后会进入「盲目优化」模式:调大分块大小、加重试、延长时间。这三个方向单独看都是合理的,但在协议错位的前提下,任何优化都是徒劳。
三轮补丁花了三周,真正解决问题只花了一天:读完 stub 源码,写出协议交互时序图,然后对照 esptool.py 的参照实现补上了 MD5 digest 帧的读取和校验6。
模板骨架
任务:一句话描述要做什么
前置步骤:
1. 读对端源码(确认帧格式、协议行为)
2. 对照参照实现(看官方库怎么处理)
3. 写出协议交互时序图
4. 用 mock 服务端测试客户端
执行步骤:
根据协议理解进行修改
重写后
备份功能不稳定,我先试了几个方向:调大了分块大小、加了重试、延长了超时,都没用。后来我去看了固件侧的 stub 源码,发现
handle_flash_read在每个数据帧后面都会追加 16 字节的 MD5 digest,但 esptool-js 完全不读这一帧。这 16 个字节留在缓冲里,每次都把下一个响应搞乱,所以备份必然失败。你要修的话,先去读
components/stub/src/esp_flash_hal.c里的handle_flash_read,搞清楚帧格式,然后对照 esptool.py 的实现,把 MD5 digest 帧的读取逻辑补上。不要在没有搞清楚对端协议的情况下直接改客户端代码。
原则五:部署物必须有版本追踪和单一来源
这个原则回答什么问题:你怎么知道线上是哪个版本?你怎么知道代码只有一个副本?
AI 不知道「版本追踪」和「单一来源」的概念。它不会主动在部署物里嵌入版本号,不会主动检查线上版本是否与仓库一致,也不会在代码存放位置上做选择。
具体 bug
排查「签名验证不上」三天,最后发现线上安装页是旧版本7。同源码本地构建与 CI 构建哈希不同,因为 ESP-IDF 默认嵌入编译时间戳 CONFIG_APP_COMPILE_TIME_DATE8。导致 plays 市场固件与 GitHub Release 固件同版本两个字节流。
dev 副本与发布副本必然漂移,第二次 writeFlash 擦掉了已写的签名9。dev 副本 blobOffset 传分区大小而非镜像长度,name blob 写到槽位末尾外 4056 字节(slot 0 写进 cardid NVS 分区),双写擦掉 MSIG9。
原始任务描述
修一下安装页的 bug,然后部署到线上。
缺失分析
这条描述没有规定版本追踪要求,也没有规定代码存放约束。AI 收到后会修代码、部署,但它不会在部署后检查版本号对不对,也不会注意代码应该放在哪个目录。
dev 副本漂移的本质是:同一份规范维护了两份副本,任何一方的修改都不会同步到另一方9。结构性修复是 server.mjs 直接服务规范的 install-slot/ 目录,杜绝开发副本漂移9。
模板骨架
任务:一句话描述要做什么
版本追踪要求:
- 固件:git describe 版本号嵌入二进制
- 网页:首行日志打印部署时的 git SHA
- 发布后用 curl 抓取响应头确认版本号
代码存放约束:
- 所有代码只放在单一目录
- dev server 和部署都从这个目录读取
- 禁止在别处维护副本
重写后
安装页有个 bug,修完部署到线上之后,我要确认两件事。
第一,部署上去的是最新代码。部署完用 curl 看响应头里的版本号,跟仓库里的 git describe 对上。如果线上版本号比仓库旧,说明部署滞后了,要重推。
第二,代码只有一个来源。所有 install-slot 相关的文件都放在 install-slot 目录下,dev server 和 Pages 部署都从这个目录读取,禁止在别处维护副本。如果有人改了别的地方,那一定是漏了同步。
另外,固件的二进制里要嵌入 git 版本号,这样刷完设备之后从串口能看出来跑的是哪个版本。
原则六:设备行为类任务必须有真机证据
这个原则回答什么问题:你怎么知道设备真的按预期工作了?
AI 没有「真机证据」这个概念。它会用逻辑推理「应该能工作」,但不会去验证。它会写「理论上应该工作」,但不会提供串口日志或屏幕截图。
具体 bug
各种推断「应该可以」,但设备行为完全不符合预期10。宿主测试全绿,真机 FAIL,两者之间的 gap 需要字节级回放和真实验签器才能定位11。
宿主测试对合成镜像验签全 PASS,但真机签名验证失败5。根因是签名脚本读的 image_len 字段语义偏差(从文件大小读而非从镜像头读),这个偏差在合成 fixture 的固定结构中恰好不暴露,但在真实镜像中必然触发5。
原始任务描述
修一下签名验证的逻辑,现在设备上不认。
缺失分析
这条描述只说了现象,没有规定验证方式。AI 收到后可能只改代码、跑宿主测试,然后提交 PR。但它不会在真机上验证,也不会提供串口日志作为证据。
「应该可以」不等于「确实可以」。逻辑正确不等于设备行为正确。嵌入式系统的行为受 Flash 布局、协议时序、硬件状态等多重因素影响,任何一环与预期不符,设备行为就会偏离。
模板骨架
任务:一句话描述要做什么
验证要求:
1. 必须提供串口日志或屏幕截图作为证据
2. 禁止只写「理论上应该工作」
3. 任务完成后,在实体设备上跑一次完整流程
重写后
签名验证的逻辑改了之后,宿主测试全绿,但真机不认。原因是签名脚本读 image_len 的方式有问题:它从文件大小读,而不是从镜像头读。合成测试数据用的固定结构刚好不暴露这个问题,但真机固件的头部结构和合成数据不一样,真机就跑不出来。
你修完之后,不要只看宿主测试。拿一个真实的固件镜像,在真机上跑一遍完整流程,把串口日志截下来发给我看。没有串口日志或者截图,这个任务不算完成。另外,如果改动涉及设备行为,禁止只写「理论上应该可以」这种话,必须用真机证据说话。
总结:三条元原则
把这六条归纳成三条元原则:
元原则一:任务描述必须自洽 AI 不可能从上下文推断出未写明的要求。所有约束、所有验证标准、所有环境假设,必须全部写在任务描述里。
元原则二:任务描述必须可执行 每一条验收标准都必须能被一条命令或一个观测动作验证。不能写「看起来正确」「应该没问题」这种无法判断的表述。
元原则三:任务描述必须覆盖 AI 的盲区 AI 会写代码,但不会验证部署、不会检查线上版本、不会判断协议对齐。这些盲区必须由人写进任务描述。
交付:测试验证脚手架 Skill
把六条原则提炼成一个可复用的 skill 文件。以后每次让 AI 写嵌入式固件的任务描述前,先用这个 skill 生成模板,再填入具体信息。
# skill: firmware-task-scaffold
# 用途:从模糊任务描述推导出完整的验收模板
# 用法:输入模糊描述 → 输出带模板骨架的任务描述
task_template:
title: "任务标题(一句话描述要做什么)"
description: "任务背景和问题现象"
acceptance_criteria:
- command: "nm build/meta-pass.elf | grep <symbol>"
expected: "有输出"
- command: "grep -r <keyword> build/meta-pass.map"
expected: "有记录"
- command: "QEMU 跑一次,串口日志包含 <message>"
expected: "看到预期日志"
- command: "grep CONFIG_<X> build/sdkconfig"
expected: "=n 或 =y"
- command: "ls -la build/meta-pass.bin"
expected: "大小 < 1.5MB"
environment_constraints:
- "禁止依赖本地 build/ 目录"
- "禁止依赖网络可达性"
- "必须在干净 checkout 环境下运行"
- "测试数据必须能版本化"
verification_steps:
- step: 1
action: "代码修复"
- step: 2
action: "重新部署到线上"
- step: 3
action: "curl 比对线上文件与仓库源文件"
- step: 4
action: "在真机上跑完整流程"
- step: 5
action: "提供串口日志或截图"
data_requirements:
- "必须用真实固件镜像,不用合成数据"
- "测试数据必须版本化"
deployment_checks:
- "固件嵌入 git describe 版本号"
- "网页首行日志打印部署时 git SHA"
- "发布后用 curl -I 确认版本号"
code_placement:
- "所有 install-slot 代码只放在 install-slot/ 目录"
- "dev server 和 Pages 部署都从这个目录读取"
- "禁止在别处维护副本"
使用方式:
- 收到模糊任务描述后,先对照 skill 中的模板骨架逐项检查缺失项
- 缺失项填入具体命令、预期输出、约束条件
- 输出一份完整的任务描述,包含验收标准、环境约束、验证步骤
参考
编译通过 ≠ 功能在里面来源:
docs/assets/meta-pass-v1-retrospective.zh_CN.md教训 2。 ↩︎sdkconfig 屏蔽数据:两次构建尺寸差 21%(1,283,232 vs 1,024,608 字节)。来源:nmem memory
09741cfa(2026-09-13)。 ↩︎测试依赖本地 build/ 来源:
docs/assets/meta-pass-v1-retrospective.zh_CN.md教训 4。 ↩︎BUG-03 排查耗时三天。来源:
docs/assets/handoff-unsigned-rootcause.zh_CN.md§0。 ↩︎真实镜像验证必要性来源:
docs/assets/handoff-unsigned-rootcause.zh_CN.md§2。 ↩︎ ↩︎ ↩︎esptool-js 不读 MD5 digest 帧根因:来源:
docs/development/engineering/debugging-workflow.zh_CN.md§1 第四轮。 ↩︎ ↩︎ ↩︎线上安装页滞后原因:来源:
docs/assets/handoff-unsigned-rootcause.zh_CN.md§3.1。 ↩︎哈希不一致原因:ESP-IDF 默认嵌入编译时间戳
CONFIG_APP_COMPILE_TIME_DATE。来源:nmem memory17fcbeb8(2026-09-11)。 ↩︎真机证据 > 推断来源:
docs/assets/meta-pass-v1-retrospective.zh_CN.md工程习惯。 ↩︎宿主与真机结论相悖时的排查方法:
docs/assets/handoff-unsigned-rootcause.zh_CN.md§0。 ↩︎
