3. ARM平台启动流程与底层初始化
ARM芯片上电后,到底发生了什么?
很多人把启动流程背得滚瓜烂熟,但真到了调板子的时候,连卡在哪个阶段都看不出来。我刚开始做嵌入式时也踩过这个坑——明明代码烧进去了,就是跑不起来。后来才明白,启动流程的每个阶段都有它存在的道理。
3.1 启动链全景:从Reset到Shell
ARM平台的启动,说白了就是一段“接力跑”。每个阶段完成特定任务,然后把控制权交给下一棒。
核心启动链:
ROM Boot → SPL (Secondary Program Loader) → U-Boot → Kernel → RootFS
我习惯把启动链分成三个关键阶段:
- 阶段1(硬件固化):BootROM,芯片出厂就写死了,你改不了
- 阶段2(一级引导):SPL,负责最基础的硬件初始化
- 阶段3(二级引导):U-Boot,完整的Bootloader,加载内核
下面这张图能帮你快速建立整体认知:
3.2 BootROM:芯片的第一口呼吸
芯片上电后,CPU从复位向量地址开始执行。ARM Cortex-A系列通常从0x00000000或0xFFFF0000开始。
BootROM里固化了几件事:
- 初始化最基本的时钟和PLL
- 配置启动引脚,检测启动介质(SD卡、eMMC、NAND、SPI Flash等)
- 从启动介质读取SPL到内部SRAM
- 跳转到SPL入口
经验之谈: 我遇到过一块板子,BootROM死活读不到SD卡。查了半天,发现是启动引脚的拉电阻焊错了。BootROM阶段你没法调试,只能靠示波器量波形。
3.3 SPL:从裸机到有记忆
SPL(Secondary Program Loader)是芯片厂商或板级支持包提供的一级引导程序。它的核心任务只有一个:初始化DDR控制器,然后把U-Boot加载到DDR中运行。
为什么需要SPL?
- BootROM太小(通常只有几十KB),放不下完整的DDR初始化代码
- 内部SRAM也小(通常64KB~256KB),装不下U-Boot
- DDR初始化需要复杂的时序配置,不同板子不一样
SPL的典型流程:
- 设置栈指针(SP),建立C运行环境
- 初始化时钟树(CPU频率、总线频率、DDR频率)
- 初始化DDR控制器(时序参数、ODT配置、ZQ校准)
- 从启动介质读取U-Boot到DDR
- 跳转到U-Boot入口
避坑指南: 我曾经在DDR初始化上栽过跟头。板子换了DDR颗粒型号,但SPL里时序参数没改。结果U-Boot加载到DDR后,跑几步就崩了。DDR初始化参数必须和硬件一一对应,别偷懒。
3.4 U-Boot:完整的Bootloader
U-Boot进入DDR后,才算真正有了“施展拳脚”的空间。它负责:
- 初始化更多外设(串口、网络、USB、存储)
- 建立MMU页表,开启MMU和Cache
- 重定向异常向量表
- 加载并启动内核
3.5 MMU与Cache初始化
MMU(内存管理单元)和Cache的初始化,是U-Boot里最容易被忽视但又最关键的部分。
为什么要开MMU?
- 不开MMU,CPU直接访问物理地址
- 开了MMU,才能用虚拟地址,实现进程隔离
- 更重要的是——开启Cache,不然CPU跑起来慢得让人抓狂
我习惯的初始化顺序:
- 关闭所有中断(防止初始化过程中被打断)
- 设置TTBR0(页表基地址寄存器)
- 设置MAIR(内存属性寄存器)
- 设置TCR(翻译控制寄存器)
- 使能MMU(SCTLR的M位)
- 使能Cache(SCTLR的C位和I位)
/* ARMv8-A MMU初始化示例(简化版) */
void mmu_init(void)
{
uint64_t ttbr0 = (uint64_t)&page_table;
uint64_t mair = MAIR_ATTR(0, MT_NORMAL) |
MAIR_ATTR(1, MT_DEVICE_nGnRnE);
uint64_t tcr = TCR_TG0_4K | TCR_SH0_INNER |
TCR_ORGN0_WBWA | TCR_IRGN0_WBWA |
TCR_T0SZ(25); /* 512GB地址空间 */
/* 设置页表基地址 */
write_sysreg(ttbr0, ttbr0_el1);
/* 设置内存属性 */
write_sysreg(mair, mair_el1);
/* 设置翻译控制 */
write_sysreg(tcr, tcr_el1);
/* 使能MMU和Cache */
uint64_t sctlr = read_sysreg(sctlr_el1);
sctlr |= SCTLR_M | SCTLR_C | SCTLR_I;
write_sysreg(sctlr, sctlr_el1);
/* 等MMU生效 */
isb();
}
关键点: MMU使能前后,PC指针指向的地址会发生变化。如果你在使能MMU后立即访问代码段,必须保证页表里已经映射了当前代码所在的物理地址。否则——直接跳飞。
3.6 异常向量表重定向
ARM的异常向量表默认在0x00000000(低向量)或0xFFFF0000(高向量)。但U-Boot运行在DDR里,通常地址不在这些位置。
所以需要做一件事:把异常向量表搬到当前运行地址,然后告诉CPU新的基地址。
ARMv8-A通过VBAR_ELx寄存器设置向量表基地址:
/* 异常向量表重定向 */
extern void exception_vector(void); /* 汇编实现的向量表 */
void vector_table_relocate(void)
{
uint64_t vbar = (uint64_t)&exception_vector;
write_sysreg(vbar, vbar_el1);
isb();
/* 验证是否生效 */
uint64_t check = read_sysreg(vbar_el1);
if (check != vbar) {
/* 重定向失败,需要排查 */
panic("Vector table relocation failed!");
}
}
个人习惯: 我每次都会在重定向后读回VBAR验证一下。曾经遇到过某款芯片的VBAR寄存器写操作有延迟,不加ISB的话读回来的还是旧值。这种坑,踩过一次就记住了。
3.7 多核启动与PSCI协议
多核系统里,上电后只有主核(Core 0)开始执行。从核(Core 1~N)处于WFI(Wait For Interrupt)状态,等着被唤醒。
PSCI(Power State Coordination Interface)是ARM标准的多核电源管理协议。它定义了一组接口:
| PSCI函数 | 功能 | 使用场景 |
|---|---|---|
| CPU_ON | 唤醒指定从核 | 多核启动 |
| CPU_OFF | 关闭指定从核 | 省电/热管理 |
| CPU_SUSPEND | 挂起指定从核 | 深度睡眠 |
| SYSTEM_OFF | 关闭整个系统 | 关机 |
| SYSTEM_RESET | 复位整个系统 | 重启 |
多核启动的典型流程:
- 主核完成MMU/Cache/异常向量表初始化
- 主核为每个从核准备独立的栈空间
- 主核调用PSCI CPU_ON,传入从核ID和入口地址
- 从核从WFI中醒来,跳转到入口地址执行
- 每个从核初始化自己的MMU上下文(共享页表)
- 所有核进入内核调度器,开始多核运行
/* PSCI CPU_ON调用示例 */
int psci_cpu_on(unsigned long target_cpu,
unsigned long entry_point,
unsigned long context_id)
{
struct psci_smc_args args;
args.function_id = PSCI_CPU_ON;
args.arg0 = target_cpu;
args.arg1 = entry_point;
args.arg2 = context_id;
/* 通过SMC指令调用ATF(ARM Trusted Firmware) */
return psci_smc_call(&args);
}
避坑指南: 我曾经在唤醒从核时,忘了给每个从核分配独立的栈空间。结果从核一跑就踩了主核的栈,数据全乱了。记住:每个核都要有自己的栈,而且栈大小要够用。
3.8 启动流程的调试技巧
启动阶段是最难调试的——没有操作系统,没有调试器,甚至串口都没初始化好。
我常用的三板斧:
- GPIO点灯:在关键节点翻转GPIO,用示波器看波形。简单粗暴,但有效
- 串口打印:UART初始化后,尽早输出调试信息。我习惯在SPL里就开串口
- JTAG/SWD:如果板子支持,用调试器单步执行。但要注意,有些芯片在BootROM阶段会锁调试接口
经验之谈: 我调试启动问题时,会在每个阶段入口和出口都放一个GPIO翻转。这样用示波器一看,就知道卡在哪个阶段了。比看代码猜快得多。
启动流程看似复杂,但拆开来看,每个阶段的任务都很明确。理解了这个链条,你就能在板子起不来的时候,快速定位问题出在哪一棒。