STM32F103 Bootloader跳转与向量表重映射实战指南

发布时间:2026/9/9 6:48:48
STM32F103 Bootloader跳转与向量表重映射实战指南 1. 项目概述做嵌入式开发的朋友早晚都会碰到Bootloader这个词。尤其是做STM32F103相关项目的时候在线升级OTA/IAP几乎绕不开Bootloader跳转和向量表重映射这两个问题。网上讲这部分的帖子不少但多数要么只给一段代码让你抄要么只讲寄存器怎么写很少有把背后的原理和实际工程里的坑一次讲透的。最近我刚把手上一个基于STM32F103C8T6的Bootloader方案从设计到落地完整走了一遍借着这个机会把启动过程、跳转逻辑、向量表重映射这些内容重新梳理了一遍整理成这篇笔记。这个内容适合谁看呢如果你正准备给自己的STM32F103项目加IAP功能或者你已经写好了Bootloader但跳转APP时总是莫名其妙地死机、跑飞、中断不响应那这篇文章应该能帮上忙。我会从最基础的启动流程讲起一直讲到跳转代码的每一个细节再到向量表重映射的两种实现方式最后把我实际调试中踩过的坑整理成排查清单。内容不涉及复杂的数学推导但都是实打实的工程经验。先说清楚本文的适用范围以STM32F103系列为例代码基于标准外设库或HAL库均可复现Flash起始地址0x08000000SRAM起始地址0x20000000向量表默认大小192字节48个中断向量 x 4字节。这些基础参数后面的分析全部会用到。2. 启动过程拆解从复位到main()之间发生了什么2.1 硬件复位后的第一条指令从哪里来很多初学者习惯把main()当成程序的起点实际上在main()被执行之前芯片已经做了大量初始化工作。对于STM32F103来说上电复位后内核从0x08000000地址开始取指。注意这里取的不是第一条指令而是初始栈指针MSP的值紧接着从0x08000004取复位中断向量也就是Reset_Handler的入口地址然后跳过去执行。刚接触这块的时候我也犯过迷糊既然Flash的起始地址是0x08000000为什么数据手册里又说“从0x00000000启动”其实STM32F103的0x00000000是一个可配置的别名区通过BOOT0/BOOT1引脚可以选择映射到主Flash、系统存储器内置Bootloader还是SRAM。默认从主Flash启动时0x00000000和0x08000000在物理上是同一个区域访问0x00000000等效于访问0x08000000。所以无论你看到的地址是0x08000000还是0x00000000只要BOOT引脚配置为从主Flash启动实际执行的代码都在0x08000000。这个机制对理解Bootloader跳转非常重要因为跳转的核心就是人为改变“复位后取向量”的过程。在Bootloader中跳转到APP本质上是我们用软件模拟了一次“软复位”——只不过复位后的入口地址从Bootloader所在的区域变成了APP所在的区域。2.2 Flash分区与内存布局一个典型的STM32F103 Bootloader方案会把Flash分成两个区域Bootloader区放在低地址段比如0x08000000 ~ 0x08003FFF16KBAPP区放在高地址段比如0x08004000之后。这样划分的好处很多最关键的是Bootloader和APP可以独立编译、独立烧录、互不干扰。哪怕APP刷坏了Bootloader还在设备还能通过Bootloader重新烧录。编译的时候要注意Bootloader工程和APP工程的Flash起始地址必须各自设置为自己的区域起始地址否则就算跳转逻辑写对了APP的中断向量表还是编译在0x08000000跳过去必然出问题。我之前见过一个项目Bootloader跳转代码写得没问题但APP工程里的Linker设置忘了改结果APP的中断向量表还是指向0x08000000跳转后所有中断全部跑飞排查了很久才定位到是链接脚本的问题。Flash布局的地址计算也值得留意。以STM32F103C8T6为例它有64KB Flash如果你计划Bootloader占16KBAPP区域就是0x08004000 ~ 0x0800FFFF总共48KB。APP区再往下可以留一部分空间做参数存储比如存储升级标志、版本号等这属于进阶玩法本文不展开但规划Flash时一定要预留好位置。2.3 SCB-VTOR寄存器与向量表重映射的第一次接触在标准的启动流程中从Flash启动时内核直接访问0x08000000处的向量表并不需要软件去设置VTOR。这是因为此时“向量表基地址 Flash基地址 0x08000000”硬件默认行为已经满足需求。但是Bootloader跳转APP后情况就变了。APP的向量表在0x08004000假设APP偏移量为0x4000内核却仍然从0x08000000取向量这显然不对。此时就需要让内核知道新的向量表地址这就是向量表重映射Vector Table Relocation要做的事。STM32F103没有像Cortex-M3后续版本那样把SCB-VTOR寄存器开放给所有系列所以早期STM32F1的工程师们只能靠“编译器重定向”的方式做变通。但后来实际测试证实STM32F103的VTOR寄存器是可以使用的只是官方文档里没有强调。这也是很多老教程和最新实践不一致的地方。我个人的建议是如果你的项目用的是较新的库或HAL库优先用SCB-VTOR 偏移量 这种写法如果用的是非常古老的固件库或自制工程不想冒险可以用自定义宏的方式做重映射。两种方法本文都会讲清楚。3. 向量表重映射的两种实现方式3.1 修改VTOR寄存器实现动态重映射先看最推荐的方案。在APP代码的启动早期加入一行#define APP_ADDRESS 0x08004000 SCB-VTOR APP_ADDRESS;这行代码的作用是把Cortex-M3内核的向量表基地址从默认的0x08000000强制指向0x08004000。之后任意中断触发时内核都会从0x08004000处读取对应的中断服务函数入口地址。但光有这一行还不够。你必须保证这行代码在APP代码里且必须位于任何中断可能被触发之前执行。最安全的位置是放在main()函数的第一行甚至在进入main()之前通过修改启动文件也能提前设置。不过更稳妥的方式是把它放在APP的SystemInit()或启动文件里因为从跳转完成到main()执行之间系统时钟初始化等过程也可能产生中断请求。我之前调试的一个项目把SCB-VTOR APP_ADDRESS 放在了main()函数的第5行——前4行有个调试用的GPIO初始化和延时结果跳转后第一次按下按键串口中断就进入了HardFault折腾了大半天才意识到是中断在重映射之前就被触发了。后来把重映射挪到main()第一行问题立刻消失。还有一个细节VTOR寄存器要求偏移量按64字节对齐。这意味着APP的起始地址必须是64的整数倍。0x08004000显然满足但如果你把APP放在0x08004100这种奇怪的地址VTOR设置就会失效中断向量表会错乱。规划Flash分区时务必注意这一点通常选择0x08004000、0x08008000、0x0800C000这些规律地址即可。3.2 不依赖VTOR的编译期映射方案再说说老方案。在STM32F103刚发布那几年许多工程师直接操作VTOR的方式还没被验证大家习惯的做法是用编译宏把向量表重新定位。具体来说在工程配置的预处理宏中加入VECT_TAB_OFFSET 0x4000然后在系统初始化文件system_stm32f10x.c里原本的向量表定义会被自动替换为带偏移量的形式。这个方案的原理是在编译阶段就把整个向量表放在链接脚本指定的偏移位置不依赖任何运行时寄存器设置。这个方式的优点是兼容性极强哪怕你的库非常老只要支持VECT_TAB_OFFSET宏就能正常工作。缺点是每次调整APP起始地址都要重新修改宏定义并重新编译灵活性不如VTOR方案。另外如果Bootloader和APP的启动代码都编译成了包含VECT_TAB_OFFSET的版本调试时要注意两个工程中这个宏的值必须一致否则会出现跳转后中断向量错位。从实践角度讲我建议新项目直接使用VTOR方案凡是支持直接写SCB-VTOR的库和芯片都应该优先选这种方式。至于老项目改造如果不想动原来的底层文件使用VECT_TAB_OFFSET宏也不算糟糕毕竟代码能跑就是硬道理。3.3 特殊场景“在RAM中运行”的重映射处理有一种特殊情况需要额外注意如果你把APP代码拷贝到RAM中运行比如为了速度或掉电保护的某些特殊需求向量表也必须跟着搬到RAM里。此时除了设置VTOR指向RAM中的向量表地址例如0x20000000 offset还要确保这个地址在RAM的有效范围内。STM32F103C8T6只有20KB SRAM地址范围0x20000000 ~ 0x20004FFF如果你的向量表偏移加上整个代码段超出了这个范围那就麻烦了。这种场景比较少见日常的Flash IAP基本用不到但我在做某些低功耗产品的RAM调试时遇到过。简单提一下如果要在RAM中运行先要把向量表拷贝到RAM再用DMA或memcpy复制代码段最后设置VTOR指向RAM中的向量表再跳转。这里面每一步都容易出错建议非必要不要用这种方式先从Flash直跳开始学。4. Bootloader跳转APP的完整实操4.1 跳转代码逐行解析接下来直接上干货。这是我实际项目中使用的跳转函数已经过反复验证你几乎可以直接抄走typedef void (*pFunction)(void); #define APP_ADDRESS 0x08004000 void JumpToApp(void) { uint32_t app_stack_top *(volatile uint32_t *)APP_ADDRESS; if (((app_stack_top 0x2FFE0000) ! 0x20000000)) { return; // 栈顶地址不合法说明Flash中不存在有效APP } pFunction app_entry (pFunction)(*(volatile uint32_t *)(APP_ADDRESS 4)); __disable_irq(); SCB-VTOR APP_ADDRESS; __set_MSP(app_stack_top); app_entry(); }这段代码的逻辑分四步第一步从APP起始地址取出栈顶值第二步校验这个值是否落在SRAM范围内0x20000000 ~ 0x20004FFF从而判断APP是否存在、是否有效第三步从APP起始地址4取出复位中断向量即APP的Reset_Handler入口地址第四步调用这些地址完成跳转。注意第三步赋值给app_entry的是“复位中断向量”不是main()的地址。跳转到Reset_Handler后APP里由启动文件引导的初始化流程会完整执行一遍然后才进入main()。这相当于让APP经历一次完整的“上电启动”只不过起始地址被我们人为改到了0x08004000。4.2 为什么跳转前要关闭全局中断代码中有一个极其关键的操作__disable_irq()。这行代码很多人不理解甚至觉得多余。但在实际工程里跳转前不关闭中断导致跳转后程序异常是我见过发生率最高的故障之一。原因在于Bootloader运行期间可能已经使能了一些中断比如定时器中断、串口中断、看门狗等或者中断标志位已经挂起。跳转后APP尚未完成自身的初始化比如NVIC配置还没设置外设还没重新初始化此时如果残留的中断被触发内核会尝试从VTOR指向的新向量表里取中断向量。但APP的向量表虽然有了APP的中断处理函数依赖的外设状态、全局变量等都还是“Bootloader时代”的残留状态跳进去大概率死机。更麻烦的是如果跳转前没有关闭中断某些中断在跳转的瞬间刚好被触发比如串口收到最后一个字节ARM内核会先响应中断再执行跳转而中断响应期间设置了新的MSP栈指针被重新赋值中断现场压栈的位置可能就不对了直接HardFault。所以正确的顺序是先__disable_irq()再设置VTOR再设置MSP最后跳转。有的工程师还会在跳转前把SysTick中断关掉因为SysTick如果在跳转后周期触发而APP的SysTick_Handler还没准备好同样会出问题。我的做法是在Bootloader的main()最开始就关闭所有不必要的外设时钟并加上__disable_irq()双保险。4.3 栈顶地址校验防止跳转到空白Flash代码里的栈顶地址校验那一行是我强烈建议保留的。原因很简单如果Bootloader判断APP区存在但实际Flash里什么都没有或者升级过程中断电只写了一半这时候直接跳转内核会把0xFFFFFFFF当成栈顶把0xFFFFFFFF当成PC跑飞几乎是必然的。加了栈顶校验后如果读出来的值不在SRAM范围内函数直接returnBootloader可以继续执行自己的逻辑比如等待重新升级而不是死机。这个校验的思路可以推广如果你在APP区前4字节存储了一个魔数Magic Number校验逻辑可以更精确。比如定义#define APP_FLAG_ADDR 0x08004000 #define APP_MAGIC 0xDEADBEEF if (*(volatile uint32_t *)APP_FLAG_ADDR ! APP_MAGIC) { // 无有效APP进入升级模式 }这种方式比单纯校验栈顶地址更灵活因为你可以把魔数、APP版本、校验和等额外信息都放在APP头部。但要注意使用魔数方案时APP工程的链接脚本也要同步调整把魔数放在向量表之前或单独定义段实现起来略复杂建议第一次做Bootloader的朋友先用栈顶地址校验跑通流程后再考虑魔数方案。4.4 跳转后CPU处于什么状态跳转到app_entry()之后所有PC、LR、SP等寄存器都是“Bootloader最后的状态”并没有自动变干净。好在我们跳的是Reset_Handler它会由启动文件重新初始化SP、调用SystemInit()、清零BSS段、设置C库环境最终进入main()。所以跳转后不需要担心寄存器残留问题APP自己的启动流程会做清洗。这里有一个需要留意的点跳转函数本身是通过函数指针调用的app_entry()执行时LR寄存器里是Bootloader的返回地址而不是APP的。这意味着如果APP的Reset_Handler里出了问题想返回到Bootloader是回不来的。正常情况下APP的启动文件会在Reset_Handler里正确设置栈和PC不需要依赖LR。但如果你调试时发现APP跑飞后栈回溯信息完全错乱多半是LR残留值导致的这不影响正常跳转但排查问题时别在这上面浪费时间。4.5 在Bootloader中为跳转预留条件很多项目并不需要上电就立刻跳转而是先进入Bootloader等待升级指令比如串口命令、按键、升级标志位再决定是否跳转APP。这种情况下跳转函数不会被无条件调用而是被放在主循环里根据条件触发。我习惯在Bootloader的main()里写成这样int main(void) { SystemInit(); GPIO_Config(); USART_Config(); if (CheckUpgradeRequest()) { ProcessUpgrade(); // 执行接收固件、擦写Flash等操作 } if (CheckValidApp()) { JumpToApp(); } while (1) { // 等待超时或继续接收升级数据 } }这样设计的好处是上电后Bootloader先检查有没有升级请求有就处理升级没有就跳转APP。如果没有有效APP则停留在Bootloader等待升级。这种流程在工业设备里非常常见也是IAP功能的基本盘。5. 跳转后常见问题与排查技巧实录5.1 跳了但完全没反应APP没执行这是最典型的问题。排查思路先看栈顶地址校验是否通过再看跳转地址是否指向了正确的Reset_Handler。我建议在跳转函数里加调试手段例如在跳转前通过串口打印app_stack_top和app_entry的值printf(stack_top: 0x%08X\r\n, app_stack_top); printf(entry: 0x%08X\r\n, (uint32_t)app_entry);正常情况app_stack_top应该在0x20000000~0x20004FFF之间app_entry应该等于APP起始地址4处的值即0x08004004。如果你发现app_stack_top等于0xFFFFFFFF说明APP区是空的如果app_entry等于0x08004005奇数说明APP程序编译时没有设置正确的地址偏移。另一个常见原因是Keil/IAR工程的烧录算法问题。用J-Link或ST-Link下载APP时如果烧录算法配置不对APP实际上并没有被写进0x08004000而是被写到了0x08000000把Bootloader覆盖了。这时跳转必然失败。解决办法是检查下载配置里的Flash起始地址和大小。5.2 跳转后中断不响应或进HardFault这类问题绝大多数指向向量表重映射没做对。快速判断方法是在APP的main()开头先读取一下SCB-VTOR的值用调试器看确认是不是0x08004000。如果不是说明重映射代码没有执行或者被放在了中断可能触发之后。另外一个非常隐蔽的坑是编译器的启动文件与HAL库的SystemInit()冲突。有些启动文件在调用main()之前会自己设置VTOR或者在SystemInit()里写死了0x08000000。如果你在main()第一行设置VTOR但早在此之前比如SystemInit()阶段某个中断已经被触发问题就发生了。解决方法是把VTOR设置提前到SystemInit()里或者直接修改启动文件中Reset_Handler的一小段汇编在进入C环境前完成重映射。这个方案对需要极早开启中断的场景非常有效。5.3 跳转后跑一段时间才死机如果是“跳转后能跑几秒然后突然HardFault”通常和外设中断有关。Bootloader里打开过的外设跳转后没有完全关闭或者APP里初始化同一外设时NVIC配置与Bootloader残留状态冲突。例如Bootloader的串口中断和APP的串口中断使用相同的IRQ通道Bootloader中断回调函数还在作用域内但外设状态已经变了中断到来时执行了错误代码。排查方法在跳转前把所有使能的中断全部失能并把NVIC的挂起标志清空NVIC-ICER[0] 0xFFFFFFFF; NVIC-ICPR[0] 0xFFFFFFFF;这两行代码把第一组中断的使能清除和挂起清除全部执行。注意如果使用的中断编号超过32还需要处理ICER[1]和ICPR[1]。这个操作建议作为跳转流程的标配。5.4 跳转后外设配置正确但串口数据乱码有时跳转本身成功了但APP的串口输出乱码。这个通常不是跳转问题而是波特率配置或时钟初始化不一致。隐患在于Bootloader和APP若各自独立配置时钟跳转后APP的SystemInit()会重新配置系统时钟如果倍频系数和外设时钟分频与Bootloader不一致跳转瞬间外设会经历一次“时钟抖动”。如果APP在时钟稳定前就访问外设数据出错。处理方式在Bootloader跳转前不依赖复杂外设或者跳转后给APP留一个短暂的延时等待时钟稳定。我个人的习惯是APP的SystemInit()里设置固定的72MHz主频F103最高主频Bootloader同样固定72MHz减少因时钟切换带来的问题。5.5 常见问题速查表现象可能原因解决思路跳转后无反应LED不亮APP区为空栈顶校验失败检查Flash有无数据打印调试信息跳转后全速跑飞VTOR未设置APP链接地址错误确认SCB-VTOR值检查工程偏移跳转后中断不响应重映射位置太晚NVIC配置冲突重映射提前到main()第一行或SystemInit()运行几秒后HardFaultBootloader中断残留外设时钟冲突清零NVIC统一系统时钟配置串口乱码时钟配置不一致波特率偏移统一Bootloader和APP的时钟配置下载后Bootloader被覆盖烧录算法配置错误检查下载算法地址范围无法跳转但单步调试正常跳转前中断干扰跳转前__disable_irq()6. 实操心得与避坑清单最后把这些经验浓缩一下。第一个坑是调试Bootloader跳转时一定要保留好调试手段。我通常会在Bootloader里留下一段串口打印专门输出跳转参数和状态信息。调试完后再把打印关掉但保留代码逻辑这样量产阶段出了问题也能快速通过临时开启打印来定位。第二个坑是跳转前务必检查看门狗IWDG/WWDG。如果在Bootloader里开启了独立看门狗跳转前忘记喂狗或关闭APP运行后看门狗超时会让芯片复位表现就是“跳转成功但很快重启”。这个坑很容易被忽略因为看门狗往往在主流程的某个角落里开启排查半天都找不到。建议Bootloader里尽量不要开看门狗或者跳转前关闭看门狗并等待它超时复位让APP从干净状态启动。第三个坑是关于0x08000000和0x00000000的混淆。做跳转时有人直接用0x00000000作为APP的起始地址这在BOOT引脚从主Flash启动时碰巧能工作但一旦BOOT引脚配置改变就会失败。规范做法是明确使用0x08000000作为Flash基地址。再说下map文件的用法。调试跳转不成功时打开APP工程的map文件搜索Reset_Handler、SystemInit、main这几个符号的地址确认它们都落在0x08004000之后。如果map文件里显示Reset_Handler在0x08000000附近说明APP编译时没有把偏移量加进去重新检查链接脚本和编译选项。如果你想进一步验证跳转是否真的执行了APP代码最直接的办法是在APP的main()第一行加一个GPIO翻转或者串口打印。如果跳转后能看到说明代码确实运行到了APP。如果看不到再看是APP根本没运行还是运行了但初始化卡住了。这种方法比一上来就怀疑跳转代码更高效。我在实际项目中还遇到过一种比较少见的情况升级过程中突然掉电重新上电后Bootloader检测到APP区的栈顶地址校验通过因为前4字节碰巧是合法的SRAM地址但后面的代码已经被写坏了导致跳转后随机死机。这种问题不能用单纯的栈顶校验解决必须在升级完成后写入一个“升级完成标志”Bootloader跳转前检查这个标志而不是只检查栈顶地址。更健壮的方案是做全量CRC校验但考虑到F103的资源有限很多产品用升级标志长度校验折中。建议产品化阶段就考虑这个场景。关于向量表重映射还有一个冷知识值得提一下如果APP代码里使用了RTOS比如FreeRTOS并且从Bootloader跳转过来RTOS的启动流程会影响你对中断的预期。FreeRTOS会接管PendSV和SysTick如果跳转时中断开启的状态不对任务调度可能马上失败。所以用RTOS的APP跳转前的关中断操作尤其重要建议跳转前确认所有优先级分组设置已经复位为默认即组44位抢占优先级。我个人的体会是Bootloader跳转和向量表重映射这两个概念单独看都不复杂但合在一起后因为涉及硬件复位行为、软件跳转、外设状态、编译链接设置等多个层面任何一个环节出问题都可能导致奇奇怪怪的故障。在做这类开发时最重要的不是背代码而是建立一套“从复位到APP运行”的完整心智模型。上面这些排查经验都是从这种理解模型里推出来的。你自己做一遍、踩几个坑再把这篇笔记里的细节对照一遍以后再做类似的IAP项目就会顺畅很多。