Skip to content

MicroBoot简介

MicroBoot 是一个由模块化代码组成的框架,旨在简化和加速嵌入式单片机程序的升级和开发过程。

开源地址:

github:https://github.com/Aladdin-Wang/MicroBootRom

1、引言

那些让人崩溃的BootLoader升级灾难

你有没有为写BootLoader无从下手发愁过?

你有没有为出厂的产品还需要开盖刷程序苦恼过?

你有没有为程序升级失败,”而抓耳挠腮过?

你有没有因为固件升级Bug被老板当众灵魂拷问:“为啥烧进去就起不来了?”

2、MicroBoot:彻底的解决升级难题

问题1:bootloader 在跳转到app前没有清理干净存在的痕迹

问题描述: 固件更新完毕后从 bootloader 跳转至 APP 前需对所用的外设 deinit ,以使外设恢复至上电时的初始状态。但是当使用复杂的外设收取固件包时, deinit 也将变得复杂,甚至很难排除对 APP 的影响。因此最佳的方法是升级完固件进行软复位,再次进入bootloader在程序运行之前,先通过判断标志的方式,来直接跳转到app,这样就相当于给APP提供了一个干净的外设环境。

程序复位执行流程如下图所示:

updata

设置标志位的常见方法

传统的设置标志位以实现复位后保留状态的方法通常有以下两种::

  • 备份寄存器:利用芯片中的不受软件复位影响的可供用户使用的寄存器 (如 STM32 中的备份寄存器);

  • No-Init 数据段:将标志位放在特殊的内存段(例如.bss.noinit),使其在复位后避免被重新初始化。

问题2: APP单独运行没有问题,通过Bootloader跳转到APP运行莫名死机

问题描述:在近几年的嵌入式社区中,流传着不少关于面相Cortex-MBootloader科普文章,借助这些文章,一些较为经典的代码片断和技巧得到了广泛的传播。

在从Bootloader跳转到用户APP的过程中,使用函数指针而非传统的汇编代码则成了一个家喻户晓的小技巧。相信类似下面 JumpToApp() 函数,你一定不会感到陌生:

typedef  void (*pFunction)(void);

void JumpToApp(uint32_t addr)
{
  pFunction Jump_To_Application;

  __IO uint32_t StackAddr;
  __IO uint32_t ResetVector;
  __IO uint32_t JumpMask;

  JumpMask = ~((MCU_SIZE-1)|0xD000FFFF);

  if (((*(__IO uint32_t *)addr) & JumpMask ) == 0x20000000) 
  {
    StackAddr = *(__IO uint32_t*)addr;
    ResetVector = *(__IO uint32_t *)(addr + 4);

    __set_MSP(StackAddr); 
    Jump_To_Application = (pFunction)ResetVector;
    Jump_To_Application(); 
  }
}

但是这段家喻户晓,被世人奉为真理的代码,却隐藏着很深的BUG,相信很多小伙伴都遇到过通过Bootloader跳转到APP后,程序时好时坏的灵异事件,具体详情请看这篇文章:震惊!这个隐藏的Bootloader漏洞究竟有多少人中招?

解决方案:

完全用汇编来处理从BootloaderApp的最后步骤,才是最稳定可靠的方案:

#if defined (__ARMCC_VERSION) && (__ARMCC_VERSION >= 6010050)
/* Avoids the semihosting issue */
__asm("  .global __ARM_use_no_argv\n");
#elif defined(__GNUC__)
/* Disables part of C/C++ runtime startup/teardown */
void __libc_init_array (void) {}
#endif

#if defined(__CC_ARM)
__asm void modify_stack_pointer_and_start_app(uint32_t r0_sp, uint32_t r1_pc)
{
    MOV SP, R0
    BX R1
}
#elif defined(__GNUC__)
void modify_stack_pointer_and_start_app(uint32_t r0_sp, uint32_t r1_pc)
{
    uint32_t z = 0;
    __asm volatile (  "msr    control, %[z]   \n\t"
                      "isb                    \n\t"
                      "mov    sp, %[r0_sp]    \n\t"
                      "bx     %[r1_pc]"
                      :
                      :   [z] "l" (z),
                      [r0_sp] "l" (r0_sp),
                      [r1_pc] "l" (r1_pc)
                   );
}
#else
#error "Unknown compiler!"
#endif

温馨提示:看不懂汇编没关系,复制到AI帮你理解!

问题3: 需要 APP 传递信息给 Bootloader

问题描述:在一些嵌入式设备中,由于产品型号和应用场景的不同,固件升级的接口(如 UART、CAN、USB等)和波特率配置需要改变。这种情况下,很多人的思路是希望bootloader也能自己给自己更新,但是这不就更加增加了工程的复杂性,变成了无限套娃!正确的解法应该是参考内置在单片机的bootrom那样,bootrom提供必要的接口API,可以让app传递参数给Bootloader,尽量让bootloader固化,一个稳定可靠的bootloader,是产品快速迭代的基石!,开发bootloader不能像开发app那样的思路野蛮生长!

解决方案:

1.定义传递信息的用户数据结构体:

BootLoader定义了一个默认的用户数据结构体,一共192个字节,APP可以在192个字节内随意向bootloader传递数据:

// <o>The user data size
//  <i>Default: 192
#define USER_DATA_SIZE            192

typedef struct {
    char chProjectName[16];
    char chHardWareVersion[16];
    char chSoftBootVersion[16];
    char chSoftAppVersion[16];  
} msgSig_t;
typedef struct {
    union {
        msgSig_t sig;
        uint8_t B[USER_DATA_SIZE];
    } msg_data;
} user_magic_data_t;

2.提供类似bootrom的函数接口:

BootLoader提供了一组函数的API,并将接口位置固定到BOOT_FLASH_OPS_ADDR地址,这样APP就可以方便的调用了

typedef struct {
    void (*fnGoToBoot)(uint8_t *pchDate, uint16_t hwLength); 
    bool (*target_flash_init)(uint32_t addr); 
    bool (*target_flash_uninit)(uint32_t addr); 
    int  (*target_flash_read)(uint32_t addr, uint8_t *buf, size_t size); 
    int  (*target_flash_write)(uint32_t addr, const uint8_t *buf, size_t size); 
    int  (*target_flash_erase)(uint32_t addr, size_t size); 
} boot_ops_t;
#define BOOT_FLASH_OPS_ADDR 0x08001000
#define __ARM_AT(x) ".ARM.__at_"#x
#define ARM_AT(x) __ARM_AT(x)
#if defined(__CC_ARM)  // ARM Compiler 5 (AC5)
    #define BOOT_FLASH_SECTION __attribute__((at(BOOT_FLASH_OPS_ADDR)))
#elif defined(__ARMCC_VERSION) && (__ARMCC_VERSION >= 6000000)  // ARM Compiler 6 (AC6)
    #define BOOT_FLASH_SECTION __attribute__((section(ARM_AT(BOOT_FLASH_OPS_ADDR))))
#elif defined(__GNUC__)  // GCC
    #define BOOT_FLASH_SECTION __attribute__((section(".boot_flash_ops"), used, aligned(4)))
#else
    #define BOOT_FLASH_SECTION
#endif

__attribute__((used))
static volatile const boot_ops_t tBootOps BOOT_FLASH_SECTION = {
    .fnGoToBoot = enter_bootloader,
    .target_flash_init = target_flash_init,
    .target_flash_erase = target_flash_erase,
    .target_flash_write = target_flash_write,
    .target_flash_read = target_flash_read,
    .target_flash_uninit = target_flash_uninit
};

3.APP的代码可以调用这些函数,并传递数据给bootloader:

APP需要重新定义用户数据,添加需要向bootloader传递的数据。

typedef struct {
    /*定义用户数据*/
    char chProjectName[16];
    char chHardWareVersion[16];
    char chSoftBootVersion[16];
    char chSoftAppVersion[16];
    char chPortName[16];
    int wPortBaudrate;
} msgSig_t;
typedef struct {
    union {
        msgSig_t sig;
        char B[sizeof(msgSig_t)];
    } msg_data;
} user_data_t;

user_data_t  tUserData = {
    .msg_data.sig.chProjectName = "app",
    .msg_data.sig.chHardWareVersion = "V1.0.0",
    .msg_data.sig.chSoftBootVersion = "V1.0.0",
    .msg_data.sig.chSoftAppVersion =  "V1.0.0",
};

typedef struct {
    void (*fnGoToBoot)(uint8_t *pchDate, uint16_t hwLength);
    bool (*target_flash_init)(uint32_t addr);
    bool (*target_flash_uninit)(uint32_t addr);
    int  (*target_flash_read)(uint32_t addr, uint8_t *buf, size_t size);
    int  (*target_flash_write)(uint32_t addr, const uint8_t *buf, size_t size);
    int  (*target_flash_erase)(uint32_t addr, size_t size);
} boot_ops_t;

比如通过UART接口来升级程序,就可以这样使用:

void uart_boot()
{
    rt_memcpy(tUserData.msg_data.sig.chPortName, "UART1", rt_strlen("UART1"));
    tUserData.msg_data.sig.wPortBaudrate = 115200;
    boot_ops_t *ptBootOps = (boot_ops_t *) 0x08001000;
    ptBootOps->fnGoToBoot((uint8_t *)tUserData.msg_data.B, sizeof(tUserData));
    rt_hw_cpu_reset();
}
MSH_CMD_EXPORT(uart_boot, go to bootloader);

APP的程序如果有操作flash的需求,便可以直接使用bootloader提供的这组API了。这样不仅APP和Bootlader可以共用一个flash驱动来节省空间,还有一个更妙的作用:

如果你使用的是类似STM32H750这样的芯片,内部只有128K的flash,一般内部flash的128k是用来做bootloader,app程序是需要跑在外部nor flash中的,我们知道nor flash是不支持Read-while-write特性的,因此在APP程序中,是不支持对flash进行擦写的。但是如果app调用的是bootloader提供的flash驱动函数时,程序已经运行在了单片机的内部flash中,这个时候便可以对nor flash进行擦写了!如果你碰巧又有保存数据的需求,那就妥妥的为你省了另外一颗flash芯片,这省下的都是成本!

4.防止参数掉电丢失,将数据固化在flash合理的位置:

该方案在APP分配的FLASH空间末端一共占用了570个字节,具体作用可以看下边的表格。为什么要放在app区域的末端,是一个巧妙的设计,聪明的你肯定会觉得flash是按照扇区擦除的,那这相当于占用了APP的一整个扇区,远远高于570个字节!但是实际上并不会占用整个扇区,app的代码依然可以使用最后一个扇区,具体原因可以往下看。

FLASH空间占用如下:

描述 用户数据备份区(192 字节) 用户数据区(192字节) Magic1 (64字节) Magic2 (64字节) Magic3 (64字节)
enter_bootloader 0XFFFFFFFF... user_data 0XFFFFFFFF 0XFFFFFFFF 0x55555555
begin_download user_data 0XFFFFFFFF... 0XFFFFFFFF 0x55555555 0XFFFFFFFF
finalize_download user_data 0XFFFFFFFF... 0x55555555 0x00000000 0XFFFFFFFF
  • APP 参数写入:在需要固件升级时,APP 会将接口类型、波特率等升级信息写入指定的用户数据区。
  • Boot参数读取:Bootloader 在启动时首先读取该存储区域,提取接口和波特率参数,并据此初始化通信配置。

用户数据在FLASH中的位置,以及变化过程如下图所示:

flash_user

bootloader在升级app程序的不同阶段数据变化:

阶段1:进入Bootloader(enter_bootloader)

  • 对于全片擦除过的单片机,此时Magic1Magic2的值为0xFFFFFFFF,表示还未开始下载过程,Magic3被设置为0x55555555,表明这是一个等待升级程序的状态。此时user data的值为0xFFFFFFFF,当APP调用enter_bootloader接口,将会把app传递进来的数据写进用户区。
void enter_bootloader(void)
{
    uint32_t wData = 0X55555555;
    target_flash_init(APP_PART_ADDR);   
    target_flash_write((APP_PART_ADDR + APP_PART_SIZE - (3*MARK_SIZE) - (USER_DATA_SIZE)), pchDate, USER_DATA_SIZE);
    target_flash_write((APP_PART_ADDR + APP_PART_SIZE - MARK_SIZE), (const uint8_t *)&wData,  sizeof(wData));
    target_flash_uninit(APP_PART_ADDR);
}

阶段2:开始下载(begin_download)

  • 当固件下载开始时,MicroBoot会首先对Magic所在的扇区擦除,然后将Magic2的值设置为0x55555555,并将用户区的数据重新写到用户数据备份区
  • 此时,Magic10xFFFFFFFFMagic3也保持为0xFFFFFFFF,这些状态便于系统在出现断电时判断下载是否已部分完成,从而支持断电重启还能留在bootloader,并且可以从用户数据备份区获取用户数据。
void begin_download(void)
{
    memset(chBootMagic, 0X55, sizeof(chBootMagic));
    target_flash_init(APP_PART_ADDR);   
    target_flash_erase(APP_PART_ADDR + APP_PART_SIZE - (3*MARK_SIZE), 3*MARK_SIZE);
    target_flash_write((APP_PART_ADDR + APP_PART_SIZE - (3*MARK_SIZE) - 2 * (USER_DATA_SIZE)), tUserMagicData.msg_data.B, USER_DATA_SIZE);
    target_flash_write((APP_PART_ADDR + APP_PART_SIZE - (2*MARK_SIZE)), chBootMagic[1], MARK_SIZE);
    target_flash_uninit(APP_PART_ADDR);
}

阶段3:完成下载(finalize_download)

  • 当固件下载完成且数据写入成功后,MicroBoot会将Magic1的值设置为0x55555555,标志着下载过程已顺利完成。

  • 此时,Magic2的值仍为0x55555555,而Magic3的值保持为0xFFFFFFFF,从而标识此阶段为下载完成、准备进入应用程序的状态。

void finalize_download(void)
{
    memset(chBootMagic, 0X55, sizeof(chBootMagic));
    target_flash_init(APP_PART_ADDR);   
    target_flash_write((APP_PART_ADDR + APP_PART_SIZE - 3*MARK_SIZE), chBootMagic[0], MARK_SIZE);
    target_flash_uninit(APP_PART_ADDR);
}

阶段4:重新进入Bootloader并跳转到APP

  • 在系统完成固件升级后,MicroBoot会执行软复位,系统重新进入bootloader。

  • bootloader在检查到Magic1Magic2均为0x55555555,而Magic30xFFFFFFFF时,就会识别到这是一个升级完成的状态。

  • 这时,MicroBoot无需对外设进行反初始化,而是直接跳转到APP,从而为应用程序提供一个干净的外设环境。

阶段5:从APP再次进入Bootloader

  • 在APP正常运行后,如果想再次进入bootloader进行升级,调用MicroBoot提供的进入bootloader的接口,将会把Magic3设置为0x55555555,复位后,将会再次回到阶段1。

  • 当下次进入bootloader时,看到Magic1Magic2Magic3均为0x55555555

程序在升级的全流程中,只在开始升级的时候,调用的begin_download()函数对app区域进行了全部擦除,并不会对app的最后一个扇区做单独的擦除操作,因此并不会影响app代码使用最后一个扇区,也就是bootloader使用的标志及数据,仅占用了app区域的最后570个字节!

bootloader安全跳转代码如下:

__attribute__((constructor))
static void enter_application(void)
{
    target_flash_init(APP_PART_ADDR);
    do {
        // User-defined conditions for entering the bootloader
        if(user_enter_bootloader()){
            target_flash_read((APP_PART_ADDR + APP_PART_SIZE - (3 * MARK_SIZE) - USER_DATA_SIZE), tUserMagicData.msg_data.B, USER_DATA_SIZE);
            break;          
        }
        // Read the magic values from flash memory to determine the next action
        target_flash_read((APP_PART_ADDR + APP_PART_SIZE - 3 * MARK_SIZE), chBootMagic[0], 3 * MARK_SIZE);

        // Check if Magic3 is 0X55555555, indicating to read user data from a specific location
        if ((0X55555555 == *(uint32_t *)&chBootMagic[2]) || (0 == *(uint32_t *)&chBootMagic[2])) {
            target_flash_read((APP_PART_ADDR + APP_PART_SIZE - (3 * MARK_SIZE) - USER_DATA_SIZE), tUserMagicData.msg_data.B, USER_DATA_SIZE);
            break;
        }
        // Check if Magic2 is 0X55555555,Magic1 is !0X55555555, indicating to read user data from a specific location
        if ((0X55555555 == *(uint32_t *)&chBootMagic[1]) && (0X55555555 != *(uint32_t *)&chBootMagic[0])) {
            target_flash_read((APP_PART_ADDR + APP_PART_SIZE - (3 * MARK_SIZE) - 2 * USER_DATA_SIZE), tUserMagicData.msg_data.B, USER_DATA_SIZE);
            break;
        }

        //Check if the value at the address (APP_PART_ADDR + 4) has the expected application identifier
        uint32_t wValue = 0;
        target_flash_read((APP_PART_ADDR + APP_PART_OFFSET), (uint8_t *)&wValue, 4);
        target_flash_uninit(APP_PART_ADDR);     
        // If all checks are passed, modify the stack pointer and start the application
        #ifdef __riscv
        if ((wValue) == (0xffffffff) || (wValue) == (0) ) {
            break;
        }       
        modify_stack_pointer_and_start_app((APP_PART_ADDR + APP_PART_OFFSET));
        #else
        // Check if the value at the address (APP_PART_ADDR + 4) has the expected application identifier
        if ((wValue & 0xff000000) != (APP_PART_ADDR & 0xff000000)) {
            break;
        }       
        modify_stack_pointer_and_start_app(*(volatile uint32_t *)APP_PART_ADDR,
                                           (*(volatile uint32_t *)(APP_PART_ADDR + APP_PART_OFFSET)));
        #endif
    } while(0); 
    target_flash_uninit(APP_PART_ADDR); 
}

函数修饰符 __attribute__((constructor))告诉编译器在程序启动时自动调用这个函数。即在主程序的 main() 函数之前执行,它的主要功能是检查系统当前的状态,并根据状态决定是进入APP还是停留在bootloader。代码中增加了用户自定义的进入bootloader条件,代码通过调用user_enter_bootloader()检查用户是否指定了进入bootloader模式,这个检查是为了给用户留出手动控制的空间,比如通过外部按键强制进入bootloader,或者延时启动app,如果返回值为true,则直接退出函数,保持在bootloader中。

问题4: APP有bug,跳转后,直接死机怎么办?

很多人做 Bootloader 时,都会遇到一个经典问题:

设备一上电就直接跳转到 APP,一旦 APP 异常,设备就“锁死”了

比如:

  • APP 崩溃
  • 向量表错误
  • Flash 写坏
  • 升级中断

结果:设备无法再次进入 Bootloader,直接变砖。

解决方案:

  • 上电先进入 Boot,给一个“安全时间窗口”,等待用户下发升级指令。
  • 通过外部按键等接口,上电强制留在bootloader。

microboot在上电执行跳转之前,为用户预留了一个user_enter_bootloader的弱函数,用户可以通过实现这个函数,来自定义强制进入bootloader的方式。

__attribute__((weak))
bool user_enter_bootloader(void)
{
    return false;
}

在 Bootloader 设计中,一个关键问题是:

  • 如何区分“上电复位”和“软复位”?

因为:

  • 只有上电时,才需要进入 Boot 的安全窗口
  • 软复位后,应该直接进入 APP

解决方法是使用“不被复位初始化的变量(NOINIT),通过修改链接脚本,将一段内存定义为 NOINIT 区域

LR_IROM1 0x08000000 0x00040000  {    ; load region size_region
  ER_IROM1 0x08000000 0x00040000  {  ; load address = execution address
   *.o (RESET, +First)
   *(InRoot$$Sections)
   .ANY (+RO)
   .ANY (+XO)
  }
  RW_IRAM1 0x20000000 0x0000C000  {  ; RW data
   .ANY (+RW +ZI)
  }
  RW_IRAM_NO_INIT +0 UNINIT  {
    * (.bss.noinit)
  }   
}

温馨提示:看不懂链接脚本没关系,复制到AI帮你理解!

放在这个noinit段里的变量具有如下特性:

场景 变量是否保留
上电(Power On) 不确定(随机值)
软复位(Reset) 保留

所以设计思路就很明确了:

  • 上电时变量无效 → 进入 Boot
  • 倒计时结束 → 设置变量 + 软复位
  • 复位后变量有效 → 进入 APP

代码如下:

/* 
 * @brief  Boot control flag stored in NOINIT section.
 *         This variable is preserved across reset and used to control
 *         whether the system should jump to APP after reboot.
 */
NOINIT
static volatile uint32_t s_wEnterAppFlag;
#define ENTER_APP_FLAG   0x55555555 /* Magic value indicating that APP entry is allowed */

/*
 * @brief  Check if entering APP is allowed.
 * @retval true  APP can be entered
 * @retval false Stay in bootloader
 */
bool can_enter_app(void)
{
    return (s_wEnterAppFlag == ENTER_APP_FLAG);
}

/*
 * @brief  Enable APP entry.
 *         This sets the flag so that after reboot the system jumps to APP.
 */
void enable_enter_app(void)
{
    s_wEnterAppFlag = ENTER_APP_FLAG;
}

/*
 * @brief  Decide whether to stay in bootloader.
 * @retval true  Stay in bootloader
 * @retval false Jump to APP
 */
bool user_enter_bootloader(void)
{
    return !can_enter_app();
}

/*
 * @brief  Delay timer callback.
 *         If timeout occurs and APP entry is not yet enabled,
 *         enable it and reboot to enter APP.
 *
 * @param  status Timer status
 * @param  pTag   User context (unused)
 */
void on_delay_event(multiple_delay_report_status_t status, void *pTag)
{
    user_magic_data_t *ptMagicData = (user_magic_data_t *)pTag;
    if (status == MULTIPLE_DELAY_TIMEOUT) {
        if(!can_enter_app()){
            enable_enter_app();
            reboot();/* Reboot to apply APP entry */
        }
    }
}

问题5: 自定义升级协议传输不稳定

问题描述:对于许多工业或者车载项目,现场环境干扰很多,会导致数据传输不稳定,常常因为收到错误数据导致升级失败,需要重新开始升级,很浪费时间。

解决方案:

MicroBoot 集成了成熟的 Ymodem 文件传输协议 来实现固件升级。相比简单的裸数据传输,Ymodem 在可靠性和工程可用性上有明显优势。

  • 分包传输 + 自动校验
  • 自动重传机制(抗干扰核心)

有了 Bootloader 之后,还需要一个工具来发送固件。传统做法要么自己开发上位机,要么借助xshell这种支持ymodem协议的串口助手,流程繁琐且容易出错。为此我专门给我制作的microlink下载器内置 Ymodem 协议,支持一键升级,无需额外开发上位机,也不需要关心协议细节。连接设备后即可完成固件下载,大幅降低使用门槛,尤其适合现场和售后升级场景。

升级演示视频如下:

问题6: 固件需要加密传输?还需要兼容更多的协议?能不能支持A/B分区升级呀?什么!还想要差分升级?

MicroBoot 从一开始就没有试图“包打天下”,而是只做一件事:

  • 把最核心的“安全启动 + 跳转机制”做到极致稳定

除此之外:

  • 所有升级能力全部模块化、可插拔

MicroBoot 不是一个“功能很多的 Bootloader”,而是一个“可以承载任何功能的 Bootloader 框架”

3、MicroBoot架构

3.1 层次框架

框架.drawio

3.2 模块化组件

  • 环形队列

一个用C语言编写的支持多类型、函数重载与线程安全的环形队列

  • 信号槽

一个用C语言模拟QT的信号槽的功能

  • 发布订阅

  • shell

  • ymodem

  • multiple_delay

一个基于统一 tick 的轻量级多路软件延时服务,支持同时管理多个延时请求,并按高/中/低优先级分别在中断或主循环中分发超时回调

4、MicroBoot移植教程