4. BL1阶段详解

BL1,说白了就是整个ATF启动流程的“第一把火”。芯片上电复位后,CPU跑的第一段代码就是它。我个人习惯把BL1看作是整个信任链的物理根——它要是出了问题,后面所有安全假设都不成立。

嗯,咱们直接切入正题。BL1的代码量其实不大,但它干的事都是“脏活累活”:初始化硬件、建立最基本的运行环境、然后把控制权交给下一级。我当年第一次调BL1的时候,就因为MMU配置漏了一个段,导致BL2加载时直接挂掉,查了整整两天……

4.1 BL1的入口点:复位向量的那点事

ARMv8-A架构下,CPU复位后会从复位向量表中取第一条指令。这个向量表的位置由硬件决定,通常固定在ROM或Boot ROM里。BL1的入口点就是这张表的第一个条目——Reset_Handler。

关键点:BL1的入口地址在芯片设计阶段就定死了,通常是0x00000000或0xFFFF0000。你改不了,只能顺着它来。

代码里长什么样?我贴一段典型的入口代码:

/* 复位向量表,放在.text段最前面 */
.section .text.vectors, "ax"
.align 12  /* 4KB对齐,ARMv8要求 */

.globl vectors
vectors:
    /* 当前EL3的同步异常向量 */
    b    _reset_handler    /* 复位 */
    .align 7
    b    _undef_handler    /* 未定义指令 */
    .align 7
    b    _svc_handler      /* SVC调用 */
    .align 7
    b    _prefetch_abort   /* 预取中止 */
    .align 7
    b    _data_abort       /* 数据中止 */
    .align 7
    b    _irq_handler      /* IRQ中断 */
    .align 7
    b    _fiq_handler      /* FIQ中断 */
    .align 7

注意那个.align 12——ARMv8要求向量表必须4KB对齐,每个向量条目间隔128字节。为什么?因为硬件会直接拿异常类型作为偏移量去查表。你想想看,如果对齐不对,CPU跳错地方,那画面太美我不敢看。

我在项目中遇到过一件事:某款SoC的Boot ROM里,BL1入口地址被硬编码在0x00010000,但芯片复位后默认从0x00000000开始执行。结果呢?需要在0x00000000处放一条跳转指令,跳到真正的BL1入口。这个“跳板”设计,很多工程师第一次接触时都会懵。

4.2 硬件初始化流程:从零搭建运行环境

进入_reset_handler后,BL1要干的第一件事就是初始化硬件。这个流程我总结为“三步走”:

  1. 平台级初始化:时钟、电源、DDR控制器、UART等基础外设
  2. 架构级初始化:异常等级、系统寄存器、中断控制器
  3. 安全初始化:TrustZone、TZC-400、安全SRAM等

咱们拆开来看。

4.2.1 平台级初始化

这一步是芯片厂商的“私活”。不同SoC的时钟树、电源域、DDR时序都不一样。BL1里通常会调用一个platform_init()函数,里面塞满了厂商提供的BSP代码。

举个例子,初始化UART用于打印日志:

void platform_init(void)
{
    /* 使能UART时钟 */
    mmio_write_32(CLK_ENABLE_REG, CLK_UART_MASK);

    /* 配置UART引脚复用 */
    mmio_write_32(PINMUX_REG, PINMUX_UART_MASK);

    /* 初始化UART控制器:波特率115200,8N1 */
    uart_init(UART_BASE, 115200);

    /* 初始化DDR控制器——这一步最耗时,也最容易翻车 */
    ddr_init(DDR_CTRL_BASE, DDR_PHY_BASE);

    /* 初始化GIC(通用中断控制器) */
    gic_init(GICD_BASE, GICR_BASE);
}

避坑指南:我曾经在DDR初始化时踩过一个坑——DDR的时序参数是从OTP里读出来的,但OTP读取函数依赖SPI控制器,而SPI控制器的时钟源还没配好。死锁了。解决方案是先配一个“安全时钟”,再用它去读OTP。这种依赖链问题,在BL1里特别常见。

4.2.2 架构级初始化

平台硬件跑起来后,就该配置CPU的架构状态了。这一步很关键,因为BL1运行在EL3,拥有最高权限。配置错了,后面所有异常等级都会跟着错。

主要干这几件事:

  • 设置SCR_EL3:安全配置寄存器,决定异常路由、Endianness、NS位等
  • 配置SCTLR_EL3:系统控制寄存器,控制MMU、Cache、对齐检查等
  • 初始化异常向量表:把VBAR_EL3指向我们刚才写的vectors
  • 设置中断路由:哪些中断走EL3,哪些走EL1/EL2

代码示例:

void arch_init(void)
{
    /* 设置SCR_EL3:不路由FIQ/IRQ到EL3,NS位=0(安全状态) */
    write_scr_el3(SCR_RES1_BITS | SCR_NS_BIT);

    /* 设置SCTLR_EL3:关闭MMU和Cache,等会儿再开 */
    write_sctlr_el3(SCTLR_RES1_BITS);

    /* 设置异常向量表基址 */
    write_vbar_el3((uint64_t)vectors);

    /* 设置中断控制器:SPI路由到EL1,PPI路由到EL3 */
    gic_set_interrupt_routing(GICD_BASE, INT_TYPE_SPI, EL1);
    gic_set_interrupt_routing(GICD_BASE, INT_TYPE_PPI, EL3);
}

你可能会问:为什么一开始要关MMU和Cache?因为此时页表还没建好,DDR可能还没完全稳定。贸然开MMU,一个TLB miss就能让你崩溃。我习惯的做法是:先关着,等页表准备好了再开。

4.2.3 安全初始化

这一步是ATF的“灵魂”。BL1要配置TrustZone地址空间控制器(TZC-400),把内存划分为安全区和非安全区。同时还要初始化安全SRAM——BL2和BL31的代码就放在这里。

典型配置:

void security_init(void)
{
    /* 配置TZC-400:区域0给非安全世界,区域1给安全世界 */
    tzc400_configure_region(0, DRAM_BASE, DRAM_SIZE, TZC_REGION_S_NONE);
    tzc400_configure_region(1, SECURE_DRAM_BASE, SECURE_DRAM_SIZE,
                            TZC_REGION_S_RDWR);

    /* 初始化安全SRAM,用于存放BL2/31镜像 */
    sram_init(SECURE_SRAM_BASE, SECURE_SRAM_SIZE);

    /* 配置TrustZone保护控制器(TZPC) */
    tzpc_init(TZPC_BASE, TZPC_DECPROT0_MASK);
}

个人经验:安全SRAM的大小通常只有几百KB,放不下整个BL2+BL31。所以BL1只加载BL2,BL2再去加载BL31。这种“接力加载”的设计,能有效利用有限的安全内存。

4.3 MMU与Cache的早期配置:性能与安全的平衡术

硬件初始化完了,接下来要开MMU和Cache。但这里有个矛盾:开了MMU,性能好;但页表还没完全准备好,容易出问题。不开MMU,代码跑得慢,尤其是从DDR加载镜像时,那速度简直感人。

BL1的解决方案是:分阶段开启。

4.3.1 第一阶段:Identity Mapping

BL1刚启动时,代码还在ROM或Boot ROM里执行。此时MMU是关的,所有地址都是物理地址。要开MMU,必须先建立页表,而且页表必须把当前代码所在的物理地址映射到相同的虚拟地址——这就是Identity Mapping。

为什么?因为开MMU的指令执行完后,PC指针还在原地。如果虚拟地址和物理地址不对应,CPU直接取不到下一条指令,立马挂掉。

代码实现:

void enable_mmu_el3(void)
{
    uint64_t mair, tcr, ttbr0;

    /* 配置MAIR:内存属性,比如Normal、Device等 */
    mair = MAIR_ATTR(0, MAIR_NORMAL_MEM) |
           MAIR_ATTR(1, MAIR_DEVICE_MEM);
    write_mair_el3(mair);

    /* 配置TCR:页表大小、粒度、ASID等 */
    tcr = TCR_TG0_4KB | TCR_SH0_INNER | TCR_ORGN0_WBWA |
          TCR_IRGN0_WBWA | TCR_T0SZ(25);  /* 512GB地址空间 */
    write_tcr_el3(tcr);

    /* 配置TTBR0:页表基址,必须是4KB对齐 */
    ttbr0 = (uint64_t)page_table_base;
    write_ttbr0_el3(ttbr0);

    /* 开MMU:设置SCTLR_EL3的M位和C位 */
    uint64_t sctlr = read_sctlr_el3();
    sctlr |= SCTLR_M_BIT | SCTLR_C_BIT | SCTLR_I_BIT;
    write_sctlr_el3(sctlr);

    /* 等一条ISB,确保MMU生效 */
    isb();
}

注意:开MMU前,页表里必须包含当前代码段、栈、以及所有要访问的外设地址。漏一个,就是Data Abort。我当年漏了UART的MMIO地址,结果开MMU后printf直接崩了,调试信息都打不出来……

4.3.2 第二阶段:完整页表

Identity Mapping只是临时方案。等BL1把DDR初始化好、把BL2镜像加载到安全SRAM后,就需要建立完整的页表了。这个页表要覆盖整个地址空间:安全DRAM、非安全DRAM、外设MMIO、以及安全SRAM。

页表结构通常用4级页表(4KB粒度),但BL1为了简单,常用2级或3级。我建议用3级——既不太大,又能覆盖足够地址空间。

页表属性配置示例:

地址区域 虚拟地址 物理地址 大小 属性
安全SRAM 0x04000000 0x04000000 256KB Normal, RW, 安全
安全DRAM 0x80000000 0x80000000 64MB Normal, RW, 安全
非安全DRAM 0x84000000 0x84000000 192MB Normal, RW, 非安全
UART 0x1C090000 0x1C090000 4KB Device, nGnRnE
GIC 0x2C000000 0x2C000000 1MB Device, nGnRnE

注意外设地址的属性是Device, nGnRnE——这是ARMv8里最严格的Device内存类型,禁止合并、重排和提前访问。为什么?因为外设寄存器对访问顺序极其敏感,乱序访问会导致硬件状态错乱。

4.3.3 Cache的早期使用策略

MMU开了,Cache要不要开?我的建议是:先开指令Cache,数据Cache等一等。

  • 指令Cache(I-Cache):开了能大幅提升代码执行速度,而且指令只读,没有一致性问题。BL1里可以放心开。
  • 数据Cache(D-Cache):开了有风险。因为BL1要操作DDR控制器、TZC-400等外设,这些外设的MMIO地址如果被Cache了,写操作可能不会立即生效。我建议等所有外设初始化完成、进入BL2加载阶段再开D-Cache。

避坑指南:我曾经在某个项目里,为了性能提前开了D-Cache,结果DDR初始化时,写DDR控制器的命令被Cache住了,没真正发出去。DDR初始化失败,系统直接死机。查了三天才发现是Cache捣的鬼。从那以后,我坚持“外设初始化阶段不开D-Cache”的原则。

4.4 BL1启动流程图

下面这张图把BL1的整个启动流程串起来了。你可以对照着看,每一步的输入输出是什么,依赖关系如何。

BL1启动流程图 BL1启动流程图 芯片上电复位 Reset_Handler入口 平台初始化 时钟 / 电源 / DDR / UART 架构初始化 SCR_EL3 / SCTLR_EL3 / VBAR 安全初始化 TZC-400 / 安全SRAM / TZPC MMU & Cache配置 Identity Mapping → 完整页表 注意点 • 每一步都有依赖关系 • DDR初始化最易翻车 • MMU开之前必须Identity • 外设阶段不开D-Cache 输出 • 安全SRAM就绪 • 页表建立完成 • 准备加载BL2

4.5 小结:BL1的“三把火”

回顾一下,BL1干了三件大事:

  1. 硬件初始化:让芯片“活过来”,能跑代码、能访问内存、能打印日志
  2. 安全环境搭建:划分安全/非安全世界,保护关键资源
  3. MMU与Cache配置:在性能和安全性之间找到平衡点

这三件事做完,BL1的任务就完成了。接下来它会从安全SRAM里加载BL2镜像,然后跳过去。嗯,这部分咱们后面再细聊。

最后说一句:BL1的代码虽然短,但每一行都踩在硬件和安全的边界上。写的时候多想想“如果这里出错了会怎样”,能帮你避开很多坑。