3. Cortex-A微架构概览

ARM芯片上电后,到底发生了什么?

很多人把启动流程背得滚瓜烂熟,但真到了调板子的时候,连卡在哪个阶段都看不出来。我刚开始做嵌入式时也踩过这个坑——明明代码烧进去了,就是跑不起来。后来才明白,启动流程的每个阶段都有它存在的道理。

3.1 启动链全景:从Reset到Shell

ARM平台的启动,说白了就是一段“接力跑”。每个阶段完成特定任务,然后把控制权交给下一棒。

核心启动链:

ROM Boot → SPL (Secondary Program Loader) → U-Boot → Kernel → RootFS

我习惯把启动链分成三个关键阶段:

  • 阶段1(硬件固化):BootROM,芯片出厂就写死了,你改不了
  • 阶段2(一级引导):SPL,负责最基础的硬件初始化
  • 阶段3(二级引导):U-Boot,完整的Bootloader,加载内核

下面这张图能帮你快速建立整体认知:

ARM平台启动链流程图 BootROM 硬件固化代码 SPL 一级引导加载 U-Boot 二级引导加载 Kernel 操作系统 阶段1:硬件固化(不可修改) 阶段2-3:软件引导(可定制) 初始化时钟/PLL 检测启动介质 初始化DDR 加载U-Boot MMU/Cache初始化 加载Kernel 进程调度 驱动加载 注:不同厂商(TI、NXP、Rockchip)的SPL实现细节有差异,但整体流程一致

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的典型流程:

  1. 设置栈指针(SP),建立C运行环境
  2. 初始化时钟树(CPU频率、总线频率、DDR频率)
  3. 初始化DDR控制器(时序参数、ODT配置、ZQ校准)
  4. 从启动介质读取U-Boot到DDR
  5. 跳转到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跑起来慢得让人抓狂

我习惯的初始化顺序:

  1. 关闭所有中断(防止初始化过程中被打断)
  2. 设置TTBR0(页表基地址寄存器)
  3. 设置MAIR(内存属性寄存器)
  4. 设置TCR(翻译控制寄存器)
  5. 使能MMU(SCTLR的M位)
  6. 使能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 复位整个系统 重启

多核启动的典型流程:

  1. 主核完成MMU/Cache/异常向量表初始化
  2. 主核为每个从核准备独立的栈空间
  3. 主核调用PSCI CPU_ON,传入从核ID和入口地址
  4. 从核从WFI中醒来,跳转到入口地址执行
  5. 每个从核初始化自己的MMU上下文(共享页表)
  6. 所有核进入内核调度器,开始多核运行
/* 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翻转。这样用示波器一看,就知道卡在哪个阶段了。比看代码猜快得多。

启动流程看似复杂,但拆开来看,每个阶段的任务都很明确。理解了这个链条,你就能在板子起不来的时候,快速定位问题出在哪一棒。