4. BootROM 设计:不可变代码、硬件根信任、第一级镜像加载与验证

BootROM,说白了就是芯片上电后执行的第一段代码。这段代码固化在芯片内部的只读存储器里,出厂后谁也别想改。为什么非要这么设计?因为它是整个安全启动链条的起点,是信任的根。

我刚开始做嵌入式安全时,总觉得 BootROM 不就是个引导程序嘛,能跑就行。直到有一次,我在一个 IoT 芯片项目里发现,攻击者通过篡改外部 Flash 里的启动代码,直接绕过了所有安全校验。那次教训让我彻底明白——如果根不可信,上面盖的楼再高也是白搭。

4.1 不可变代码:为什么必须“焊死”

BootROM 的代码必须不可变,这是硬性要求。你想想看,如果攻击者能修改 BootROM,那整个安全体系就崩塌了。所以芯片厂商通常会用以下几种方式保证它不可变:

  • Mask ROM:芯片流片时直接把代码刻在硅片上,物理上无法修改。成本高,但最安全。
  • OTP(一次性可编程)存储器:出厂后只能写一次,之后锁死。适合小批量或需要定制密钥的场景。
  • 熔丝控制:通过烧断芯片内部的熔丝,永久禁用写接口。我在一个车规芯片上见过这种设计,熔丝一断,连调试接口都进不去。

核心原则:BootROM 的代码必须经过严格的审查和测试,因为一旦流片,任何 bug 都无法通过软件修复。只能通过芯片改版(Stepping)来解决,代价极高。

我个人习惯在 BootROM 里只放最核心的逻辑——硬件初始化、安全校验、第一级镜像加载。功能越少,出问题的概率越低。别想着把什么驱动都塞进去,那是给自己挖坑。

4.2 硬件根信任:信任链的起点

硬件根信任(Root of Trust, RoT)是安全启动的基石。它通常由以下几部分组成:

组件 作用 存储位置
公钥哈希 用于验证第一级镜像签名的公钥的摘要 OTP / 熔丝
芯片唯一密钥 用于解密或签名,每个芯片不同 OTP / eFuse
安全计数器 防止回滚攻击,记录镜像版本号 OTP / 专用寄存器
调试接口锁 防止攻击者通过 JTAG/SWD 读取内部状态 熔丝控制

嗯,这里要注意一点:公钥哈希不是直接存公钥,而是存哈希值。为什么?因为公钥可能很长(比如 RSA-4096 有 512 字节),OTP 空间很宝贵。存哈希值,BootROM 只需要在运行时计算镜像携带的公钥的哈希,然后和 OTP 里的值比对就行。

避坑指南:我曾经在一个项目里看到,工程师直接把公钥明文存在 OTP 里,结果 OTP 空间不够用,不得不砍掉一些安全特性。后来我建议改用哈希方式,问题迎刃而解。记住,OTP 是按位算钱的,能省则省。

4.3 第一级镜像加载与验证流程

BootROM 的主要任务就是加载并验证第一级镜像(通常叫 SPL 或 BL1)。这个镜像一般放在外部 Flash 的固定偏移位置。流程大致如下:

  1. 硬件初始化:设置时钟、配置内存控制器、初始化基本外设(比如 SPI 控制器用于读 Flash)。
  2. 读取第一级镜像:从 Flash 的预定义地址读取镜像到内部 SRAM。注意,此时外部 DRAM 还没初始化,所以只能用片上 SRAM。
  3. 验证镜像签名:用 OTP 里的公钥哈希验证镜像的签名。如果验证失败,直接进入错误处理(比如死循环或点亮错误灯)。
  4. 跳转到镜像:验证通过后,清空敏感寄存器,跳转到镜像入口地址执行。

下面这张图展示了整个流程:

BootROM 第一级镜像加载与验证流程 芯片上电 / 复位 BootROM 硬件初始化 从 Flash 读取第一级镜像 验证镜像签名 验证失败 → 错误处理 验证通过 → 跳转执行 关键安全约束 1. 镜像必须放在固定偏移,防止攻击者替换 2. 签名验证失败后不能继续执行,必须进入安全状态

4.4 签名验证的细节

签名验证是 BootROM 里最关键的环节。我见过不少芯片在这里栽跟头。常见的验证方式有两种:

  • RSA 签名验证:镜像携带签名,BootROM 用公钥解密签名得到哈希值,然后和镜像自身的哈希比对。优点是安全性高,缺点是计算量大。
  • HMAC 验证:用芯片唯一的密钥计算镜像的 HMAC,然后和预存的值比对。优点是速度快,缺点是密钥管理复杂。

我个人更倾向于 RSA,因为它的密钥管理更灵活。不过要注意,RSA 验证在 BootROM 里跑起来很慢——一个 2048 位的签名验证可能要几百毫秒。所以有些芯片会内置硬件加速器,把计算时间压缩到几毫秒。

警告:千万不要在 BootROM 里实现完整的 RSA 软件库。BootROM 空间通常只有几十 KB,放不下。我建议只实现必要的模幂运算,或者直接调用硬件加速器。否则,你很快就会发现 ROM 空间不够用。

4.5 代码示例:BootROM 验证伪代码

下面是一个简化的 BootROM 验证伪代码,展示了核心逻辑:

// BootROM 主流程
void bootrom_main(void) {
    // 1. 硬件初始化
    init_clocks();
    init_spi_flash_controller();
    init_internal_sram();

    // 2. 从 Flash 固定偏移读取第一级镜像
    uint8_t *image_buf = (uint8_t *)SRAM_BASE;
    flash_read(FIRST_STAGE_OFFSET, image_buf, MAX_IMAGE_SIZE);

    // 3. 解析镜像头,获取签名和公钥
    image_header_t *hdr = (image_header_t *)image_buf;
    uint8_t *signature = image_buf + hdr->signature_offset;
    uint8_t *public_key = image_buf + hdr->public_key_offset;

    // 4. 验证公钥哈希是否匹配 OTP 中的根哈希
    uint8_t pubkey_hash[32];
    sha256(public_key, hdr->public_key_len, pubkey_hash);
    if (memcmp(pubkey_hash, OTP_ROOT_HASH, 32) != 0) {
        enter_error_state();  // 公钥被篡改
    }

    // 5. 验证镜像签名
    uint8_t image_hash[32];
    sha256(image_buf + hdr->data_offset, hdr->data_len, image_hash);
    if (rsa_verify(public_key, signature, image_hash) != 0) {
        enter_error_state();  // 签名无效
    }

    // 6. 验证通过,跳转到镜像入口
    disable_debug_interface();
    clear_sensitive_registers();
    jump_to(image_buf + hdr->entry_offset);
}

这段代码看起来简单,但实际实现时有很多坑。比如 memcmp 函数要用常量时间比较,防止时序攻击。我见过一个项目,就是因为用了标准的 memcmp,攻击者通过测量比较时间差异,逐字节猜出了 OTP 里的哈希值。

技巧:实现常量时间比较函数,确保无论比较结果如何,执行时间都一样。代码很简单:

int safe_memcmp(const void *a, const void *b, size_t len) {
    int result = 0;
    const uint8_t *pa = a, *pb = b;
    for (size_t i = 0; i < len; i++) {
        result |= pa[i] ^ pb[i];
    }
    return result;
}

4.6 常见陷阱与避坑指南

做 BootROM 设计这么多年,我踩过的坑不少。这里列几个最常见的:

  • 镜像大小超限:内部 SRAM 通常只有几十到几百 KB,第一级镜像必须严格控制大小。我曾经有个项目,工程师把整个驱动栈都塞进第一级镜像,结果 SRAM 装不下,最后不得不重新划分镜像层级。
  • Flash 读取时序问题:BootROM 初始化 Flash 控制器时,时序参数必须正确。如果读出来的数据是错的,签名验证肯定失败。我建议在验证前先读一个固定的 Magic Number 做自检。
  • 回滚攻击:攻击者可能把旧版本的镜像(有已知漏洞)写回 Flash。BootROM 必须检查镜像版本号,确保不低于安全计数器的值。这个计数器通常存在 OTP 里,只能递增不能递减。
  • 调试接口未关闭:如果 JTAG/SWD 接口在 BootROM 执行期间还开着,攻击者可以直接 halt CPU,读取内部状态。我习惯在 BootROM 的最开始就关闭调试接口,只留一个熔丝控制的紧急恢复通道。

嗯,最后说一句。BootROM 设计没有银弹,每个芯片的约束都不一样。但核心思想是相通的——用不可变的硬件根,构建一条可验证的信任链。只要根是安全的,上面的每一级都可以通过签名验证来保证完整性。