单片机BootLoader严谨实现详解(以STM32、GD32和NXP为例)

Bootloader(引导加载程序)的主要任务是引导加载并运行应用程序,我们的软件升级逻辑也一般在BootLoader中实现。本文将详细介绍BootLoader在单片机中的实现,包括STM32、GD32、NXP Kinetis等等的所有单片机,因为无论是什么样的芯片,它实现的逻辑都是一样的。

注意,本篇文章主要是介绍实现一个严谨的BootLoader需要掌握的基本知识和需要考虑的细节,如果不注意一些细节,应用层的代码很可能会受到影响。

  • 对于Linux的BootLoader来说其实也是一样的,但它还需要初始化MMU、引导内核等等,这里我们不做过多的讨论。
  • 文章目录

  • 1 基础知识
  • 1.1 NOR Flash和NAND Flash
  • 1.2 程序数据段
  • 1.3 程序镜像文件格式
  • 1.4 Flash相关函数需要放入RAM中执行?
  • 2 BootLoader实现实例
  • 2.1 查看芯片的Flash映射
  • 2.2 Flash的擦写
  • 2.2.1 Flash擦写的代码
  • 2.2.2 重定位Flash擦写的代码到SRAM中
  • 2.3 MPU、低功耗和时钟的操作
  • 2.4 BootLoader内存分配
  • 2.5 链接脚本修改
  • 2.7 上下文保持一致
  • 2.8 获取SP和PC
  • 2.9 跳转APP
  • 2.9 BootLoader完整流程
  • 3 待优化
  • 4 总结
  • 1 基础知识

    1.1 NOR Flash和NAND Flash

    NOR Flash和NAND Flash是两种常见的非易失性存储器(Flash Memory)类型,它们在内部结构、使用场景和性能方面存在一些显著的区别。以下是它们之间的一些主要区别:

    1. 内部结构:
    2. NOR Flash: NOR Flash的内部结构类似于传统的存储器单元,支持随机访问。因此,它适用于需要快速随机访问的应用场景,例如执行代码(XIP,eXecute In Place)。
    3. NAND Flash: NAND Flash的内部结构更适合大容量、顺序读写的应用场景。它采用页和块的结构,通常需要使用控制器来管理读写操作。
    4. 执行方式(XIP – Execute In Place):
    5. NOR Flash: 由于其支持随机访问,NOR Flash 可以直接在存储器中执行代码(XIP),无需将代码加载到RAM中。
    6. NAND Flash: 通常需要将代码从NAND Flash加载到RAM中才能执行,因为它不太适合随机访问。
    7. 位反转(Bit Inversion):
    8. NOR Flash: NOR Flash 通常不需要位反转,即代码可以直接在Flash中运行,无需进行位翻转。
    9. NAND Flash: 由于NAND Flash内部的存储单元是多级存储,读取时可能需要对数据进行位反转,以确保正确的数据解析。
    10. 擦写次数:
    11. NOR Flash: NOR Flash 的擦写次数相对较高,通常可以达到数百万次,使其更适用于作为代码存储器。
    12. NAND Flash: NAND Flash 的擦写次数相对较低,通常在几千次到几百万次之间,取决于具体的 NAND Flash 类型。因此,对于频繁擦写的应用,可能需要考虑其他存储器类型。

    总体而言,选择使用NOR Flash还是NAND Flash取决于具体的应用场景和需求。NOR Flash适用于需要随机访问和高擦写次数的应用,例如嵌入式系统中的代码存储。NAND Flash适用于大容量存储和顺序访问的应用,例如存储大型文件和媒体内容。


    注意事项:
    对于STM32等单片机来说,它们都内置了NOR Flash,都是支持XIP的。但对于一些高端的单片机来说,如I.MX RT系列的MCU,在硬件上就需要自己接Flash,用户可以接NOR也可以接NAND,对应了不同的引导方式,具体就需要查看芯片手册了。

    当然,对于单片机的绝大多数场景来说,代码放在NOR Flash中跑的概率比较高,所以本篇文章介绍的也是基于NOR Flash的BootLoader的实现。

  • 对于Linux来说,由于单单编译出来的内核本身就很大,而NOR Flash的成本较高,所以更常见的是将程序存储在一些NON-XIP的介质中,如EMMC、SD卡、NAND Flash,然后上电后将程序拷贝到SDRAM中运行。当然上电拷贝的程序也需要实现,一般芯片会自带一个很小的NOR Flash,里面存放一些固定的启动代码,当然不同厂商芯片的实现不同。
  • 1.2 程序数据段

    在程序中,通常会涉及到不同的段,这些段在内存中有着不同的属性和用途。以下是一些常见的程序段及其作用:

    1. 代码段(Text): 通常是只读的

      存储程序的执行代码,包括可执行指令和常量数据。在程序运行时,代码段的内容会被加载到内存中,并且在执行期间不可修改。

    2. 数据段(Data): 包括初始化数据(initialized data)和未初始化数据(uninitialized data)

      存储程序中的全局变量和静态变量。初始化数据在程序启动时会被初始化,而未初始化数据在程序启动时不会被初始化,其初始值为零或未定义。

    3. 只读数据段(Read-Only Data,rodata):

      存储常量数据,如字符串常量、只读常量等。在程序运行时,rodata段的内容不能被修改。

    4. 未初始化数据段(BSS):

      存储未初始化的全局变量和静态变量。在程序启动时,BSS段的内容被初始化为零或未定义的值。

    5. 栈(Stack):

      存储函数的局部变量和函数调用的状态信息。栈是一个先进后出(FILO)的数据结构,用于支持函数调用和返回。

    6. 堆(Heap):

      用于存储动态分配的内存,例如通过 malloc()new分配的内存。堆的管理通常由程序员负责,需要手动分配和释放内存。

    1.3 程序镜像文件格式

    对于不同的IDE来说,编译后生成的程序的镜像格式都不太一样,常见的有以下几种:

  • AXF:用于基于ARM的微控制器。它包含可执行代码、数据和调试信息。AXF文件通常在开发和调试过程中使用
  • HEX:由十六进制数及其对应的内存地址组成,可以将程序解析和编程到目标设备的内存中
  • S19:以特定格式的ASCII字符表示二进制数据。S19文件包含数据和内存地址,常用于编程旧的微控制器和EEPROM
  • ELF:包含可执行代码、数据和其他加载和执行程序所需的信息,可用于调试、分析和部署到目标设备
  • SREC:类似于S19的文件格式。它以ASCII字符表示二进制数据,但遵循不同的格式
  • BIN:BIN文件是直接包含可执行机器代码的二进制文件。它们通常用于以原始二进制格式存储最终编译的代码。
  • 不管什么格式,都是为不同下载器或者调试而服务的,经过解析后下载进MCU内部FLASH的数据还是bin格式

    1.4 Flash相关函数需要放入RAM中执行?

    嵌入式Flash由多个块(block)组成,每个块包含了在该块内进行读取、擦除和写入时所需的电路。大多数闪存都存在一个限制:不允许在同一块内在执行擦/写操作的同时,执行读取操作(比如CPU从Flash读取指令运行代码)

    举个例子,如果有一段代码在block1中执行,那在这个代码的执行期间,不允许对block1中的任何部分进行擦/写,这可能会导致读写冲突,进而引发错误。

    以下是两个解决办法
    (1)从不同的Flash块执行命令
    如果MCU有多个Flash块,可以将擦/写Flash的代码放置在一个块中,而将其它代码或数据存储在另一个块中。
    (2)从SRAM执行Flash命令
    如果MCU只有一个Flash块,或用户在每个可用块内都要存放代码和写入,在这些场景中,可以将Flash命令移到SRAM中执行。

    2 BootLoader实现实例

    这里我将以NXP的Kinetis K系列芯片为例进行BootLoader的实现,我使用的芯片为MK64FN1M0xxx12,官方的开发板为FRDM-K64F

  • 不同MCU的BootLoader实现原理都相同,希望大家能学到一些通用的知识,而不是特定于某个单片机的。
  • 接下来我们就来在一个新的平台中,如何一步一步地通过阅读芯片手册来实现BootLoader。

    2.1 查看芯片的Flash映射

    如下图所示:

    所以在我们使用的芯片中有自带Flash,而且分为了两个block,其中block 0的范围是0x00000~0x7FFFF;block 1的范围是0x80000~0xFFFFF,也就是两个block各有512KB。另外,在上电后程序将从0地址取值运行。

    2.2 Flash的擦写

    2.2.1 Flash擦写的代码

    在前面的程序镜像文件格式中,我们知道更新程序无非就是将原始的bin文件写到Flash中,所以最重要的一步就是看看芯片内置Flash如何通过程序进行擦写。

    首先我们要知道,在写Flash之前必须保证所有的内存为0xFF,这是因为写操作只能将电平从1改为0,所以我们在写入Flash之前,必须要先对Flash进行擦除(一般是以块为单位进行)。

    不同的芯片有不同的Flash控制器,这个一般在SDK中有提供相应的Flash驱动,这里不就做详细地分析了。在MK64中,初始化完Flash后可以调用下面两个函数来擦除和写入Flash:

    status_t mem_erase(uint32_t address, uint32_t length);
    status_t mem_write(uint32_t address, uint32_t length, const uint8_t *buffer);
    

    2.2.2 重定位Flash擦写的代码到SRAM中

    在MK64内存映射中,我们知道MK64中有两个block,每个block为512KB,就有前面所说的“Flash相关函数需要放入RAM中执行”的问题,那么第一个解决方案(单独将Flash函数放到第二个block上)其实不太实用,而且很麻烦。所以我们更多使用的是将Flash相关函数重定位到SRAM中执行。

    对于MK64的Flash来说,由于是内部的Flash,对于Flash的读写操作来说,只需要更改FTFE寄存器即可。比如如果要擦除某个sector,只需要将这个sector的相关信息填充到FTFE对应的寄存器中,然后将FTFE_FSTAT寄存器的第7位CCIF置1,即可根据我们填充的参数来启动Flash操作。

    所以我们实际上只需要填充好相应的Flash操作寄存器,然后将CCIF位置为1,然后硬件会将CCIF清零,然后我们再等待CCIF置1即可。对于填充寄存器部分,由于没有运行代码,所以可以在Flash中运行,而对于操作CCIF标志位的部分,我们需要将其重定位到SRAM中运行,以下是CCIF位操作的代码:

    void flash_run_command(FTFx_REG_ACCESS_TYPE ftfx_fstat)
    {
        // clear CCIF bit
        *ftfx_fstat = FTFx_FSTAT_CCIF_MASK;
    
        // Check CCIF bit of the flash status register, wait till it is set.
        // IP team indicates that this loop will always complete.
        while (!((*ftfx_fstat) & FTFx_FSTAT_CCIF_MASK))
        {
        }
    }
    

    我们只要保证这个函数在SRAM中运行就行了,所以我们先将这个函数编译出来,然后通过.map内存映射文件,将去bin文件反汇编objdump,然后找到这个函数在汇编上的机器码,我们这里保存为数组:

    const static uint16_t s_flashRunCommandFunctionCode[] = {
        0x2180, /* MOVS  R1, #128 ; 0x80 */
        0x7001, /* STRB  R1, [R0] */
        /* @4: */
        0x7802, /* LDRB  R2, [R0] */
        0x420a, /* TST   R2, R1 */
        0xd0fc, /* BEQ.N @4 */
        0x4770  /* BX    LR */
    };
    

    然后我们再初始化Flash的时候,将这个机器码拷贝到SRAM中即可,然后使用一个函数指针指向拷贝到的位置,就可以调用这个函数了:

    // 声明函数callFlashRunCommand(对应上面的flash_run_command)
    static void (*callFlashRunCommand)(FTFx_REG_ACCESS_TYPE ftfx_fstat);
    // 声明保存二进制代码的数组
    #define kFLASH_ExecuteInRamFunctionMaxSizeInWords 16U
    static uint32_t s_flashRunCommand[kFLASH_ExecuteInRamFunctionMaxSizeInWords];
    // 拷贝二进制码到数组中
    memcpy((void *)&s_flashRunCommand, (void *)s_flashRunCommandFunctionCode, sizeof(s_flashRunCommandFunctionCode));
    // 将callFlashRunCommand函数指针指向数组地址
    callFlashRunCommand = (void (*)(FTFx_REG_ACCESS_TYPE ftfx_fstat))((uint32_t)s_flashRunCommand + 1);
    

    这样后续调用callFlashRunCommand函数,就和flash_run_command函数是一个效果,但是callFlashRunCommand 就是在RAM中运行的了。前面说了Flash的所有操作,擦除、写入等等函数,最终都是会置CCIF位来启动Flash控制器进行操作,所以最后只要保证擦除、写入等封装好的函数最后调用的是callFlashRunCommand函数启动即可。


    细心的人可能发现上面强制转换时s_flashRunCommand还加了1:
    在ARM架构中,函数指针的值通常是奇数。这是因为ARM处理器使用Thumb指令集,而Thumb指令集中的指令是16位的,因此函数的地址通常是2的倍数。由于函数指针的最低位是用来指示Thumb指令集的状态的,所以函数指针的值通常是奇数。

    然而,实际上函数在内存中的存储地址是偶数。因为Thumb指令集中的指令是16位的,而ARM处理器要求指令在内存中的地址是4的倍数。因此,当你想要获取函数在内存中的真实地址时,你需要将函数指针的值加上1,以得到实际的偶数地址。

    简而言之,通过执行 “+1” 操作,你可以将奇数的函数指针值调整为函数实际在内存中的偶数地址,以正确访问函数的二进制代码。这是在处理ARM函数指针时经常需要考虑的一种调整。


    当然,如果你的MCU还支持对Flash的数据进行缓存的话,那就还需要将清除缓存的函数重定位到SRAM中:

    // 函数原型:这里不做详细分析了,实际就是控制寄存器
    void flash_cache_clear_command(FTFx_REG32_ACCESS_TYPE ftfx_reg)
    {
        *ftfx_reg = (*ftfx_reg & ~FMC_PFB01CR_CINV_WAY_MASK) | FMC_PFB01CR_CINV_WAY(~0);
        *ftfx_reg |= FMC_PFB0CR_S_INV_MASK;
        __ISB();
        __DSB();
    }
    
    // 函数二进制
    const static uint16_t s_flashCacheClearCommandFunctionCode[] = {
        0x6801,         /* LDR  R1, [R0] */
        0x22f0,         /* MOVS R2, #240    ; 0xf0 */
        0x0412,         /* LSLS R2, R2, #16 */
        0x430a,         /* ORRS R2, R2, R1 */
        0x6002,         /* STR  R2, [R0] */
        0xf3bf, 0x8f6f, /* ISB */
        0xf3bf, 0x8f4f, /* DSB */
        0x4770          /* BX   LR */
    };
    
    // 声明函数指针
    static void (*callFlashCacheClearCommand)(FTFx_REG32_ACCESS_TYPE ftfx_reg);
    // 声明数组
    #define kFLASH_ExecuteInRamFunctionMaxSizeInWords 16
    static uint32_t s_flashCacheClearCommand[kFLASH_ExecuteInRamFunctionMaxSizeInWords];
    // 拷贝函数
    memcpy((void *)s_flashCacheClearCommand, (void *)s_flashCacheClearCommandFunctionCode, sizeof(s_flashCacheClearCommandFunctionCode));
    // 设置函数指针
    callFlashCacheClearCommand = (void (*)(FTFx_REG32_ACCESS_TYPE ftfx_reg))((uint32_t)flashCacheClearCommand + 1);
    // 调用例子
    callFlashCacheClearCommand((FTFx_REG32_ACCESS_TYPE)&MCM->PLACR);
    

    在每次擦除、写完Flash之后,都需要调用这个函数flush一下cache。

  • 注意:在我这个例子中使用机器码的方式手动拷贝这些函数到SRAM中,实际上比较通用的做法是在链接脚本中定义一个链接在RAM中的段,同时把这个段链接到SRAM中,然后在函数声明的地方加上__attribute__((section("段名"))) ,或者直接将整个Flash相关的函数所在的文件使用Exclude从Flash链接段中去除,然后在RAM中声明。
  • 2.3 MPU、低功耗和时钟的操作

    1、MPU
    对于MPU来说,在我之前的文章中有详细地介绍MPU内存保护单元详解及例子,感兴趣的可以看一下。

    MPU是Cortex-M系列芯片都有的一个特性,它涉及到Cache的一些问题,如果使能的话,对于一些直接与硬件接触的操作,如我们希望在BootLoader中实现通过USB获取固件并升级,而USB一般使用了DMA,这样的话数据的一致性会受到影响。当然我们可以使用CMSIS中的SCB_CleanDCache等函数在执行DMA之前清理一下D-Cache,但这些都太麻烦了,这里建议在BootLoader中直接关掉MPU

    2、低功耗
    MK64芯片支持低功耗模式,为了防止在固件升级的过程中进入低功耗而引发Flash的未知状态,我们需要将低功耗模式关闭。当然有的芯片是自动开启低功耗,有的则是没有开启低功耗,我的建议还是以防万一,在上电时关闭一下低功耗。
    MK64中通过SMC(System Mode Controller,系统模式控制器)中的PMCTRL中的RUNM位控制低功耗模式:

    我们在上电之后将这两个位置为0即可,表示进入正常运行模式。

    3、时钟
    我们在BootLoader中可能会使用到一些外设,我们可以在启动时就将所有GPIO的时钟打开。当然也可以在使用的时候再单独打开,比如要使用串口,在串口初始化函数中初始化时钟也行。

    在MK64中通过SIM(System Integration Module,系统集成模块)的SCGC5寄存器可以控制GPIOA~GPIOE时钟的使能。

    2.4 BootLoader内存分配

    首先我们要规定一下BootLoader的大小,假设我们给BootLoader留40KB的大小(需要保证编译出来的BootLoader的bin文件小于40KB),那么在0~0xA000部分就存放BootLoader的代码,从0xA000开始就存放应用程序的代码。当然我们的程序大小不能超过block1,因为block1和block2的内存虽然在逻辑上是连续的,但是CPU无法从block1读取一半指令,又从block2读取一半指令执行。如下图所示:

    2.5 链接脚本修改

    对于APP来说,它的偏移现在在0xA000处,所以我们要在IDE中修改链接脚本,将程序链接到0xA000处,我这里使用的是IAR,只需要更改它的链接文件.icf中的__ICFEDIT_intvec_start__即可(变量名可能不同,具体参考你目录下的链接脚本):

    define symbol __ICFEDIT_intvec_start__ = 0x0000A000;  /*-User Application Base-*/
    

    对于Keil和IAR,我同样写过文章分析其链接脚本的格式,大家可以参考一下:

  • IAR中ICF链接文件详解和实例分析
  • KEIL中SCF分散加载链接文件详解和实例分析
  • 2.7 上下文保持一致

    我们必须保证程序在进BootLoader前是什么状态,在进APP前就应该是什么状态。

    我的真实经历是,同事在BootLoader中使用UART升级,打开了UART中断,但退出BootLoader时没有关闭这个中断。于是在APP的初始化函数中,将数据段复制到RAM中的时候,这个中断就会影响拷贝的值。比如你在程序中初始化了一个char *a = "123";,但实际上a的值可能为1a3

    详细的步骤如下:
    1、清理Flash的缓存:一般Flash有一个flush类似的函数,保证之前的Flash操作都执行完毕
    2、清除所有中断标志位:主要是控制NVIC寄存器,参考代码如下:

    __STATIC_INLINE void NVIC_ClearEnabledIRQs(void)
    {
        NVIC->ICER[0] = 0xFFFFFFFF;
        NVIC->ICER[1] = 0xFFFFFFFF;
        NVIC->ICER[2] = 0xFFFFFFFF;
        NVIC->ICER[3] = 0xFFFFFFFF;
        NVIC->ICER[4] = 0xFFFFFFFF;
        NVIC->ICER[5] = 0xFFFFFFFF;
        NVIC->ICER[6] = 0xFFFFFFFF;
        NVIC->ICER[7] = 0xFFFFFFFF;
    }
    
    __STATIC_INLINE void NVIC_ClearAllPendingIRQs(void)
    {
        NVIC->ICPR[0] = 0xFFFFFFFF;
        NVIC->ICPR[1] = 0xFFFFFFFF;
        NVIC->ICPR[2] = 0xFFFFFFFF;
        NVIC->ICPR[3] = 0xFFFFFFFF;
        NVIC->ICPR[4] = 0xFFFFFFFF;
        NVIC->ICPR[5] = 0xFFFFFFFF;
        NVIC->ICPR[6] = 0xFFFFFFFF;
        NVIC->ICPR[7] = 0xFFFFFFFF;
    }
    
  • 执行上面两个函数即可,但上面的代码是基于Cortex-M4或Cortex-M7内核的,其它内核自行参考内核手册的NVIC章节编写。
  • 3、设置VTOR为默认值

    kDefaultVectorTableAddress = 0
    SCB->VTOR = kDefaultVectorTableAddress;
    

    4、恢复时钟
    比如程序中用到了USB的话,系统时钟速率在之前应该配置地很高,这里需要恢复最初始的时钟配置。同时如果前面开启了所有GPIO的时钟的话,这里也要全部关闭。比如使用了UART,打开了对应GPIO的时钟的话,需要在此关闭。

    对于MK64来说,如果打开USB的话,配置时钟的时候还使能了这些位,都需要关闭。

    5、使能中断
    这和我们刚刚清理的中断标志位不一样,在上电后默认总中断的相应是使能的,为了进一步处理中断请求并继续系统的正常运行,需要重新使能系统对于中断的相应。

    __enable_irq()
    

    6、内存屏障
    最后我们确保指令和数据的一致性以及正确的执行顺序,这里是保证在APP跳转之前我们的这些设置都起作用了。当然这里的__DSB可以省略,因为我们前面更改的都是强有序内存(这些系统内存即使不使能MPU也是强有序的)。这里更多地考虑的是平台之间的兼容,如代码从Cortex-M4移动到Cortex-M7一样可以使用。

    __ISB();
    __DSB();
    

    2.8 获取SP和PC

    在更新完固件后,我们需要跳转到位于0xA000处的APP,现在的问题是,APP的堆栈指针是什么,应该将PC指针设置为多少才能跳转到APP中。
    获取SP和PC
    如下图所示,实际上固件的0地址存放的就是堆栈指针,在上电后硬件将设置MSP(主堆栈指针)的值为bin文件0偏移处的值。

    我们再来看一下APP的.s启动文件:

    可以看到第一个果然是堆栈指针,这里的CSTACK可以在链接脚本中指定。同时我们发现第二个向量是Reset_Handler函数的地址,我们将PC值设置为Reset_Handler的值不就可以跳转到APP了吗?获取这两个值的函数如下:

    #define APP_VECTOR_TABLE ((uint32_t *)0xA000)
    static void get_user_application_entry(uint32_t *appEntry, uint32_t *appStack)
    {
    	*appEntry = APP_VECTOR_TABLE[1];
        *appStack = APP_VECTOR_TABLE[0];
    }
    

    2.9 跳转APP

    前面获取了SP(appStack)和PC(appEntry),这里就派上用场了。但在跳转APP之前,我们还需要做两件事:
    1、设置堆栈指针
    因为前面说的是上电的时候硬件会设置SP,所以仅仅设置的是BootLoader中的SP,对于APP的堆栈指针需要我们自己设置:

    __set_MSP(appStack);
    __set_PSP(appStack);
    

    实际上我们只需要设置MSP就行了,PSP如果使用了RTOS自然会设置。但这里我们还是给PSP一个默认值。

    2、设置向量表地址
    同样地,上电后硬件设置的是BootLoader的向量表,我们要将其设置为APP的向量表位置:

    #define APP_VECTOR_TABLE ((uint32_t *)0xA000)
    SCB->VTOR = (uint32_t)APP_VECTOR_TABLE;
    

    最后我们就可以跳转到APP了,声明一个函数指针,然后指向Reset_Handler,然后执行即可更改PC指针为Reset_Handler

    static void (*farewellBootloader)(void) = 0;
    farewellBootloader = (void (*)(void))appEntry;
    farewellBootloader();
    

    2.9 BootLoader完整流程

    下面来列举一下BootLoader的实现步骤:
    1、退出低功耗:如果芯片支持的话,需要关闭
    2、关闭MPU:建议关闭,否则代码中需要兼容Cache
    3、开启所有GPIO的时钟:非必要,可在用到具体某个外设时再打开
    4、配置系统时钟树:建议使用芯片内部的时钟作为主时钟源
    5、初始化Flash:包括Flash参数的配置、Flash时钟的配置、拷贝代码到SRAM
    6、更新固件
    实际上就是可以通过UART、SDCARD、USB等各种外设(记得初始化这些外设的引脚)获取最新的固件,然后调用mem_erasemem_write函数将固件写入Flash中。
    7、清理上下文:上下文保持一致
    8、获取SP和PC值,设置MSP/PSP/VTOR
    9、跳转APP

    3 待优化

    另外,对于固件升级来说,还有两点需要考虑。
    1、可靠升级:如果在固件升级的过程中,已经把0xA000处之前的APP擦掉了,准备写入新的固件,但此时如果突然设备断电,那么就没有程序了,原来的程序也不能运行,所以我们还需要保证BootLoader的可靠。

    2、加密:现在反汇编的技术已经很成熟了。我最近使用的I.MX RT1170直接硬件自带了OTFAD引擎,可以边解密AES-128加密的代码边运行,可见加密的重要性。而对于这些普通的MCU来说,我们也可以自己设计加密算法。对于MK64来说,有AES解密的引擎,但没有这个功能的MCU也没关系,我们也可以自己解密。可以参考我写的两篇关于AES的文章:

  • AES加密(1):AES基础知识和计算过程
  • AES加密(2):AES代码实现解析
  • 我这里给大家提供一个思路,下图是我在MK64平台中实现的BootLoader:

    这里我使用了AES-128加密,从串口/SDCARD直接边读取加密固件,边解密原始固件到0x80000开始处的位置。同时我在APP的头字段中包含一些字段(很多中断向量表都是空的,可以用来存储一些Boot信息),其中包括CRC字段,解密完后可以用于校验固件的合法性。校验完后将APP从0x80000处拷贝到0xA000处,这样就也保证了可靠升级。最后再校验一次0xA000处的CRC,就表示升级成功了。

    4 总结

    本文介绍了对于实现一个BootLoader需要考虑的方面,其实本文更多的是想传递一种严谨的思想,而不是从网上随便复制一段代码就去用。在你严谨地做事的同时,就会考虑到更多的东西,比如这里你可能还会学到MPU、低功耗、内存屏障等知识,正是对这一个个知识的好奇、深入理解并积累,同时保持严谨的态度,你才会在不知不觉中成为“高手”。

    物联沃分享整理
    物联沃-IOTWORD物联网 » 单片机BootLoader严谨实现详解(以STM32、GD32和NXP为例)

    发表评论