Bootloader跳转APP代码流程
199.2.7 Bootloader跳转APP代码流程
#define INTER_FLASH_APP_ADDR (0x8008000U) /* inter_flashif.h:APP 向量表起始地址,等价于 0x08008000。 */
#define AC5_MODE 1 /* boot_manager.c:课程工程使用 Arm Compiler 5 对应的声明分支。 */
typedef void (*APP_FUNC)(void); /* 定义函数指针类型:无参数、无返回值,供调用 APP 复位入口。 */
#define CLOSE_ALL_INT() __set_PRIMASK(1) /* 把 PRIMASK 置 1,屏蔽所有可配置优先级的异常和中断。 */
uint8_t boot_check_stack2jump_app(uint32_t addr) /* 检查 addr 处的初始 MSP,并尝试跳到该映像。 */
{ /* APP 检查与跳转函数开始。 */
#if (AC5_MODE == 1) /* 当前课程工程令 AC5_MODE 为 1。 */
APP_FUNC jump2app; /* AC5 分支:定义保存 APP 复位入口的局部函数指针。 */
#else /* 非 AC5 分支开始。 */
static APP_FUNC jump2app; /* 其他编译器分支:使用静态函数指针;保留课程源码。 */
#endif /* 结束编译器相关的条件编译。 */
if (((*(uint32_t *)addr) & 0x2FFE0000) == 0x20000000) /* 读取向量表首字,并粗略检查它是否像 SRAM 地址。 */
{ /* 初始 MSP 通过课程掩码检查。 */
jump2app = (APP_FUNC) * (volatile uint32_t *)(addr + 4); /* 读取向量表第 2 个字并转成函数指针。 */
__set_MSP(*(volatile uint32_t *)addr); /* 把向量表首字写入主栈指针 MSP。 */
CLOSE_ALL_INT(); /* 跳转前令 PRIMASK=1,暂时屏蔽可配置优先级的中断。 */
jump2app(); /* 间接调用 APP 的 Reset_Handler,开始执行 APP 启动代码。 */
printf("Bootloader end load app\r\n"); /* APP 复位入口若意外返回,才可能执行到这条日志。 */
} /* 合法初始 MSP 分支结束。 */
else /* 向量表首字未通过课程掩码检查。 */
{ /* APP 无效提示分支开始。 */
printf("APP Not Found!\r\n"); /* 提示指定地址处没有通过当前检查的 APP。 */
} /* APP 无效提示分支结束。 */
return 0; /* 课程函数固定返回 0;成功跳转时通常不会返回这里。 */
} /* APP 检查与跳转函数结束。 */
boot_check_stack2jump_app(INTER_FLASH_APP_ADDR); /* main.c:ota_flag=0 时跳转到已经存在的旧 APP。 */
boot_check_stack2jump_app(INTER_FLASH_APP_ADDR); /* main.c:YMODEM 接收成功后跳转到刚写入的新 APP。 */
__set_PRIMASK(0); /* APP 的 main.c:清除 PRIMASK,使可配置优先级中断可以响应。 */
本课集中解释 08Ymodem_Bootloader 中的 boot_check_stack2jump_app():Bootloader 如何从 APP 向量表取得初始主栈指针和复位入口,再把控制权交给位于 0x08008000的 APP。
这里要先建立一个准确概念:Bootloader 调用 APP 的 Reset_Handler是一次软件跳转,不是芯片发生了新的硬件复位。 APP 会重新经历自己的启动文件、SystemInit()、C 运行库初始化和 main(),但 Bootloader 已经配置的外设、时钟、NVIC 使能/挂起状态并不会因为这一跳自动恢复到上电默认值。
一、Bootloader跳转APP流程
Bootloader 初始化参数区后读取 Flash 中的升级标志:
读取并校验 0x08006000 参数区
│
├─ ota_flag = 0
│ └─ 检查 0x08008000 处现有 APP → 跳转旧 APP
│
├─ ota_flag = 1
│ └─ YMODEM 接收 APP.bin → 清 ota_flag
│ → 检查并跳转新 APP
│
└─ 参数读取失败
└─ 前面的初始化或后续错误路径处理
main.c第 114 行和第 143 行在课程工程中都是:
boot_check_stack2jump_app(INTER_FLASH_APP_ADDR); /* 两条路径都把 0x08008000 作为 APP 向量表起点。 */
两次调用的跳转机制完全相同,差别只在调用时机:
- 第一次调用发生在
ota_flag == 0时,目标是芯片中原来已有的 APP; - 第二次调用发生在
Ymodem_Receive()返回COM_OK、升级标志被清零之后,目标是刚写入的新 APP。
INTER_FLASH_APP_ADDR在配套源码中写成 0x8008000U,它与补齐前导零的 0x08008000U数值相同。原转录中的“八两个零八三个零”等内容都是地址口述识别错误。
跳转函数实际完成四步:
- 读取
addr + 0处的 32 位值,粗略判断初始 MSP 是否像 SRAM 地址; - 读取
addr + 4处的 32 位值,把它解释为 APP 的Reset_Handler函数指针; - 用第一个值重设 MSP,并用 PRIMASK 暂时屏蔽可配置优先级中断;
- 调用函数指针,进入 APP 的启动流程。
1.1 代码讲解
1.1.1 addr与两次解引用
调用时 addr = INTER_FLASH_APP_ADDR = 0x08008000。因为 addr的类型是整数 uint32_t,表达式 addr + 4表示地址增加 4 字节。
| 表达式 | 实际读取位置 | 读取内容 |
|---|---|---|
*(uint32_t *)addr | 0x08008000 | APP 向量表第 1 项:初始 MSP |
*(volatile uint32_t *)(addr + 4) | 0x08008004 | APP 向量表第 2 项:Reset_Handler入口 |
分解第一个表达式:
(uint32_t *)addr:把数值地址强制转换为“指向 32 位无符号整数的指针”;- 前面的
*:对指针解引用,真正读取该地址保存的 4 字节; - 读出的不是“APP 地址本身”,而是 APP 链接器写在向量表首项中的 初始主栈指针值。
第二个表达式多了 volatile,表示每次都要实际访问该地址,不能把读取随意替换成先前缓存的值。读出的 32 位整数再强转为 APP_FUNC,于是 jump2app()就能通过函数指针间接调用这个入口。
1.1.2 函数指针不是指针函数
课程声明为:
typedef void (*APP_FUNC)(void); /* APP_FUNC 表示“指向无参数、无返回值函数的指针”类型。 */
括号非常关键:
void (*p)(void):p是函数指针,保存函数入口地址;void *f(void):f是一个函数,这个函数返回void *指针,属于指针函数。
本课使用的是前者。jump2app = (APP_FUNC)...只是把向量表里的入口值保存为可调用的函数指针;执行 jump2app()时,编译器才生成间接分支指令,让处理器开始从 APP 的 Reset_Handler取指。
1.1.3 初始 MSP 掩码检查
课程源码的判断为:
if (((*(uint32_t *)addr) & 0x2FFE0000) == 0x20000000) /* 用掩码粗筛向量表首字的地址特征。 */
其意图是:正常 Cortex-M APP 的第一个向量应是 SRAM 中的初始栈顶,STM32F103 的 SRAM 区域以 0x20000000开头;如果 Flash 被擦除,首字通常为 0xFFFFFFFF,这个判断不会通过。
但它不是“APP 文件一定正确”的完整证明:
- 该掩码没有精确检查本芯片 64 KiB SRAM 的上下界;
- 没有检查
addr + 4中的复位入口是否位于 APP Flash 区; - 没有检查复位入口最低位是否为 1。Cortex-M3 异常向量最低位应为 1,用来表示 Thumb 状态;
- 没有校验固件长度、签名、CRC 或其他向量。
因此,把函数命名理解为“检查栈顶特征后尝试跳转”最准确。输出 APP Not Found!也只代表首字没有通过这一个掩码,并不能区分“没有文件”和其他向量表损坏。
1.1.4 APP_FUNC的 AC5 条件分支
源码设置 AC5_MODE为 1,因此实际编译的是普通局部变量:
APP_FUNC jump2app; /* 当前 AC5 配置实际采用的函数指针声明。 */
#else中的 static APP_FUNC jump2app仍是课程源码的一部分,但本 target 不会编译它。这个条件只影响函数指针对象的存储期和编译器适配,不改变“读取复位入口 → 设置 MSP → 调用入口”的算法。视频没有进一步展开其编译器差异,不应据此凭空补写另一套跳转实现。
1.1.5 成功跳转后为何通常不会返回
jump2app()进入 APP 的 Reset_Handler后,启动代码最终进入 APP 的 main(),而嵌入式 main()通常停留在无限循环。因此正常情况下,下面这行不会执行:
printf("Bootloader end load app\r\n"); /* 只有 APP 启动入口异常返回到调用点时才可能执行。 */
同理,函数末尾的 return 0不能被当作“成功跳转返回 0”:成功跳转通常根本不返回;初始 MSP 检查失败时,它也返回 0。这个返回值在当前源码中没有区分成功与失败。
1.2 单片机内存结构
1.2.1 本项目的真实 SRAM 范围
视频的 Keil Target 页面、工程 .uvprojx和 Bootloader 分散加载文件一致显示:
IRAM1 Start = 0x20000000
IRAM1 Size = 0x00010000 = 65536 B = 64 KiB
所以实际 SRAM 字节地址范围为:
0x20000000 ~ 0x2000FFFF
0x20010000是这段 SRAM 的末端后一地址;由于 Cortex-M 栈向低地址增长,初始 MSP 可以设置在这一个上边界,再通过压栈进入有效 SRAM。原笔记写成 0x2000E000不符合当前 STM32F103ZE 工程配置。
ST 的 STM32F103xC/D/E 数据手册也给出高密度器件最多 64 KiB SRAM,参见 STM32F103ZE 数据手册。
1.2.2 代码段、数据段、堆和栈
常见裸机映像中各区域的用途如下,但它们的准确位置由链接脚本决定,并不是所有工程都按一张固定示意图排列:
| 区域 | 典型运行位置 | 作用 |
|---|---|---|
| 向量表、代码、只读数据 | Flash | 保存入口地址、机器指令和常量 |
.data | SRAM;初值来自 Flash | 保存有初值的可写全局/静态变量 |
.bss / ZI | SRAM | 保存零初始化或未显式初始化的全局/静态变量 |
| 堆 | SRAM | 供动态内存分配使用,裸机工程可能完全不用 |
| 栈 | SRAM | 保存调用现场、部分局部数据和异常栈帧等,通常向低地址增长 |
“参数、返回值、局部变量全都放在栈里”是过度简化。按照 ARM 调用约定,前几个参数和很多返回值优先使用寄存器;寄存器不够、需要保存现场或局部对象无法只放寄存器时,才会使用栈。函数进入和退出时,编译器生成的序言/尾声通常通过改变 SP 分配和回收栈帧。
1.2.3 APP 与 Bootloader 的栈顶不一定数值不同
Bootloader 和 APP 是两个独立链接的固件,各自的向量表首字都声明自己的初始 MSP。跳转时必须读取 APP 的首字并重新设置 MSP,不能继续假定 Bootloader 的 MSP 适用于 APP。
不过,“两个栈顶地址必然不同”也不严谨:二者使用同一片 SRAM,若启动文件、链接布局和栈大小恰好相同,首字可能得到相同的数值。正确做法仍是把 APP 向量表中的值作为唯一依据,而不是比较它是否与 Bootloader 的数值不同。
1.3 单片机复位流程
1.3.1 向量表前两项
Cortex-M3 向量表的开头固定表示:
| 相对偏移 | 内容 | 在本课 APP 中的绝对位置 |
|---|---|---|
0x00 | 初始 MSP 值 | 0x08008000 |
0x04 | Reset_Handler入口 | 0x08008004 |
0x08 | NMI 入口 | 0x08008008 |
0x0C | HardFault 入口 | 0x0800800C |
APP BIN 的第 0 字节就是这张向量表的开始。上节课核对过配套 APP.bin:首字为 0x20000900,第二字为 0x080081E1。后者最低位为 1,符合 Cortex-M 异常入口使用 Thumb 状态的要求。
ST 的 Cortex-M3 编程手册明确给出向量表偏移 0x0000为初始 SP、0x0004为 Reset,并说明异常向量最低位必须为 1,参见 PM0056。
1.3.2 真正硬件复位与软件跳转的区别
主 Flash 启动时,STM32 把 Flash 映射到 Cortex-M3 复位所访问的启动地址。硬件复位流程可概括为:
硬件从启动映射区的向量表首字装载 MSP
→ 从第二字装载复位入口并开始执行 Reset_Handler
→ Reset_Handler 调用 SystemInit
→ 进入 C 运行库 __main
→ 初始化 .data/.bss 等运行环境
→ 调用用户 main
而 Bootloader 跳 APP 时没有再触发复位电路,只是用 C 代码模拟最关键的两次取值:
从 APP_BASE + 0 读取 MSP,并调用 __set_MSP
→ 从 APP_BASE + 4 读取复位入口
→ 通过函数指针分支到 APP Reset_Handler
因此,视频所说“运行这个复位函数后单片机开始复位”应理解为“开始执行 APP 的复位处理程序”,不能理解为发生了 NVIC_SystemReset()那样的系统复位。
1.3.3 MSP、PSP 与 PC
- MSP(Main Stack Pointer):复位后的默认栈指针,异常处理也使用它。本课 Bootloader 和无 RTOS APP 主要使用 MSP。
- PSP(Process Stack Pointer):可在 Thread 模式下选用,常见于 RTOS 任务;本课跳转代码没有操作 PSP。
- PC(Program Counter):控制处理器从哪里取指执行。调用
jump2app()后,间接分支把执行流转到 APP 的复位入口。
原笔记中的“PC 计算器”应为 PC 寄存器/程序计数器。入门时可把它理解为当前执行流的位置,但“始终保存下一条指令地址”仍是简化描述;Cortex-M 有流水线,软件读取 PC 时的可见值还受体系结构规则影响。本课只需掌握分支到复位入口会改变程序执行位置。
CMSIS 对 __set_MSP()和__set_PRIMASK()的定义说明:复位时默认使用 MSP,初始值来自向量表首个 32 位字;PRIMASK 是一位全局优先级屏蔽寄存器。
1.4 单片机启动流程
1.4.1 Stack_Size = 0x400到底表示什么
视频打开 startup_stm32f103xe.s,可以看到:
Stack_Size EQU 0x400
Stack_Mem SPACE Stack_Size
__initial_sp
0x400 = 1024,所以启动文件为该工程预留 1 KiB 主栈空间。但 MSP 并不是被写成数值 0x400:__initial_sp是栈空间顶部的链接地址,向量表首项保存的是这个地址。
还要区分两个概念:
Stack_Size是链接时预留的最大栈区域大小;- MSP 是运行时指向当前栈顶的地址,函数调用、返回和异常进出会改变它。
1.4.2 启动文件如何组织向量表
课程启动文件中的关键关系是:
__Vectors 第 1 项 → __initial_sp
__Vectors 第 2 项 → Reset_Handler
链接器把 RESET段放到各自映像的起始处:Bootloader 的向量表位于 0x08000000,APP 的向量表位于 0x08008000。所以相同的启动文件模板,经过不同链接地址后会形成两张不同的向量表。
1.4.3 Reset_Handler之后不是直接进入用户 main
视频展示的 Arm Compiler 5 启动代码先导入 SystemInit和__main,执行关系是:
Reset_Handler
→ SystemInit()
→ __main ← Arm C 运行库入口,不是用户 main.c 中的 main
→ 复制/初始化 RW 数据
→ 清零 ZI/.bss
→ 完成必要的 C 运行环境初始化
→ 调用用户 main()
因此:
SystemInit()是系统初始化函数,不是“系统复位函数”;__main与用户写的main不是同一个符号;- Bootloader 跳入 APP 的
Reset_Handler,正是为了让 APP 重新执行自己的系统和 C 运行时初始化,不能直接跳用户main()。
上节课在 APP 的 SystemInit()中还设置了 SCB->VTOR = 0x08008000。这一步把后续异常和中断的向量表切到 APP;本课的 boot_check_stack2jump_app()本身没有设置 VTOR。
1.5 Bootloader跳转APP流程详解
1.5.1 实际控制权交接顺序
结合第 198 课的 APP 配置,完整顺序如下:
Bootloader 正在运行,VTOR/栈/外设状态属于 Bootloader
→ 检查 APP 向量表首字
→ 从 0x08008004 取得 APP Reset_Handler
→ 把 0x08008000 首字写入 MSP
→ PRIMASK 置 1
→ 调用 APP Reset_Handler
→ APP SystemInit 把 VTOR 改到 0x08008000
→ APP 的 __main 完成 C 运行时初始化
→ APP main 依次执行 HAL_Init、SystemClock_Config
→ APP 执行 __set_PRIMASK(0)
→ APP 初始化 GPIO、USART、CAN 等具体外设
→ 进入 APP 业务循环
这里的三个关键地址必须相互匹配:
| 项目 | 地址 |
|---|---|
| APP 在 Keil 中的链接起点 | 0x08008000 |
Bootloader 的 INTER_FLASH_APP_ADDR | 0x08008000 |
APP 运行时写入 SCB->VTOR的值 | 0x08008000 |
1.5.2 PRIMASK 并非真正“关闭所有中断源”
课程宏名叫 CLOSE_ALL_INT(),但 __set_PRIMASK(1)的准确作用是屏蔽所有 可配置优先级 的异常和中断:
- 普通外设 IRQ 和 SysTick 等不会被处理;
- NMI 和 HardFault 不受 PRIMASK 屏蔽;
- NVIC 中各 IRQ 的使能位、挂起位没有被清除;
- 外设本身的中断使能和状态标志也没有被清除。
APP 中的 __set_PRIMASK(0)只是取消这层全局屏蔽,并不等于重新配置了所有具体中断。若 Bootloader 留下了挂起中断,一旦 APP 清零 PRIMASK,它就可能立即响应。因此生产级 Bootloader 通常还会根据实际外设清理 SysTick、DMA、通信外设和 NVIC 状态;这些改进不在本课配套源码中,本笔记不把它们伪装成课程已经实现的代码。
1.5.3 为什么 APP 必须重新开中断
PRIMASK 属于处理器核心状态,软件跳转不会自动清零它。Bootloader 在跳转前执行:
__set_PRIMASK(1); /* 屏蔽可配置优先级异常和中断。 */
APP 的 main.c在系统时钟配置后执行:
__set_PRIMASK(0); /* 清除全局屏蔽,让已正确配置并使能的中断可以响应。 */
如果漏掉 APP 中这一行,轮询代码仍可能运行,但 SysTick、USART、CAN 等依赖可配置中断的功能可能表现为不工作。它与 APP 的 VTOR 重定位是两个独立条件:
- VTOR 正确:中断发生时能从 APP 向量表找到正确入口;
- PRIMASK 为 0:这些可配置中断有机会真正进入处理程序。
两者缺一不可。
1.5.4 本课源码的边界与注意事项
__set_MSP()之后,代码仍在一个普通 C 函数中继续执行宏和函数指针调用。切换 MSP 后应避免再依赖 Bootloader 原栈上的局部数据;这类交接代码对编译器和优化设置较敏感,课程用AC5_MODE保留了编译器分支,实际项目应对生成汇编进行验证。jump2app()是普通函数调用形式,APP 的Reset_Handler理论上不应返回。若它返回,Bootloader 将在 MSP 已经切换、PRIMASK 仍为 1 的状态下继续执行,不能视为正常路径。- 课程只粗略检查初始 MSP。更严格的产品代码还应检查复位入口范围、Thumb 位、映像长度与完整性,并在交接前有计划地反初始化 Bootloader 资源。
- 清除升级标志发生在 YMODEM 成功后、跳转前。如果新映像随后不能启动,当前课程没有双分区回滚或确认机制;这些属于更完整 IAP 方案需要解决的问题。
二、常见错误定位
| 现象 | 优先检查 |
|---|---|
打印 APP Not Found! | 0x08008000首字是否为合理 MSP;BIN 是否烧到正确地址;APP 是否正确链接 |
| 跳转后立刻 HardFault | 0x08008004复位入口及 Thumb 位;APP 链接地址;栈地址;固件是否完整 |
APP 能进入 main(),一开中断就异常 | SCB->VTOR是否改为 0x08008000;Bootloader 是否留下挂起中断 |
| APP 的中断完全不响应 | APP 是否执行 __set_PRIMASK(0);具体 NVIC 与外设中断是否初始化和使能 |
跳转函数后打印 Bootloader end load app | APP 的 Reset_Handler异常返回;这不是正常成功标志 |
| 升级后反复复位 | 参数标志、APP 向量、Bootloader 错误路径以及新固件完整性 |
三、本课结论
Bootloader 跳转 APP 的核心不是简单地“把 PC 改到 0x08008000”,而是把一个独立固件的启动上下文交给处理器:
0x08008000保存 APP 声明的初始 MSP;0x08008004保存 APP 的复位入口;- Bootloader 装载 APP 的 MSP,并间接分支到复位入口;
- APP 自己执行
SystemInit()、运行库初始化和main(); - APP 把 VTOR 切到自己的向量表,并解除 Bootloader 留下的 PRIMASK 屏蔽。
理解这五步后,就能把“同一片 Flash 上有两个独立固件”与“控制权如何从一个固件安全交给另一个固件”连成完整逻辑。