2.4 Bootloader
Bootloader也叫引导加载程序,是一段用于将操作系统的镜像导入到内存中的代码。它可以在允许操作系统运行之前进行一些检查。Bootloader管理嵌入式系统的镜像,并通过各种通信接口协议(如BLE、串行接口)对系统镜像进行升级。
通常,带有引导加载程序的系统至少有两个程序在一个MCU上,因此必须要检查是否需要更新镜像以及管理升级的进度。
Apache Mynewt项目的bootloader在运行固件镜像之前将验证固件的加密签名。它同时将启动过程中的每个阶段维护一个详细的状态日志。
“安全引导加载程序(Secure Bootloader)”应该放在一个特定MCU的受保护的存储中。
Apache Mynewt bootloader是MCUboot的基础,MCUboot是一个针对32-bit MCU的安全引导加载程序,已经被移植到其他操作系统中广泛使用。
Mynewt的bootloader由两个包组成:
- bootutil库(boot/bootutil)
- boot应用程序(apps/boot)
由于Mynewt代码是结构化的,从而通用bootutil库执行了bootloader的大部分功能。实际跳转到主镜像的最后一步是在bootutil库之外实现的。最后一步需要根据特定架构项目中实现。因为以下两个原因,引导、加载程序的功能是分离开来的:
- 通过将架构依赖的代码分开,bootutil库可以在多个boot loaders中重复使用;
- 通过从库中排除最后一个引导步骤,bootloader可以进行单元测试。
限制
当前引导和加载仅支持包含以下特征的镜像:
- 从flash中启动的镜像
- 从一个固定的位置运行
镜像格式
镜像头格式如下:
#define IMAGE_MAGIC 0x96f3b83c
#define IMAGE_MAGIC_NONE 0xffffffff
struct image_version {
uint8_t iv_major;
uint8_t iv_minor;
uint16_t iv_revision;
uint32_t iv_build_num;
};
/** Image header. All fields are in little endian byte order. */
struct image_header {
uint32_t ih_magic;
uint16_t ih_tlv_size; /* Trailing TLVs */
uint8_t ih_key_id;
uint8_t _pad1;
uint16_t ih_hdr_size;
uint16_t _pad2;
uint32_t ih_img_size; /* Does not include header. */
uint32_t ih_flags;
struct image_version ih_ver;
uint32_t _pad3;
};
ih_hdr_size字段指明了镜像头的长度,也即说明了镜像本身的偏移量。该字段提供了向后兼容性,保证即使镜像头格式发生变化,依然可通过此字段进行解析。
下面是镜像头可用的标志(ih_flags):
#define IMAGE_F_PIC 0x00000001
#define IMAGE_F_SHA256 0x00000002 /* Image contains hash TLV */
#define IMAGE_F_PKCS15_RSA2048_SHA256 0x00000004 /* PKCS15 w/RSA and SHA */
#define IMAGE_F_ECDSA224_SHA256 0x00000008 /* ECDSA256 over SHA256 */
#define IMAGE_F_NON_BOOTABLE 0x00000010
#define IMAGE_HEADER_SIZE 32
可选type-length-value记录(TLVs)包含了镜像metadata,放置在镜像的结尾处。如安全数据就放在镜像的末段。
/** Image trailer TLV format. All fields in little endian. */
struct image_tlv {
uint8_t it_type; /* IMAGE_TLV_[...]. */
uint8_t _pad;
uint16_t it_len /* Data length (not including TLV header). */
};
/*
* Image trailer TLV types.
*/
#define IMAGE_TLV_SHA256 1 /* SHA256 of image hdr and body */
#define IMAGE_TLV_RSA2048 2 /* RSA2048 of hash output */
#define IMAGE_TLV_ECDSA224 3 /* ECDSA of hash output */
镜像映射
Mynewt设备的镜像根据flash map来划分。在上层,镜像映射将数字IDs映射到flash区域。一个flash区域是一个具有以下属性的磁盘区域:
- 一个区域可以在不影响其他区域的情况下完整擦除
- 写一个区域不影响对其他区域的写操作
bootloader使用以下flash区域:
#define FLASH_AREA_BOOTLOADER 0
#define FLASH_AREA_IMAGE_0 1
#define FLASH_AREA_IMAGE_1 2
#define FLASH_AREA_IMAGE_SCRATCH 3
镜像分区
闪存的应用部分被分为两个分区(或槽,slot):主分区和第二分区。bootloader只会运行主分区中的镜像,以便能够从flash的固定位置启动。如果bootloader需要运行第二分区中的镜像,需要在启动之前将两个镜像进行交换。故而,除bootloader与两个镜像区域外,还需要留一块区域(Mynewt中叫做SCRATCH)以便可以可靠的进行镜像交换。
启动状态
从逻辑上讲,你可以考虑将每个镜像与一对标志相关联:pending(等待)和confirmed(确认)。在启动时,bootloader通过检查每一对标志来确定设备的状态。这些标志有以下含义:
- pending,镜像在下一次重启前进行测试;若没有后续的确认命令,将在第二次重启时恢复到原始镜像;
- confirmed,是否一直使用第一分区中的镜像,设置确认标志后,将启动第一分区镜像。
也就是说,当用户想要运行第二镜像,将第二分区设置为pending标志并重启设备。在启动时,bootloader将在flash中将两个镜像进行交换,清除第二分区的pending标志,运行刚刚拷贝到第一分区的镜像。当然,这只是一个临时状态,若设备重启,bootloader将会将镜像切换到原来的分区(再一次进行交换),从原始的镜像启动。如果用户并不想切换到原始状态,那么需要设置第一分区的confirmed标志,以便从第一分区镜像启动。
切换镜像分为两个过程,设置+确认,以防止设备镜像被破坏而“变砖”。如果设备在启动第二镜像时立即崩溃,bootloader将会恢复到原始工作镜像,而不会重复启动坏镜像。
以下示意图说明了镜像可能存在的三种启动状态。
| slot-0 | slot-1 |
---------------+--------+--------|
pending | | |
confirmed | X | |
---------------+--------+--------'
Image 0 confirmed; |
No change on reboot |
---------------------------------'
| slot-0 | slot-1 |
---------------+--------+--------|
pending | | X |
confirmed | X | |
---------------+--------+--------'
Image 0 confirmed; |
Test image 1 on next reboot |
---------------------------------'
| slot-0 | slot-1 |
---------------+--------+--------|
pending | | |
confirmed | | X |
---------------+--------+--------'
Testing image 0; |
Revert to image 1 on next reboot |
---------------------------------'
引导向量
在引导时,bootloader将通过检查引导向量来决定设备处于哪种启动状态。引导向量包含两个记录(叫做image trailers,镜像拖车?),记录在每一个镜像分区的末尾。一个image trailer的结构如下:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
~ MAGIC (16 octets) ~
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
~ ~
~ Swap status (128 * min-write-size * 3) ~
~ ~
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Copy done | 0xff padding (up to min-write-sz - 1) ~
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Image OK | 0xff padding (up to min-write-sz - 1) ~
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
这些记录在每一个镜像分区的结果。该记录紧接着的偏移量代表了下一个flash分区的起始(Flash通常都是分页的,因此需要保证一个区域相对独立、完整)。
注意:
min-write-size为Flash硬件的一个特性。如果硬件允许在任意地址上写入单个字节,那么min-size=1,如果硬件只允许在偶数地址写,那么min-size=2,以此类推。
关于引导向量字段定义如下:
MAGIC,如下的16字节,按照主机序写:
const uint32_t boot_img_magic[4] = {
0xf395c277,
0x7fefd260,
0x0f505235,
0x8079b62c,
};
swap状态,一系列的单字节记录。每个记录对应一个镜像区内的flash扇区。一个swap状态字节,表示对应扇区数据的位置。在镜像交换过程中,镜像数据一次移动一个扇区。若设备在交换操作完成之前重新启动,那么交换状态记录对于交换的恢复则非常重要。
Copy done,一个字节,表示这个槽中的镜像是否完整的(01=done,0xff=not done)。
Image OK,一个字节,表示这个槽中的镜像是否被用户确认为好的(01=确认,0xff=未确认)。
引导向量记录基于flash硬件的限制。因此,它并没有一个非常直观的设计,故而通过观察引导向量也很难了解设备的状态。最好是通过一组表隐射所有交换类型(None,Test,Revert)的向量状态。在这些表中,pending和confirmed标志用以说明情况,但实际上并没有在引导向量中。
State I
| slot-0 | slot-1 |
-----------------+--------+--------|
magic | Unset | Unset |
image-ok | Any | N/A |
-----------------+--------+--------'
pending | | |
confirmed | X | |
-----------------+--------+--------'
swap: none |
-----------------------------------'
State II
| slot-0 | slot-1 |
-----------------+--------+--------|
magic | Any | Good |
image-ok | Any | N/A |
-----------------+--------+--------'
pending | | X |
confirmed | X | |
-----------------+--------+--------'
swap: test |
-----------------------------------'
State III
| slot-0 | slot-1 |
-----------------+--------+--------|
magic | Good | Unset |
image-ok | 0xff | N/A |
-----------------+--------+--------'
pending | | |
confirmed | | X |
-----------------+--------+--------'
swap: revert (test image running) |
-----------------------------------'
State IV
| slot-0 | slot-1 |
-----------------+--------+--------|
magic | Good | Unset |
image-ok | 0x01 | N/A |
-----------------+--------+--------'
pending | | |
confirmed | X | |
-----------------+--------+--------'
swap: none (confirmed test image) |
-----------------------------------'
上层操作
了解bootloader的基础知识后,我们可以探索bootloader的操作流程。
- 检查交换状态区域,是否有被中断的交换需要恢复。若有,完成未完成的交换操作,跳转到步骤3;否则进入步骤2处理。
- 检查引导向量,是否需要交换。若不需要交换,进入步骤3。若需要交换,首先检查镜像是否有效(完整性与安全性),若有效,执行交换操作,待交换完成,写入引导向量,进入步骤3。若镜像无效,删除无效镜像,将交换失败记录到引导向量,执行步骤3.
- 引导镜像槽0中的镜像。
镜像交换
由于以下两个原因,bootloader将交换两个镜像槽中的镜像:
- 用户设置了镜像测试操作,镜像槽1中的镜像需要运行一次;
- 重启时,测试镜像处于未确认状态,bootloader将恢复到原始镜像(处于镜像槽1中)。
若引导向量指示第二分区中的镜像需要运行,bootloader需要将其拷贝到第一分区中。当前处于第一分区中的镜像需要保留在flash中,以便以后可以使用。如果bootloader在交换操作的中间重新设置,那么两个镜像都可以恢复,镜像交换流程如下:
确定每个镜像槽有多少flash分区,需要保证两个镜像槽的大小一样。
按降序迭代磁区索引(sector index)列表(即按照最大索引开始),当前元素=“index”。
TODO,添加具体交换流程,可参考源码
将交换过程完整记录到镜像槽0的image trailer
交换状态记录
交换恢复
完整性检查
当一个镜像拷贝到第一分区时将要检查镜像的完整性。如果bootloader没有执行镜像交换,那么就不会执行完整性检查。
在完整性检查期间,bootloader将验证镜像的以下方面:
- 32位的魔法数字必须正确(0x96F3B83C)
- 镜像必须包含一个SHA256 TLV
- 计算出的SHA256必须与SHA256 TLV内容匹配
- 镜像可能包含一个签名TLV,若包含则必须使用bootloader中的秘钥来验证。
镜像签名与验证
完整性检查的最后一步是签名验证。bootloader可以在构建时嵌入一个或多个公钥。在签名验证期间,bootloader验证镜像的私钥是否与bootloader中的公钥所对应。镜像签名TLV,指示对应签名的键值。bootloader使用这个索引来确定关联的公钥。
镜像签名流程:
- 通过镜像来计算hash,接下对该hash值签名。签名在newt工具创建镜像时计算,该签名将放置在image trailer中。
- 关于签名对应的公钥需要保存到bootloader中,在运行镜像前需要景行验证。
- 工具也允许使用多个签名秘钥。当需要防止生产单元运行开发镜像,而开发单元又能够运行生产镜像以及开发镜像,此时通过多个签名秘钥的形式将显得非常重要。
参考资料:
1、MCUboot,https://github.com/runtimeco/mcuboot/