Skip to content

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暂存区 0x080400000x20007000(搬运后执行)
Bootloader——0x08000000

二、 go 命令与跳转入口 ​

1. 参数解析 ​

go 命令注册在 iap_service_init() 里,处理函数只用两个小工具解析参数:cmd_arg_of() 跳过命令名取参数,cmd_arg_is() 判断参数是否恰好等于某个词(忽略行尾空白):

c
// 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 flash0x08020000跳 Flash APP,要求该区已有合法镜像
go sram0x20007000先校验暂存区、再搬运、再跳
gos_last_image_base最近一次落盘成功的目标,初值指向 Flash APP 区

go 不带参数时依赖 s_last_image_base,它是个 RAM 变量:复位后回到 IAP_APP_BASE。脚本里最好显式写参数,别依赖隐式状态。

2. FLASH APP 的跳转路径 ​

go flash 是最短的一条:目标地址就是运行地址,落盘时已经写过 Flash,这里不需要搬运,直接校验后交权。

c
// 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、回读校验,然后才交权。

SRAM APP 的搬运与跳转流程

c
// 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 flashgo sram
目标地址0x080200000x20007000
跳前动作无搬运,直接校验校验暂存区 → 搬 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:

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
c
// 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 镜像里有需要初始化的段:

asm
; 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 栈上的局部变量已经"读不回来"了——编译器若把函数指针放在栈上,跳转瞬间就取到了垃圾值。所以:

c
// 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 如何参与取指 ​

向量表与 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,硬件自动完成的动作完全相同:

  1. 外设把异常号报给内核(USART1_IRQn = 37,属于外部中断,对应向量表第 53 项);
  2. 把 xPSR、PC、LR、R12、R3、R2、R1、R0 八个字压入当前栈,共 32 字节;
  3. 按 VTOR + 项号 × 4 取出处理函数地址,写入程序计数器;
  4. 把状态寄存器的中断号字段置为 37,进入处理模式。

这四步不需要任何代码。两个场景的差别只落在两处:第 2 步压栈用的是当前栈(栈顶是谁由当前运行方决定),第 3 步的取指地址完全由 VTOR 决定。

步骤跳转前(Bootloader 在跑)跳转后(APP 在跑)
压栈栈顶是 Bootloader 的,32 字节落在它自己的栈里栈顶已换成 APP 的,32 字节落在 APP 的栈里
取指VTOR = 0x08000000 → 进 Bootloader 的 USART1_IRQHandlerVTOR = 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 之后,剩下的全是代码,两个工程的链路完全同构:

text
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 技能辅助生成