这是我们的Zero to Main()系列的第三个POST,在这个系列中,我们将在acortex-M系列微控制器上从零代码引导工作固件。
在此之前,我们编写了一个启动文件来引导我们的C环境,并编写了一个linkerscript来在正确的地址获取正确的数据。这两个将允许我们编写一个可以在我们的微控制器上加载和运行的单片固件。
实际上,这不是大多数固件的结构。仔细研究一下vendorSDK,您会注意到它们都推荐使用引导加载程序来加载您的应用程序。引导加载器是一个小程序,负责加载和启动应用程序。
在这篇文章中,我们将解释为什么你可能需要bootloader,如何实现一个,并介绍一些你可以用来使你的bootloader更有用的高级技术。
最常见的情况是,您可能需要引导加载程序来加载软件。有些微控制器,如Dialog的DA14580,几乎没有板载闪存,而是依赖外部设备来存储固件代码。在这种情况下,引导加载程序的工作是将代码从不可执行存储(如SPI闪存)复制到可以执行的内存区域(如RAM)。
引导加载器还允许您将任务关键型或有安全隐患的程序部分与定期更改的应用程序代码解耦。例如,您的引导加载程序可能包含固件更新日志,因此无论应用程序固件中的错误有多严重,您的设备都可以恢复。
最后但并非最不重要的一点是,引导加载器是可信引导体系结构的重要组件。例如,您的引导加载程序可以验证加密签名,以确保应用程序没有被替换或篡改。
让我们一起构建一个简单的引导加载器。要开始,我们的引导加载器必须做两件事:
我们需要确定内存映射,编写一些引导加载器代码,并更新ouApplications以使其可引导加载。
在本例中,我们将使用与之前Zeroto主帖子中相同的设置:
我们必须首先决定要为引导加载程序分配多少空间。代码空间非常宝贵-您的应用程序可能会需要更多空间-如果不更新引导加载程序,您将无法更改此空间,因此请尽可能将其设置为较小。
另一个重要因素是闪存扇区大小:您希望确保可以在不擦除引导加载程序数据的情况下擦除应用程序扇区,反之亦然。因此,引导加载程序区域必须以闪存扇区边界(通常为4kB)结束。
0x0+-+|引导加载器|0x4000+。
/*memory_map.ld*/memory{boot trom(Rx):Origin=0x00000000,Length=0x00004000 Apm(RX):Origin=0x00004000,Length=0x0003C000 ram(Rwx):Origin=0x20000000,Length=0x00008000}__boot trom_start__=Origin(Boot Trom);__boot trom_size__=length(Boot Trom);__Approm_start__=Origin(Apm);__Approm_Size_=length(Apm);
由于链接器脚本是可组合的,因此我们将能够将内存映射包含到为引导加载程序和应用程序编写的链接器脚本中。
您会注意到上面的链接器脚本声明了一些变量。我们需要这些来让我们的引导加载程序知道在哪里可以找到应用程序。为了使它们在C代码中可访问,我们在一个头文件中声明它们:
接下来,让我们编写一些引导加载程序代码。我们的引导加载程序需要在引导时开始执行,然后跳转到我们的应用程序。
从上一篇文章中我们知道了如何完成第一部分:我们需要一个地址为0x0的有效堆栈指针,以及一个设置地址为0x4的环境的有效的Reset_Handler函数。我们可以重用以前的启动文件和linkerscript,但有一个更改:我们使用memory_map.ld,而不是定义我们自己的内存节。
我们还需要根据内存将代码放在botrom区域,而不是上一篇文章中的rom区域。
/*bootloader.ld*/include memory_map.ld/*段定义*/段{.text:{Keep(*(.Vector.Vector.*))*(.text*)*(.rodata*)_etext=.;}>;boot trom...}。
要跳到我们的应用程序中,我们需要知道应用程序的Reset_Handler在哪里,以及要加载哪个堆栈指针。同样,我们从前面的POST中知道,这些应该是我们的二进制中的前两个32位字,所以我们只需要使用__APPROM_START__变量从我们的内存映射中取消对这些地址的引用。
/*bootloader.c*/#include<;inttyes.h>;#include";memory_map.h";int main(Void){uint32_t*app_code=(uint32_t*)__Apm_start__;uint32_t app_sp=app_code[0];uint32_t app_start=app_code[1];/*TODO:启动应用*//*未达到*/While(1){}。
接下来,我们必须加载堆栈指针并跳转到代码。这将需要少量汇编代码。
ARM MCU使用MSR指令将立即或寄存器数据加载到系统寄存器中,在本例中为MSPregister或“Main Stack Pointer”。
跳转到地址是通过分支完成的,在我们的例子中是使用bx指令。
我们将这两个文件封装到一个start_app函数中,该函数接受我们的pc和sp作为参数,并获得最小的引导加载器:
/*app.c*/#include<;inttyes.h>;#include";memory_map.h";static void start_app(uint32_t pc,uint32_t sp)__attribute__((裸)){__asm(";\n\msr msp,r1/*将R1加载到MSP*/\n\bx R0/*分支到R0*/\n\";);}int main(Void){uint32_t*app_code=(uint32_t*)__prom_start__;uint32_t app_sp=app_code[0];uint32_t app_start=app_code[1];start_app(app_start,app_sp);/*未达到*/while(1){}}。
注意:在将控制权转移到应用程序之前,必须取消初始化在引导加载程序中初始化的硬件资源。否则,您可能会违反应用程序代码对系统状态所做的假设。
我们必须更新我们的应用程序才能利用新的内存映射。通过更新我们的链接器脚本以包含memory_map.ld并将我们的部分更改为转到Approm区域而不是rom,可以再次做到这一点。
/*app.ld*/include memory_map.ld/*节定义*/节{.text:{Keep(*(.Vector.Vector.*))*(.text*)*(.rodata*)_etext=.;}>;Approm...}。
我们还需要更新微控制器使用的向量表。向量表包含我们系统中每个异常和中断处理程序的地址。当中断信号进入时,ARM内核将调用矢量表中相应偏移量处的地址。
例如,硬故障处理程序的偏移量是0xc,因此当遇到硬故障时,ARM内核将跳转到位于该偏移量的表中包含的地址。
默认情况下,向量表位于地址0x0,这意味着当我们的芯片上电时,只有引导加载程序可以处理异常或中断!幸运的是,ARM提供了向量表OffsetRegister来动态更改向量表的地址。寄存器位于地址0xE000ED08,布局简单:
其中TBLOFF是向量表的地址。在我们的示例中,这是我们的text部分或_stext的开始。要在我们的应用程序中设置它,我们向Reset_Handler添加以下内容:
/*start_samd21.c*//*设置向量表基地址*/uint32_t*Vector_table=(uint32_t*)&;_stext;uint32_t*vtor=(uint32_t*)0xE000ED08;*vtor=((Uint32_T)Vector_table&;0xFFFFFF8);
ARMv7-m架构的一个特殊之处是对矢量表的对齐要求,如参考手册的B1.5.3节所述:
向量表必须自然对齐到其对齐值大于或等于(支持的异常数x 4)的2的幂,最小对齐为128个字节。偏移量0处的条目用于初始化SP_Main的值,请参见第B1-8页的SP寄存器。所有其他条目必须设置位[0],因为该位用于定义异常条目上的EPSR T位(有关详细信息,请参见第B1-20页的重置行为和第B1-21页的异常条目行为)。
我们的SAMD21 MCU在16个系统保留异常的基础上有28个中断,表中总共有44个条目。把它乘以4,结果是176。2的次幂是256,所以我们的向量表必须是256字节对齐的。
因为很难看到引导加载程序的执行,所以我们在每个程序中都添加了一行打印代码:
/*boot loader.c*/#include<;inttyes.h>;#include";memory_map.h";static void start_app(uint32_t PC,uint32_t sp){__ASM(";\n\MSR MSP,R1/*将R1加载到MSP*/\n\bx R0/*分支到R0*/\n\";);}int main(){Serial_init();printf(";引导加载器!\n";);ial_deinit();uint32_t*app_code=(uint32_t*)__Approm_start__;uint32_t app_sp=app_code[0];uint32_t app_start=app_code[1];start_app(app_start,app_sp);//在(1);}
/*app.c*/int main(){SERIAL_INIT();SET_OUTPUT(LED_0_PIN);printf(";App!\n";);而(TRUE){PORT_PIN_TOGGLE_OUTPUT_LEVEL(LED_0_PIN);for(INT i=0;i<;100000;++i){}}。
请注意,引导加载程序必须在启动应用程序之前取消初始化串行外设,否则您将很难再次尝试初始化它。
您可以编译这两个程序,并使用gdbb加载生成的ELF文件,这将把它们放在正确的地址。然而,更方便的做法是构建包含两个程序的单个二进制文件。
使用objcopy从ELF文件创建二进制文件。为了适应我们的用例,objcopy有一些方便的选项:
$arm-one-eabi-objcopy--help|grep-C2 pad-b--byte<;num>;在每个交错块中选择byte<;num>;,GAP-Fill<;val&>;用<;val&>;--pad-to<;addr&>;填充最后一段,直到地址<;addr>;--set-start<;addr>;将起始地址设置为<;addr>;{--CHANGE-START|--ADJUST-START}<;增量>;
-pad-to选项将把二进制文件填充到一个地址,而-ap-full将允许您指定要用来填充空白的字节值。因为我们要将固件写入闪存,所以应该用闪存的擦除值0xFF填充,并填充到引导加载程序的最大地址。
我们在Makefile中实现这些规则,以避免每次都必须键入它们:
#Makefile$(BUILD_DIR)/$(PROJECT)-app.bin:$(BUILD_DIR)/$(PROJECT)-app.elf$(OCPY)$<;$@-O BINARY$(SZ)$<;$(BUILD_DIR)/$(PROJECT)-boot.bin:$(BUILD_DIR)/$(PROJECT)-boot.elf$(OCPY)-pad-to=0x4000-GAP-Fill=0xFF-O BINARY$<;$@$(SZ)$<;
最后但并非最不重要的一点是,我们需要连接两个二进制文件。尽管这听起来可能很有趣,但这最好是使用CAT来实现:
到目前为止,我们的引导加载器并不是很有用,它只加载我们的应用程序。没有它我们也可以做得一样好。在接下来的几节中,我将介绍您可以使用引导加载器做的一些有用的事情。
使用引导加载器通常要做的一件事是监视稳定性。这可以通过相对简单的设置来完成:
应用程序稳定一段时间(例如1分钟)后,它会将计数器重置为0。
如果计数器达到3,引导加载程序不会启动应用程序,而是发出错误信号。
这需要在应用程序和引导加载器之间共享持久数据,这些数据在重新启动后仍会保留。在一些体系结构上,提供了非易失性寄存器,这使得这一点变得很容易。所有具有RTC备份寄存器的STM32微控制器都是这种情况。
通常,我们可以使用RAM区域来获得相同的结果。只要系统保持通电状态,即使设备重新启动,RAM也会保持其状态。
/*memory_map.ld*/memory{boot trom(Rx):Origin=0x00000000,Length=0x00004000 Apm(Rx):Origin=0x00004000,Length=0x0003C000 Shared(Rwx):Origin=0x20000000,Length=0x1000 ram(Rwx):Origin=0x20001000,Length=0x00007000}/*共享数据从共享区域原点开始*/_Shared_DATA_START=Origin(Shared);
然后,我们可以创建一个数据结构并将其分配给此节,并使用getters读取它:
/*shared.h*/#include<;inttyes.h>;uint8_t Shared_Data_Get_boot_count(Void);void Shared_Data_Incremental_boot_count(Void);void Shared_Data_Reset_boot_count(Void);/*shared.c*/#include";shared.h";extern uint32_t_Shared_Data_start;#杂注ma pack(推送)struct Shared_Data{uint8_t boot_count;};#杂注包(POP)struct Shared_Data*SD=(struct Shared_Data*)_Shared_Data_stat;uint8_t Shared_Data_Get_boot_count(Void){return SD->;boot_count;}void Shared_Data_Increment_boot_count(Void){SD->;boot_count++;}void Shared_Data_Reset_boot_count(Void){SD->;boot_count=0;}。
我们将共享模块编译到我们的应用程序和引导加载程序中,并且可以在这两个程序中读取引导计数。
更常见的是,引导加载器用于在应用程序执行之前重新定位它们。重新定位涉及将应用程序代码从一个位置复制到另一个位置以执行它。当您的应用程序存储在非执行内存(如SPI闪存)中时,这很有用。
/*memory_map.ld*/memory{boot trom(Rx):Origin=0x00000000,Length=0x00010000 Approm(Rx):Origin=0x00010000,Length=0x00004000 ram(Rwx):Origin=0x20000000,Length=0x00004000 ERAM(Rwx):Origin=0x20004000,Length=0x00004000}__botrom_start__=Origin(Boot Trom);__Approm_Start__=Origin(Approm);__Approm_Size__Length=(Approm);__ERAM_Start_=Origin(ERAM);__Bootrom_Size_Start_=Origin(Boot Trom);__Approm_Start__=Origin(Apm);_Approm_Size__Length=(Approm);_ERAM_Start_=Origin(ERAM);__ERAM_SIZE__=长度(ERAM);
在本例中,Approm是我们的应用程序存储,ERAM是我们要将程序复制到的可执行RAM。在执行代码之前,我们的引导加载程序需要将代码从Approm复制到ERAM。
我们从上一篇博客中了解到,可执行代码通常以.text部分结束,因此我们必须告诉链接器,该部分存储在Approm中,但是从ERAM执行,这样我们的程序才能正确执行。
这类似于我们的.data节,它存储在rom中,但在程序运行时驻留在ram中。我们使用AT链接器命令指定存储区域,使用>;操作符指定加载区域。这是生成链接器脚本部分:
/*app.ld*/sections{.text:{Keep(*(.Vector.Vector.*))*(.text*)*(.rodata*)}>;ERAM AT>;Approm...}。
然后,我们更新引导加载程序,以便在启动应用程序之前将代码从一个应用程序复制到另一个应用程序:
/*booloader.c*//*将APP代码复制到ERAM*/uint32_t*src=(uint32_t*)&;__Approm_start__;uint32_t*dst=(uint32_t*)&;__eram_start__;int size=(Int)&;__Approm_size_;printf(";将固件从%p复制到%p\n";,src,dst);Memcpy(dst,src,size);/*find app start&;sp*/uint32_t app_sp=dst[0];uint32_t app_start=dst[1];/*清理这里可能已经初始化的外设*//*启动app*/start_app(app_start,app_sp);
最后但并非最不重要的一点是,我们可以使用内存保护单元来保护引导加载程序,使其无法从应用程序访问。这可防止在执行过程中意外擦除引导加载程序。
如果您不了解MPU,请查看Chris几周前发布的精彩博客文章。
请记住,我们的MPU区域必须是2的幂大小。谢天谢地,我们的引导加载程序已经是!0x4000是2^14字节。
/*bootloader.c*/int main(Void){/*...*/base_addr=0x0;*mpu_rbar=(base_addr|1<;<;4|1);//ap=0b110,将区域设置为只读,而不考虑权限//TEXSCB=0b000010,因为代码位于";闪存";//size=13,因为我们希望涵盖16kiB//enable=1*MPU_RASR=(0 b110<;<;24)|(0 b000010;<;<;16)|(13<;<;1)|0x1;start_app(app_start,app_sp);/*未到达*/While(1){}}。
我们希望阅读这篇文章能让你对引导加载器的工作方式有一个很好的了解,以及你可以用它们做些什么。和以前的文章一样,可以在Zero to Main存储库的Github上找到代码示例。
您的引导加载程序做了哪些很酷的事情?请在评论中或通过[email protected]告诉我们有关它的所有信息。