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库之外实现的。最后一步需要根据特定架构项目中实现。因为以下两个原因,引导、加载程序的功能是分离开来的:

  1. 通过将架构依赖的代码分开,bootutil库可以在多个boot loaders中重复使用;
  2. 通过从库中排除最后一个引导步骤,bootloader可以进行单元测试。

限制

当前引导和加载仅支持包含以下特征的镜像:

  • 从flash中启动的镜像
  • 从一个固定的位置运行

镜像格式

镜像头格式如下:

  1. #define IMAGE_MAGIC 0x96f3b83c
  2. #define IMAGE_MAGIC_NONE 0xffffffff
  3. struct image_version {
  4. uint8_t iv_major;
  5. uint8_t iv_minor;
  6. uint16_t iv_revision;
  7. uint32_t iv_build_num;
  8. };
  9. /** Image header. All fields are in little endian byte order. */
  10. struct image_header {
  11. uint32_t ih_magic;
  12. uint16_t ih_tlv_size; /* Trailing TLVs */
  13. uint8_t ih_key_id;
  14. uint8_t _pad1;
  15. uint16_t ih_hdr_size;
  16. uint16_t _pad2;
  17. uint32_t ih_img_size; /* Does not include header. */
  18. uint32_t ih_flags;
  19. struct image_version ih_ver;
  20. uint32_t _pad3;
  21. };

ih_hdr_size字段指明了镜像头的长度,也即说明了镜像本身的偏移量。该字段提供了向后兼容性,保证即使镜像头格式发生变化,依然可通过此字段进行解析。

下面是镜像头可用的标志(ih_flags):

  1. #define IMAGE_F_PIC 0x00000001
  2. #define IMAGE_F_SHA256 0x00000002 /* Image contains hash TLV */
  3. #define IMAGE_F_PKCS15_RSA2048_SHA256 0x00000004 /* PKCS15 w/RSA and SHA */
  4. #define IMAGE_F_ECDSA224_SHA256 0x00000008 /* ECDSA256 over SHA256 */
  5. #define IMAGE_F_NON_BOOTABLE 0x00000010
  6. #define IMAGE_HEADER_SIZE 32

可选type-length-value记录(TLVs)包含了镜像metadata,放置在镜像的结尾处。如安全数据就放在镜像的末段。

  1. /** Image trailer TLV format. All fields in little endian. */
  2. struct image_tlv {
  3. uint8_t it_type; /* IMAGE_TLV_[...]. */
  4. uint8_t _pad;
  5. uint16_t it_len /* Data length (not including TLV header). */
  6. };
  7. /*
  8. * Image trailer TLV types.
  9. */
  10. #define IMAGE_TLV_SHA256 1 /* SHA256 of image hdr and body */
  11. #define IMAGE_TLV_RSA2048 2 /* RSA2048 of hash output */
  12. #define IMAGE_TLV_ECDSA224 3 /* ECDSA of hash output */

镜像映射

Mynewt设备的镜像根据flash map来划分。在上层,镜像映射将数字IDs映射到flash区域。一个flash区域是一个具有以下属性的磁盘区域:

  • 一个区域可以在不影响其他区域的情况下完整擦除
  • 写一个区域不影响对其他区域的写操作

bootloader使用以下flash区域:

  1. #define FLASH_AREA_BOOTLOADER 0
  2. #define FLASH_AREA_IMAGE_0 1
  3. #define FLASH_AREA_IMAGE_1 2
  4. #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将会恢复到原始工作镜像,而不会重复启动坏镜像。

以下示意图说明了镜像可能存在的三种启动状态。

  1. | slot-0 | slot-1 |
  2. ---------------+--------+--------|
  3. pending | | |
  4. confirmed | X | |
  5. ---------------+--------+--------'
  6. Image 0 confirmed; |
  7. No change on reboot |
  8. ---------------------------------'
  9. | slot-0 | slot-1 |
  10. ---------------+--------+--------|
  11. pending | | X |
  12. confirmed | X | |
  13. ---------------+--------+--------'
  14. Image 0 confirmed; |
  15. Test image 1 on next reboot |
  16. ---------------------------------'
  17. | slot-0 | slot-1 |
  18. ---------------+--------+--------|
  19. pending | | |
  20. confirmed | | X |
  21. ---------------+--------+--------'
  22. Testing image 0; |
  23. Revert to image 1 on next reboot |
  24. ---------------------------------'

引导向量

在引导时,bootloader将通过检查引导向量来决定设备处于哪种启动状态。引导向量包含两个记录(叫做image trailers,镜像拖车?),记录在每一个镜像分区的末尾。一个image trailer的结构如下:

  1. 0 1 2 3
  2. 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
  3. +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
  4. ~ MAGIC (16 octets) ~
  5. +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
  6. ~ ~
  7. ~ Swap status (128 * min-write-size * 3) ~
  8. ~ ~
  9. +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
  10. | Copy done | 0xff padding (up to min-write-sz - 1) ~
  11. +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
  12. | Image OK | 0xff padding (up to min-write-sz - 1) ~
  13. +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

这些记录在每一个镜像分区的结果。该记录紧接着的偏移量代表了下一个flash分区的起始(Flash通常都是分页的,因此需要保证一个区域相对独立、完整)。

注意:

min-write-size为Flash硬件的一个特性。如果硬件允许在任意地址上写入单个字节,那么min-size=1,如果硬件只允许在偶数地址写,那么min-size=2,以此类推。

关于引导向量字段定义如下:

  1. MAGIC,如下的16字节,按照主机序写:

    1. const uint32_t boot_img_magic[4] = {
    2. 0xf395c277,
    3. 0x7fefd260,
    4. 0x0f505235,
    5. 0x8079b62c,
    6. };
  2. swap状态,一系列的单字节记录。每个记录对应一个镜像区内的flash扇区。一个swap状态字节,表示对应扇区数据的位置。在镜像交换过程中,镜像数据一次移动一个扇区。若设备在交换操作完成之前重新启动,那么交换状态记录对于交换的恢复则非常重要。

  3. Copy done,一个字节,表示这个槽中的镜像是否完整的(01=done,0xff=not done)。

  4. Image OK,一个字节,表示这个槽中的镜像是否被用户确认为好的(01=确认,0xff=未确认)。

引导向量记录基于flash硬件的限制。因此,它并没有一个非常直观的设计,故而通过观察引导向量也很难了解设备的状态。最好是通过一组表隐射所有交换类型(None,Test,Revert)的向量状态。在这些表中,pending和confirmed标志用以说明情况,但实际上并没有在引导向量中。

  1. State I
  2. | slot-0 | slot-1 |
  3. -----------------+--------+--------|
  4. magic | Unset | Unset |
  5. image-ok | Any | N/A |
  6. -----------------+--------+--------'
  7. pending | | |
  8. confirmed | X | |
  9. -----------------+--------+--------'
  10. swap: none |
  11. -----------------------------------'
  12. State II
  13. | slot-0 | slot-1 |
  14. -----------------+--------+--------|
  15. magic | Any | Good |
  16. image-ok | Any | N/A |
  17. -----------------+--------+--------'
  18. pending | | X |
  19. confirmed | X | |
  20. -----------------+--------+--------'
  21. swap: test |
  22. -----------------------------------'
  23. State III
  24. | slot-0 | slot-1 |
  25. -----------------+--------+--------|
  26. magic | Good | Unset |
  27. image-ok | 0xff | N/A |
  28. -----------------+--------+--------'
  29. pending | | |
  30. confirmed | | X |
  31. -----------------+--------+--------'
  32. swap: revert (test image running) |
  33. -----------------------------------'
  34. State IV
  35. | slot-0 | slot-1 |
  36. -----------------+--------+--------|
  37. magic | Good | Unset |
  38. image-ok | 0x01 | N/A |
  39. -----------------+--------+--------'
  40. pending | | |
  41. confirmed | X | |
  42. -----------------+--------+--------'
  43. swap: none (confirmed test image) |
  44. -----------------------------------'

上层操作

了解bootloader的基础知识后,我们可以探索bootloader的操作流程。

  1. 检查交换状态区域,是否有被中断的交换需要恢复。若有,完成未完成的交换操作,跳转到步骤3;否则进入步骤2处理。
  2. 检查引导向量,是否需要交换。若不需要交换,进入步骤3。若需要交换,首先检查镜像是否有效(完整性与安全性),若有效,执行交换操作,待交换完成,写入引导向量,进入步骤3。若镜像无效,删除无效镜像,将交换失败记录到引导向量,执行步骤3.
  3. 引导镜像槽0中的镜像。

镜像交换

由于以下两个原因,bootloader将交换两个镜像槽中的镜像:

  • 用户设置了镜像测试操作,镜像槽1中的镜像需要运行一次;
  • 重启时,测试镜像处于未确认状态,bootloader将恢复到原始镜像(处于镜像槽1中)。

若引导向量指示第二分区中的镜像需要运行,bootloader需要将其拷贝到第一分区中。当前处于第一分区中的镜像需要保留在flash中,以便以后可以使用。如果bootloader在交换操作的中间重新设置,那么两个镜像都可以恢复,镜像交换流程如下:

  1. 确定每个镜像槽有多少flash分区,需要保证两个镜像槽的大小一样。

  2. 按降序迭代磁区索引(sector index)列表(即按照最大索引开始),当前元素=“index”。

    TODO,添加具体交换流程,可参考源码

  3. 将交换过程完整记录到镜像槽0的image trailer

交换状态记录

交换恢复

完整性检查

当一个镜像拷贝到第一分区时将要检查镜像的完整性。如果bootloader没有执行镜像交换,那么就不会执行完整性检查。

在完整性检查期间,bootloader将验证镜像的以下方面:

  • 32位的魔法数字必须正确(0x96F3B83C)
  • 镜像必须包含一个SHA256 TLV
  • 计算出的SHA256必须与SHA256 TLV内容匹配
  • 镜像可能包含一个签名TLV,若包含则必须使用bootloader中的秘钥来验证。

镜像签名与验证

完整性检查的最后一步是签名验证。bootloader可以在构建时嵌入一个或多个公钥。在签名验证期间,bootloader验证镜像的私钥是否与bootloader中的公钥所对应。镜像签名TLV,指示对应签名的键值。bootloader使用这个索引来确定关联的公钥。

镜像签名流程:

  1. 通过镜像来计算hash,接下对该hash值签名。签名在newt工具创建镜像时计算,该签名将放置在image trailer中。
  2. 关于签名对应的公钥需要保存到bootloader中,在运行镜像前需要景行验证。
  3. 工具也允许使用多个签名秘钥。当需要防止生产单元运行开发镜像,而开发单元又能够运行生产镜像以及开发镜像,此时通过多个签名秘钥的形式将显得非常重要。

参考资料:

1、MCUboot,https://github.com/runtimeco/mcuboot/