4、Bootloader设计(下):向量表重映射、跳转逻辑实现、看门狗喂狗策略

好,咱们接着聊Bootloader。上一节我们把启动流程和分区规划讲清楚了,这一节要动真格的了——向量表重映射、跳转逻辑、还有看门狗喂狗。这三个东西,任何一个出问题,你的OTA就是空中楼阁。

我见过太多人,Bootloader写好了,跳转也成功了,结果跑起来就死机。查了半天,原来是向量表没重映射。还有的人,升级到一半看门狗复位了,直接变砖。嗯,这些坑我都踩过,今天咱们一个一个填平。

4.1 向量表重映射:为什么必须做?

先问一个问题:MCU上电后,从哪里开始执行代码?

答案是复位向量。这个向量存在哪里?存在Flash的最开头,也就是0x08000000地址。这是硬件写死的,没得商量。

那我们的Bootloader放在0x08000000,应用程序放在0x08010000。当Bootloader跳转到应用程序时,CPU还是习惯性地去0x08000000找向量表。这就出问题了——应用程序的向量表在0x08010000啊!

所以,你必须告诉CPU:「嘿,向量表搬家了,新地址在0x08010000。」这个过程就叫向量表重映射。

核心要点:不重映射向量表,中断一个都进不去。你的定时器、串口、按键中断,全部失效。

具体怎么做?不同MCU厂商的寄存器不一样。以STM32为例,有个寄存器叫SCB->VTOR(Vector Table Offset Register)。你只需要把应用程序的基地址写进去就行:

// 假设应用程序起始地址为 0x08010000
#define APP_ADDRESS 0x08010000

// 关闭全局中断
__disable_irq();

// 设置向量表偏移
SCB->VTOR = APP_ADDRESS;

// 重新使能全局中断
__enable_irq();

就这么简单?对,就这么简单。但有个细节要注意:必须在跳转之前设置VTOR。我见过有人把VTOR写在应用程序的main函数里,结果Bootloader跳过去之后,还没跑到main就触发了中断,直接死机。

我的经验:VTOR设置最好放在应用程序的启动文件里,也就是Reset_Handler的最开头。这样一上电就生效,万无一失。

4.2 跳转逻辑实现:从Bootloader到App的最后一跃

向量表搞定了,接下来就是跳转。跳转的核心就两件事:

  1. 获取应用程序的栈指针(MSP)
  2. 获取应用程序的复位向量地址
  3. 跳转过去

说白了,就是模拟一次CPU复位。应用程序的起始地址处,前4个字节是栈指针,紧接着4个字节是复位向量。我们直接取出来,然后跳转。

看代码:

typedef void (*pFunction)(void);

// 应用程序起始地址
#define APP_ADDRESS 0x08010000

void jump_to_app(void)
{
    uint32_t msp_value;
    pFunction app_entry;

    // 1. 关闭所有外设中断
    // 这一步很重要,我后面会讲
    deinit_all_peripherals();

    // 2. 关闭全局中断
    __disable_irq();

    // 3. 获取应用程序的栈指针
    msp_value = *(volatile uint32_t*)APP_ADDRESS;

    // 4. 获取应用程序的复位向量
    app_entry = (pFunction)(*(volatile uint32_t*)(APP_ADDRESS + 4));

    // 5. 设置主栈指针
    __set_MSP(msp_value);

    // 6. 跳转
    app_entry();
}

这里有个坑,我当年踩过。你看第1步,为什么要关闭所有外设中断?

你想啊,Bootloader里可能开了定时器、串口、ADC。跳转之前如果不关掉,这些中断还在跑。应用程序一启动,突然来个定时器中断,但应用程序的中断向量表还没初始化好——直接进HardFault。

警告:跳转前必须关闭所有外设时钟和中断。我建议写一个deinit_all_peripherals()函数,把用到的外设逐个关掉。不要偷懒只关全局中断,因为有些外设的DMA还在后台跑。

另外,还有一个容易被忽略的点:SysTick定时器。Bootloader里通常用SysTick做延时,跳转前一定要把SysTick关掉,否则应用程序的SysTick中断和Bootloader的冲突。

4.3 看门狗喂狗策略:OTA过程中的生死时速

看门狗这个东西,用好了是守护神,用不好就是催命符。

在OTA升级过程中,你正在擦除Flash、写入数据,这个过程可能持续几秒甚至几十秒。如果看门狗超时了,啪,复位了。升级到一半的固件就废了。

所以,喂狗策略必须精心设计。我总结了几种方案:

方案 描述 适用场景 风险
方案一:升级前关狗 进入OTA模式后,直接关闭看门狗 开发调试阶段 升级过程中如果死机,无法自动恢复
方案二:定时喂狗 在擦写循环中,每完成一个扇区就喂一次 Flash擦写时间可控的场景 如果擦写出错卡死,喂狗也救不了
方案三:独立看门狗+备份区 使用独立看门狗,配合备份区做断点续传 对可靠性要求极高的产品 实现复杂,占用额外Flash空间

我个人最推荐方案二,但要做一些改进。来看代码:

// 假设Flash每页大小为2KB,擦写时间约20ms
// 看门狗超时时间设为500ms

void ota_write_firmware(uint32_t address, uint8_t *data, uint32_t len)
{
    uint32_t page_count = 0;

    while (len > 0)
    {
        // 擦除一个扇区
        flash_erase_page(address);
        
        // 写入数据
        flash_write_page(address, data, PAGE_SIZE);

        // 喂狗!每完成一页就喂一次
        watchdog_feed();

        address += PAGE_SIZE;
        data += PAGE_SIZE;
        len -= PAGE_SIZE;
        page_count++;

        // 进度上报(可选)
        ota_progress_report(page_count);
    }
}

这个策略的关键是:喂狗频率要远大于看门狗超时时间。比如看门狗设500ms,你每20ms喂一次,就算某次擦写卡了200ms,也还有300ms的余量。

避坑指南:我曾经在一个项目里,把看门狗超时设成了100ms,Flash擦写一次要80ms。结果每次擦写都卡在临界点上,偶尔一次超时就复位了。后来我把超时改成500ms,问题就解决了。记住,看门狗的超时时间至少要大于最长的单次操作时间,再留50%的余量。

还有一个进阶技巧:在进入OTA模式之前,先记录一个标志位到备份寄存器。这样如果升级过程中看门狗复位了,Bootloader启动时检测到这个标志,就知道上次升级失败了,可以进入恢复模式,而不是傻乎乎地再跳转到损坏的应用程序。

// 进入OTA前,设置标志
HAL_RTCEx_BKUPWrite(&hrtc, RTC_BKP_DR1, 0xA5A5);

// Bootloader启动时检测
if (HAL_RTCEx_BKUPRead(&hrtc, RTC_BKP_DR1) == 0xA5A5)
{
    // 上次升级未完成,进入恢复模式
    enter_recovery_mode();
}
else
{
    // 正常启动,检查应用程序是否有效
    if (is_app_valid())
    {
        jump_to_app();
    }
}

4.4 综合实战:一个完整的跳转流程

好了,我们把今天讲的东西串起来,写一个完整的Bootloader跳转函数:

void bootloader_jump_to_app(void)
{
    uint32_t app_base = 0x08010000;
    uint32_t msp;
    pFunction entry;

    // 1. 检查应用程序有效性(CRC校验等)
    if (!is_app_valid(app_base))
    {
        // 应用程序无效,停留在Bootloader
        return;
    }

    // 2. 关闭所有外设
    deinit_all_peripherals();

    // 3. 关闭全局中断
    __disable_irq();

    // 4. 关闭SysTick
    SysTick->CTRL = 0;

    // 5. 清除所有挂起的中断
    NVIC->ICER[0] = 0xFFFFFFFF;
    NVIC->ICER[1] = 0xFFFFFFFF;
    NVIC->ICPR[0] = 0xFFFFFFFF;
    NVIC->ICPR[1] = 0xFFFFFFFF;

    // 6. 设置向量表
    SCB->VTOR = app_base;

    // 7. 获取栈指针和入口地址
    msp = *(volatile uint32_t*)app_base;
    entry = (pFunction)(*(volatile uint32_t*)(app_base + 4));

    // 8. 设置栈指针
    __set_MSP(msp);

    // 9. 跳转
    entry();

    // 永远不会执行到这里
    while(1);
}

这个函数我用了很多年,基本没出过问题。你拿去改改就能用。

最后说一句:跳转之前,记得把看门狗喂饱。不然刚跳过去,还没来得及初始化,看门狗就复位了,那可就太冤了。

本章小结:

  • 向量表重映射是跳转的前提,用SCB->VTOR实现
  • 跳转前必须关闭所有外设和中断,避免冲突
  • 看门狗喂狗策略要保证每步操作都有足够余量
  • 备份寄存器标志位可以实现断点续传和故障恢复

下一节我们讲固件包的生成与签名校验,那是OTA安全的第一道防线。到时候见。