LV050-IAP跳转与向量表原理
一、 IAP 的硬件基础
1. 三个前提条件
IAP 能成立,靠的是 Cortex-M3 与 STM32 Flash 提供的三个能力,缺一个都做不成:
- Flash 可以在运行时被改写:FPEC 允许程序自己去擦写内部 Flash,且改写期间取指会被硬件暂停,不会出现"执行到一半的代码被擦掉"的情况;
- 向量表可以被重定位:
SCB->VTOR决定异常入口的基址,Bootloader 与 APP 各有各的向量表,切换只改一个寄存器; - 栈顶可以由软件设置:
__set_MSP()可以直接换栈,配合向量表第 1 项就能完成"交权"。
2. 分区与数据通路
整个方案把 512KB Flash 与 64KB SRAM 切成固定的几块,地址边界在三个地方各写一遍(iap.h、IROM1、pack_iap.py),任何一处不一致都会在运行阶段暴露。
下载 → 落盘 → 执行的数据通路分三条,区别只在"镜像最终从哪里执行":
| 镜像 | 收帧缓冲 | 落盘 | 执行 |
|---|---|---|---|
flash-app | 外部 SRAM 0x68000000 | 内部 Flash 0x08020000 | 同一地址,就地执行 |
sram-app | 外部 SRAM 0x68000000 | 暂存区 0x08040000 | 0x20007000(搬运后执行) |
| Bootloader | — | — | 0x08000000 |
二、 go 命令与跳转入口
1. 参数解析
go 命令注册在 iap_service_init() 里,处理函数只用两个小工具解析参数:cmd_arg_of() 跳过命令名取参数,cmd_arg_is() 判断参数是否恰好等于某个词(忽略行尾空白):
// atk-f103/Drivers/BSP/Iap/iap_service.c
static void cmd_go(const char *line)
{
const char *arg = cmd_arg_of(line);
uint32_t base;
if (cmd_arg_is(arg, "flash")) { base = IAP_APP_BASE; }
else if (cmd_arg_is(arg, "sram")) { base = IAP_SRAM_APP_BASE; }
else if (*arg == '\0') { base = s_last_image_base; } /* 最近一次装好的 */
else
{
printf("IAP: unknown go target '%s' (usage: go [flash|sram])\r\n", arg);
return;
}
...
}三种写法对应三种意图:
| 命令 | 目标 | 说明 |
|---|---|---|
go flash | 0x08020000 | 跳 Flash APP,要求该区已有合法镜像 |
go sram | 0x20007000 | 先校验暂存区、再搬运、再跳 |
go | s_last_image_base | 最近一次落盘成功的目标,初值指向 Flash APP 区 |
go 不带参数时依赖 s_last_image_base,它是个 RAM 变量:复位后回到 IAP_APP_BASE。脚本里最好显式写参数,别依赖隐式状态。
2. FLASH APP 的跳转路径
go flash 是最短的一条:目标地址就是运行地址,落盘时已经写过 Flash,这里不需要搬运,直接校验后交权。
// atk-f103/Drivers/BSP/Iap/iap_service.c(FLASH 分支)
if (cmd_arg_is(arg, "flash"))
{
base = IAP_APP_BASE; /* 0x08020000 */
}
#if SVC_NET_EN
iap_tcp_shutdown(); /* 跳转前关干净网络通道 */
#endif
if (iap_load_app(base) != 0) /* 内部会再查一次向量表 */
{
printf("IAP: APP image invalid at 0x%08X, stay in bootloader\r\n", (unsigned int)base);
}三个判定点:
- 该区有没有写过镜像,由
iap_load_app()内的向量表检查回答(栈顶在内部 RAM、复位向量在0x08020000~0x08040000内且为 Thumb); - 校验不过就返回
-1,打印APP image invalid ... stay in bootloader,不会跳进空白区; - 跳转前先
iap_tcp_shutdown(),避免 DM9000 的残留中断在 APP 里打进来。
3. SRAM APP 的跳转路径
go sram 多了三步:校验暂存区、把 24KB 搬进内部 SRAM、回读校验,然后才交权。
// atk-f103/Drivers/BSP/Iap/iap_service.c(SRAM 分支)
if (iap_sram_stage_is_valid() == 0U)
{
printf("IAP: no sram-app image in flash staging 0x%08X (send its app.img first)\r\n", ...);
return;
}
iap_wr_res_t wr = iap_load_sram_app(); /* 只做搬运 + 回读校验, 不跳转 */
if (wr != IAP_WR_OK)
{
printf("IAP: sram load from staging failed (%s), stay in bootloader\r\n", iap_wr_name(wr));
return;
}
s_last_image_base = IAP_SRAM_APP_BASE; /* 之后裸发 go 就跳到 SRAM */搬运完成后,cmd_go() 与 Flash 分支走同一条收尾:关网络通道,再 iap_load_app(0x20007000)。此时 iap_load_app() 检查的是 SRAM 里 的向量表,两条判据换成 SRAM 的边界(复位向量落在 0x20007000 ~ 0x2000D000)。
几个容易混淆的点:
iap_load_app()是唯一的跳转出口,两条路径都汇到它,区别只在"跳之前做了多少准备";- 搬运长度固定为容器容量 24KB,不按帧头长度搬——暂存区没写到的尾部是
0xFF,落在容器内不会被执行到; iap_load_sram_app()的名字容易误解,它不包含跳转,只负责memcpy与回读校验。
4. 两条路径的对比
| 对比项 | go flash | go sram |
|---|---|---|
| 目标地址 | 0x08020000 | 0x20007000 |
| 跳前动作 | 无搬运,直接校验 | 校验暂存区 → 搬 24KB → 回读 CRC |
| 校验对象 | Flash 第 2 块的向量表 | 暂存区与 SRAM 两处向量表 |
| 搬运的介质 | 无 | Flash 0x08040000 → SRAM 0x20007000 |
| 失败提示 | APP image invalid at ... | no sram-app image ... / sram load ... failed |
| 掉电后 | 镜像仍在,可再跳 | 需重新 go sram(暂存区仍在) |
| 耗时 | 毫秒级 | 毫秒级搬运 + 24KB 回读校验 |
三、 跳转的执行序列
1. iap_load_app()
该函数在 atk-f103/Drivers/BSP/Iap/iap.h 中声明,定义在 iap.c:
int iap_load_app(uint32_t appxaddr);【函数作用】
读出指定地址处的向量表,依次完成关闭中断源、切换向量表基址、更换栈顶、放开中断,最后跳转到该镜像的复位向量。成功时永不返回。
【参数含义】
appxaddr:镜像起始地址,必须是页对齐的容器基址(0x08020000或0x20007000)
【返回值】
- 返回
-1:该地址处不是合法镜像,未执行跳转 - 其余情况不返回
2. 每一步在做什么
| 阶段 | 动作 | 目的 |
|---|---|---|
| 校验与准备 | 读向量表前 8 字节,取出 MSP 与复位向量,存进静态变量 | 确认值得跳,并准备好目标 |
| 关中断源 | 等串口 TC、__disable_irq()、清 SysTick 三个寄存器、禁用并清除 USART1 挂起 | 日志发完,不让 APP 拿着未初始化的句柄进中断 |
| 换运行上下文 | SCB->VTOR = appxaddr、__set_MSP(msp)、__set_PRIMASK(0) | 换掉三样东西 |
| 跳转 | s_app_entry() | 控制权交给 APP 的 Reset_Handler |
// atk-f103/Drivers/BSP/Iap/iap.c
msp = *(volatile uint32_t *)appxaddr; /* 初始栈顶 */
rst = *(volatile uint32_t *)(appxaddr + 4U); /* 复位向量 */
s_app_entry = (void (*)(void))rst; /* 先落到静态区 */
while ((USART1->SR & USART_SR_TC) == 0U) { } /* 等最后一行日志发完 */
__disable_irq();
SysTick->CTRL = 0U;
SysTick->LOAD = 0U;
SysTick->VAL = 0U;
HAL_NVIC_DisableIRQ(USART1_IRQn);
HAL_NVIC_ClearPendingIRQ(USART1_IRQn);
SCB->VTOR = appxaddr;
__set_MSP(msp);
__set_PRIMASK(0U);
s_app_entry();3. 为什么跳 Reset_Handler
跳的是向量表第 2 项,不是 main()。原因是 APP 镜像里有需要初始化的段:
; startup_stm32f103xe.s
Reset_Handler PROC
LDR R0, =SystemInit
BLX R0 ; 配时钟、Flash 预取
LDR R0, =__main
BX R0 ; 拷贝 .data、清零 .bss、再进 main
ENDP.data 的初值存在 Flash 里,必须由 __main 拷到 RAM;.bss 必须清零。直接跳 main() 会让 APP 带着未初始化的全局变量运行,这类故障极难定位,因为现象取决于 RAM 的残留内容。
4. 为什么跳转目标存静态变量
__set_MSP() 一执行,栈指针就换成了 APP 的栈。此时 Bootloader 栈上的局部变量已经"读不回来"了——编译器若把函数指针放在栈上,跳转瞬间就取到了垃圾值。所以:
// atk-f103/Drivers/BSP/Iap/iap.c
/* 跳转目标存成静态变量而不是局部变量: __set_MSP() 之后栈帧已经换到 APP 的栈上,
* 局部变量若被编译器压在栈里就再也读不回来了 */
static void (*s_app_entry)(void);5. 向量表合法性检查
iap_load_app() 与 iap_sram_stage_is_valid() 都只看向量表前 8 字节,判据一样:
| 检查项 | 判据 | 拦下什么 |
|---|---|---|
| 初始栈顶 | 落在 0x20000000 ~ 0x20010000 | 全 0xFF 的空区(0xFFFFFFFF 越界)、链接到别处的镜像 |
| 复位向量 | 落在目标容器内,且最低位为 1 | 垃圾数据、非 Thumb 地址 |
这就是"从没烧写过也能安全敲 go"的原因:不合法就直接返回并打印提示,不会跳进空白区跑飞。
四、 向量表与 VTOR
1. 向量表的结构
向量表是镜像开头的连续 4 字节字数组,第 0 项是初始栈顶(不是函数地址),第 1 项是复位向量,之后依次是 NMI、HardFault……直到 54 个外部中断。
2. VTOR 如何参与取指
异常发生时,硬件按 SCB->VTOR + 异常号 × 4 取出处理函数地址再跳过去。于是"改 VTOR"等价于"换一整套中断入口":
- 跳转前
VTOR = 0x08000000,中断进 Bootloader 的处理函数; - 跳转时换成
0x08020000(或0x20007000),中断进 APP 的处理函数。
3. 不迁移的后果
如果只设了 MSP 而没改 VTOR,会出现两种典型现象:
- APP 里开了某个外设中断(比如 USART1),中断一来就跳到 Bootloader 的处理函数,那里操作的是 Bootloader 的句柄和状态,行为完全不可预期;
- 如果 Bootloader 恰好禁用了该中断,APP 看起来"能跑",但一旦使能中断就随机 HardFault——这类问题容易被误判成"APP 业务代码有 bug"。
4. 本项目的两处设置
| 位置 | 代码 | 作用 |
|---|---|---|
| 跳转前(Bootloader) | SCB->VTOR = appxaddr; | 把向量表切到目标镜像 |
APP 启动(main() 开头) | SCB->VTOR = APP_BASE; | 让镜像具备独立启动能力 |
第二处对"被 Bootloader 跳转"是冗余的,但它让同一份源码在调试器直跑时也能正常工作:sram-app-debug 用 APP_BASE=0x08000000 覆盖默认值,复位后 VTOR 是 0,没人替它改。
5. 一次中断的完整硬件流程
前面说"改 VTOR 等于换一整套中断入口",把这句话拆到寄存器层面就是下面四步。以 USART1 收到一个字节为例,无论当前跑的是 Bootloader 还是跳转后的 APP,硬件自动完成的动作完全相同:
- 外设把异常号报给内核(
USART1_IRQn= 37,属于外部中断,对应向量表第 53 项); - 把
xPSR、PC、LR、R12、R3、R2、R1、R0八个字压入当前栈,共 32 字节; - 按
VTOR + 项号 × 4取出处理函数地址,写入程序计数器; - 把状态寄存器的中断号字段置为 37,进入处理模式。
这四步不需要任何代码。两个场景的差别只落在两处:第 2 步压栈用的是当前栈(栈顶是谁由当前运行方决定),第 3 步的取指地址完全由 VTOR 决定。
| 步骤 | 跳转前(Bootloader 在跑) | 跳转后(APP 在跑) |
|---|---|---|
| 压栈 | 栈顶是 Bootloader 的,32 字节落在它自己的栈里 | 栈顶已换成 APP 的,32 字节落在 APP 的栈里 |
| 取指 | VTOR = 0x08000000 → 进 Bootloader 的 USART1_IRQHandler | VTOR = 0x08020000 → 进 APP 自己的 USART1_IRQHandler |
| 执行 | HAL_UART_IRQHandler → 喂 Bootloader 的接收状态机 | HAL_UART_IRQHandler → 喂 APP 注册的消费者 |
| 返回 | 按 EXC_RETURN 回到被中断的指令 | 同上 |
【注意】
跳转后若 VTOR 没迁移,第 3 步算出的仍是 Bootloader 的入口。那不是一次"取指地址非法"的硬件错误——地址有效、函数存在,硬件不会报错,它会安安静静地执行 Bootloader 的处理函数;而那个函数操作的句柄已被 APP 的 __main 清零。这类故障表现为踩内存或随机 HardFault,很容易被误判成"APP 业务代码有 bug"。
中断与主函数共用一个栈,这一点在预算栈深时必须一起算:32 字节的硬件压栈之外,中断处理函数与被中断的主函数叠加占用同一片栈区。
硬件把控制权交到 USART1_IRQHandler 之后,剩下的全是代码,两个工程的链路完全同构:
USART1_IRQHandler stm32f1xx_it.c, 工程里的中断入口
└─ HAL_UART_IRQHandler 交给 HAL 统一分发
├─ 读状态寄存器与数据寄存器, 清掉 RXNE 标志位
└─ 字节收齐后调用接收完成回调
└─ usart.c 里的回调把字节交给已注册的消费者
└─ iap_rx_byte() 喂进接收状态机复位为什么不走这套流程。 复位是硬件行为,软件无法介入:内核把 VTOR 强制清回 0x00000000,从该地址连续读两个字装进 MSP 与 PC;而 0x00000000 处并没有真实存储区,启动引脚为 00 时被别名映射到内部 Flash 起点 0x08000000。所以复位读到的栈顶与复位向量必然属于 Bootloader。跳转后能跑 APP,靠的是 SCB->VTOR = appxaddr 加跳复位向量这套软件模拟,它只在 Bootloader 主动移交时成立一次;复位把 VTOR 清回 0,这套安排随之作废。
五、 跳转后的运行与风险
1. APP 的冷启动
跳转完成后,APP 从 Reset_Handler 开始完整走一遍冷启动:SystemInit() 配时钟 → __main 准备数据段 → main() 重设 VTOR → HAL_Init() → 外设初始化 → 打印 ready → iwdg_start()。Bootloader 之前的初始化对它不可见,也不能依赖。
2. 跳转后的寄存器状态
一个高频疑问:**跳转不动寄存器,那 APP 为什么还要重新初始化串口——它岂不是不初始化就能直接用?**答案分三层,每层都不能省。
(1)跳转动作本身确实不碰任何外设寄存器。iap_load_app() 只改三样东西:VTOR、MSP、PC。串口的 GPIO 复用、USART_BRR 波特率分频、CR1 使能位,一位都不会因为跳转而变化。
(2)**但 APP 接手的不是"干净状态"。**Bootloader 在跳转前主动收尾过(见 三、2 的关中断序列):停掉 SysTick、禁用并清除 USART1 中断挂起。此外还有 Bootloader 自己收帧留下的运行残留——RXNE/ORE 标志可能悬着、NVIC 里有 pending 位、甚至半个没读完的字节还躺在 DR 里。"寄存器没被跳转破坏"不等于"寄存器处于 APP 期望的状态"。
(3)**最根本的一层:初始化的另一半在软件里。**HAL 驱动的状态机(huart1 的 gState/RxState、回调指针、锁)住在 APP 自己的 .bss 里,而 .bss 是 __main 刚清零的。于是跳转后出现"硬件活着、软件死了"的局面:串口寄存器配置完整,但 huart1.gState 是全零的 HAL_UART_STATE_RESET——APP 里 printf 走 HAL_UART_Transmit(),HAL 先查 gState,直接返回 HAL_BUSY,一个字节都发不出去且静默失败;中断接收更是彻底不可用,句柄里根本没有登记接收缓冲。
| 用法 | 不重新初始化会怎样 |
|---|---|
裸寄存器轮询发送(直写 USART1->DR) | 时钟一致、GPIO 已由 Bootloader 配好的 happy path 下"碰巧能用"——直觉的来源 |
HAL 发送(printf / HAL_UART_Transmit) | 静默失败:句柄状态机是 RESET,返回 HAL_BUSY |
| 中断接收 | 不可用:Bootloader 关了使能与 NVIC 通道,句柄里也没有登记缓冲 |
唯一的例外是"代码正在其中执行"的外设:外部 SRAM-APP 依赖 FSMC 的遗留配置,且没有"运行前自己初始化"的机会(第一条指令就经 FSMC 取指),所以对它立的是"Bootloader 配好、APP 不动"的契约,而不是全量重配。能否依赖遗留,取决于"代码是否正在依赖它运行"与"重配是否有代价":
| 对比 | FSMC(外部 SRAM-APP) | 串口等普通外设 |
|---|---|---|
| 代码是否依赖它执行 | 是,每条指令经它取指 | 否 |
| APP 能否自己初始化 | 不能(鸡生蛋) | 能,重配无风险 |
| 结论 | 契约:Bootloader 配好、APP 不动 | 策略:不信任遗留,全量重配 |
由此得到 APP 侧的工程原则:外设初始化必须自包含。同一份源码要能在 xxx-debug Target 下由调试器直接下载、上电就跑——那条路径上没有任何"前手"配过串口。依赖"前手恰好配好"的镜像只在 IAP 链上成立,换一条进入路径就跑不起来。外部 SRAM 场景下 FSMC 契约的完整清单见 LV065-外部SRAM-APP方案分析。
3. 中断源的移交
Bootloader 在跳转前做了三件事:停 SysTick、禁用并清除 USART1 中断、清 PRIMASK。这既是"别让 APP 收尾别人的中断",也是"别让旧的中断挂起位在 APP 里立刻触发"。
4. 复位与硬约束
reboot 走的是 HAL_NVIC_SystemReset()(SYSRESETREQ),它复位内核与片内外设,但不复位外置的 DM9000。于是有两条硬约束:
- 复位或跳转前必须先关网络通道(
iap_tcp_shutdown()),否则 DM9000 的残留中断会在 FSMC 还没初始化的窗口里打进来; - 软复位后仍然从
0x08000000开始,也就是回到 Bootloader,"重启后又看到 Bootloader 提示"是正常现象。
5. 已知的两处窗口
| 窗口 | 成因 | 现状 |
|---|---|---|
| 跳转前的极短中断窗口 | __set_PRIMASK(0) 在 s_app_entry() 之前 | APP 的中断此时尚未使能,实际不可触发;保持现状 |
| 看门狗无法关闭 | IWDG 一旦启动,软件无法停止;跳转不是复位,它继续倒计时 | Bootloader 初始化不使能、末尾才使能;跳转后由 APP 用同一组参数重新装载并接替喂狗,详见 LV055-看门狗问题与解决方案 |
本文档由 markdowncli 技能辅助生成