乐于分享
好东西不私藏

Bootloader 跳进 APP 后能运行,中断为什么不工作?

Bootloader 跳进 APP 后能运行,中断为什么不工作?

如果你也关心这类内容,欢迎点个关注。后面我会持续分享更有价值、更实用的干货,尽量让每一次阅读都对你有启发。

导语APP 能跑,不代表中断已经接上。先查向量表,再查 NVIC 和跳转前留下的状态。Bootloader 跳转到 APP 后,轮询代码能跑,界面也能显示,但串口接收、定时器回调或外部中断完全没有反应。这个问题通常不是某一个 HAL_NVIC_EnableIRQ() 没调用,而是软件跳转没有完成一次真正的复位:向量表基址、MSP、NVIC 挂起状态、全局中断屏蔽位和外设标志都可能还停留在 Bootloader 阶段。

有一类启动故障很容易把人带到错误方向:

Bootloader 能正常运行,跳到 APP 后,main() 也能进入,轮询读取 GPIO、刷新界面甚至打印日志都没问题。可是串口中断不进、定时器回调不来、DMA 完成标志没有后续处理。

如果把同一个 APP 直接从调试器下载后复位,所有中断又恢复正常。

先把一句话说清:软件跳转不等于复位。

这说明 APP 并不是完全不能运行,而是“通过 Bootloader 进入 APP”和“芯片复位后进入 APP”走了两条不同的启动路径。前者只是一次函数跳转,后者会让 Cortex-M 内核、向量表入口、NVIC 和外设从复位状态重新开始。

APP 能跑,只能证明跳转地址可执行

复位后,CPU 从复位向量得到初始 MSP,从第二个向量得到 Reset_Handler。启动文件完成 .data 搬运、.bss 清零和系统初始化后,才进入 main()

软件跳转通常只做了最后一步:取出 APP 的第二个字,强制把它当成函数地址调用。CPU 确实可能顺利进入 APP,但下面这些状态不会因为调用了一个函数自动消失:

  • SCB->VTOR 可能仍然指向 Bootloader 的向量表;

  • Bootloader 开过的 SysTick 仍然在计数;

  • NVIC 的使能位和挂起位仍然保留;

  • PRIMASK 或 BASEPRI 可能仍然屏蔽中断;

  • UART、DMA、定时器的状态位和中断源仍然来自上一阶段;

  • 如果没有重新设置 MSP,APP 可能继续使用 Bootloader 的栈。

所以“能进 main()”不是中断链路正常的证明。它只证明当前 PC 能执行到 APP 的代码。 

先确认 APP 的向量表没有问题

一张 Cortex-M 向量表最前面至少有两个关键字:第一个是初始 MSP,第二个是复位入口。外部中断的入口从第 16 项开始排列。

APP 链接时,Flash 起始地址必须是 APP 分区地址,向量表也必须被放在这个地址的起始位置。以 0x08020000 作为示例分区起点,链接脚本至少要满足类似关系:

MEMORY{    APP_FLASH (rx) : ORIGIN = 0x08020000, LENGTH = 896K}SECTIONS{    .isr_vector :    {        . = ALIGN(128);        KEEP(*(.isr_vector))    } > APP_FLASH}

这里的地址和容量只是说明契约,具体数值要和分区表、烧录工具及芯片 Flash 容量一致。KEEP(*(.isr_vector)) 不能省,否则链接器可能把没有普通代码引用的向量表处理掉。

Bootloader 在跳转前可以先检查前两个字,而不是看到分区里有数据就直接调用:

#define APP_BASE    0x08020000u#define APP_FLASH_END 0x08100000u#define SRAM_START  0x20000000u#define SRAM_END    0x20030000ustaticboolAddress_InRange(uint32_t value,                            uint32_t start, uint32_t end){    return value >= start && value < end;}boolAppVector_IsValid(uint32_t app_base){    uint32_t app_msp = *((volatile uint32_t *)app_base);    uint32_t app_reset = *((volatile uint32_t *)(app_base + 4u));    uint32_t reset_address = app_reset & ~1u;    // MSP 要落在当前芯片的 SRAM,且满足基本对齐要求。    if ((app_msp & 0x7u) != 0u ||        !Address_InRange(app_msp, SRAM_START, SRAM_END)) {        return false;    }    // Cortex-M 入口必须带 Thumb 位,并位于 APP 可执行区域。    if ((app_reset & 1u) == 0u ||        !Address_InRange(reset_address, APP_BASE, APP_FLASH_END)) {        return false;    }    // 两个入口字有效,只说明启动地址形态正确,还要继续校验镜像完整性。    return true;}

这里的检查不能替代镜像 CRC 或签名校验。它解决的是另一件事:确认 Bootloader 读到的初始栈和复位入口至少像一份能执行的镜像。

向量表正确,为什么还可能没有中断?

即使 APP 的向量表放对了,跳转前的内核状态仍然可能把中断挡住。

最常见的是 __disable_irq()。它会设置 PRIMASK,后续代码即使调用了 HAL_NVIC_EnableIRQ(),也只是打开了某条中断线,CPU 仍可能不响应可屏蔽中断。另一个常见情况是 Bootloader 的 SysTick 或 UART 中断已经产生了挂起位,APP 刚打开全局中断就先执行了上一阶段留下的处理路径。

因此,跳转动作至少需要覆盖四类状态:全局屏蔽、SysTick、NVIC 和外设本身。下面的顺序是关键,不是把几行初始化函数随意拼在一起。

typedefvoid(*AppEntry)(void);voidBoot_JumpToApp(uint32_t app_base){    uint32_t app_msp = *((volatile uint32_t *)app_base);    uint32_t app_reset = *((volatile uint32_t *)(app_base + 4u));    AppEntry entry = (AppEntry)(uintptr_t)(app_reset & ~1u);    // 先阻止 Bootloader 的中断在清理过程中再次进入。    __disable_irq();    SysTick->CTRL = 0u;    SysTick->LOAD = 0u;    SysTick->VAL = 0u;    Board_DeinitPeripherals();    // 关闭并清除 NVIC,避免 APP 开中断后立刻处理旧挂起项。    for (uint32_t i = 0u; i < NVIC_REG_COUNT; ++i) {        NVIC->ICER[i] = 0xFFFFFFFFu;        NVIC->ICPR[i] = 0xFFFFFFFFu;    }    SCB->VTOR = app_base;    __DSB();    __ISB();    __set_CONTROL(0u);    __ISB();    // 切换到 APP 的主栈,避免继续沿用 Bootloader 的栈帧。    __set_MSP(app_msp);    __DSB();    // 保持中断屏蔽,交给 APP 完成初始化后再统一打开。    entry();    while (1) { }}

NVIC_REG_COUNT 要按目标芯片的中断寄存器数量确定。外设反初始化函数还需要关闭 DMA 请求、清除外设状态位,并释放片选或收发使能脚;只清 NVIC,不清 UART、DMA 或定时器自己的标志,下一阶段仍可能收到旧事件。

这里故意没有在跳转前调用 __enable_irq()。如果此时 APP 的时钟、GPIO、DMA 和中断服务环境还没有准备好,挂起中断可能在 APP 的第一条初始化语句之前进入。跳转函数保持屏蔽,APP 在自己的初始化顺序完成后再打开全局中断。

APP 需要重新接管向量表和中断开关

有些 CMSIS 启动文件会在 SystemInit() 中设置 SCB->VTOR,有些工程则没有打开对应宏。不要只看模板文件的名字,要在调试器中确认寄存器的实际值。

APP 的早期初始化至少要完成三件事:重新确认向量表、恢复系统时钟和外设、最后解除全局屏蔽。若项目使用 RTOS,解除中断的具体位置应交给 RTOS 启动流程,但原则不变:中断源、向量表和栈准备好之前,不要让中断进入。

void App_EarlyInit(void){    // APP 再次声明自己的向量表,避免依赖 Bootloader 的寄存器状态。    SCB->VTOR = APP_BASE;    __DSB();    __ISB();    SystemClock_Config();    App_Peripherals_Init();    // 时钟、外设和中断源准备完成后,再恢复全局中断。    // 这里之后产生的 IRQ 才会进入 APP 自己的服务函数。    __enable_irq();}

App_Peripherals_Init() 不应只重新写一遍配置寄存器,还要处理复位后不会自动清掉的状态:读取并清除 UART 溢出标志、清 DMA 中断标志、重新设置定时器计数和更新标志、恢复 GPIO 复用状态。否则 NVIC 虽然已经指向 APP,外设却没有产生新的有效中断。 

不要只盯着中断回调,先把证据打印出来

排查时可以在 APP 初始化完成后,针对一条具体中断读取四个值:

  1. SCB->VTOR 是否等于 APP 的向量表地址;

  2. VTOR + 16 + IRQn 取出的入口是否落在 APP 的代码区;

  3. NVIC->ISER 是否打开了对应中断,NVIC->ISPR 是否一直挂起;

  4. PRIMASK 或 BASEPRI 是否仍在屏蔽中断,外设状态寄存器是否真的产生了请求。

下面的辅助函数用于把向量入口、使能位和挂起位放在同一条日志里:

voidApp_CheckInterruptPath(IRQn_Type irqn){    if (irqn < 0return;    // 这里只检查外部 IRQ,系统异常入口另按 Fault 路径排查。    uint32_t bank = (uint32_t)irqn >> 5;    if (bank >= NVIC_REG_COUNT) return;    uint32_t mask = 1u << ((uint32_t)irqn & 31u);    uint32_t vtor = SCB->VTOR;    const uint32_t *vectors = (const uint32_t *)vtor;    uint32_t handler = vectors[16u + (uint32_t)irqn];    uint32_t enabled = NVIC->ISER[bank] & mask;    uint32_t pending = NVIC->ISPR[bank] & mask;    uint32_t primask = __get_PRIMASK();    // 一条日志同时回答:入口在哪里、线开没开、请求挂没挂。    // enabled 和 pending 都为 0 时,还要继续看外设自己的请求标志。    Log_InterruptPath(vtor, handler, enabled, pending, primask);}

如果 pending 一直为 1,优先检查外设标志是否没有清掉,或者 ISR 入口根本不是 APP 的函数。如果 enabled 为 0,检查 APP 是否只开了外设源却没有开 NVIC。如果这些都正常但回调仍不进,再看 PRIMASKBASEPRI 和中断优先级配置。 

几种很像、但处理顺序不同的故障

main() 能跑,所有中断都不进。先看 PRIMASKSCB->VTOR 和 MSP。这更像是跳转交接不完整,而不是某一个外设配置错误。

只有某一个中断不进,其他中断正常。看这条 IRQ 的 ISERISPR、外设状态位和向量入口。不要重新改整个跳转函数。

中断刚打开就进了一次,之后再也不进。 可能是 Bootloader 留下的挂起位或外设标志先被处理,ISR 没有正确清除源标志。先清现场,再确认新的事件是否能再次产生。

直接复位正常,Bootloader 跳转异常。 做一次差异记录:复位后的 VTORMSPPRIMASK、SysTick、NVIC 和外设寄存器分别是什么;跳转后的同一组寄存器是什么。两条启动路径的差异通常比继续改回调函数更有价值。 

跳转函数不是越短越好

一个只包含“取地址、设 MSP、调用入口”的跳转函数看起来很干净,但它把清理责任全部留给 APP,故障就会变成“有时能启动、有时中断不进”。反过来,Bootloader 也不应该替 APP 配置业务外设和任务。

比较稳妥的选择是:Bootloader 负责验证镜像、停止上一阶段的内核和外设状态、交接 VTOR 与栈;APP 负责自己的时钟、外设、任务和中断开启。

写在最后

Bootloader 跳进 APP 后,代码能执行不代表启动环境完整。

先确认 APP 向量表和链接地址,再按顺序清理 SysTick、NVIC、外设状态,最后交接 VTORCONTROL 和 MSP。APP 完成自己的初始化后,再恢复全局中断。

排查时不要只看“回调有没有进”,把 SCB->VTOR、向量入口、ISERISPRPRIMASK 和外设标志放在一起看,通常很快就能判断问题是在入口、屏蔽、使能还是事件源。

《往期精彩回顾》

HardFault 实战排查手册:CFSR 逐位判断、栈帧还原与 5 个案例

换个外设型号,为什么半个项目都要跟着改?驱动分层该怎么做

错误码已经定义好了,为什么 MCU 固件还是越报错越乱?

驱动失败后,到底该重试、降级还是上报告警?错误码应该怎么设计

协议结构体不要直接 memcpy:对齐、字节序与版本兼容

如果你觉得有用,欢迎转发给也在关注嵌入式MCU的程序员朋友。