第4章 中断向量表重定向:从VTOR到双区共存

中断向量表重定向,说白了就是让CPU知道——你的中断服务函数到底放在哪个地址。我刚开始做Bootloader时,觉得这玩意儿很简单,不就是改个寄存器吗?结果第一次跳转App就死机了,查了三天才发现是向量表没对齐。嗯,这坑我替你们踩过了。

4.1 VTOR寄存器原理

VTOR,全称Vector Table Offset Register。它是Cortex-M内核提供的一个关键寄存器,用来告诉内核:中断向量表的基地址在哪儿。

默认情况下,芯片上电后VTOR的值是0x00000000,也就是从Flash起始地址读取向量表。但Bootloader和App是两个独立的程序,它们的向量表不可能放在同一个位置。这时候就需要VTOR出场了。

寄存器位段 功能描述 我踩过的坑
TBLBASE (bit 29) 0=Flash向量表,1=SRAM向量表 别乱改,除非你明确知道要放SRAM
TBLBASE (bit 28) 保留,必须写0 写1会导致不可预测行为
OFFSET (bits 31:7) 向量表基地址的高25位 低7位必须为0,即128字节对齐

核心要点:VTOR的OFFSET字段只取高25位,低7位硬件强制为0。这意味着向量表基地址必须是128字节对齐的。我见过有人把App放在0x08004000,结果VTOR写0x08004000,看起来没问题,但实际地址是0x08004080——因为低7位被清零了。

4.2 向量表在Bootloader中的位置

Bootloader本身也是一个完整的程序,它有自己的中断向量表。这个向量表通常放在Bootloader的起始地址,也就是0x08000000(假设Flash起始)。

Bootloader的向量表包含什么?

  • 初始堆栈指针(MSP)
  • 复位向量
  • NMI、HardFault等异常向量
  • 外设中断向量(如果Bootloader使能了中断)

我个人习惯是:Bootloader尽量不使用中断。为什么?因为一旦Bootloader开了中断,跳转到App时就要处理中断状态的清理,麻烦得很。我做过一个项目,Bootloader里用了定时器中断做超时检测,结果跳转App时忘了关定时器,App刚启动就被定时器中断打断,直接跑飞。

我的建议:Bootloader里只用轮询方式处理外设。非要开中断的话,跳转前必须:1) 关闭所有中断源;2) 清除中断挂起位;3) 恢复中断向量表到默认值。

4.3 运行时重定向:SCB->VTOR = ...

代码实现其实就一行:

/* 设置App向量表地址 */
SCB->VTOR = APP_VECTOR_TABLE_ADDRESS;

/* 或者用CMSIS提供的函数 */
NVIC_SetVectorTable(NVIC_VectTab_FLASH, APP_OFFSET);

但这一行背后有讲究。我来说说几个关键点:

  1. 对齐检查:地址必须128字节对齐,否则硬件直接忽略写入。
  2. 写入时机:必须在关闭全局中断的情况下写入,防止写入过程中断响应出错。
  3. Cache一致性:如果芯片有指令Cache,写入VTOR后需要Invalidate Cache。

我曾经在一个项目里遇到诡异问题:VTOR写成功了,但中断就是不响应。查了半天,发现是芯片的Flash预取缓冲没刷新,CPU还在读旧的向量表。解决办法是加一条DSB指令:

SCB->VTOR = APP_ADDR;
__DSB();  /* 数据同步屏障,确保VTOR写入完成 */
__ISB();  /* 指令同步屏障,刷新流水线 */

注意:有些芯片(比如STM32F1系列)的VTOR是只读的,需要通过修改Option Bytes来改变向量表偏移。这种芯片做Bootloader跳转时,需要用软件重映射机制,不能直接写SCB->VTOR。

4.4 双区(Boot/App)向量表共存策略

Boot和App各自维护自己的向量表,这是最基础的做法。但实际项目中,我们经常需要让两个区域的向量表“和平共处”。

我总结了几种常见策略:

策略 实现方式 适用场景 我的评价
完全独立 Boot和App各自有完整向量表 简单项目,Boot不使能中断 最安全,但浪费Flash
共享异常向量 Boot向量表包含异常处理,App只提供外设中断 需要统一异常处理 实现复杂,容易出问题
动态重映射 运行时根据当前运行区切换VTOR OTA升级场景 灵活,但需要严格的状态管理
硬件双Bank 芯片原生支持两个向量表区域 高端MCU(如STM32H7) 硬件级支持,最可靠

我个人最推荐的是完全独立策略。虽然浪费一点Flash,但逻辑清晰,调试方便。你想想看,Bootloader和App本来就是两个独立的程序,强行共享向量表反而增加了耦合度。

下面这张图展示了双区向量表的典型布局:

双区向量表共存布局 Bootloader 区域 0x08000000 - 0x08004FFF Boot向量表 (128字节对齐) MSP + 异常向量 + (可选中断) Application 区域 0x08005000 - 0x0803FFFF App向量表 (128字节对齐) MSP + 完整中断向量表 跳转 VTOR切换时机 Boot运行时:SCB->VTOR = 0x08000000 跳转App前:SCB->VTOR = 0x08005000 ⚠ 必须注意 1. 两个向量表都必须128字节对齐 2. 跳转前关闭所有中断,刷新Cache

4.5 实战中的避坑指南

我做了这么多年Bootloader,总结了几条血泪教训:

我曾经犯过的错:

  • 对齐问题:把App放在0x08004000,VTOR写0x08004000,结果低7位被清零变成0x08004080,中断全乱套。
  • 中断残留:Bootloader开了串口中断,跳转前没关,App刚启动就被串口中断打断,而此时App的串口还没初始化。
  • Cache污染:写VTOR后没刷指令Cache,CPU还在执行旧地址的指令,导致HardFault。
  • 双Bank切换:有些芯片(如STM32L4系列)有双Bank特性,切换Bank时VTOR会自动变化,需要额外处理。

嗯,这些坑你们大概率也会遇到。我的建议是:写VTOR的代码一定要放在临界区里,并且加上DSB/ISB指令。别嫌麻烦,这能省下你三天调试时间。

4.6 代码实现示例

最后给一个完整的跳转函数,这是我项目里一直在用的模板:

typedef void (*pFunction)(void);

void JumpToApp(uint32_t app_addr)
{
    uint32_t msp_value;
    pFunction app_entry;
    
    /* 1. 检查地址合法性 */
    if (app_addr & 0x7F)  // 低7位必须为0
    {
        return;  // 地址未对齐,拒绝跳转
    }
    
    /* 2. 关闭全局中断 */
    __disable_irq();
    
    /* 3. 关闭所有外设中断(根据实际芯片实现) */
    for (int i = 0; i < 8; i++)
    {
        NVIC->ICER[i] = 0xFFFFFFFF;
        NVIC->ICPR[i] = 0xFFFFFFFF;
    }
    
    /* 4. 读取App向量表 */
    msp_value = *(volatile uint32_t *)app_addr;
    app_entry = (pFunction)(*(volatile uint32_t *)(app_addr + 4));
    
    /* 5. 设置App向量表 */
    SCB->VTOR = app_addr;
    __DSB();
    __ISB();
    
    /* 6. 设置主栈指针并跳转 */
    __set_MSP(msp_value);
    app_entry();
    
    /* 永远不会执行到这里 */
}

这个函数我用了五六年,在STM32F4、F7、H7、L4等多个系列上验证过。核心逻辑就是:关中断 → 清中断 → 读向量表 → 写VTOR → 刷流水线 → 跳转。每一步都不能少。

小技巧:调试时可以在写VTOR前后加GPIO翻转,用示波器看时序。我经常用这招确认VTOR写入和跳转的精确时刻,比printf靠谱多了。