做嵌入式开发这些年,“安全启动”这个词越来越躲不开了。尤其产品一旦走到量产、要走OTA,客户第一句话可能就是:固件被人读出来怎么办?别人能不能刷个第三方包进去?设备被篡改后会不会变成攻击跳板?这些问题不是危言耸听,我在一个表计项目里就亲眼见过,升级口暴露在外,测试人员误发了一包损坏固件,Bootloader连校验都没有直接跳转,设备当场死机,返工一片。所以STM32上的安全启动和固件更新,不是“要不要做”的问题,而是“怎么做才不出事”的问题。
这篇文章我从实际开发角度,把安全启动的底层逻辑、RDP保护、密钥管理、SBSFU参考实现、固件更新状态机和回滚保护完整过一遍。适合正在做量产固件、准备做OTA、或者想从普通Bootloader升级到安全方案的开发者参考。内容不追求教科书式严谨,只讲我在板子上真实跑通过、也踩过坑的东西。
1. 为什么STM32要加安全启动
1.1 裸奔Bootloader的四个致命伤
大多数项目里的Bootloader长什么样?上电检查某个Flash地址有没有数据,CRC算一下,没问题就跳转App,完事。这套方案在样机阶段完全够用,但放到量产和公网环境,问题非常明显。
第一,没有真实性校验。CRC只能保证数据在传输中没被改错,它防不了“有人故意构造一份合法CRC的坏固件”。攻击者不需要读懂你的代码,只需要把你的固件包抓下来改几个字节,重新算一遍CRC刷进去,Bootloader照样放行。
第二,没有保密性。很多人不知道,STM32默认状态下调试口是完全开放的,SWD连上就能把整个Flash读出来。你的算法、通信协议、服务器地址全在里边。就算烧录时没开调试口,只要RDP等级是Level 0,拿到板子就等于拿到源码。
第三,没有防回滚机制。攻击者先记录一个旧版本固件包,等你修复了漏洞、升级了高版本之后,他把旧版本刷回去,让设备停留在有漏洞的状态。这在安全更新里叫降级攻击,普通Bootloader完全没有概念。
第四,升级过程不可控。在传输到一半断电、传错分区、两个Bank都写脏的情况下,设备只能变砖。没有恢复模式和故障转移机制,售后成本很高。
1.2 安全启动到底解决了什么
安全启动的核心思路很简单:芯片上电后,先执行一段不可被篡改的信任根代码,由它对应用程序做签名校验和完整性校验,全部通过才跳转执行。这样即使App被改过、被替换成非官方固件、被降级到旧版,都会被拦在启动门外。
固件更新则是在这个基础上,解决“新固件怎么安全进来”的问题。传输前加密防窃听,写入前验签防伪造,更新失败能回退防变砖。总结起来,一套完整方案要覆盖四件事:固件保密性、完整性、真实性、更新过程安全性。
我在项目里给客户做汇报时,经常用一个类比:普通Bootloader等于小区单元门,谁都能进;安全启动等于电梯刷卡闸机,不仅要刷卡,还要核对你是不是业主、你去的楼层是不是你家。虽然每次回家都多一步,但丢东西的概率低太多了。
2. 打好信任地基:RDP、密钥与芯片唯一ID
2.1 信任根落在哪
安全启动的前提,是有一段代码从一开始就值得信任,而且没人能改。在STM32上,这段代码的存放位置通常有两个选择:一是芯片BootROM里固化的出厂引导程序,二是你烧进内部Flash、写保护后锁死的SBSFU区。
前者由芯片原厂维护,你无法改动,适合做最底层的信任锚;后者完全由你控制,适合放校验逻辑和应用策略。SBSFU参考实现用的就是第二种方案:用户应用区写保护,Boot区运行时不开放写权限,这样即使App被攻破,也改写不了Boot区代码。
这里有个容易忽视的点:信任根的“不可篡改”属性必须硬件配合。如果只是代码层面加个判断,攻击者直接通过调试接口改Flash,一切白搭。所以后面要提到RDP和Option Bytes,它们才是把“不可篡改”落到实处的关键。
2.2 RDP读保护等级对照
STM32的RDP(Read-out Protection)分为三级,每一级的安全强度差别很大,量产前必须想清楚。我做了个对照表,方便直接参考:
| RDP等级 | 调试访问 | Flash读取 | 能否回退 | 适用场景 |
|---|---|---|---|---|
| Level 0 | 完全开放 | 允许读取 | 无需回退 | 开发调试阶段 |
| Level 1 | 禁止访问Flash | 禁止读取 | 可回退到Level 0,但会全片擦除 | 大多数量产产品 |
| Level 2 | 完全禁用调试口 | 禁止一切外部访问 | 不可回退,永久锁定 | 安全要求极高的产品 |
Level 1是性价比最高的量产选择。调试口连不上,Flash读不出来,但如果你需要返厂维修,还可以降级重刷,代价是全片擦除,代码和数据都会消失。
Level 2就真的要慎重了,一旦锁死,连官方工具都救不回来,芯片基本等于报废。我在一个门锁项目里为了“更安全”选了Level 2,结果后来客户要求加一个功能,只能整片换芯片,那个月被生产部门骂惨了。
2.3 密钥体系与芯片UID的用法
安全启动的校验强度,最终落在密钥上。常见做法是非对称签名 + 对称加密组合:ECDSA P-256做固件签名校验,AES-GCM做固件数据加密。私钥保存在自己的服务器或HSM里,公钥在首次量产时烧进芯片OTP区域。
这时候就轮到芯片唯一ID上场了。STM32每一颗芯片都有96位唯一ID,不同型号地址不同,但都出厂烧死不可改。这个ID不只是查询用,它可以把你的安全方案从“所有设备一把密钥”升级成“一机一密”。做法是:服务器端用设备UID派生每个设备的固件加密密钥,固件包只对该设备有效,其他设备拿到也用不了。就算某台设备的密钥被逆向出来,也无法影响整个产品线。
我建议在产品设计早期就把UID读出来放在固件头里做绑定校验。等到量产再想加,要么返工改Bootloader,要么增加产线工序,成本完全不同。
3. 一步一步把安全启动跑起来
3.1 方案选型:官方SBSFU还是自研Bootloader
实现安全启动有两条路:直接用ST官方SBSFU,或者自己写一套。我两条路都走过,说点实在的。
SBSFU是STM32CubeFW包里提供的参考实现,全称Secure Boot and Secure Firmware Update。它已经帮你写好了ECDSA验签、AES-GCM加解密、多固件分区管理、更新状态机、回滚保护这些麻烦事,你要做的大多是配置和适配。我的建议是:产品阶段优先用SBSFU改造,学习阶段自己写一遍。
自己写的好处是彻底搞懂原理,坏处是密码学这种东西,自己实现太容易埋雷。比如签名校验里有一个字节没比对到位,或者缓冲区溢出了,外部攻击者可能就利用这个漏洞绕过校验。我见过一个团队自研验签逻辑,把签名数据copy到局部变量里再比较,编译器优化后直接跳过了部分校验,量产固件被人在网上发了破解教程。
不过SBSFU也有门槛:代码结构复杂,CubeIDE工程打开后文件树几十个文件,第一次看容易晕。所以不要急着改逻辑,先跑通官方demo,再一对一地调。
3.2 跑通官方SBSFU:编译、烧录与首次安全启动
我以NUCLEO-L4R5ZI开发板为例,实际操作步骤如下。
第一步,准备软件。ST官方推荐用STM32CubeIDE,加上STM32CubeProgrammer烧录工具。CubeFW_L4固件包里自带SBSFU例程,路径在Projects/NUCLEO-L4R5ZI/Applications/SBSFU。
第二步,编译两个工程。SBSFU例程分了多个子工程,核心是两个:一个是SBSFU本身,生成Boot固件;一个是UserApp,也就是被保护的应用程序。两个工程要分别编译,注意UserApp的链接脚本已经在分区表里定位好了,编译出来直接落在规定的Flash地址上。
第三步,烧录和首次安全启动。这一步和烧普通固件不一样,不能用ST-LINK直接把UserApp拖进去就完事。SBSFU有专门的首次安装流程,需要在STM32CubeProgrammer里选择Secure Firmware Install模式,按照步骤先注入公钥到OTP,再烧录Boot固件,最后烧录已经签名加密的UserApp镜像。
我强烈建议第一次跑通之前,不要跳过SFI流程手动改烧录顺序。一旦公钥没有正确注入OTP,或者UserApp没有经过签名加密,SBSFU启动时会直接停在“验证失败”的状态,串口上只会打印一行Error,很容易让人误以为是代码编译错。
第四步,观察串口日志。SBSFU默认会输出启动日志,正常流程能看到它完成签名校验、跳到UserApp,UserApp再打印自己的日志。到这一步,你的安全启动链路就算闭环了。
如果你的板子不是官方开发板,移植时重点关注三处改动:Flash分区地址(SBSFU区、UserApp区、暂存区各占多少)、Option Bytes配置(哪些区域写保护)、以及跳转时的向量表偏移。向量表偏移分两步,一个是链接脚本里FLASH_APP_OFFSET,一个是代码里SCB->VTOR = APP_BASE_ADDRESS,少一个App都会跑飞。
3.3 手写最小安全Bootloader的核心框架
如果你想彻底搞懂,强烈建议自己写一版最简化实现。不需要做加密,只需要做到:上电读App头,验签,通过就跳转。
核心伪代码大概是这样:
#define APP_BASE_ADDRESS 0x08008000 #define SIG_OFFSET 0x40 static void jump_to_app(void) { uint32_t app_sp = *(volatile uint32_t *)APP_BASE_ADDRESS; uint32_t app_pc = *(volatile uint32_t *)(APP_BASE_ADDRESS + 4); // 关闭中断、清理外设时钟 __disable_irq(); SysTick->CTRL = 0; // 设置向量表偏移 SCB->VTOR = APP_BASE_ADDRESS; // 切换到App栈指针并跳转 __set_MSP(app_sp); ((void (*)(void))app_pc)(); }验签部分用mbedTLS提供的ECDSA函数,公钥从OTP读出,签名和App版本一起放在App头部。这几个要点容易踩坑:验签函数返回后必须显式检查返回值,不能只调用不判断;Bootloader代码不要引用UserApp的库函数,否则链接时地址错乱;跳转前要恢复默认的中断优先级、关掉非必要外设时钟,否则App初始化时可能HardFault。
我自己手写这版的时候,第一次跳转后App直接死机,查了半天发现是Bootloader初始化了串口,但跳转前没关串口中断,App启动时中断来了,而复位向量已经切走,找不到处理函数。从那以后我习惯在跳转函数里把所有外设中断全禁用,再统一清一次NVIC。
4. 固件更新全流程:打包、签名校验、回滚保护
4.1 固件更新包的结构与处理流程
有了安全启动,固件更新才能安全落地。一套完整的固件更新流程,不只是“把新固件放进Flash”,它是一整个状态机。
固件包结构我一般这样设计:
| 字段 | 长度 | 说明 |
|---|---|---|
| Magic | 4字节 | 固定值,如0xA5A5A5A5,用于识别合法镜像 |
| 版本号 | 4字节 | 单调递增,用于防止降级攻击 |
| 镜像长度 | 4字节 | 加密后固件长度 |
| IV/Nonce | 16字节 | AES-GCM初始化向量 |
| 签名 | 64字节 | ECDSA P-256签名,覆盖前面所有字段和固件内容 |
| 固件密文 | 可变 | AES-GCM加密后的App二进制 |
接收端拿到固件包后,按顺序执行:先校验Magic和版本号,再验签名,签名通过才解密写入暂存区,全部写完后重启,由SBSFU再次完整校验,通过后切换到新固件。这个流程里每一步失败都要有明确的结果码,不能静默跳过。
我见过不少项目把版本号仅仅当作显示用途,升级时根本不比较,结果攻击者可以把设备刷回任意旧版本,然后利用旧版本漏洞控制设备。版本号比较必须放在接收端、由Bootloader或安全引导代码执行,不能只靠上位机自觉。
4.2 双Bank升级与版本回滚保护
升级最怕的是什么?不是失败,而是失败之后设备变砖。双Bank设计是解决这个问题的常见方案。
双Bank的意思是把应用Flash分成两个区:当前运行区(Bank A)和备用区(Bank B)。新固件写入Bank B时,Bank A照常运行,不打断业务。写入完成并校验通过后,把启动标志翻转,下次开机从Bank B启动。如果新固件启动失败,Bootloader检测到异常,还能自动切回Bank A,完成回滚。
这个方案要求Flash容量大概是应用固件大小的两倍。不少STM32型号从硬件上支持双Bank,比如读Bank B时也可以从Bank A执行,不需要额外搬运。如果你的Flash容量紧张,退而求其次用“暂存区 + 搬移”方案,也有,但升级过程多一个拷贝步骤,失败概率更高。
回滚保护则要分两层来看。一层是防降级攻击,版本号单调递增;另一层是业务连续性,新版本起不来要能回退到旧版本。这两个目标需要同时满足,实际设计时我做了一个简单的启动计数器和看门狗配合机制:新版本启动后在规定时间内上报一次“我已经正常运行”,如果超时没上报,Bootloader判定启动失败,自动回滚到上一个稳定版本。
4.3 更新通道与传输可靠性
更新通道的选择取决于产品形态。消费类设备常用UART或USB DFU,联网设备走HTTP或HTTPS下载,低功耗物联网设备走LoRa、NB-IoT之类的窄带通道。
STM32上做HTTP客户端,方案很多。轻量级可以用LwIP自带的httpd或raw API自己写解析,复杂一点也可以在LwIP上移植cURL。需要留意的是,HTTP只是传输层,固件本身必须加密签名,否则中间人抓包就能拿到你的固件。如果通道支持TLS,优先用TLS,它能解决传输保密和通道完整性。如果窄带设备没法跑TLS,那就回到固件级的AES-GCM加密,保证每一包数据即使被截获也无法解密和篡改。
UART通道升级时,我建议用YModem协议加接收超时处理。STM32的UART接收用DMA加空闲中断,能更高效地处理不定长数据。我在一个项目里遇到过一包数据被粘包、一个包被拆成两段的问题,后来改成“帧头+长度+CRC”的自定义协议,逐包确认回复,才把传输成功率提上来。
不管什么通道,都必须做断点续传或失败重传机制。设备端接收固件时,要记录已经写入的块号,中断后从断点继续,而不是从头再来。否则几兆的固件在弱网环境里,永远升不完。
5. 常见问题与排查技巧实录
5.1 上电启动失败:先从这六点查
SBSFU或自研Bootloader加好之后,最容易遇到的问题就是上电不启动、没有任何日志,或者跳转到App后马上死机。我把排查思路整理成了列表,照着查基本能定位。
第一,先确认SBSFU有没有跑起来。在Boot区加一个GPIO翻转,用示波器看电平变化,比盲猜快得多。有日志口的直接从日志口看。
第二,检查公钥有没有正确烧进OTP。很多人改了自己的密钥,却在SFI流程里用了默认密钥,验签当然失败。
第三,检查Flash分区地址。SBSFU区的链接脚本、UserApp的链接脚本、烧录地址三者必须严格一致。地址错一位,跳转就全乱。
第四,确认向量表偏移。App工程里不是只改链接脚本就行,代码里要设置SCB->VTOR,否则中断向量表还在Boot区,App一进中断就崩。
第五,跳转前有没有把外设恢复默认。Bootloader里初始化了时钟、串口、DMA,跳转前得停掉并清理NVIC挂起中断,否则App初始化时会撞上残余状态。
第六,确认RDP等级没有被意外提升。如果调试器连不上,先试试Options Bytes能不能读;不能读又修改不了,多半是Level 2了。
5.2 固件更新失败:签名、解密与升级状态机
固件更新失败的问题往往集中在签名校验不过、解密乱码、更新后重启又回到旧版本这三类。
签名校验不过,90%是密钥不匹配。上位机签名用的私钥和芯片里存的公钥不是同一对。我之前自己生成密钥时,不小心把测试密钥当成了生产密钥签发固件,结果线上设备全部拒绝升级,还好只是测试环境。建议密钥生成后立刻给ID做标签,并且生产密钥和测试密钥严格分离。
解密乱码,先查块对齐。AES-GCM是分组密码,固件加密时如果最后一块没有补齐到16字节,解密端算出来的认证标签就会对不上。很多上层的“解密失败”,实际上不是密码算错了,而是固件长度没有按块对齐。
更新后回到旧版本,要查启动标志在哪里翻转、什么时候翻转。如果新固件没有运行到“确认正常”的函数,Bootloader会在下次上电时认为新固件不可用,自动回滚。这是设计行为,不算Bug。但如果你希望新固件启动失败时能保留用于调试的日志,建议在回滚前把异常原因保存到一段独立的保留区。
5.3 调试工具与生产环节的坑
工具选择上,STM32 ST-LINK Utility确实是老工具了,官方早已停止更新,新项目就统一用STM32CubeProgrammer。它支持SFI安全烧录、RDP等级设置、Option Bytes读写、外部Flash编程,一个工具全搞定。
调试口连不上的情况,最常见的是烧录时Option Bytes把SWD关了,或者RDP等级被升到Level 1以上。遇到这种问题,先在CubeProgrammer里勾选“Connect under reset”再重试,多数Level 1的情况都能救回来。如果是Level 2,说实话除了换芯片没有太好的办法,所以生产流程里一定要有防误操作的检查项。
生产环节还有一个容易被忽略的问题:密钥怎么注入产线。如果直接在产线电脑上保存生成密钥用的私钥,等于把整个产品线的安全拱手让出。建议量产时采用SFI流程,通过STM32CubeProgrammer的Secure Programming功能把公钥烧进OTP,同时私钥只存在于离线的签名服务器上,不进入产线网络。
调试阶段还有一个技巧:SBSFU支持打开调试日志,输出每一步校验结果。把DBG_TRACE开关打开之后,启动卡在哪一步一目了然,比我用示波器一个一个引脚查效率高太多。出了问题先开日志,再动手改代码。
6. 个人经验与后续建议
做了几个安全启动相关的项目之后,我最大的体会是:技术实现只是其中一半成本,更重的是密钥管理和升级策略的设计。代码写错了可以改,密钥泄露了整个产品线都得重来。所以哪怕是小项目,也建议至少做到私有密钥离线保存、双人交叉备份、产线不接触私钥这三条底线。
另外,不要一上来就奔着“绝对安全”去,安全是分级的。根据产品价值选合适方案,比如消费级设备做到Level 1加签名校验就够,对安全性要求高的安防、金融支付设备才需要Level 2加一机一密。把攻击成本拉高到大于产品本身的价值,就已经达到了商用量产的目标。
最后分享一个小技巧:如果你准备在项目里引入安全启动,不要等到产品功能全部做完了再加,那时候大部分Flash布局和启动流程都定了,改动成本极高。最好从原型阶段就把Boot分区、App分区、暂存区、保留区的地址画出来,哪怕第一版只做最简单的CRC校验,也要把分区结构固定下来。后面升级到签名校验、加密升级、双Bank,只需要在已有框架上加逻辑,不用推倒重来。