小智Pro的固件在线更新,上次那篇文章主要把整体链路撸了一遍,从云端平台打包、设备端下载、分区写入到重启生效,算是给了一个全景图。文章发出去之后,后台收到不少留言,问得比较多的集中在几个点上:升级包到底是怎么做的、差量包用的什么算法、设备端为什么有时候升级失败、回滚是怎么判断的、以及在线更新到底安不安全。这篇就顺着这些提问,把原理层面的细节和设备实际跑起来之后的流程拆开再讲一层。涉及的内容绝大部分也是我们在几类IOT设备上真实踩过、调过的问题,不一定是最标准的教科书方案,但至少是可落地、可复现的思路。
如果你在做嵌入式设备开发、IOT平台接入,或者只是给自家小智Pro刷机折腾过固件的玩家,这篇文章应该都能有点参考价值。上篇偏看得见的部分,这篇偏藏起来的部分。
1. 回顾整体链路,补充几个容易被忽视的边界场景
1.1 在线更新的完整链路
固件在线更新,本质上就是把新的软件打包、分发、写到设备的存储里,然后让设备按新版本的逻辑重新启动运行。这句说上去简单,真正落地涉及的环节其实包括:
- 服务器端:构建新固件、生成升级包(全量或差分)、做签名加密、在平台侧配置灰度发布策略、推送升级通知。
- 网络通道:设备通过HTTPS/MQTT通道拿到升级包,期间要支持断点续传、弱网重试、限速控制。
- 设备端:下载、完整性校验、签名校验、解压或合并差量、写到备用分区、更新启动标志、重启、自检、上报结果。
我习惯把这条链路叫“一包、两端、三态、四校验”。一包指升级包本身;两端指云端和设备端;三态指设备的三种运行状态——旧固件、新固件、中间态(正在升级/等待确认);四校验收的是升级包的完整性校验、哈希校验、签名校验、版本校验。上一篇文章里我把这条链路讲得比较快,这次把几个关键节点逐个拆开,配合实际调试时遇到过的问题来展开。
1.2 几个容易被忽略的边界场景
很多方案设计时走的是理想链路:设备在线、网络良好、空间充足,一次就能升级完。但实际跑起来,有几个边界场景对方案成熟度的影响比主链路还大。
第一个是弱网环境下的升级。设备如果在移动网络下或者Wi-Fi信号不稳定的环境里,下载升级包可能反复失败,所以在线更新方案必须设计断点续传。通过HTTP Range请求把没传完的包继续下,而不是每次从头拉。
第二个是存量设备存储空间不足。小智Pro这类带语音交互能力的设备,固件本体加上模型资源、本地语音库,占用的分区空间往往比想象中多。要是升级包比可用空间还大,写入阶段就会失败。我在设备端会专门做一个空间预检步骤,在开始下载之前就检查剩余Flash和系统分区空间,不够就直接提示用户清理。
第三个是批量设备并发更新。平台一次性推送几千台设备同时下载,如果CDN没接好,设备端又没有随机退避策略,升级请求可能把入口带宽打满。这个后期才意识到,后来统一在设备端加了随机时延(设备ID哈希取模,分散到数小时甚至一两天内下载),压力一下子小了很多。
这三个场景后面会反复提到,因为它们直接影响了很多具体设计。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 升级包到底怎么做出来的:全量与差分的选型思路
2.1 全量包与差量包
升级包最常见的两种形态是全量包和差量包。全量包好理解,就是完整的新版本固件镜像。差量包则是记录新老固件之间差异的数据块,设备端拿到差量包之后,需要配合自己当前运行的旧固件内容,通过合并算法还原出新的完整固件。
为什么会有差量这个东西?核心原因就是带宽和流量。一个语音交互设备的固件全量包可能几十MB到上百MB,用移动网络的设备拉一次全包,流量消耗不小,下载时间也长。差量包如果控制在几MB级别,体验就完全不一样了。我见过比较极端的例子,一份全量包78MB,用bsdiff算出来的差量包只有4.2MB,差了快20倍。
差量算法常用的是bsdiff和hdiffpatch这两类。bsdiff的算法思路是先把新旧两个文件都做后缀排序,然后找出匹配的块和差异块,把匹配块的索引压缩存储,是一种比较经典的二进制差分方案。hdiffpatch在嵌入式场景下更省内存,它支持流式合并,设备端不需要把整个新旧固件都放到内存里,而是边读边合并边写入Flash。
在小智Pro这种设备上,我推荐的做法是:第一版发布用全量包,简单可靠,新设备或者跨大版本的设备直接全量升级;后续小版本迭代用差量包,节省流量。差量包的生成需要服务器端保留历史版本的固件,所以工程上要把构建产物按版本号归档和存储。
2.2 升级包的安全封装:签名、加密与校验
升级包如果裸奔分发,等于把设备的后门敞开。我在打包阶段就做了三层保护:哈希校验、数字签名、可选加密。
哈希校验用的是SHA-256,升级包在头部记录整个payload的SHA-256值,设备下载完成后先算一遍,不一致直接丢弃,防止传输过程中出现损坏。
数字签名用的是ECDSA P-256。服务器端持有私钥签名,设备端烧录公钥,签名覆盖升级包的头部关键字段和payload哈希。过程理解起来很简单,我在本地打包脚本里先算payload哈希,再对哈希值做ECDSA签名,然后把签名结果拼进包头。设备收到后,先用公钥验证签名,验证通过之后才算一个合法的升级包。
为什么要做防回滚?这个我在第五章展开。这里先提一嘴:升级包头部里一定会带版本号,而且版本号必须单调递增,设备端会拒绝比当前版本低的升级包。
可选加密是针对一些带敏感配置信息的设备。固件本身通常会做加密,防止有人把Flash拆下来读固件提取模型或密钥。我在小智Pro上用的是AES-128-CTR对payload做流式加密,设备端在写入之前先解密再写。代价是解密过程会占一点CPU资源,换来的收益是对拆机提取的防护。
3. 写入新固件:分区策略与设备端写盘流程
3.1 A/B双分区模式
A/B双分区方案,用一句话说就是设备里准备两份可启动的系统分区,一份是当前正在运行的,一份是空闲待写入的。升级时把新固件写入空闲分区,写入完成后把启动标志切换过去,重启后从新分区启动。如果新分区启动失败,引导加载程序还可以自动回滚到旧分区。
这个方案的优点非常明显:升级过程中即使掉电、写坏,设备仍然可以从另一个分区启动,几乎不会变砖。缺点是Flash占用翻倍,需要设备存储芯片有足够容量。小智Pro这类产品如果存储是64MB或者128MB的NOR Flash,A/B分区划下来,系统分区可能就要占到一大半空间,留给数据和模型的空间就紧了。
A/B方案的启动标志一般存放在独立的misc分区或者bootloader驻留的配置区。我习惯用一个boot_ctrl结构体来记录当前活跃槽位、已尝试启动次数、升级完成标志等,每次启动时bootloader读取这个结构体,决定从哪个分区启动。
3.2 单分区+恢复区(Recovery)模式
如果是存储容量紧张的设备,A/B分区划不过来,那就得退而求其次,采用单系统分区+独立恢复区的方案。这个方案比较像手机圈早年的卡刷思路:设备上保留一个精简的恢复系统,它不参与日常业务运行,只负责一件事——把用户下载到的升级包写入系统分区。
升级过程分两个阶段:第一阶段,业务系统下载完升级包,校验通过后设置一个升级启动标志,然后重启进入恢复系统;第二阶段,恢复系统读取升级包,把它写入系统分区,写入完成后再次重启,业务系统以新固件启动。
这个方案的优点是可以大幅节约Flash空间,一个系统分区加一个很小的恢复系统就够了。缺点也明显:整个升级过程中有一次中间重启,如果用户在这个窗口断电,设备可能停在恢复系统里,需要做二次容错。我对此的处理办法是在恢复系统里也做自恢复逻辑:一旦发现系统分区不可启动、又没有新的升级包可写,就自动把上次备份的旧固件写回去,或者提示用户重新推送升级包。
A/B双分区和单分区+Recovery的取舍,我整理了一张对比表:
| 对比项 | A/B双分区 | 单分区+Recovery |
|---|---|---|
| Flash占用 | 高(约为双倍系统分区) | 低(一个系统分区+一个小恢复区) |
| 升级过程是否有中间重启 | 无(写入后直接切换再重启) | 有(业务系统→恢复系统→业务系统两次重启) |
| 掉电或写坏安全性 | 高,旧分区可兜底启动 | 中,需恢复系统自恢复逻辑兜底 |
| 实现复杂度 | 中,分区管理略复杂 | 中,恢复系统开发和维护成本高 |
| 适用场景 | 存储充裕、对稳定性要求高 | 存储紧张、成本敏感 |
3.3 小智Pro这类设备的方案取舍
结合小智Pro的定位——语音助手类终端设备,对稳定性要求很高,用户也不会乐意一天到晚刷机,我更倾向于A/B双分区方案。宁可存储成本多摊一点,也要把升级失败变砖的概率降到最低。哪怕升级写一半断电了,开机能自动回退到旧系统,对用户来说是无感知的。
如果非要在单分区方案上硬做,那至少要把三个点补齐:恢复系统本身的稳定性、升级包的完整性校验前置、写入过程中的掉电保护(比如写入前关闭看门狗,写入后立即刷掉缓存再重启)。但这些都是刀尖上跳舞,能上A/B就上A/B。
4. 升级启动、状态机与回滚机制
4.1 从旧到新的启动流程
设备端从下载升级包到完成升级,我通常用一个状态机来管理,状态包括:空闲、下载中、校验中、写入中、等待确认、已确认、回滚中、失败。
为了讲清楚整个流程,我拿A/B分区方案举例,把一次完整升级的启动过程描述一遍:
第一步,云端推送升级通知,设备端收到后检查当前版本和升级包版本,如果版本有效且满足升级条件(电量、空间、温度等),进入下载状态。下载过程中支持断点续传和校验。
第二步,下载完成后,设备端做完整性校验和安全校验,全部通过后开始把升级包写入备用分区。写入完成之后并不立刻切换启动标志,而是先设置一个pending标志,让bootloader在下次启动时尝试从新分区启动。
第三步,设备重启,bootloader读取到pending标志,把启动槽位切换到备用分区。此时如果新系统能正常拉起,设备会把自己的状态标记为已确认,同时通知云端升级成功。
第四步,如果新系统在启动过程中崩溃或者启动超时(比如连续三次尝试都没成功),bootloader会把槽位切回旧分区,并把状态置为回滚中,设备回到升级前的版本继续运行,同时上报失败。
这里面的关键细节在于已确认这个标志不能设得太早。我见过一些设备把确认时机放在新系统起来的第一秒,这其实不够稳,因为有些慢死问题(新固件起来后跑了几十秒才崩溃)不会在启动瞬间暴露。更稳的做法是等待业务进程就绪、自检通过、网络连接上云之后,才认为新固件活着,再写确认标志。
4.2 回滚触发的几个条件
回滚机制的触发条件我总结为三大类:
第一类是启动即失败。系统分区校验失败、内核无法解压、用户态进程反复崩溃,这些都会在启动早期暴露出来。bootloader会在连续失败N次之后自动回滚到旧分区。
第二类是业务自检失败。新固件引导起来了,但关键模块异常,比如Wi-Fi模块初始化失败、模型文件损坏、语音识别进程起不来。这类问题bootloader感知不到,必须在业务层做一个自检逻辑,收集关键模块的健康状态,如果检查不过,主动请求回滚。
第三类是健康检查超时。新固件启动后在规定时间内没有上报心跳或没有在线确认升级成功,云端或设备端会判定升级异常,对A/B方案来说就是执行回滚。
需要特别提醒的是,回滚并不总是100%成功。旧固件分区在写入新固件过程中如果被殃及过(比如分区布局调整、共享数据区被改),回滚后也可能启动异常。所以在Flash写入的设计上,写入备用分区时绝不能动到当前运行分区的数据。
5. 安全防护:签名校验和防回滚到底在防什么
5.1 升级包签名校验链路
很多人觉得签名校验只是防黑客,其实它同时防了操作失误。我在这个项目里对升级包的校验是一个多级验证的过程,顺序很重要:
第一步,设备下载完成后读升级包头部,检查魔数字段是否合法,这是最基础的格式判断。第二步,计算整个payload的SHA-256,与包头记录的哈希值做对比,这一步能发现传输损坏或恶意篡改。第三步,从包头取出ECDSA签名,用设备端存储的公钥验证签名是否匹配。只有三步全部通过,才允许进入写入阶段。
这个顺序不能乱。先校验哈希再校验签名的原因是:哈希校验的计算成本低,发现损坏可以立刻丢弃,不必先走一遍性能开销更大的非对称验签。如果先验签再验哈希,攻击者可以构造一个哈希不匹配但签名非法的包,设备就会在验签阶段浪费计算资源。当然实际开发中两种顺序都有人用,但我倾向于先哈希后签名,节省算力占用。
5.2 防回滚机制要防什么
防回滚主要防的是两种场景:
第一种是用户或攻击者手动降级固件到旧版本,而旧版本存在已知漏洞。比如当前版本修复了一个蓝牙协议栈的远程代码执行漏洞,攻击者把设备降回老版本,漏洞就重新暴露了。第二种是设备厂商自己有时候也会踩的坑——撤包回退导致版本混乱。
防回滚的常见实现有三种:
版本号比较是最基础的一种。升级包头带版本号,设备端比较新版本号与当前版本号,如果新包版本号低于或等于当前版本号,拒绝升级。这个办法对软件层防呆有效,但对直接刷机的人无效。
分区版本戳是在分区元数据里记录版本信息。bootloader启动时检查分区版本戳是否合规,不合规则不启动。它比单纯包头版本号更进一步,可以在启动阶段兜底。
eFuse是一次性可编程熔丝,用于固化不可降级的版本底线。一旦eFuse被烧断,设备在任何情况下都不能启动低于该版本的固件。这个方案适合安全要求最高的场景,但代价是不可逆,产品规划阶段就要想清楚。
我实际用的方案是版本号比较加分区版本戳结合,先软件层拦截,再做启动阶段兜底,兼顾灵活性和安全性。
