MCU 固件启动即崩,bootloader 如何发现
向量表检测的盲区
MCU 的 bootloader 在跳转 App 之前,一般会读一次 App 分区首地址,把里面的第一个字当成初始栈指针,检查它是否落在 SRAM 范围内。在范围内就跳转,不在就认为 App 损坏。
先看 App 的 bin 在 Flash 里怎么摆放。以 STM32F4 为例,App 烧在内部 Flash 的 App 分区,bin 就是从分区起始地址开始逐字节铺开的一份镜像:
App 分区起始地址
│
├─ 0x00 初始栈指针 MSP ← 向量表第 0 项
├─ 0x04 复位向量 Reset_Handler ← 向量表第 1 项
├─ 0x08 NMI 处理函数
├─ 0x0C HardFault 处理函数
├─ ... 其余异常/中断向量
│
├─ .text 代码段(真正的程序逻辑)
├─ .rodata 只读常量
└─ .data 已初始化变量的初值
向量表固定在固件最开头,所以所谓的向量表检测,代码上只有这么几行:
c
uint32_t sp = *(volatile uint32_t *)app_addr; /* 读向量表第 0 项 = 初始 SP */
if (sp 落在 SRAM 地址范围内) /* 它像不像一个 RAM 地址 */
jump_to_app();
读的是开头 4 个字节,判断标准只有一条:这个值在不在 SRAM 范围里。
这个检查漏掉的情况不少。代码段排在向量表后面,掉电或者坏块可能只擦坏中间几页,开头 4 字节还完好,检测就放行,程序跑到坏代码上 HardFault。SP 的判断范围是整段 SRAM,几十上百 KB,被破坏之后碰巧落进去的概率不低。复位向量就在 SP 后面 4 个字节,检测从来不读它,PC 坏成非法地址照样往里跳。
还有一种情况是固件一个字节都没坏,SP、PC、代码全对,只是启动逻辑有 BUG。检测必然通过,然后进入跳转、崩溃、看门狗复位、再跳转的循环。现场看到的现象是设备能上电、串口有输出,就是起不来,也没人知道它到底重启了多少次。
所以这里要回答的问题是固件有没有真正跑起来过,它的向量表像不像真的只是其中一小部分。
用 RTC 备份寄存器记失败次数
做法类似软件看门狗。bootloader 每次启动把计数加一,然后跳 App;App 完成核心初始化之后把计数清零。如果 App 起不来,清零这步永远走不到,计数一次一次往上累,累到阈值(比如 5)时 bootloader 判定固件损坏,不再跳转,转去恢复流程。
App Bootloader App Bootloader #mermaid-svg-tqlmxboM5BGCAbkA{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-tqlmxboM5BGCAbkA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-tqlmxboM5BGCAbkA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-tqlmxboM5BGCAbkA .error-icon{fill:#552222;}#mermaid-svg-tqlmxboM5BGCAbkA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-tqlmxboM5BGCAbkA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-tqlmxboM5BGCAbkA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-tqlmxboM5BGCAbkA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-tqlmxboM5BGCAbkA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-tqlmxboM5BGCAbkA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-tqlmxboM5BGCAbkA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-tqlmxboM5BGCAbkA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-tqlmxboM5BGCAbkA .marker.cross{stroke:#333333;}#mermaid-svg-tqlmxboM5BGCAbkA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-tqlmxboM5BGCAbkA p{margin:0;}#mermaid-svg-tqlmxboM5BGCAbkA .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-tqlmxboM5BGCAbkA text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-tqlmxboM5BGCAbkA .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-tqlmxboM5BGCAbkA .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-tqlmxboM5BGCAbkA .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-tqlmxboM5BGCAbkA .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-tqlmxboM5BGCAbkA #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-tqlmxboM5BGCAbkA .sequenceNumber{fill:white;}#mermaid-svg-tqlmxboM5BGCAbkA #sequencenumber{fill:#333;}#mermaid-svg-tqlmxboM5BGCAbkA #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-tqlmxboM5BGCAbkA .messageText{fill:#333;stroke:none;}#mermaid-svg-tqlmxboM5BGCAbkA .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-tqlmxboM5BGCAbkA .labelText,#mermaid-svg-tqlmxboM5BGCAbkA .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-tqlmxboM5BGCAbkA .loopText,#mermaid-svg-tqlmxboM5BGCAbkA .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-tqlmxboM5BGCAbkA .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-tqlmxboM5BGCAbkA .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-tqlmxboM5BGCAbkA .noteText,#mermaid-svg-tqlmxboM5BGCAbkA .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-tqlmxboM5BGCAbkA .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-tqlmxboM5BGCAbkA .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-tqlmxboM5BGCAbkA .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-tqlmxboM5BGCAbkA .actorPopupMenu{position:absolute;}#mermaid-svg-tqlmxboM5BGCAbkA .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-tqlmxboM5BGCAbkA .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-tqlmxboM5BGCAbkA .actor-man circle,#mermaid-svg-tqlmxboM5BGCAbkA line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-tqlmxboM5BGCAbkA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ① 正常启动:App 主动清零 下次上电重新 +1,计数永远在 0~1 之间 ② 循环重启:清零永远不发生 loopApp 起不来(崩溃 / 跑飞) 看门狗复位,回到 bootloader,计数继续累加 计数 +1跳转核心初始化完成清零计数(归 0)计数 +1跳转崩溃计数达阈值 → 判定损坏,进入恢复
这段逻辑不关心 App 为什么坏,只关心它有没有活到清零那一刻。向量表坏、代码段坏、固件带 BUG 启动就崩,都会让计数涨上去。
计数器需要一个重启不丢、复位也不丢的位置。RTC 备份寄存器(BKP)满足这个条件,软复位和看门狗复位后内容都在,只有 VBAT 掉电才会丢。
寄存器的分配
RTC 备份寄存器在 VBAT 掉电后是随机值。计数器如果直接放一个整数,彻底断电再上电,寄存器里可能是 0x7FFFFFFF,bootloader 会当成失败次数已经爆掉,直接进恢复流程。
所以要配一个模数。一个 32 位 BKP 寄存器切成两段:
┌─────────────────────────────┬──────────┐
│ 高 29 bit:模数 magic │ 低 3 bit │
│ (固定值 0x5A5A5A58) │ 计数 0~7 │
└─────────────────────────────┴──────────┘
低 3 位放计数,取值 0 到 7;高 29 位放固定模数 0x5A5A5A58。读的时候先比高 29 位,对不上说明这是掉电后的随机值,计数从 0 开始算;对得上才认低 3 位。随机值撞上这个模数的概率是 2⁻²⁹,大约五亿分之一。
c
#define OTA_BOOT_FAIL_COUNT_MASK 0x00000007u /* 低 3 bit: 计数 */
#define OTA_BOOT_FAIL_COUNT_MAGIC 0x5A5A5A58u /* 高 29 bit: 模数 */
#define OTA_BOOT_FAIL_COUNT_THRESHOLD 5u /* 连续失败阈值 */
uint32_t ota_boot_fail_count_inc(void)
{
uint32_t reg = RTC->BKP1R;
uint32_t count;
if ((reg & ~OTA_BOOT_FAIL_COUNT_MASK) != OTA_BOOT_FAIL_COUNT_MAGIC)
count = 0; /* 寄存器非法(掉电随机),从 0 重算 */
else
count = reg & OTA_BOOT_FAIL_COUNT_MASK;
if (count < OTA_BOOT_FAIL_COUNT_MASK)
count++; /* 封顶 7,不溢出 */
RTC->BKP1R = OTA_BOOT_FAIL_COUNT_MAGIC | count;
return count;
}
void ota_boot_fail_count_clear(void)
{
RTC->BKP1R = OTA_BOOT_FAIL_COUNT_MAGIC; /* 计数归零,保留模数 */
}
两侧各做什么
bootloader 启动时加一,用返回值决定要不要强制恢复:
c
/* bootloader 主流程 */
uint32_t boot_count = ota_boot_fail_count_inc(); /* +1,并读当前计数 */
if (boot_jump_app_valid()) /* 向量表检查仍在,作为第一道粗筛 */
{
boot_jump_to_app(); /* 跳 App,成功后 App 自行清零 */
}
else if (boot_count >= OTA_BOOT_FAIL_COUNT_THRESHOLD)
{
enter_recovery(); /* 连续失败达阈值,判定 App 损坏 */
}
App 在核心初始化完成之后清零,这是固件活着的唯一证据:
c
/* App 主流程 */
int app_start(void)
{
init_all(); /* 初始化外设、模块、FlashDB 等 */
ota_boot_fail_count_clear(); /* 初始化成功 = 正常启动,清零计数 */
start_scheduler(); /* 进入主循环 / 调度器,不再返回 */
return 0;
}
清零的位置要放在核心初始化后面。App 如果在初始化中途崩了,clear 这行执行不到,计数不会被清,下一轮 bootloader 继续加一,直到触发阈值。
为什么不用 Flash
看一下写入频率就清楚了:bootloader 每次启动写一次,App 启动成功再写一次,设备正常开机一次就要写同一个位置两遍。每天开关几十次,一年几万次,NOR Flash 的擦写寿命典型只有 10 万次,撑不了多久,擦写过程中掉电还有写坏数据的风险。
RTC 备份寄存器是寄存器,读写次数没有限制,也没有擦写延迟,完全不需要等待。启动计数这种每次上电都要写、丢了也无所谓的场景,用它比用 Flash 合适。Flash 留给升级固件、保存参数这类低频但要持久的数据。
为什么不用 CRC
CRC 能发现 Flash 内容变了,但在这个场景下有两个问题。
CRC 的用法是算出一个值,跟事先存好的参考值比对。参考值必须和 Flash 里的固件严格一致,而这个一致性只在 OTA 下载时天然成立:下载流程边收边算,最后把 CRC 写进参数区或者固件头。
换一条烧录路径,这条链就断了。用 J-Link、ST-Link、DAP-Link 直接烧录时,工程师烧的是编译出来的 .hex 或 .bin,没人会去同步更新那笔 CRC。烧完上电,bootloader 一算,跟参数区里的旧值对不上(很多时候是 0),一个正常的固件就被判成损坏,触发强制恢复。产线首次烧录也一样,除非流程里额外加一步"烧录后写 CRC",而这一步很容易漏。
要修就得在构建和烧录流程里加 CRC 后处理,还要保证 J-Link 调试、产线烧录、OTA 每条路径都覆盖到。有人图省事直接 J-Link 烧一次,问题就又回来了。
第二个问题更麻烦。CRC 只能说明 Flash 里的字节没被改过,说明不了固件能不能跑。App 部分损坏,内容变了,CRC 能发现;固件有严重 BUG 一启动就崩,内容没变,算出来的 CRC 完全正确,CRC 什么也发现不了。
启动失败计数正好绕开这两点。它不依赖任何需要维护的记录,只看固件有没有在初始化完成后主动来清零。这个动作是固件自己发起的,所以跟烧录方式无关:OTA 烧的、J-Link 烧的、产线烧的,只要能跑都会清零;固件有 BUG 跑不起来就不会清零,计数一路涨到阈值。
| CRC 校验 | 启动失败计数 | |
|---|---|---|
| 检测对象 | 静态损坏(Flash 内容变了) | 动态故障(跑不起来 / 循环重启) |
| 触发速度 | 第一次启动即发现 | 连续 N 次失败才触发 |
| 烧录方式 | 依赖 CRC 记录维护,J-Link 直烧会误判 | 无关 |
| 维护成本 | 需构建/烧录流程统一加 CRC | 无 |
两者可以一起用。CRC 做第一道快速筛查,前提是记录的维护跟得上;启动失败计数兜住 CRC 发现不了的运行时问题,以及记录缺失导致的误判。
放进完整的恢复流程
启动失败计数只能发现问题,解决还得靠恢复机制。bootloader 的流程是这样:
#mermaid-svg-f8ERPHjsIj9O9wvZ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-f8ERPHjsIj9O9wvZ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-f8ERPHjsIj9O9wvZ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-f8ERPHjsIj9O9wvZ .error-icon{fill:#552222;}#mermaid-svg-f8ERPHjsIj9O9wvZ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-f8ERPHjsIj9O9wvZ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-f8ERPHjsIj9O9wvZ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-f8ERPHjsIj9O9wvZ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-f8ERPHjsIj9O9wvZ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-f8ERPHjsIj9O9wvZ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-f8ERPHjsIj9O9wvZ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-f8ERPHjsIj9O9wvZ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-f8ERPHjsIj9O9wvZ .marker.cross{stroke:#333333;}#mermaid-svg-f8ERPHjsIj9O9wvZ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-f8ERPHjsIj9O9wvZ p{margin:0;}#mermaid-svg-f8ERPHjsIj9O9wvZ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-f8ERPHjsIj9O9wvZ .cluster-label text{fill:#333;}#mermaid-svg-f8ERPHjsIj9O9wvZ .cluster-label span{color:#333;}#mermaid-svg-f8ERPHjsIj9O9wvZ .cluster-label span p{background-color:transparent;}#mermaid-svg-f8ERPHjsIj9O9wvZ .label text,#mermaid-svg-f8ERPHjsIj9O9wvZ span{fill:#333;color:#333;}#mermaid-svg-f8ERPHjsIj9O9wvZ .node rect,#mermaid-svg-f8ERPHjsIj9O9wvZ .node circle,#mermaid-svg-f8ERPHjsIj9O9wvZ .node ellipse,#mermaid-svg-f8ERPHjsIj9O9wvZ .node polygon,#mermaid-svg-f8ERPHjsIj9O9wvZ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-f8ERPHjsIj9O9wvZ .rough-node .label text,#mermaid-svg-f8ERPHjsIj9O9wvZ .node .label text,#mermaid-svg-f8ERPHjsIj9O9wvZ .image-shape .label,#mermaid-svg-f8ERPHjsIj9O9wvZ .icon-shape .label{text-anchor:middle;}#mermaid-svg-f8ERPHjsIj9O9wvZ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-f8ERPHjsIj9O9wvZ .rough-node .label,#mermaid-svg-f8ERPHjsIj9O9wvZ .node .label,#mermaid-svg-f8ERPHjsIj9O9wvZ .image-shape .label,#mermaid-svg-f8ERPHjsIj9O9wvZ .icon-shape .label{text-align:center;}#mermaid-svg-f8ERPHjsIj9O9wvZ .node.clickable{cursor:pointer;}#mermaid-svg-f8ERPHjsIj9O9wvZ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-f8ERPHjsIj9O9wvZ .arrowheadPath{fill:#333333;}#mermaid-svg-f8ERPHjsIj9O9wvZ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-f8ERPHjsIj9O9wvZ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-f8ERPHjsIj9O9wvZ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-f8ERPHjsIj9O9wvZ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-f8ERPHjsIj9O9wvZ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-f8ERPHjsIj9O9wvZ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-f8ERPHjsIj9O9wvZ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-f8ERPHjsIj9O9wvZ .cluster text{fill:#333;}#mermaid-svg-f8ERPHjsIj9O9wvZ .cluster span{color:#333;}#mermaid-svg-f8ERPHjsIj9O9wvZ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-f8ERPHjsIj9O9wvZ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-f8ERPHjsIj9O9wvZ rect.text{fill:none;stroke-width:0;}#mermaid-svg-f8ERPHjsIj9O9wvZ .icon-shape,#mermaid-svg-f8ERPHjsIj9O9wvZ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-f8ERPHjsIj9O9wvZ .icon-shape p,#mermaid-svg-f8ERPHjsIj9O9wvZ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-f8ERPHjsIj9O9wvZ .icon-shape .label rect,#mermaid-svg-f8ERPHjsIj9O9wvZ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-f8ERPHjsIj9O9wvZ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-f8ERPHjsIj9O9wvZ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-f8ERPHjsIj9O9wvZ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
是
否
上电 / 复位
启动失败计数 +1
计数 >= 阈值?
判定 App 损坏,进入恢复流程
App 向量表有效?
跳 App,App 正常启动后清零计数
计数一到阈值就不再跳固件,直接走恢复分支。固件不管是完全损坏、部分损坏还是带病循环重启,设备都不会硬砖,要么自动回滚,要么停在下载模式等人来处理。
几个需要注意的地方
计数器跨复位累加的前提是 RTC 备份域还有电。崩溃后由看门狗复位(IWDG 或者 NRST)没问题;如果是断电冷启动、VBAT 又没接电池,RTC 掉电,计数每次都从 1 重新开始。模数保证这种情况下不会误判,但断电场景下计数不累计。实际遇到的循环重启绝大多数是看门狗复位,不是反复断电。
3 位计数的上限是 7,阈值取 5 表示连续 5 次起不来就动手。阈值太小容易误伤,偶发一次启动失败就被判死;太大恢复太慢。具体取值看设备的看门狗超时和现场能接受多长的停机时间。
清零时机要选在核心初始化之后。一进 main 就清零的话,App 在初始化中途崩了计数早就归零,等于没做。
小结
向量表检测只能看出固件像不像一份有效的固件,启动失败计数看的是它有没有真的跑起来。一个 32 位 RTC 寄存器、一个模数、一个阈值,几十行代码,不占存储介质,也不增加 Flash 磨损。