上周入手了 FoloToy AI Passport,一台 ESP32 驱动的 AI 对话玩具,叫 AI 护照。它有个 plays 市场,里面攒了不少玩法固件[1],有几个玩法很吸引我,一个是把护照变对讲机,通过蓝牙通讯,还有一个是拿 BLE 做雷达寻宝,此外还有不少社区作品也值得尝试。
玩法很多,但有个让我感觉很别扭的现实:设备一次只能刷一个固件。想从对讲机换成寻宝游戏,就得打开电脑、连上线、整片 Flash 重刷。刷完,上一个玩法就没了。带孩子去公园玩,前一秒玩的对讲机,如果下一秒想玩雷达了,只能回家再重刷一次。
设备有8M的存储空间,我看固件大多数体积都在2M以内,明明装得下,为什么一次只能有一个灵魂呢?
于是一个念头冒出来:如果固件能像游戏卡带一样,一次插几张在机器里,想玩哪张插哪张,不就行了?
这就是 meta-pass 的起点:一个烧在 factory 分区里的多固件启动器。它常驻设备,把别的固件请进本地 Flash 槽位,想玩哪个就引导哪个,不用再整片重刷。从念头到MVP版本跑通发布,一共用了两天。
虽然全程AI护航,但毕竟是自己第一次做 SOC 的开发,学到了不少新东西,所以记录一下。
从「来回切换」的目标到三个具体问题
「一次刷几个,来回切换」听上去是一句话,但是需要分解为三个问题:固件放在哪、怎么装进去、怎么回得来。
放在哪:OTA 分区本来就是现成的槽位
ESP32 的 Flash 里本来就能放好几个 app 分区。系统自带一个叫 OTA 的机制,可以指定下次开机从哪个分区启动,重启后就从新分区加载固件[2]。这套机制本来是为「在线升级留退路」设计的,但换个角度看,它就是一排现成的卡带槽。
所以布局是这样:meta-pass 启动器烧在 factory 分区,常驻不动;OTA 分区拿来当槽位,一个槽位存一个子固件。启动器扫描各槽位,列出清单,用户选一个,esp_ota_set_boot_partition() 指过去,esp_restart() 重启,子固件就起来了。切换从「整片重刷几分钟」变成「重启几秒钟」。
怎么装进去:免线缆的 Wi-Fi,和一根数据线的 USB
装固件做了两条通道。

第一条是免线缆的:设备开 Wi-Fi 热点(SoftAP),屏幕显示随机密码和一个 6 位一次性配对码,手机或电脑连上热点,浏览器访问 http://192.168.4.1 打开本地页面上传 .bin 文件,但要注意,这条通道只接受已经拆好的单应用包,不能刷整包镜像(权衡后没做拆包能力)。安全就靠这个配对码:它只显示在设备屏幕上,手里拿着设备、看得见屏幕的人才有权限传进固件。传完校验,失败就擦掉整个槽位,不留半成品。
第二根通道是一根数据线:按住 UP 键开机,设备进入 ROM 下载模式,电脑上用 Chrome 打开安装页,通过 Web Serial 把固件直接写进槽位。Web Serial 是浏览器操作数据线的功能,按安全规定只肯在 localhost 或 HTTPS 页面上用[3],设备端那个 http://192.168.4.1 页面不满足条件,所以这个页面放在电脑端,顺便可以做得功能更完整更好用。写入用的是 esptool-js[4],写后自动校验。
为什么需要第二条?因为社区市场只发布「整片打包」的固件文件,要先拆开才能取出应用部分;而且市场的接口不许浏览器直接访问(没有 CORS 头),得有人帮忙转发。电脑端的安装页正好两件事都能干:本地服务器负责转发市场数据,页面负责拆包,下载完还能算出一串指纹(SHA-256),跟社区公布的指纹对一遍,对上了才说明文件没被换过。

怎么回来:自动回滚机制保安全
玩别人的固件,最怕变砖(刷坏了开不了机)。OTA 自带的回滚机制给了现成的答案[2]:新启动的固件必须自己调用 esp_ota_mark_app_valid_cancel_rollback() 宣布「我活着」,否则任何一次重启(崩溃、断电、卡死)之后,bootloader 自动退回上一个能用的分区。
这个机制直接定下两类固件的规矩:
- 愿意配合的固件(已适配):开机自检没问题,就告诉系统「我正常」,以后开机都用它。想回启动器,按约定的键退回来。
- 不配合的固件(未适配):永远不会说「我正常」,那就当「试运行」:玩一次,不管因为什么重启,都自动回到启动器。
防变砖这件事,不需要任何第三方固件配合,这样市场上那些完全不知道 meta-pass 存在的固件,刷进来照样安全。
边界约束下的三个设计
可行性确定之后,真正有意思的部分才开始。单片机嘛,资源一定是有限的,所以这篇文章里主要记录那些在硬件或平台的条件限制下,我想出来为实现目标所做的设计。
组合键在物理上不存在,于是有了两级长按
最初设想的返回方式是组合键,比如「上 + OK 同按退回启动器」。一查硬件,此路不通。
问题出在硬件上:这台设备的三个按键接在同一根线(GPIO0)上,靠电压高低区分谁被按了。两个键一起按,电压会混成其中一个键的值:UP 和任何键一起按,机器只认 UP;DOWN 和 OK 一起按,机器只认 DOWN。所以组合键在这台机器上物理就不存在。GPIO0 这根线还兼着另一个差事:开机那一瞬间,它的状态决定机器用什么模式启动[5],所以「开机时按住某个键恢复」这种常规做法也用不了。
剩下的路只有一条,在「按多久」上做文章。于是有了两级长按:OK 按得久一点,是应用内返回;按两倍久,是退回启动器(只有适配过的子固件能这样退回;没适配的用不了,靠关机重启回启动器)。按键系统里加了一个 BSP_BTN_LONG2 事件,原来的长按用法不变。
NVS (子固件显示标签)写不进,于是在槽位尾部加了名字 blob
启动器列出槽位清单时,应该显示什么名字?直觉是显示固件的真名,比如「Pocket Walkie」。但真名只记在商店的网页上,固件文件内部的 project_name 字段普遍是编译模板留下的默认值,社区固件清一色叫 FoloToy-AI-Passport,扫描镜像拿不到真名。
那安装的时候把名字记下来行不行?可以,但是放哪呢?AI说放 NVS(ESP32 的键值存储区)很自然,但 USB 安装通道工作时,设备处在 ROM 下载模式,esptool 只能写裸 flash,写不了 NVS 这种结构化数据。AI说内置一份「市场固件名单」,我说这太傻了还不经济。
最后的方案是把名字写进槽位本身:每个槽位的尾部留出最后一个 4KB 小块,安装时把显示名写进去,带上 MNAM 标记、长度和校验值。启动器扫描时校验通过就显示真名,校验不过就用镜像里的默认名。名字跟着固件走,删槽位时整区擦除,名字一起消失,干净彻底。

存量固件不会为你重开发,所以把签名变成可选
安全上最省事的做法是只跑签过名的固件。但市场里已有的固件没有一个带签名,要求它们全部重新适配,等于宣判这个项目只能玩自己的固件。
所以规矩改成了「完整性必须查,签名不强制」。任何固件装进来都要先过一遍检查:文件头对不对、是不是给 ESP32-C3 用的、大小有没有超标、内部结构完不完整,再算出整串 SHA-256 指纹显示在确认页上,方便和发布方公布的指纹对一遍。带签名的固件显示「已签名」徽章,不带的也能跑,只是启动前多一页警告,要超长按确认。

这条原则后来贯穿了整个项目:新机制不给老固件提要求。试运行是这样,签名可选是这样,显示名 blob 也是这样,没有 blob 的老固件照样能装能跑,只是名字难看一点。
踩坑两则
设计文档写得再细,真机和现实总会各补一刀。第一版做下来记了两个坑,都在 USB 安装页上。
真机首测,页面整页卡死。 USB 安装页第一版要从 CDN 上临时下载 esptool-js 才能跑。为什么要从 CDN 下载?因为安装页是一个没有构建步骤的纯静态页面,双击就能跑,用 CDN 链接是浏览器里引用第三方库最省事的写法。代价是运行时依赖外网,平时在电脑上感觉不到,结果真机测试遇到 CDN 打不开,页面卡在下载上,整个白屏。于是改为把 esptool-js 直接装进项目里,3 个文件也就 81KB,不用联网。
同一个白屏背后,AI 写的代码里还藏着另一个 bug:服务端白名单里漏了 name-blob.js,页面模块整个加载失败。为什么会有白名单呢?这个本地服务器要为安装页做两件事:一是把页面需要的文件从电脑里读出来发过去,二是帮忙转发社区市场的数据。风险出在第一件事上。服务器读的是你电脑里的文件,可它分不清谁在要:浏览器里开的任何一个网页,都能偷偷向它要东西。如果它对什么路径都照读照发,你电脑上的任何文件都可能被一个陌生网页读走,这显然不安全。所以白名单机制起到了安全围栏的作用:能发的文件逐个登记,白名单外的一律不给。白名单需要手工登记,要访问的文件忘了登记,页面访问不到,流程就被阻断了。解决了这两个bug,白屏问题就解决了。
文档里的 API 名和包里的不一样。 安装页调 esptool-js 写 flash 时,AI 按 esptool 的 Python 版习惯写了 write_flash,跑起来报错。查本地实际安装的包才发现,esptool-js 0.5.6 的 API 是 camelCase,得写 writeFlash。这个坑的教训是:AI 记忆里的是它训练时候学习的知识,不一定对得上你电脑里实际安装的版本。所以写调库代码的时候,要提示AI必须先确认本地安装包的接口文档。
两天之后
功能做完,在模拟器里从头到尾验了一遍:拼了一个完整镜像,把 Pocket Walkie 和 Passport Radar 分别预先装进两个槽位,上传启动。槽位列表显示出两个真名;挑一个槽位启动,走未签名警告页,超长按确认,对讲机的 WALKIE 界面正常跑起来;断电重开,它作为没适配过的社区固件,不会自报「我正常」,于是按回滚机制自动回到启动器。官方固件 Radar 的菜单按键也正常,说明经过 meta-pass 引导,子固件的按键不受影响。

也有测试没覆盖的地方,比如:Walkie 在模拟器里按键无响应,但它独立整片烧录时也一样,说明不是 meta-pass 引入的,后来在真机上验证没问题;Radar 的主功能依赖 BLE,但模拟器没有 BLE 支持,也是上真机验证。
回头看两天前那个念头,「一次刷几个,来回切换」已经实现了:孩子的玩具现在装着两个玩法,在启动器里上下选择,几秒切换。对讲机玩腻了换寻宝,寻宝玩腻了换回来,谁也不用擦掉谁。至于后来从两个槽位扩成三个、顺手修掉瘦身时暴露的一个潜伏 bug,那是下一篇的事了。
我的心得
约束就是设计的来源。 这篇文章里几乎每个设计的出处都是一条「不行」:组合键物理上不行、NVS 在下载模式下不行、强制签名对老固件不行。每堵墙都把方案往更简单的方向挤了一步。没有这些约束,凭空地「设计」,多半会做出更复杂也更脆弱的东西。
别给已经存在的东西提要求。 想让市场里已有的固件为你改变,等于没有市场。把适配做成可选的加分项,而不是必须的门槛,已有的固件就全是你的朋友。这条经验不只属于固件,做任何插件机制、任何平台,都值得先问一句:现有的玩家需要为我改什么吗?答案是「什么也不用改」的时候,别人才愿意来。
meta-pass 的代码在 GitHub 上开源(MIT),设计文档里有完整的分区表、决策记录和验收清单:github.com/alexwwang/meta-pass。
