MCU基础-BootLoader简易实现

BootLoader原理在MCU基础章节有相关理解与总结。

最近在HC32F460上搞OTA升级,自然绕不开BootLoader。整体思路很朴素:Boot放在Flash开头,负责升级和跳转;APP挪到后面干活。芯片是HC32F460PETB,512K Flash,规划Boot占前16K,APP从0x4000开始:

|← 16K BootLoader →|←——————— APP ———————→|
0x00000000         0x00004000            0x0007FFFF
graph TD
    Reset[上电复位] --> Boot[BootLoader @ 0x0000]
    Boot -->|有升级标志| OTA[搬运固件到APP区]
    OTA --> Jump[跳转]
    Boot -->|无升级| Jump
    Jump --> APP[APP @ 0x4000]

经过理论的熏陶后,这似乎看起来很简单,但实际动手还是踩了一些坑:地址不知道改哪、LENGTH算错、链接报错看不懂、Keil和GCC行为还不一样、跳转代码里埋雷……这些问题统一整理在文末,正文先把实现跑通。

1. 实现思路

设计两个独立工程:My-Bootloader(Boot)、My-OTA(APP),同源码库结构,各自维护一份.ld和hc32f4xx_conf.h。

要做的事情就三件:

  1. APP搬家:告诉链接器”程序从0x4000开始放”,并且中断向量表要跟着搬;
  2. Boot跳转:从APP区取出SP和复位向量,关干净中断,切栈跳过去;
  3. 两边配套:Boot关掉的中断,APP要负责打开。

对应到具体修改点:

位置 工程侧 作用 本例取值
.ld链接脚本的MEMORY ORIGIN APP 代码真正的链接地址(核心) 0x00004000
.ld的LENGTH APP APP可用区域大小 496K
VTOR(向量表偏移) APP 中断向量表重定位 0x4000
LL_ICG_ENABLE APP 关闭ICG段(不归APP管) DDL_OFF
__enable_irq() APP 恢复被Boot关掉的总中断 -
跳转地址 Boot 跳转入口 0x4000
LL_ICG_ENABLE Boot 保持打开(ICG烧在0x400) DDL_ON

注意:其中三处地址必须严格一致,.ld的ORIGIN == APP的VTOR == Boot跳转地址。向量表偏移还有个硬件约定:必须128字节(0x400)对齐!0x4000满足要求。

2. APP工程改动

2.1. 链接脚本(.ld)

MEMORY区块里把FLASH的起点挪到0x4000,长度记得减掉Boot占的16K:

/* My-OTA: HC32F460xE.ld */
MEMORY
{
    FLASH       (rx): ORIGIN = 0x00004000, LENGTH = 496K    /* 512K - 16K */
    RAM        (rwx): ORIGIN = 0x1FFF8000, LENGTH = 188K
    RET_RAM    (rwx): ORIGIN = 0x200F0000, LENGTH = 4K
}

RAM不用动:Boot跳转APP后,RAM会被APP的启动代码重新初始化,不用给Boot让位。

2.2. 向量表重定位(VTOR)

system_hc32f460.c里把VECT_TAB_OFFSET改成APP起始地址,SystemInit()里已经写好了设置代码,改宏就行:

/* Vector Table base offset field */
#ifndef VECT_TAB_OFFSET
#define VECT_TAB_OFFSET                 (0x4000UL)     /*!< This value must be a multiple of 0x400. */
#endif

void SystemInit(void)
{
    // ...
    /* Configure the Vector Table relocation */
    SCB->VTOR = VECT_TAB_OFFSET;    /* Vector Table Relocation */
}

注意:This value must be a multiple of 0x400. 硬性约定向量表偏移,如果不满足会怎样呢?中断会无法进入吗?

2.3. 关闭ICG段

APP不占0地址,ICG段(详见常见问题)留着只会报链接错误,直接关掉:hc32f4xx_conf.h里

#define LL_ICG_ENABLE                               (DDL_OFF)

同时把.ld里钉死ICG地址的这段删掉(或注释),避免以后误开开关再报错:

/* 删除:
.icg_sec 0x00000400 :
{
    KEEP(*(.icg_sec))
} >FLASH
*/

2.4. 恢复总中断

Boot跳转前会__disable_irq(),这个状态会带进APP,而启动代码和SystemInit()都不会自动打开。所以APP的main早期(任何外设中断使能之前)补一句:

void main(void)
{
    __enable_irq();     /* Boot关的,APP来开 */
    // ...外设初始化
}

3. BootLoader工程改动

Boot的.ld保持从0地址开始,LL_ICG_ENABLE保持DDL_ON,基本不用动。核心就是跳转函数。

3.1. 跳转函数

先看最初能跑的版本:

static void JumpToApp(uint32_t u32AppAddr)
{
    uint32_t u32AppSp = *(volatile uint32_t *)u32AppAddr;        // 1. 拿APP的堆栈指针
    uint32_t u32AppPc = *(volatile uint32_t *)(u32AppAddr + 4);  // 2. 拿APP的复位向量
    __set_MSP(u32AppSp);                                         // 3. 设主堆栈
    void (*pAppReset)(void) = (void (*)(void))u32AppPc;
    pAppReset();                                                 // 4. 跳!
}

原理:APP向量表的前两个字就是初始SP和Reset_Handler地址(和正常上电复位CPU做的事情一样),取出来设好MSP直接跳。烧进去实测能跳,happy path是通的。

但这是”裸跳”,坑不少(详见常见问题),加固版:

static void JumpToApp(uint32_t u32AppAddr)
{
    uint32_t u32AppSp = *(volatile uint32_t *)u32AppAddr;
    uint32_t u32AppPc = *(volatile uint32_t *)(u32AppAddr + 4U);

    /* 防空:APP未烧写/被擦除(擦除态为0xFFFFFFFF) */
    if ((u32AppSp | u32AppPc) == 0xFFFFFFFFUL) {
        return;   /* 留在Boot,调用方while(1)或等待升级 */
    }

    __disable_irq();
    SysTick->CTRL = 0U;                       /* 关Boot用过的中断源 */

    for (int i = 0; i < 5; i++) {             /* 清144个IRQ:失能+清pending */
        NVIC->ICER[i] = 0xFFFFFFFFUL;
        NVIC->ICPR[i] = 0xFFFFFFFFUL;
    }
    __DSB();  __ISB();

    __set_MSP(u32AppSp);
    __ISB();
    ((void (*)(void))u32AppPc)();
    while (1);
}

几个要点:

  1. 防空检查:空片时0x4000处是0xFFFFFFFF,不做检查直接__set_MSP(0xFFFFFFFF)就HardFault了,两行代码防生产事故;
  2. 关中断源+清NVIC:Boot用过的SysTick、定时器、串口中断都还在跑,只关全局中断不够干净,把NVIC的使能位和pending位也清了(HC32F460是Cortex-M4,144个IRQ,5组32位寄存器);
  3. __DSB()/__ISB():保证前面的寄存器操作真正生效后再切栈;
  4. 切栈后不回头:__set_MSP()之后当前函数就没有栈了,所以所有取值都提前做完,切完直接跳。

3.2. main流程

void main(void)
{
    System_Init();
    LOG_ERROR("Boot V0.1");

    // todo 读外部Flash、校验固件、搬运到APP区(OTA主体,后续补充)

    JumpToApp(APP_START_ADDR);

    while (1);     /* 正常不会到这,防空跳失败 */
}

建议裸数字0x4000统一用宏APP_START_ADDR,.ld、VTOR、跳转三处共用一个定义,避免改一处漏两处。

4. 实测效果

  1. 先不要修改APP工程偏移地址,保证APP程序内容能正常运行,这里工程仅设计了指示灯与日志心跳打印功能。
  2. 修改偏移地址,并重新编译。此时单独烧录APP工程无法正常工作。
  3. 编译BootLoader工程。
  4. 将APP工程改动和BootLoader工程编译出来的.hex文件merge一下,重新烧录。观察指示灯与日志心跳,日志正常打印,证明程序跳转成功。

运行日志如下,可以看到已经成功跳转。

00> [INFO ]  cmn_log.c:50 _SEGGER_RTT: 0x1FFF8904
00> [INFO ]  main.c:41 SystemCoreClock: 200000000Hz
00> [INFO ]  main.c:42 ->APP
00> [DEBUG]  bsp_led.c:57 Heart beats, 0
00> [DEBUG]  bsp_led.c:57 Heart beats, 1
00> [DEBUG]  bsp_led.c:57 Heart beats, 2
00> [DEBUG]  bsp_led.c:57 Heart beats, 3
00> [DEBUG]  bsp_led.c:57 Heart beats, 4
00> [DEBUG]  bsp_led.c:57 Heart beats, 5
00> [DEBUG]  bsp_led.c:57 Heart beats, 6
00> [DEBUG]  bsp_led.c:57 Heart beats, 7
...

5. 要点总结

  1. 改程序地址是改.ld的ORIGIN,配套改APP的VTOR和Boot的跳转地址,三处一致;
  2. LENGTH是从ORIGIN开始的大小,APP偏移后记得减:LENGTH = 总容量 - 偏移量;
  3. RAM不用给Boot让位,APP启动代码会重新初始化;
  4. HC32的ICG段钉死在0x400,只属于占0地址的BootLoader;APP关掉LL_ICG_ENABLE,Boot保持打开;
  5. 跳转前:防空检查、关中断、清NVIC、切MSP;跳转后:APP记得__enable_irq()。

常见问题

改程序地址是改CMake的objcopy吗?

不是。工程是CMake+GCC工具链,CMakeLists里生成hex/bin的后处理命令里有个--change-addresses参数:

add_custom_command(TARGET ${PROJECT_NAME}.elf POST_BUILD
    COMMAND ${CMAKE_OBJCOPY} -O ihex --change-addresses 0x0000 $<TARGET_FILE:${PROJECT_NAME}.elf> ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/${PROJECT_NAME}.hex
    COMMAND ${CMAKE_OBJCOPY} -O binary $<TARGET_FILE:${PROJECT_NAME}.elf> ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/${PROJECT_NAME}.bin
    COMMENT "Generating hex and binary files"
    COMMAND size $<TARGET_FILE:${PROJECT_NAME}.elf>
)

--change-addresses只是给生成的hex文件里的地址记录整体加一个偏移量,属于”输出文件层面的地址平移”,不会改变代码真正被链接到的地址。而且这里写的是0x0000,等于完全没作用(顺手删掉更干净)。如果改成比如0x10000,只会得到一个”看起来”在新地址的hex,固件内部所有绝对地址(向量表、函数跳转、常量访问)仍是旧地址,烧进去程序必跑飞!这里要注意

LENGTH需要改吗?程序过大怎么办?

需要。LENGTH是从ORIGIN开始算的这段区域的大小,不是芯片总容量。写成512K的话,0x00004000 + 512K = 0x00084000,超出芯片实际Flash结尾0x0007FFFF整整16K。

Q: 程序过大,会写到芯片别的地方吗?
A: LENGTH写对时不会。链接器只会把代码放进.ld定义的FLASH区域,放不下时链接直接报错,根本不会生成hex/bin:

region `FLASH' overflowed by 1234 bytes
section `.text' will not fit in region `FLASH'

链接器没有任何”自动找别的地方放”的行为——每个section只会进显式指定的MEMORY区域,放不下就报错。这就是LENGTH要写准的意义:编译期保险。LENGTH写错时才是慢性毒药:程序涨到496K~512K之间,链接器误以为空间够,链接”成功”,hex里包含芯片上根本不存在的地址,烧写时报错或静默截断,程序跑飞。

真正会”写到别处”的唯一途径:在.ld里显式定义了多个区域(比如末尾的Data Flash、OTA备份区),并把section分配过去。不定义、不分配就不会去。监控程序大小看POST_BUILD里size的输出,text + data就是占用的Flash大小,和496K(507904字节)比较即可。

烧写会写到Flash以外的地方吗?

不会,烧写只进Flash。hex/bin文件里只包含可装载段(LOAD sections):.text(代码)、.rodata(const)、.data的初始值(也存在Flash)。RAM相关的段(.bss、栈、堆)根本不在烧写镜像里。

RAM是运行时程序自己初始化的:启动代码把.data初值从Flash拷贝到RAM、把.bss清零,之后栈、堆、变量都在RAM里跑。Data Flash(EEPROM备份区)、OTP这些区域,链接器完全不碰,只有代码调用Flash编程接口才会写。

链接报错:.icg_sec is not within region FLASH

APP地址改好后编译,报错:

ld.exe: address 0x420 of output/MyOTA.elf section `.icg_sec' is not within region `FLASH'

顺着.icg_sec这个段名一路翻库代码,因果链如下:

  1. hc32f4xx_conf.h里LL_ICG_ENABLE配置为DDL_ON;
  2. 于是hc32_ll_icg.c编译了u32ICGValue[]数组,GCC下放进.icg_sec段:
#if defined (__ARMCC_VERSION) && (__ARMCC_VERSION >= 6010050)
const uint32_t u32ICGValue[] __attribute__((section(".ARM.__at_0x400"))) =
#elif defined (__GNUC__) && !defined (__CC_ARM)
const uint32_t u32ICGValue[] __attribute__((section(".icg_sec"))) =
// ...
  1. .ld里这个段被强制固定地址:
.icg_sec 0x00000400 :
{
    KEEP(*(.icg_sec))
} >FLASH
  1. APP的FLASH已经从0x4000开始了,0x400落在BootLoader区域里、不在FLASH区域内 → 链接器发现0x400~0x420(8个字,32字节)越界 → 报错。

这里要先搞清楚ICG是什么:ICG(Initial Configuration Group,初始配置组)是HC32F460的硬件特性,芯片复位时只会从Flash 0x400处读取ICG配置(紧跟着0x0~0x3FF的向量表区域),包括看门狗、Flash读保护、HRC频率选择等上电默认行为。它只对”占着0地址的那份程序”有意义——也就是BootLoader。APP根本不该带ICG段,就算强行塞进去也没用,硬件不会读它。

Q: 为什么Keil编译同样代码”没事”?
A: 因为链接器对”绝对地址段”的处理机制不同。Keil(armlink,默认--autoat)遇到.ARM.__at_0x400这类段,会自动另开一个小region把它放在0x400,不要求这个地址落在你定义的IROM1(0x4000起)里,所以链接照样过。GCC(ld)没有autoat机制,先钉死地址0x400、又要它属于FLASH区域,两个条件矛盾就直接报错。GCC是”把矛盾喊出来”,Keil是”默默兼容”。

但Keil”没事”其实是假象:armlink生成的hex里仍然包含0x400处的这32字节ICG数据。用J-Flash直接烧APP的hex → 会把这32字节写进BootLoader的地盘(覆盖Boot的ICG),碰巧两边ICG值相同时看不出来,一旦配置不同就埋雷;走OTA升级(Boot只写APP区)→ 这32字节被忽略,无害但纯属冗余。所以正确做法与编译器无关:APP工程就该关掉LL_ICG_ENABLE,Keil只是没提醒你,GCC提醒了。

JumpToApp为什么不能直接裸跳?

Q: 为什么必须关中断?
A: Boot已经使能的SysTick/定时器/USART还在跑。跳转后分两个阶段出问题:APP设好VTOR之前触发中断 → 仍走Boot的向量表,但此时MSP已经是APP的栈,现场错乱;APP设好VTOR之后触发 → 走APP的处理函数,但APP从没使能过这个外设,逻辑错乱。所以既要关全局中断,也要把NVIC的使能位和pending位清干净。

Q: PRIMASK怎么会”遗传”给APP?
A: __disable_irq()设置的PRIMASK=1会一直带进APP。而APP的startup和SystemInit()里都没有__enable_irq()——若APP的main里也没有,中断永远不工作。两边必须配套:Boot关,APP开。

Q: 为什么要做防空检查?
A: 空片或APP被擦除时,*(uint32_t*)0x4000 == 0xFFFFFFFF(Flash擦除态),__set_MSP(0xFFFFFFFF)直接HardFault。SP/PC任一为0xFFFFFFFF就应留在Boot等待升级,两行代码防生产事故。

Q: __set_MSP()之后有什么讲究?
A: 切完MSP后当前函数就”没有栈”了。裸跳版先取值再切栈、-O2下值都在寄存器里,实际能跑;严谨做法是切完加__ISB()(清空流水线,保证后续取指用新状态),并保证之后不碰栈上局部变量。

参考资料

results matching ""

    No results matching ""