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要干的第一件事就是初始化硬件。这个流程我总结为“三步走”:
- 平台级初始化:时钟、电源、DDR控制器、UART等基础外设
- 架构级初始化:异常等级、系统寄存器、中断控制器
- 安全初始化: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的整个启动流程串起来了。你可以对照着看,每一步的输入输出是什么,依赖关系如何。
4.5 小结:BL1的“三把火”
回顾一下,BL1干了三件大事:
- 硬件初始化:让芯片“活过来”,能跑代码、能访问内存、能打印日志
- 安全环境搭建:划分安全/非安全世界,保护关键资源
- MMU与Cache配置:在性能和安全性之间找到平衡点
这三件事做完,BL1的任务就完成了。接下来它会从安全SRAM里加载BL2镜像,然后跳过去。嗯,这部分咱们后面再细聊。
最后说一句:BL1的代码虽然短,但每一行都踩在硬件和安全的边界上。写的时候多想想“如果这里出错了会怎样”,能帮你避开很多坑。