上个月在客户现场折腾一块基于STM32L433的采集板,遇到了一个特别典型的“更新固件后翻车”问题:用STM32CubeProgrammer把固件烧进去,进度条走到100%,断开调试器,复位一按,程序没跑起来。串口助手收到一串乱码,板载LED一个都不亮,万用表量电源正常,晶振起振正常——怎么看都像是芯片“死”了。可当我重新接上SWD想重新烧录时,CubeProgrammer又能正常识别到芯片ID。这个状态非常迷惑,既不是彻底砖,也不是正常运行,典型的stuck in bootloader。
这类问题在L4系列上其实非常常见,尤其STM32L433这种低功耗MCU,很多工程师在量产阶段或者OTA升级流程里都会踩到。这篇文章我结合自己的排查经历,把“为什么更新完固件会卡在bootloader”的底层逻辑、完整排查链路和恢复方案都捋一遍,同时会讲清楚烧录过程中有哪些操作会无意间改写启动配置。不管是刚接触STM32的新手,还是做量产维护的老手,这篇都值得看完。
1. 现象复盘:更新完固件,板子卡死在bootloader
先说结论:标题里的“stuck in bootloader”这个描述,其实可以拆成两种完全不同的情况,排查思路差异非常大。
第一种,芯片复位后进入了STM32出厂自带的ROM引导程序,也就是System Memory里的System Bootloader。这个程序是芯片出厂时就固化在ROM里的,用户擦不掉,也无法修改。它通过USART、USB DFU、I2C、SPI等接口接收固件,是工厂烧录和开发调试的救命通道。如果更新固件后复位,芯片跳进了这个程序,它本身不会运行你的应用代码,只是安静地等待上位机发送下载命令。表现就是:串口工具打开后没有任何反应,或者收到一些无意义的字符;整个系统看起来“死”了,但芯片其实活着,还在运行ROM里的引导程序。
第二种,你的工程里自己写了一个基于应用层的Bootloader(比如用于OTA升级的引导程序),更新固件后,Bootloader因为跳转条件不满足、App校验失败、Flash内容不完整等原因,始终停留在Bootloader主循环里,不跳转到应用区。这种“卡死”和第一种在现象上很接近,但本质完全不同,排查和恢复方法也不一样。
我这次遇到的L433,属于第一种,芯片跑在了System Bootloader里。怎么确认的?很简单,芯片在System Bootloader里,USART1会处于可响应状态。我用一个USB转TTL模块接上板子的调试串口,发送一个0x7F字节,收到了0x79的ACK——这是ST的标准bootloader握手协议(参考AN3155)。如果你能收到这个ACK,基本可以断定芯片正在运行ROM里的System Bootloader,而且USART外设工作正常。
这里提醒一点,很多人在现场看到“串口收到乱码”就以为是波特率不对,其实0x7F这种主动握手测试,比猜波特率靠谱得多。而且整条链路需要确认:板子的调试串口有没有经过电平转换芯片?如果是RS232,必须确保USB转TTL模块的地线和板子共地,否则握手永远不成功。
1.1 先判断“假死”还是“真卡死”
这里分享一个我自己的快速判断方法:在你确认能通过SWD连接芯片的前提下,用CubeProgrammer断开连接,然后断开调试器排线,给板上电,用示波器或逻辑分析仪测一下某个用户程序一开始就会翻转的GPIO引脚。如果这个引脚完全没有变化,说明用户程序根本没有执行。然后再接上SWD,在调试模式下暂停CPU,看一下PC寄存器的值。
如果PC指向0x1FFFxxxx附近,说明CPU跑在System Memory区域——也就是进入了ROM Bootloader。如果PC指向0x0800xxxx但程序没有正常跑起来,那说明CPU已经跑进了用户Flash区域,但不是卡在启动向量就是App本身跑飞了,原因要往应用代码、中断向量表、链接脚本方向找。这两种情况的PC地址完全不同,用一款几十元的ST-Link就能查清楚。
2. 启动链路的底层逻辑:L433为什么“忽然”去了bootloader
很多工程师对STM32启动模式的理解还停留在“BOOT0引脚拉高就是ISP下载,拉低就是运行Flash”的阶段。这个理解在F1系列上基本够用,但到了L4系列,尤其是有nBOOT0和nBOOT1选项字节的低功耗系列,就不够用了。这也是L433更新固件后卡bootloader的根源所在。
STM32L433芯片复位后,CPU会从一个固定的起始地址取第一条指令,这个地址由启动配置决定。L433有三种启动源:主Flash(0x08000000)、System Memory(0x1FFF0000,即ROM Bootloader)、SRAM(0x20000000)。具体选择哪个,是由BOOT0引脚电平和选项字节里的nBOOT0、nBOOT1位共同决定的。
关键点在这里:L4系列上,BOOT0引脚的电平并不是“一票决定制”,它的优先级会受到选项字节的影响。更直白地说,即便你的BOOT0引脚外部已经拉低,CPU复位后也有可能因为nBOOT0被清0、nBOOT1被置1,而跳到System Memory区域去执行Bootloader。这就解释了为什么你明明什么都没动硬件,固件更新完后,芯片还是跑进了bootloader。
2.1 L433启动模式全解:BOOT0引脚、nBOOT0、nBOOT1的三角关系
我整理了一张L433启动配置的简化表,基于参考手册RM0394中的描述,方便对照排查:
| BOOT0引脚电平 | nBOOT0选项位 | nBOOT1选项位 | 启动区域 |
|---|---|---|---|
| 0 | 1(默认) | x(任意) | 主Flash |
| 0 | 0 | 0 | SRAM |
| 0 | 0 | 1 | System Memory |
| 1 | x | x(部分情况) | System Memory |
这张表里最值得留意的是第二行和第三行:当BOOT0引脚为低电平时,启动源完全由nBOOT0和nBOOT1两个选项位接管。如果nBOOT0=0且nBOOT1=1,那CPU复位后就会进入System Memory,也就是ROM Bootloader。
很多人在CubeProgrammer里烧录时,页面底部会有一个Option Bytes的配置区域,烧录时如果顺手把nBOOT0勾选取消,或者加载了一个之前从别的板子导出的配置,相当于把BOOT0引脚的控制权交给了选项字节,复位后芯片跑进bootloader就毫不奇怪了。
2.2 为什么“烧录”这个动作会改动选项字节
CubeProgrammer在烧录时,默认行为是只烧Flash里的内容,不发生成配置。但如果你在软件里手动修改了Option Bytes页面的设置,或者加载过一个Embedded Flash Configuration的工程模板,CubeProgrammer会在烧录流程最后,把整个Option字节区域重新写一遍。一旦nBOOT0位被写成了0,nBOOT1位被写成了1,你的板子等于被永久(直到下次修改选项字节)设成了“复位后进bootloader”模式。
还有一种更隐蔽的情况:量产工具或自动化烧录脚本里,为了启用某些外设或者关闭看门狗,会主动往Option Bytes里写入配置,比如把nBOOT1改成1来配合从System Memory启动的工厂测试。如果这批工装脚本复用到了正式量产流程里,而操作人员又不知道这条配置的实际效果,就会出现批量性“卡bootloader”问题。
2.3 “我根本没动过选项字节”其实是最大的错觉
我排查过多次现场问题,绝大多数工程师在被告知“选项字节被改过”时都会反驳:我CubeProgrammer就只烧了程序,其他什么都没动。但真实情况往往出在这几个环节:
第一,CubeProgrammer左上角的连接设置里有三组模式:Normal、Hot Plug、Under Reset。如果你的连接使用Under Reset模式,软件会通过复位引脚在连接期间使芯片复位。如果此时目标固件里刚好有修改Option Bytes的代码(比如你之前调试时写了一个“软件进入bootloader”的测试函数),连接和复位的过程就可能触发这些代码。
第二,工程里如果用了OpenOCD、pyOCD或者自研的烧录脚本,脚本里可能有一条stm32l4x option_write的命令。这类脚本往往是从网上复制来的,别人能用,你能用,但脚本具体写了哪些位,很多人根本没逐行看过。
第三,也是最常见的:CubeProgrammer保存过的.stm32cubeprg工程文件里,记录了上一次烧录时的全部设置,包括Option Bytes。如果你的工程文件是从另一个芯片型号(比如STM32F103)继承过来的,里面的nBOOT0配置很可能不适用于L433,烧录时就会把原来的默认值覆盖掉。
3. 用CubeProgrammer做一次完整排查:从连接模式到选项字节
遇到卡在bootloader的板子,千万别急着反复补烧、反复复位。先做一轮系统排查,把问题定位准了再动手。整个过程我大概花了二十分钟,主要分四步。
3.1 第一步:用SWD连接,先确认能不能连上
把调试器接到板子上,打开CubeProgrammer,选择ST-LINK接口,连接模式首先选Normal。能正常连接说明芯片的调试接口没有被锁死,这是最幸运的情况。
如果连接报错Error: Connection error,说明芯片可能进入了低功耗模式或者调试接口被禁用。这时候把模式换成Under Reset再试。Under Reset模式会在连接过程中拉低NRST复位引脚,保证CPU暂停在复位向量,然后趁这个机会接管调试接口。
这一步非常关键,因为一旦CPU跑在ROM Bootloader里,某些调试端口的初始化的确会出现时序冲突,Normal模式连接可能不稳定。我用Under Reset模式连上这块L433时,CubeProgrammer立刻识别出了Device ID 0x435,这基本等于宣告了芯片是活的,只是没有运行用户程序。
3.2 第二步:分辨当前启动源,读Option Bytes
连接成功后,先不急着烧录,打开Option Bytes页面,逐项检查。重点关注:
- nBOOT0位:如果显示为0,这就是嫌疑最大的根因
- nBOOT1位:如果显示为1,并且nBOOT0=0,组合出的启动源就是System Memory
- RDP读保护等级:显示为AA(Level 0)表示没有开启读保护,显示为BB(Level 1)或CC(Level 2)则说明保护已启用
我这次遇到的板子,nBOOT0=0、nBOOT1=1、RDP=AA,三组数据一摆出来,结论已经清楚了:复位后芯片去执行System Memory里的ROM Bootloader了,用户应用程序根本没机会运行。
同时,在CubeProgrammer的主界面里,下面的Memory显示区会展示当前目标芯片的映射情况。如果地址显示从0x1FFF0000区域开始,也能佐证当前启动源是System Memory。两种手段交叉验证,结论基本不会错。
3.3 第三步:检查Flash内容和启动地址
确认启动源之后,还要再看一眼用户Flash区域的内容。CubeProgrammer内存视图里,跳到0x08000000地址,看看前8个字节是不是你编译出来的固件头部。正常的ARM Cortex-M固件,前4字节是初始SP值(通常指向0x20000000之后),后4字节是复位向量(通常指向0x0800xxxx)。如果这个区域全FF或者内容乱七八糟,说明固件根本没写进去,或者写错了地址。
这块L433板子让我比较意外的是,0x08000000处的数据看起来是合法的固件头,说明固件确实写进去了,只是CPU没有从这里启动。这更加验证了问题是出在启动配置,而不是烧录动作本身。
3.4 第四步:确认读保护等级
最后再确认一下RDP等级。RDP Level 1在CubeProgrammer里显示为BB,开启后,SWD调试口仍能连接,但Flash内容无法通过调试接口直接读取和修改。如果烧录时你意外把RDP等级提到了Level 1,同时又把nBOOT1改了,会出现一种更麻烦的情况:能连接,但无法直接写入Flash。
RDP Level 2(显示CC)就非常严重了,这个级别一旦设定,芯片的调试接口和bootloader接口会全部永久关闭,任何方式都无法再连接调试接口,相当于物理锁定。好在L433这块板子RDP是AA,说明还没到最坏的情况。
提示:RDP Level 2是不可逆的。千万不要在手滑的情况下把这项设置成CC,因为后果是换芯片,没有第二条路。
4. 三类根因定位:更新动作本身如何改写了启动配置
排查清楚了现象,接下来回答真正的问题:为什么一次普通的固件更新会让L433的启动配置“翻车”?我在实际踩坑中总结了三种最常见的根因,你可以对照自己手里的工况来判断。
4.1 根因A:nBOOT0被清0,nBOOT1指向System Memory
这是最纯粹、也是最常见的根因。CubeProgrammer烧录窗口底部有一个“Option Bytes”标签,一些操作习惯不好的工程师会在这里面勾选各种选项。比如为了测试安全启动,把nBOOT0取消勾选;或者从同事那边拿来一个工程文件,里面的Option配置已经被人为改过。
当nBOOT0=0、nBOOT1=1时,L433复位后不看BOOT0引脚的电平,强制从System Memory启动,进入ROM Bootloader。它的本质是启动源的“软件配置”优先级超过了“硬件引脚”优先级。我在现场判断的时候,即便BOOT0引脚被万用表量到是0V,也没有因此排除这个可能,因为L433给出的判断权已经不在引脚上了。
4.2 根因B:烧录地址错乱,向量表被覆盖
第二种情况和卡在bootloader之间的关系更隐蔽。如果你的量产脚本里给CubeProgrammer命令行指定了错误的起始地址,比如把-s 0x08008000写成了-s 0x08080000,固件会被写到Flash靠后的区域,而0x08000000处的向量表可能被上一次的残留内容或者随机数据占据。
CPU复位后,按照默认启动配置从主Flash启动,取0x08000000处的前4字节作为初始SP,后4字节作为复位向量。如果这些数据是无效的,CPU会立刻跑飞或者进入HardFault。有些跑飞情况下,PC会落到奇怪的地址范围,比如0x1FFFxxxx附近,看起来就像进了bootloader。
还有一种更坑的写法:命令行里用了-e全部擦除,然后烧录地址写成了0x08004000,但应用固件里包含自己的Bootloader区,Bootloader的跳转目标仍然指向0x08000000。结果就是Bootloader区被擦成FF,跳转后取到非法向量,系统一样卡死。
所以在排查卡bootloader问题时,如果你确认Option Bytes没问题,一定要去核对烧录脚本里的地址参数。这个地址错误在生产脚本里非常难发现,因为它只在一部分板卡上出现,依赖上一次烧录留下的Content。用命令行模式量产,强烈建议每次烧录前都先做一次全片擦除,尤其是Bootloader+App双区结构的固件。
4.3 根因C:读保护Level升级后的“假活真锁”
第三个根因比较少见但后果严重:更新固件时误动了RDP读保护等级。有些工程师为了让量产固件不容易被读出,会把RDP从Level 0提升到Level 1。Level 1本身不阻止SWD连接,但会阻止调试器直接读取Flash内容。此时CubeProgrammer连接芯片还能显示ID,可一旦你要读取Flash或者做全片擦除,就会弹出一堆报错。
如果你同时把RDP设置为Level 1并修改了nBOOT0/nBOOT1,就会面临一个尴尬局面:芯片能连接,但Flash被保护。想降级保护等级,又必须做一次Mass Erase,而Mass Erase会把固件也擦掉。对于现场来说,这个动作必须非常谨慎,否则固件都没了,只能重新烧。
最极端的是把RDP误设置为Level 2。Level 2会让芯片的调试端口和bootloader端口全部禁用,芯片变成一个不能通过SWD、UART、USB等方式访问的“黑盒”。如果碰到这种情况,基本只能换芯片,难度比重新焊接一颗MCU还大。这里也解释了一个常见的网络热词:“device must be bootloader unlocked”说的就是类似机制,某些芯片在开启读保护后,必须先进入解锁流程才能刷写固件。
4.4 附:自制Bootloader的跳转条件也要排查
如果你用的是自己写的Bootloader(比如OTA升级),那么“卡在bootloader”这个现象还可能是以下原因:
第一,Bootloader对App区域的CRC校验失败。更新固件时只写了App区,但Bootloader区的校验因子没有同步更新,启动时校验不通过,就一直停在Bootloader里等待重新下发固件。
第二,跳转地址与App实际编译地址不匹配。比如Bootloader把App放在0x08008000,但App工程里IROM1或者链接脚本设置的起始地址是0x08000000,那么跳转过去后中端向量表位置错误,程序立刻HardFault。
第三,跳转条件依赖某个标志位,比如备份寄存器里的一个值。更新固件后如果标志位没被正确清除,Bootloader每次启动都认为“需要重新更新固件”,于是停在Bootloader等固件。要排查这一层,用调试器暂停CPU,PC落在用户Flash区域还是System Memory区域就能迅速区分。
5. 恢复与预防:选项字节修复、命令行走捷径、工程化避坑
排查之后,恢复步骤其实就清晰了。核心思路就一句话:把启动配置重新改回“复位后从主Flash启动”,然后把固件重新烧一遍。下面提供两种恢复路径,外加一些预防性建议。
5.1 恢复路径一:CubeProgrammer GUI,最直观
在CubeProgrammer的Option Bytes页面,把nBOOT0重新勾选上(置1),把nBOOT1改回你需要的状态(通常置0),点击Write按钮。软件会写入Option Bytes,然后会自动复位或者提示你手动复位。
这一步完成后,先不要急着烧固件。先断开连接,重新上电,看程序能不能跑起来。如果App之前已经烧进去了,只是启动配置不对,那么这时候程序就应该正常运行了。如果Flash是空的,需要重新烧一次App。
建议在GUI里修改Option Bytes后,顺手把整页配置截图保存下来,并导出一个.stm32cubeprg文件到版本管理工具里。万一以后有其他板子出问题,可以直接加载这个工程文件恢复。
5.2 恢复路径二:命令行脚本,适合生产线
GUI操作适合单板排查,如果手里有几十块板子都中招,命令行脚本才是效率最高的方式。CubeProgrammer自带命令行工具STM32_Programmer_CLI。在安装目录的bin文件夹下。
恢复选项字节的命令如下:
STM32_Programmer_CLI -c port=SWD mode=UR STM32_Programmer_CLI -c port=SWD mode=UR -ob nBOOT0=1 nBOOT1=0第一条命令是连接,这里用Under Reset模式更稳妥。第二条是写Option Bytes。写完后可以顺手读一下确认:
STM32_Programmer_CLI -c port=SWD mode=UR -ob displ如果输出里显示nBOOT0=1、nBOOT1=0,那么启动配置已经恢复。接下来再烧App固件,同样用命令行:
STM32_Programmer_CLI -c port=SWD mode=UR -d app.hex -s 0x08000000注意:-s参数要和你编译工程里实际的Flash起始地址完全一致。不要凭记忆写0x08000000,去看链接脚本。
对于批量生产,我会把这几步封装成一个独立脚本,先写选项字节,再烧固件,最后做一次校验。每一步都检查命令返回值,有错误直接中断,并把板卡编号记下来。这个习惯帮我挡掉过很多生产线上的隐形问题。
5.3 预防:从烧录流程和BootLoader设计两端堵漏
恢复只是一次性的,真正要做的是不再踩坑。我总结了几条实用建议:
所有量产烧录必须走脚本:在脚本里显式地写一遍
nBOOT0=1 nBOOT1=0,即使芯片出厂默认就是0,也每次写一遍。把启动配置固化,而不是依赖默认值。不要把“恢复出厂工程文件”随便改:
CubeProgrammer工程文件(.stm32cubeprg)里包含了所有配置,如果别人发给你一个配置,先花五分钟读一遍Option Bytes页内容,再开始烧录。CubeProgrammer烧录前,在Option Bytes页面看到RDP等级不是AA,或者nBOOT0不是1,就要停下来问清楚:这个工程为什么这么配?配这个的意图是什么?如果答不上来,大概率就是历史遗留问题。
如果是OTA产品,Bootloader里要设计完整的回滚机制。不要只做单分区,至少要用A/B双分区加一个“启动失败计数”。App启动后定时喂狗,喂不上就认为App异常,Bootloader自动回滚到上一个可用版本。这套逻辑不复杂,但能成批减少现场“卡死”型返修。
严格区分“系统Bootloader”和“应用Bootloader”。系统Bootloader是ROM里的,只能通过UART/USB/SPI/I2C等方式配合上位机使用;应用Bootloader是你自己写的,运行在主Flash区。排查“卡死”时,先搞清楚当前CPU跑的是哪个程序,否则很容易把应用Bootloader的问题归咎到ROM Bootloader,方向完全跑偏。
5.4 延伸:从“卡死”想到A/B分区和回滚,工程上到底在防什么
这次L433的问题本质上是“更新过程没有建立一个失败的退出通道”。无论是CubeProgrammer手工更新还是OTA自动更新,如果新的固件因为某些原因无法运行,设备必须要有能力回到之前可用的版本,否则一台一台换芯片在现场是不可接受的。这也是为什么网络热词里“bootloader双分区ab分区”“带回滚功能bootloader源代码”这些搜索词热度一直很高。
A/B分区在你选择了AB分区后,系统的逻辑大致是:当前使用A分区,下一版本会先写入B分区,写入完成后做一个Flag标记,然后在重启时切到B分区。B分区启动后首件事情是主动向系统报告“我启动成功了”,如果没有收到这个报告,Bootloader会在下一次启动时回滚到A分区。
这套机制的实现代价并不高,L433的Flash容量足够做A/B双分区,关键是你要提前在Bootloader里预留好分区表、启动计数器和回滚标志位。我见过不少项目在这上面吃尽苦头:前期嫌麻烦只做单分区,出一次现场事故后,整体返修成本够做好几百套A/B方案了。所以如果你正在做OTA,或者未来有OTA计划,趁Bootloader还没定型,强烈建议把A/B分区和回滚逻辑放进去。
最后再分享一次我的处理心得
这块L433板子最终的恢复动作其实只花了几秒钟:在CubeProgrammer的Option Bytes里把nBOOT0拉回1,把nBOOT1拉回0,点Write,重新上电,程序立刻正常运行。但前面的排查过程花了将近半小时,因为它逼着我确认了启动源、选项字节、Flash内容、RDP等级、烧录脚本地址等所有环节,才敢下结论。
我个人的体会是:STM32的bootloader问题,十有八九不是bootloader本身坏了,而是“启动配置”和“预期启动路径”不一致。尤其L4系列以后,选项字节的控制力越来越强,把BOOT0引脚拉高的土办法已经不够用了。排查这类问题,思路一旦清晰,工具提供的信息足够多,按顺序读一遍就全清楚了。
如果你现在手里也有一块怎么复位都不跑程序的L433板子,先去读Option Bytes,大概率会省下大半天的折腾时间。