3. BL1阶段详解:BL1的初始化流程、加载BL2、验证BL2签名的机制

好,我们进入整个安全启动链条的第一环——BL1。说白了,BL1就是BootROM里的那一段固化代码,芯片一上电,PC指针直接跳到这里。它干的事情很纯粹:初始化最基础的环境,找到并验证BL2,然后跳过去。

我个人习惯把BL1看作“信任根的物理载体”。为什么这么说?因为它不可更改,是写在ROM里的。你想想看,如果BL1本身能被篡改,那后面所有的安全校验都是白搭。所以,理解BL1的每一步,就是在理解整个信任链是如何“生根”的。

3.1 BL1的初始化流程:从Reset到Main

BL1的入口点通常是bl1_entrypoint,在汇编阶段就开始干活了。我把它拆成几个关键步骤,这样好记:

  1. 设置异常向量表:CPU一启动,先得告诉它“出错了往哪跳”。BL1会把自己的异常向量表基址写到VBAR寄存器。
  2. 切换异常级别:从EL3开始,这是ARMv8里权限最高的级别。BL1全程跑在EL3,因为它要管理安全世界的初始化。
  3. 初始化平台基础硬件:包括UART(串口)、定时器、GIC(中断控制器)的早期初始化。注意,这时候DDR可能还没起来,代码在SRAM或ROM里跑。
  4. 设置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验证BL2签名流程图 BL1启动 加载BL2头部(含签名) 解析签名算法(RSA/ECDSA) 计算BL2镜像的SHA-256哈希 用公钥解密签名 → 得到哈希值 比较两个哈希值是否一致 公钥来源 公钥存储在OTP或eFuse中 由芯片厂商烧录,不可更改 BL1直接从寄存器读取 签名算法 RSA-2048/PKCS#1 v1.5 或ECDSA-P256 算法在FIP头部指定

验证的核心逻辑在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会做最后的清理工作:

  1. 关闭不需要的外设时钟,降低功耗。
  2. 设置BL2的执行上下文,包括入口地址、栈指针、异常级别(EL2或EL1-S)。
  3. 调用bl2_entrypoint,跳转到BL2。

这里有一个容易忽略的点:跳转前要清空BL1用过的栈和敏感数据。因为BL1和BL2共享SRAM,BL1的栈里可能残留密钥材料。如果不清理,BL2(或者后续的BL31/BL33)就有可能通过内存泄露获取这些敏感信息。我在做安全审计时,就发现过这类问题——BL1的密钥在跳转后还留在内存里,被攻击者通过一个漏洞读取到了。

建议:在bl1_prepare_next_image函数中,显式调用zeromem清理BL1的栈区域。虽然这会增加几微秒的开销,但相比安全收益,完全值得。