3. BL1阶段详解:BL1的初始化流程、加载BL2、验证BL2签名的机制
好,我们进入整个安全启动链条的第一环——BL1。说白了,BL1就是BootROM里的那一段固化代码,芯片一上电,PC指针直接跳到这里。它干的事情很纯粹:初始化最基础的环境,找到并验证BL2,然后跳过去。
我个人习惯把BL1看作“信任根的物理载体”。为什么这么说?因为它不可更改,是写在ROM里的。你想想看,如果BL1本身能被篡改,那后面所有的安全校验都是白搭。所以,理解BL1的每一步,就是在理解整个信任链是如何“生根”的。
3.1 BL1的初始化流程:从Reset到Main
BL1的入口点通常是bl1_entrypoint,在汇编阶段就开始干活了。我把它拆成几个关键步骤,这样好记:
- 设置异常向量表:CPU一启动,先得告诉它“出错了往哪跳”。BL1会把自己的异常向量表基址写到VBAR寄存器。
- 切换异常级别:从EL3开始,这是ARMv8里权限最高的级别。BL1全程跑在EL3,因为它要管理安全世界的初始化。
- 初始化平台基础硬件:包括UART(串口)、定时器、GIC(中断控制器)的早期初始化。注意,这时候DDR可能还没起来,代码在SRAM或ROM里跑。
- 设置C运行环境:初始化BSS段、设置栈指针SP。这一步做完,才能跳转到C代码的
bl1_main。
核心要点:BL1的初始化是“最小必要”原则。它不会去初始化所有外设,只保证自己能跑起来、能访问存储设备(比如Flash或eMMC)、能驱动UART打印日志就够了。初始化太多东西反而增加攻击面。
嗯,这里要注意一个细节:MMU(内存管理单元)在BL1阶段通常是关闭的。因为代码在ROM/SRAM里跑,地址是固定的,不需要虚拟地址映射。我见过有新手在BL1里尝试开MMU,结果把页表放在还没初始化的DDR里,直接挂死。这种坑,踩一次就记住了。
3.2 加载BL2:从存储介质到SRAM
BL1初始化完成后,下一步就是找到BL2的镜像并把它加载到SRAM里。为什么是SRAM?因为DDR可能还没初始化,或者DDR的初始化代码本身就在BL2里。这有点像“先有鸡还是先有蛋”的问题——BL1用SRAM当临时落脚点。
加载流程大致如下:
| 步骤 | 动作 | 说明 |
|---|---|---|
| 1 | 读取Boot设备信息 | 从硬件寄存器或FIP(Firmware Image Package)头部获取BL2在Flash中的偏移和大小 |
| 2 | 调用平台驱动 | 通过plat_get_image_source等接口,从NAND、eMMC、NOR Flash等介质读取数据 |
| 3 | 拷贝到SRAM | 目标地址通常是BL2_BASE,这个宏在平台头文件里定义 |
| 4 | 验证完整性 | 检查镜像的哈希值,确保传输过程中没有损坏 |
我在项目中遇到过一个问题:某个平台的Flash控制器在DMA传输时,地址没有做Cache对齐,导致BL2加载到SRAM后,部分数据是错的。排查了很久,最后发现是memcpy之前没有做Cache无效化操作。所以,加载完成后,一定要做flush_dcache_range或inv_dcache_range,确保CPU读到的是真实数据,而不是Cache里的旧值。
小技巧:调试BL2加载问题时,可以在bl1_load_bl2函数里加打印,输出读取的偏移、大小和校验值。很多时候,问题出在FIP打包工具生成的头部信息不对,而不是加载逻辑本身。
3.3 验证BL2签名:信任链的第一道锁
这是BL1最核心的安全职责。BL2的镜像在Flash里是加密签名的,BL1必须用公钥验证签名,确认镜像是合法的、未被篡改的,然后才跳转执行。
验证流程我画了一张图,方便你理解:
验证的核心逻辑在bl1_plat_handle_pre_image_load和bl1_plat_handle_post_image_load这两个回调里。ATF的框架把平台相关的验证逻辑抽象出来了,但底层调用的还是auth_mod模块。
代码层面,关键函数是auth_mod_verify_img,它的大致逻辑如下:
// 伪代码,展示核心验证流程
int auth_mod_verify_img(unsigned int img_id,
void *img_ptr,
size_t img_len) {
// 1. 从FIP头部获取签名信息
img_auth_info_t *auth = get_auth_info(img_id);
// 2. 计算镜像的哈希值
sha256_context_t ctx;
sha256_init(&ctx);
sha256_update(&ctx, img_ptr, img_len);
sha256_final(&ctx, hash_computed);
// 3. 从OTP读取公钥
uint8_t pub_key[PUB_KEY_SIZE];
plat_get_rotpk(pub_key, PUB_KEY_SIZE);
// 4. 用公钥解密签名,得到期望的哈希值
uint8_t hash_from_sig[HASH_SIZE];
rsa_pkcs1_verify(auth->signature, auth->sig_len,
pub_key, hash_from_sig);
// 5. 比较两个哈希值
if (memcmp(hash_computed, hash_from_sig, HASH_SIZE) != 0) {
ERROR("BL2 signature verification failed!\n");
panic();
}
return 0; // 验证通过
}
警告:公钥的存储位置至关重要。如果公钥可以被软件修改,那攻击者就能用自己的密钥签名恶意BL2。所以,公钥必须存储在一次性可编程(OTP)存储器或eFuse中,并且BL1在读取后要锁定访问权限。我曾经见过一个方案,公钥放在Flash里,结果被攻击者直接替换了——那整个安全启动就形同虚设了。
3.4 验证失败的处理:Panic还是Fallback?
验证失败后,BL1的行为是平台相关的。ATF默认的行为是调用panic(),进入死循环。但有些平台会实现fallback机制:
- 尝试从备份分区加载:如果主分区的BL2签名验证失败,尝试从恢复分区加载一个旧版本。
- 进入下载模式:等待USB或UART下载新固件。
- 直接锁死:最安全的做法,什么都不做,防止任何未授权代码执行。
我个人建议,在产品中不要做fallback。安全启动的初衷就是“要么跑可信代码,要么不跑”。一旦允许fallback,攻击者就可以通过破坏主分区来强制系统降级到有漏洞的旧版本。这就是所谓的降级攻击(Rollback Attack)。
3.5 跳转到BL2:最后的交接
验证通过后,BL1会做最后的清理工作:
- 关闭不需要的外设时钟,降低功耗。
- 设置BL2的执行上下文,包括入口地址、栈指针、异常级别(EL2或EL1-S)。
- 调用
bl2_entrypoint,跳转到BL2。
这里有一个容易忽略的点:跳转前要清空BL1用过的栈和敏感数据。因为BL1和BL2共享SRAM,BL1的栈里可能残留密钥材料。如果不清理,BL2(或者后续的BL31/BL33)就有可能通过内存泄露获取这些敏感信息。我在做安全审计时,就发现过这类问题——BL1的密钥在跳转后还留在内存里,被攻击者通过一个漏洞读取到了。
建议:在bl1_prepare_next_image函数中,显式调用zeromem清理BL1的栈区域。虽然这会增加几微秒的开销,但相比安全收益,完全值得。