Bootloader(引导加载程序
)的主要任务是引导加载并运行应用程序,我们的软件升级逻辑也一般在BootLoader中实现。本文将详细介绍BootLoader在单片机中的实现,包括STM32、GD32、NXP Kinetis等等的所有单片机,因为无论是什么样的芯片,它实现的逻辑都是一样的。
注意,本篇文章主要是介绍实现一个严谨的BootLoader需要掌握的基本知识和需要考虑的细节,如果不注意一些细节,应用层的代码很可能会受到影响。
NOR Flash和NAND Flash是两种常见的非易失性存储器(Flash Memory)类型,它们在内部结构、使用场景和性能方面存在一些显著的区别。以下是它们之间的一些主要区别:
总体而言,选择使用NOR Flash还是NAND Flash取决于具体的应用场景和需求。NOR Flash适用于需要随机访问和高擦写次数的应用,例如嵌入式系统中的代码存储。NAND Flash适用于大容量存储和顺序访问的应用,例如存储大型文件和媒体内容。
注意事项:
对于STM32等单片机来说,它们都内置了NOR Flash,都是支持XIP的。但对于一些高端的单片机来说,如I.MX RT系列的MCU,在硬件上就需要自己接Flash,用户可以接NOR也可以接NAND,对应了不同的引导方式,具体就需要查看芯片手册了。
当然,对于单片机的绝大多数场景来说,代码放在NOR Flash中跑的概率比较高,所以本篇文章介绍的也是基于NOR Flash的BootLoader的实现。
在程序中,通常会涉及到不同的段,这些段在内存中有着不同的属性和用途。以下是一些常见的程序段及其作用:
代码段(Text): 通常是只读的
存储程序的执行代码,包括可执行指令和常量数据。在程序运行时,代码段的内容会被加载到内存中,并且在执行期间不可修改。
数据段(Data): 包括初始化数据(initialized data)和未初始化数据(uninitialized data)
存储程序中的全局变量和静态变量。初始化数据在程序启动时会被初始化,而未初始化数据在程序启动时不会被初始化,其初始值为零或未定义。
只读数据段(Read-Only Data,rodata):
存储常量数据,如字符串常量、只读常量等。在程序运行时,rodata段的内容不能被修改。
未初始化数据段(BSS):
存储未初始化的全局变量和静态变量。在程序启动时,BSS段的内容被初始化为零或未定义的值。
栈(Stack):
存储函数的局部变量和函数调用的状态信息。栈是一个先进后出(FILO)的数据结构,用于支持函数调用和返回。
堆(Heap):
用于存储动态分配的内存,例如通过 malloc()
或 new
分配的内存。堆的管理通常由程序员负责,需要手动分配和释放内存。
对于不同的IDE来说,编译后生成的程序的镜像格式都不太一样,常见的有以下几种:
不管什么格式,都是为不同下载器或者调试而服务的,经过解析后下载进MCU内部FLASH的数据还是bin格式。
嵌入式Flash由多个块(block
)组成,每个块包含了在该块内进行读取、擦除和写入时所需的电路。大多数闪存都存在一个限制:不允许在同一块内在执行擦/写操作的同时,执行读取操作(比如CPU从Flash读取指令运行代码)。
举个例子,如果有一段代码在block1中执行,那在这个代码的执行期间,不允许对block1中的任何部分进行擦/写,这可能会导致读写冲突,进而引发错误。
以下是两个解决办法:
(1)从不同的Flash块执行命令
如果MCU有多个Flash块,可以将擦/写Flash的代码放置在一个块中,而将其它代码或数据存储在另一个块中。
(2)从SRAM执行Flash命令
如果MCU只有一个Flash块,或用户在每个可用块内都要存放代码和写入,在这些场景中,可以将Flash命令移到SRAM中执行。
RWW
(Read while Writing
),注意Flash有RWW
特性表示支持一个块擦写的同时在另一个块进行写,我手上MK64的芯片的内置Flash是支持RWW
的。但大部分都是不支持RWW
的,所以最好还是把Flash相关函数链接到RAM中。这里我将以NXP的Kinetis K系列芯片为例进行BootLoader的实现,我使用的芯片为MK64FN1M0xxx12
,官方的开发板为FRDM-K64F
。
接下来我们就来在一个新的平台中,如何一步一步地通过阅读芯片手册来实现BootLoader。
如下图所示:
所以在我们使用的芯片中有自带Flash,而且分为了两个block,其中block 0的范围是0x00000~0x7FFFF
;block 1的范围是0x80000~0xFFFFF
,也就是两个block各有512KB。另外,在上电后程序将从0地址取值运行。
在前面的程序镜像文件格式中,我们知道更新程序无非就是将原始的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);
在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。
__attribute__((section("段名")))
,或者直接将整个Flash相关的函数所在的文件使用Exclude从Flash链接段中去除,然后在RAM中声明。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
时钟的使能。
首先我们要规定一下BootLoader的大小,假设我们给BootLoader留40KB的大小(需要保证编译出来的BootLoader的bin文件小于40KB),那么在0~0xA000部分就存放BootLoader的代码,从0xA000开始就存放应用程序的代码。当然我们的程序大小不能超过block1,因为block1和block2的内存虽然在逻辑上是连续的,但是CPU无法从block1读取一半指令,又从block2读取一半指令执行。如下图所示:
对于APP来说,它的偏移现在在0xA000处,所以我们要在IDE中修改链接脚本,将程序链接到0xA000处,我这里使用的是IAR,只需要更改它的链接文件.icf
中的__ICFEDIT_intvec_start__
即可(变量名可能不同,具体参考你目录下的链接脚本):
define symbol __ICFEDIT_intvec_start__ = 0x0000A000; /*-User Application Base-*/
对于Keil和IAR,我同样写过文章分析其链接脚本的格式,大家可以参考一下:
我们必须保证程序在进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; }
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();
在更新完固件后,我们需要跳转到位于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]; }
前面获取了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();
下面来列举一下BootLoader的实现步骤:
1、退出低功耗:如果芯片支持的话,需要关闭
2、关闭MPU:建议关闭,否则代码中需要兼容Cache
3、开启所有GPIO的时钟:非必要,可在用到具体某个外设时再打开
4、配置系统时钟树:建议使用芯片内部的时钟作为主时钟源
5、初始化Flash:包括Flash参数的配置、Flash时钟的配置、拷贝代码到SRAM
6、更新固件
实际上就是可以通过UART、SDCARD、USB等各种外设(记得初始化这些外设的引脚)获取最新的固件,然后调用mem_erase
和mem_write
函数将固件写入Flash中。
7、清理上下文:上下文保持一致
8、获取SP和PC值,设置MSP/PSP/VTOR
9、跳转APP
另外,对于固件升级来说,还有两点需要考虑。
1、可靠升级:如果在固件升级的过程中,已经把0xA000处之前的APP擦掉了,准备写入新的固件,但此时如果突然设备断电,那么就没有程序了,原来的程序也不能运行,所以我们还需要保证BootLoader的可靠。
2、加密:现在反汇编的技术已经很成熟了。我最近使用的I.MX RT1170直接硬件自带了OTFAD引擎,可以边解密AES-128加密的代码边运行,可见加密的重要性。而对于这些普通的MCU来说,我们也可以自己设计加密算法。对于MK64来说,有AES解密的引擎,但没有这个功能的MCU也没关系,我们也可以自己解密。可以参考我写的两篇关于AES的文章:
我这里给大家提供一个思路,下图是我在MK64平台中实现的BootLoader:
这里我使用了AES-128加密,从串口/SDCARD直接边读取加密固件,边解密原始固件到0x80000开始处的位置。同时我在APP的头字段中包含一些字段(很多中断向量表都是空的,可以用来存储一些Boot信息),其中包括CRC字段,解密完后可以用于校验固件的合法性。校验完后将APP从0x80000处拷贝到0xA000处,这样就也保证了可靠升级。最后再校验一次0xA000处的CRC,就表示升级成功了。
本文介绍了对于实现一个BootLoader需要考虑的方面,其实本文更多的是想传递一种严谨的思想,而不是从网上随便复制一段代码就去用。在你严谨地做事的同时,就会考虑到更多的东西,比如这里你可能还会学到MPU、低功耗、内存屏障等知识,正是对这一个个知识的好奇、深入理解并积累,同时保持严谨的态度,你才会在不知不觉中成为“高手”。