
我调 IAP 头两年最崩溃的不是“升级失败”而是升级报告“成功了”产品复位之后却一片死寂。Flash 校验通过CRC 没错Bootloader 也确实执行了跳转结果 App 一声不吭串口一根毛都不打印。拆到最后十次里有八次都指向同一个东西中断向量表重映射Vector Table Relocation没有在正确的时间点、用正确的方式完成。这个问题的特点是它不像烧录失败那样有明显的错误码而是藏在“跳转成功”和“App 真正跑起来”之间那段极短的时间里。不管你的升级通道是串口、USB、还是以太网 IAP也不管目标芯片是 STM32、GD32 还是 HC32 这类国产 Cortex-M只要你是从 Bootloader 往 App 跳中断向量表这个坎就绕不过去。这篇文章我不打算从零讲 IAP 原理而是把“升级成功但复位死机”的完整排查链路和跳转禁忌梳理出来。每一节对应一个我实际踩过或者帮别人定位过的坑尤其适合正在调 BootloaderApp 双区固件、又被 HardFault 折磨到失眠的开发者。1. 跳转后的第一声巨响IAP升级完成后死机的经典现场1.1 调试器复位后停的位置往往已经告诉你答案先描述一下最常见的现场。产品通过 IAP 把 App 固件下到 Flash 指定区域进度走到 100%上位机发复位命令板子重启。然后屏幕不亮、电机不动、串口无输出、指示灯停在 Bootloader 那个状态。拿万用表戳一下电源电压正常晶振起振芯片没烧。这时候最有效的动作就是接上调试器。很多情况下你会看到 CPU 停在两个典型位置第一PC 停在HardFault_Handler里那个B .死循环。这说明内核在取指或者访问数据时发生了硬故障异常入口把 PC 切到了 HardFault 服务函数。第二PC 停在一个看起来完全不是代码的地址比如0xFFFFFFFE或者某个超出 Flash 映射范围的值。这种情况更直白——CPU 试图从一个不存在的地址取指等于是跑飞了。不管是哪种你都应该先不要急着去怀疑 App 的代码逻辑。IAP 场景下跳转瞬间发生的 HardFault根因大概率在前面那几步启动和跳转流程而不是main()里某个变量写错。1.2 “升级成功”与“升级可用”之间差着一个向量表这里的关键认知是校验通过只能说明数据被正确烧进了 Flash完全不能说明 CPU 复位后能正确找到并执行这些代码。Cortex-M 内核复位后硬件固定从0x00000000读初始栈指针MSP从0x00000004读复位向量Reset_Handler 地址然后跳过去执行。由于大多数 MCU 在复位后都把0x00000000地址映射到主 Flash 起始位置所以如果 Bootloader 固件放在0x08000000Boot 就能正常启动。而 App 固件放在0x08008000或者更后面的偏移处CPU 根本不知道要去那里找 App 的向量表更不知道初始栈指针该用哪个。如果不做中断向量表重映射App 代码就算烧得再对复位后也永远轮不到它跑。这就引出了 IAP 跳转的核心动作在跳进 App 之前让 CPU 把中断向量表的源头从 Bootloader 区域切到 App 区域。这一行看起来就是向SCB-VTOR写一个地址但写早了、写晚了、写完了又被覆盖都会导致死机。后面几节我把这些坑一个一个拆开。2. 中断向量表重映射不是“改个地址”是骗过CPU的取指逻辑2.1 复位后CPU从哪里找栈顶和复位入口先把最底层的行为说透。Cortex-M 的异常向量表是一串 32 位字排列顺序固定第一个字是初始 MSP第二个字是复位向量后面依次是 NMI、HardFault、各外设中断向量。CPU 每次发生异常或中断都会从当前向量表的对应偏移处取出服务函数的地址然后切换现场执行。问题在于CPU 怎么知道“当前向量表”在哪里Cortex-M3/M4/M7 提供了一个寄存器SCB-VTORVector Table Offset Register它告诉内核向量表的基地址。复位时 VTOR 是 0于是 CPU 从地址 0 开始找向量。由于绝大多数 MCU 把 Flash 映射到地址 0实际取到的是 Flash 起始处的向量也就是 Bootloader 的向量表。当 App 放在 Flash 偏移0x8000处时想让 CPU 从0x08008000取向量就必须把SCB-VTOR写成0x08008000。这就是所谓“重映射”的本质不是移动向量表而是告诉 CPU 换个地方取向量。有一个细节容易被忽略SCB-VTOR的取值不是随意来它需要对照向量表大小对齐。比如你的 App 中断向量有 64 个一个向量 4 字节表大小就是 256 字节那么 App 的起始地址至少要 256 字节对齐。这个对齐边界一般是向量表大小的下一个 2 的幂。多数芯片的 Flash 扇区边界天然满足这个条件但如果你自己手动指定了一个非对齐偏移就有可能出现在某些中断下取向量错位的死法。2.2 VTOR的重定向范围与写时刻既然要写 VTOR什么时候写就非常关键。一种常见写法是在 Bootloader 跳转前写也就是“先改向量表再切栈再跳”。这种做法最为推荐因为它保证 App 的Reset_Handler一开始执行时CPU 的中断向量源就已经是 App 的表了。哪怕跳转瞬间来了一个中断硬件取到的也是 App 的向量而不是 Bootloader 的。另一种写法是跳进 App 之后在 App 的启动早期再写 VTOR。很多芯片的 SDK 会在SystemInit()里做这件事。这种写法不是不行但它留下了一个危险窗口从BX跳转到 App、到SystemInit()完成 VTOR 赋值之间如果发生了任何中断CPU 拿到的还是 Bootloader 的向量表。而这时的程序已经跑在 App 的Reset_Handler里了中断处理函数却跳到 Bootloader 的代码段两边的全局变量状态、时钟配置、外设初始化完全对不上结果大概率是 HardFault。所以我的习惯是跳转前在 Bootloader 里就完成 VTOR 重映射并且在写 VTOR 之前就把全局中断关掉。双保险不让中断有钻空子的机会。2.3 没有VTOR的老M0怎么活SRAM向量表复制的备用方案这里专门说一下国产 M0/M0 芯片比如 HC32L136 这类低功耗平台。Cortex-M0 的 VTOR 支持是个历史遗留问题。Cortex-M0/M0 在设计上并不像 M3/M4 那样强制实现 VTOR具体有没有要看芯片厂商。有的国产 M0 芯片手册里明确写了SCB-VTOR寄存器可用有的则根本没引出这个寄存器你要是照抄 M3 的跳转代码写了个寂寞中断照样从地址 0 取。对于没有 VTOR 的 M0经典做法是“向量表搬到 SRAM”。大致思路是把 App 的向量表在启动早期用memcpy从 Flash 复制到 SRAM 的起始地址然后配置芯片的系统存储器映射让地址 0 映射到 SRAM。这样 CPU 从地址 0 取向量实际取到的是 SRAM 里那份复制出来的 App 向量表中断向量就被“骗”到了 SRAM 上。这个方案有一个坑SRAM 里的向量表中每个向量仍然填的是 Flash 里 App 中断服务函数的实际地址。CPU 取出这些地址后照样跳回 Flash 执行中断代码。所以向量表本身放 SRAM 没问题但中断服务函数必须烧在 Flash 的 App 地址段。另外复制时机要尽量早最好在Reset_Handler最开始、C 运行时初始化.data之前完成否则一旦 SRAM 被初始化流程当成普通数据区覆盖向量表就毁了。判断你的芯片是否支持 VTOR最靠谱的办法是看芯片手册里的“内存映射与系统控制”章节或者翻 SDK 的core_cm0plus.h头文件里有没有定义VTOR字段。别指望代码在 M3 上能跑就直接移植到 M0 上这两类内核的差异不是换一个编译器就能抹平的。3. 重映射的绝对禁忌每条都是用死机换来的3.1 禁忌一开中断跳转旧向量表“接盘”了App的异常我最开始写 IAP 跳转时犯过的错就是在中断全开的情况下直接跑跳转函数。逻辑上是这样的Bootloader 正在跑后台可能开着串口接收中断、定时器中断或者外部中断。如果我保持全局中断使能然后执行SCB-VTOR APP_BASE; __set_MSP(app_sp); reset_handler();看起来 VTOR 已经切到 App 了中断来了也应该按 App 的向量表走。但问题是这一系列操作不是原子的SCB-VTOR写入后可能还没来得及生效总线写操作有延迟一个中断已经通过旧缓冲期进来了。更隐蔽的情况是在写 VTOR 之前某个中断就已经在 NVIC 里挂起了Pending只是优先级不够暂时没有抢占。等你切完 VTOR、切完 MSP、跳进 App 的Reset_Handler全局中断一旦打开或者当前异常退出挂起的中断立刻被响应。这时 CPU 会根据新的 VTOR 找到 App 的向量——这倒还好如果 App 那个中断的 ISR 还没做外设初始化进去就访问还没开启的外设寄存器同样会 HardFault。正确姿势很简单跳转前调用__disable_irq()把全局中断关掉并且在跳转完之前绝对不要打开。注意App 的Reset_Handler里如果调用了某些 SDK 的初始化函数可能会有隐式打开中断的操作这种地方要格外小心。很多官方 IAP Demo 的缺陷就在这里因为它们只在JumpToApp()里关了中断但 App 启动文件里的一段代码又把PRIMASK清了等于前功尽弃。3.2 禁忌二不切MSP直接调复位函数App的栈被Bootloader布局绑架Cortex-M 有一个硬件行为只有在复位或者异常入口CPU 才会自动从向量表第一个字装载初始 MSP。普通函数跳转不会触发这个装载过程。所以如果你写了reset_handler (void (*)(void))(*(uint32_t *)(APP_BASE 4)); reset_handler();却没有先__set_MSP(*(uint32_t *)APP_BASE)那么 App 的Reset_Handler会继续使用 Bootloader 的当前 MSP。表面上看Bootloader 的栈空间还活着App 早期运行也未必立刻崩。但仔细想想App 的链接脚本定义了属于自己的 RAM 布局__initial_sp通常指向 App RAM 段的顶端。Bootloader 的栈顶可能和 App 的 RAM 布局重叠也可能完全不在一块区域里。当 App 的 C 启动代码开始初始化.data、清零.bss后Bootloader 原来栈里残留的数据会被清掉而 MSP 可能还指向那片已经被初始化代码改写过的区域。或者在 App 后续运行中栈向下生长生长到某个地址把 App 的核心全局变量给踩了死机就变得随机而难缠。所以跳转前的固定操作必须是uint32_t app_sp *(volatile uint32_t *)APP_BASE; uint32_t app_pc *(volatile uint32_t *)(APP_BASE 4); __set_MSP(app_sp);先切栈指针再取复位地址执行。顺序不能反因为你调用__set_MSP用的当前栈还是 Bootloader 的但执行完这一句之后CPU 的栈就已经落在 App 的地址上。后续不再依赖 Bootloader 的任何栈帧。3.3 禁忌三启动文件里悄悄改VTOR覆盖掉你刚刚做好的重映射这是一个特别阴间的坑Bootloader 这边明明正确设置了SCB-VTOR APP_BASE跳转也顺利App 前几百条指令跑得也挺正常结果一旦某个中断发生CPU 依然跑到 Bootloader 的向量表取向量。查到最后发现问题出在 App 的SystemInit()或者启动文件上。很多芯片厂商提供的system_stm32f1xx.c、system_gd32f1xx.c这类文件里SystemInit()会向SCB-VTOR写值。有的默认写0x08000000有的写成(uint32_t)__VECTOR_TABLE。如果 App 工程沿用了默认配置那么 App 一启动就把 Bootloader 刚设置好的 VTOR 覆盖回了 Flash 首地址。中断一来CPU 按 Bootloader 的向量表执行跳到地址0x08000000附近的向量对应的 ISR跟 App 完全不是一回事直接 HardFault。解决方法是在 App 工程的SystemInit()里显式设置SCB-VTOR APP_BASE;或者检查启动文件里有没有通过__VECTOR_TABLE这类宏间接写 VTOR 的代码。这个问题很隐蔽因为死机现场发生在 App很多人会怀疑 App 代码却很少有人去翻 App 启动文件。我建议做 IAP 双区工程时把 App 的启动文件、SystemInit()、链接脚本的向量表地址全部统一成同一个宏至少不会自己人打自己人。3.4 禁忌四跳转前外设中断悬挂IRQ不请自来跳转前不仅要把全局中断关掉还要把外设本身的中断使能位清掉把 NVIC 里挂起的中断状态清干净。举个例子。Bootloader 跑的是串口升级USART 开了一堆中断RXNE、IDLE、DMA 半传输等等。假如下载完固件、校验通过后串口恰好收到最后一个字节RXNE 中断标志已经置起来了但还没来得及进中断处理函数你就复位跳转了。NVIC 里这个中断是 Pending 状态。跳到 App 后App 初始化自己的串口时一旦使能 USART 中断这条 Pending 中断立刻触发。如果 App 的中断向量表已经正确映射CPU 会找到 App 的USARTx_IRQHandler。但 App 的串口可能还处于初始化早期甚至外设时钟都没开利索ISR 里访问寄存器时就是一次总线错误HardFault 随即而来。正确的清理顺序是跳转前先逐个 Disable 外设中断再清外设中断标志再NVIC_ClearPendingIRQ最后关全局中断。很多简化的 IAP Demo 只做最后一步这在大多数情况下碰巧没事但不等价于正确。升级这种功能追求的是“任何时刻都不死机”不是“十次里九次不死机”。3.5 禁忌五Bootloader的标志位与App共用RAM软复位后状态错乱再聊一个跟热词完全对应的问题“IAP boot里面定义的变量复位后会怎样”有个很常见的 IAP 设计Bootloader 里定义一个标志变量g_iap_done下载完成后置 1复位后 Bootloader 启动时判断这个标志为 1 就跳转 App否则继续等待升级。很多人会遇到的现象是复位后这个标志丢了Bootloader 每次都当作没有升级包永远停在升级等待状态板子看起来也像是“死机”。要解释这个现象得把复位类型和链接段分开看。Cortex-M 发生软件复位或者看门狗复位时RAM 内容其实不会自动清零。但是 Bootloader 的 C 启动代码在复位后会执行.data拷贝和.bss清零如果g_iap_done被编译器放在了.bss段那它复位一启动就被清成 0之前置 1 的状态当然就没了。如果确实需要变量跨复位保留应该把它放到专门的.noinit段并告诉链接脚本不要初始化这块区域。更稳妥的方案是使用 RTC 备份寄存器、备份 SRAM 或者外部 EEPROM/Flash 来记录升级状态。因为这些存储区域不受 C 启动代码的.bss清零影响。另外一个更隐蔽的场景Bootloader 和 App 各自编译链接脚本里指定的 RAM 区域如果重叠App 启动时清零.bss可能会顺带把 Bootloader 留在 RAM 里的变量区域抹掉。这种“共用 RAM”的问题表面上看是标志位丢失实际上已经破坏了两段程序之间的通信约定。建议 Boot 和 App 的 RAM 布局在开发初期就统一规划尽量不要把关键标志放在交叉地带。4. 死机之后怎么查三步定位法与现场快速止血4.1 第一步从复位现场抓PC、LR、BFAR、异常寄存器排错第一步不是改代码重烧而是拿到死机现场的第一手证据。接上调试器复位到 HardFault 现场后重点看几个寄存器。PC 是当前挂掉的位置LR 告诉你从哪调进来的PSP/MSP 指向异常压栈的现场。对于 M3/M4 芯片还要读SCB-CFSR配置与故障状态寄存器它会把 HardFault 的具体原因拆成三类IACCVIOL说明取指访问非法DACCVIOL说明数据访问非法UNALIGNED说明非对齐访问INVSTATE说明在非 Thumb 状态下试图执行等等。如果 CFSR 里的某一位置 1故障类别基本就定性了。如果是总线错误SCB-BFAR会记下出错的数据地址。比如你在 App 的 ISR 里访问了一个没使能时钟的外设寄存器BFAR 给出的地址就能直接对应到外设寄存器地址再回头查这个外设是不是没初始化。大多数调试器在 HardFault 停住时都能直接看调用栈。如果调用栈显示HardFault_Handler的上一层是某个中断服务函数那说明问题不是发生在初始跳转而是发生在这个中断被触发之后方向完全不同。4.2 第二步核对烧录地址、链接脚本和VTOR的“三角关系”我帮人排查 IAP 死机时第一件事永远是先对三个地址Bootloader 里跳转代码使用的APP_BASE、App 链接脚本里的 Flash 起始地址、烧录器实际下载 App 的目标地址。这三个地方只要有一个不一致就必然出问题。比如 Bootloader 里写#define APP_BASE 0x08008000App 的链接脚本却把 Flash 起点设成0x08010000烧录器烧到0x08008000。结果跳转时读到的第二个向量字是0x08010000处的前 4 个字节——那根本不是 Reset 向量而是 Flash 里某个其他数据CPU 跳过去就是乱跑。还有更恶心的烧录工具和链接脚本都对但 App 的 Flash 下载地址因为用了“偏移下载”功能把整个 App 镜像烧到了0x08010000链接脚本却按0x08008000生成。烧进去以后复位向量确实在0x08008000处但内容是 0xFFFFFFFFCPU 取到0xFFFFFFFE类似的地址立刻 HardFault。所以排查的顺序是先打开 Map 文件看__VECTOR_TABLE的实际地址再比较 Bootloader 里的APP_BASE最后看烧录配置。这三个地址永远必须一致。4.3 第三步对照.lss反汇编确认第一步跳转没问题如果前面两步都对再往下就是验证“跳转执行的第一条指令到底是不是 App 的 Reset_Handler”。建议生成.lss反汇编文件在 App 的 Reset_Handler 入口打断点单步走几步。正常情况应该看到SystemInit被调用然后跳转__main或者__libc_init_array之类的库启动函数。如果断点根本没有停在你的 Reset_Handler而是停在一个奇怪的函数里大概率是 Reset 向量取错了。还有一个非常典型的细节Cortex-M 的 Thumb 指令要求跳转地址最低位为 1。如果从向量表第二个字读出来的app_pc最低位是 0那么跳转时内核会把它当作 ARM 模式执行然后立刻触发INVSTATE错误。检查方法很简单跳转前加一句if ((app_pc 1u) 0u) return;至少不会让整机直接变砖。4.4 提高复现率的旁路看门狗与掉电复位的区别调试 IAP 死机时有一个很烦的现象接上调试器一切都正常拔掉调试器一上电就死。这往往是因为调试器在复位和停顿时把 CPU 的状态“修正”了掩盖了原来的现场。另一个影响排查的干扰项是独立看门狗 IWDG。如果 Bootloader 或者 App 里开了 IWDG而你在断点处停留时间过长看门狗超时会把芯片复位掉你看到的已经不是故障现场了。所以调试这类问题时我一般先临时关掉 IWDG或者把喂狗逻辑放得非常靠前。如果故障只在掉电瞬间或者上电瞬间复现现场不好抓可以考虑在 Bootloader 和 App 之间用一个非易失的“诊断标记”在关键步骤先后写入不同的值比如复位后在某个位置读取这个标记就知道死机发生在哪一步之前。RTC 备份寄存器是很好的载体它不受软件复位影响也不占 Flash 寿命。5. 实测可用的标准跳转模板以及我被GD32/HC32坑过的细节5.1 一个兼容Cortex-M3/M4/M0的跳转函数把前面所有禁忌都堵上之后我长期使用的跳转模板长这样。它可以直接抄进 Bootloader 工程前提是你要把APP_START_ADDR、DeInitAllPeripherals()、SetSysClockToDefault()替换成你自己的实现。#define APP_START_ADDR 0x08008000u #define RAM_TOP_ADDR 0x20010000u /* 按芯片实际RAM上限填写 */ typedef void (*AppResetHandler)(void); static void DeInitAllPeripherals(void) { /* 逐个关闭Boot中打开的外设串口、定时器、DMA、ADC等 */ /* 建议直接调用厂商库的XXX_DeInit()把寄存器恢复默认值 */ } static void SetSysClockToDefault(void) { /* 如果Boot已经切到外部晶振PLL先切回内部HSI 避免App以不同时钟配置启动时Flash等待周期不匹配 */ } void IAP_JumpToApp(void) { uint32_t app_sp; uint32_t app_pc; AppResetHandler reset_handler; __disable_irq(); app_sp *(volatile uint32_t *)APP_START_ADDR; app_pc *(volatile uint32_t *)(APP_START_ADDR 4u); /* 合法性检查栈顶不能是0/0xFFFFFFFFPC最低位必须是1(Thumb) */ if ((app_sp 0u) || (app_sp 0xFFFFFFFFu) || (app_sp RAM_TOP_ADDR) || ((app_pc 1u) 0u)) { __enable_irq(); return; } SetSysClockToDefault(); DeInitAllPeripherals(); /* 清NVIC里残留的Pending中断 */ /* 标准CMSIS没有直接DisableAll的函数要遍历或按需逐个清 */ NVIC-ICER[0] 0xFFFFFFFFu; NVIC-ICPR[0] 0xFFFFFFFFu; /* 关键顺序VTOR重映射 - 数据同步 - 切MSP - 跳转 */ SCB-VTOR APP_START_ADDR; __DMB(); __set_MSP(app_sp); reset_handler (AppResetHandler)(app_pc); reset_handler(); /* 如果函数返回说明跳转失败恢复中断以便处理 */ __enable_irq(); }注意这里我没有在跳转后调用__enable_irq()。App 的启动流程会在合适的时候自己开中断。如果你在跳转前就开了前面说的中断窗口问题就会重新出现。5.2 GD32F103上Flash等待周期和Cache的另类死机GD32F103 是我用得比较多的国产替代型号Cortex-M3 内核有 VTOR理论上按 STM32 那套写就行。但我在实际项目里踩过一个跟 Flash 等待周期有关的坑。如果 Bootloader 把系统主频干到 108MHz却没有把 Flash 等待周期配置到合适的值跳转进 App 后虽然代码和向量表都正确但 Flash 读取时序跟不上 CPU 频率会导致随机性 HardFault。这种故障在调试器下特别难复现因为调试器连接可能改变了总线状态。处理办法是在SetSysClockToDefault()里先把时钟切回内部 HSI并在 App 启动阶段再做一次时钟初始化、正确配置 Flash 等待周期等 Flash 控制器状态稳定后再倍频。另外 GD32 的 Flash 擦写操作期间如果 App 的某个中断正好赶上写 Flash 周期某些型号可能出现总线 stall处理不当也会表现为死机。IAP 跳转前确保没有后台 Flash 写入或者 DMA 传输正在进行这一点对所有自带 Flash 的 MCU 都适用。5.3 HC32L136这类低功耗M0的向量重映射注意点HC32L136 是典型的国产低功耗 M0 平台做 IAP 时要特别核对两个问题。第一是 VTOR 是否真的存在。HC32L136 这类芯片用 M0 内核但厂商是否把SCB-VTOR寄存器实现出来完全看芯片设计。建议直接翻开 SDK 中core_cm0plus.h里找VTOR字段定义同时看芯片参考手册里SCB寄存器表。如果实际没有 VTOR就只能走 2.3 节说的 SRAM 向量表方案先把向量表复制到 SRAM 起始地址再设置系统内存映射让地址 0 指向 SRAM。第二是低功耗外设的残留状态。HC32L136 这类芯片上低功耗定时器、RTC、外部唤醒引脚往往是常年开着的。如果 Bootloader 或者上一轮 App 已经设好了某个唤醒中断跳转之前没有把它关掉、清掉 NFC 标志App 一旦进入低功耗模式或者初始化这些外设时挂起的中断会像幽灵一样冒出来触发一个还没准备好的 ISR然后 HardFault。跳转前对这些低功耗外设做一次彻底清理比什么逻辑判断都重要。这类低功耗芯片还有一个特点部分型号支持按 SRAM 分区掉电保存数据。如果当前 SRAM 某段区域处于断电状态而 App 启动早期恰好访问了那一段地址就会产生总线错误。跳转前最好打印或者检查一下电源控制寄存器的状态保证 App 用到的 RAM 区域已经被正常供电。最后说一个我个人的小习惯跳转函数里一定要做 App 镜像合法性检查也就是判断第一个字是否是合理的 RAM 地址、第二个字最低位是否为 1。看似多余实际能挡掉大量因为上位机误配下载偏移、Flash 擦写没擦干净的返修问题。有这层保护即使升级包内容有问题Bootloader 也还能活着维修端通过 Boot 重新烧写就能救回来远比让整机死在 HardFault 里省心。