2、硬件基础:CPU架构(ARM/RISC-V)、内存映射、外设寄存器访问方式、启动模式与启动介质概述

Bootloader 的本质是硬件与操作系统之间的桥梁,其首要任务是在上电后的极短时间内完成对 CPU、内存、外设和启动介质的初始化,为内核运行准备好一个稳定、可控的硬件环境。因此,理解 CPU 架构、内存映射、寄存器访问方式以及启动介质的工作原理,是编写可靠 Bootloader 的前提。本章将围绕这四个核心维度展开,帮助读者建立从芯片手册到实际代码的映射能力。

Bootloader 硬件初始化核心知识框架 CPU 核心 ARM Cortex-A / RISC-V CPU 架构 • 异常模型(IRQ/FIQ/SVC) • 特权级(EL0-EL3 / M/U) • MMU 与 Cache 控制 • 启动时 CPU 状态配置 • 中断向量表重定位 → 决定 Bootloader 如何接管控制权 内存映射 • 地址空间划分(RAM/ROM) • 外设基地址(Peripheral Base) • 内存属性(Device/Strong) • 重映射(Remap)机制 • 片内 SRAM / DDR 地址窗口 → 决定链接脚本与访问合法性 寄存器访问方式 • 内存映射 I/O(MMIO) • volatile 关键字必要性 • 读-改-写与位带操作 • 访问宽度(8/16/32/64) • 内存屏障(DSB/DMB/ISB) → 保证外设操作的原子性与顺序性 启动介质 • NOR / NAND Flash • eMMC / SD 卡 • SPI NOR / SPI NAND • UART / USB / 网络启动 • 启动模式选择引脚(Boot Mode) → 决定 Bootloader 第一阶段加载路径 四者协同:架构定规则 → 映射定地址 → 寄存器定操作 → 介质定来源

2.1 CPU 架构:ARM 与 RISC-V 的启动关键差异

Bootloader 编写者首先需要回答一个问题:CPU 上电后处于什么状态?对于 ARM 架构,Cortex-A 系列处理器上电后通常处于 EL3(若存在 TrustZone)或 SVC 模式,MMU 关闭、Cache 关闭、异常向量表地址未知(或指向 ROM 中的固定地址)。而 RISC-V 架构则从上电时的 Machine Mode(M 模式)开始执行,所有中断默认关闭,PC 从芯片厂商定义的 Reset Vector 地址取值。这两种架构在启动路径上的差异,直接决定了 Bootloader 第一行汇编代码的写法。

核心原理:ARM 的异常模型包含七种异常类型(Reset、Undefined、SVC、Prefetch Abort、Data Abort、IRQ、FIQ),每种异常对应独立的处理器模式。Bootloader 在启动初期必须将异常向量表复制到 SRAM 或 DDR 的指定位置,并通过 VBAR 寄存器(ARMv7-A)或 VBAR_ELx(ARMv8-A)完成重定位。RISC-V 则采用统一的异常向量基址寄存器 mtvec,支持 Direct 和 Vectored 两种模式,Bootloader 需要根据中断源类型设置 mtvec 的 MODE 字段。

在实际开发中,判断当前 CPU 架构和实现版本是第一步。ARM 通过读取 MIDR 寄存器获取厂商 ID 和产品 ID;RISC-V 则通过 misa 寄存器(Machine ISA)识别支持的扩展指令集。例如,若 misa 的 bit2 为 1,则支持 C 压缩指令扩展,这会影响 Bootloader 代码的编译选项和链接地址对齐要求。

对比维度ARM Cortex-A(典型)RISC-V(RV64GC)
上电模式EL3 / SVCMachine Mode(M)
异常向量表VBAR / VBAR_ELxmtvec(Direct/Vectored)
MMU 初始状态关闭关闭
通用寄存器位宽32/64 可选32/64 可选
启动时中断状态默认屏蔽(DAIF 置位)默认屏蔽(mstatus.MIE=0)
特权指令访问CPSR/SPSR 控制mstatus/mepc/mcause 控制

一个常见的误区是认为 Bootloader 只需要设置栈指针和跳转到 C 语言入口即可。实际上,在跳转之前必须完成至少三项工作:配置异常向量表、设置 CPU 工作模式(关闭中断)、初始化栈指针(SP)。对于 ARM,还需要考虑大小端模式(通常通过 SCTLR 寄存器配置);对于 RISC-V,则需要确认 misa 扩展位是否与编译时使用的 -march 选项匹配,否则执行非法指令会触发异常。

风险提示:在 ARM 上,如果 Bootloader 在 MMU 关闭时访问了未映射的外设地址,会触发 Data Abort 异常。而在 RISC-V 上,访问非法地址会触发 Load Access Fault 或 Store/AMO Access Fault。两者的异常处理流程不同,调试手段也不同——ARM 可以通过 DFAR 寄存器获取故障地址,RISC-V 则需要读取 mtval 寄存器。建议在 Bootloader 早期阶段就建立统一的异常捕获机制,打印出异常类型和故障地址,便于现场定位。

2.2 内存映射:链接脚本与地址窗口的工程实践

内存映射是 Bootloader 开发中最容易出错、也最需要严谨对待的部分。每个 SoC 的数据手册都会给出完整的地址映射表,例如:0x0000_0000 可能映射到片内 ROM,0x2000_0000 映射到 SRAM,0x4000_0000 映射到外设寄存器,0x8000_0000 映射到 DDR。Bootloader 的链接脚本(Linker Script)必须严格遵循这个映射关系,否则代码跳转后立即崩溃。

以典型的 ARM 嵌入式 SoC 为例,Bootloader 通常分为两个阶段:Stage 1 在片内 SRAM 中运行(因为 DDR 尚未初始化),Stage 2 则被复制到 DDR 中运行。因此,链接脚本需要定义两个不同的内存区域(MEMORY 命令),并为每个阶段设置独立的加载地址(LMA)和虚拟地址(VMA)。在 MMU 开启之前,LMA 和 VMA 相同;开启 MMU 后,VMA 可能被映射到更高的地址空间。

/* 典型 ARM Bootloader 链接脚本片段 */
MEMORY
{
    SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 256K
    DDR  (rwx) : ORIGIN = 0x80000000, LENGTH = 512M
}

SECTIONS
{
    .text_stage1 :
    {
        *(.text.stage1)
    } > SRAM

    .text_stage2 :
    {
        *(.text*)
    } > DDR

    .data :
    {
        *(.data*)
    } > DDR AT> SRAM  /* 数据从 SRAM 加载,运行在 DDR */
}

判断内存映射是否正确的方法并不复杂:在 Bootloader 启动后,通过打印关键符号的地址(例如 &_start、&__bss_start)与链接脚本中的预期值进行比对。如果地址落在错误的区域,说明链接脚本或启动代码中的地址计算有误。另一个常见问题是内存属性配置——在开启 MMU 后,外设区域必须标记为 Device 或 Strongly-ordered 内存类型,禁止使用 Cache 和写缓冲,否则外设寄存器读写可能被乱序执行。

经验提醒:在编写链接脚本时,建议为每个段显式声明 NOLOAD 属性(如 .bss、.stack),避免 ELF 文件过大。同时,在启动汇编代码中,必须对 .bss 段进行清零操作,否则未初始化的全局变量会保留上电时的随机值,导致 Bootloader 行为不可预测。清零操作应使用 32 位或 64 位批量写入,而不是逐字节循环,以提高启动速度。

2.3 外设寄存器访问方式:MMIO 与位操作技巧

Bootloader 对外设的控制本质上是对寄存器的读写。绝大多数 SoC 采用内存映射 I/O(MMIO)方式,即外设寄存器被映射到 CPU 的地址空间中,通过普通的 load/store 指令访问。例如,UART 的数据寄存器地址可能是 0x40001000,状态寄存器地址是 0x40001014。在 C 语言中,通常使用指针强制类型转换来访问:

#define UART_BASE 0x40001000UL
#define UART_DR   (*(volatile unsigned int *)(UART_BASE + 0x00))
#define UART_FR   (*(volatile unsigned int *)(UART_BASE + 0x18))

void uart_putc(char c)
{
    /* 等待发送 FIFO 为空 */
    while (UART_FR & (1 << 5));
    UART_DR = c;
}

这里的关键是 volatile 关键字。它告诉编译器该变量的值可能随时被硬件改变,禁止编译器将其优化到寄存器中缓存。如果没有 volatile,编译器可能将 UART_FR 的读取操作优化为只读一次,导致死循环。此外,访问宽度必须与寄存器定义一致——32 位寄存器不能使用 8 位指针访问,否则在 ARM 上会触发 Unaligned Access 异常,在 RISC-V 上则可能产生非对齐访问陷阱。

对于需要修改寄存器中某几位而保持其他位不变的场景,必须使用读-改-写(Read-Modify-Write)操作。例如,要设置 GPIO 方向寄存器的 bit3 为输出,同时不影响其他引脚:

volatile uint32_t *gpio_dir = (volatile uint32_t *)0x50000000;
uint32_t tmp = *gpio_dir;   /* 读 */
tmp |= (1 << 3);            /* 改 */
*gpio_dir = tmp;            /* 写 */

在 ARM 和 RISC-V 中,这种操作不是原子性的,如果中断在读写之间发生,可能导致数据丢失。因此,在 Bootloader 中操作关键寄存器时,应暂时屏蔽中断,或使用位带(Bit-band)操作(Cortex-M 系列支持)来保证原子性。对于 RISC-V,没有位带机制,但可以通过原子指令(AMO)实现类似功能,不过 Bootloader 阶段通常直接屏蔽中断更简单。

操作类型ARM 实现RISC-V 实现注意事项
读寄存器LDRLW/LD必须使用 volatile 指针
写寄存器STRSW/SD注意访问宽度匹配
读-改-写LDR + ORR + STRLW + OR + SW非原子,需关中断
内存屏障DSB/DMB/ISBfence外设操作后必须加 DSB
位操作BIC/ORR 指令ANDI/ORI 指令RISC-V 的 ANDI 仅支持 12 位立即数
风险提示:在 Bootloader 中访问外设寄存器时,必须确保 MMU 页表中该区域的属性为 Device 或 Strongly-ordered。如果错误地配置为 Normal 内存类型,CPU 可能会对寄存器访问进行重排或合并,导致外设行为异常。例如,两个连续的写操作可能被合并为一个宽写,这在配置时钟分频器或 DMA 描述符时会造成严重故障。建议在开启 MMU 后,立即使用 DSB 指令确保所有外设访问完成。

2.4 启动模式与启动介质:从 ROM 到 DDR 的完整链路

启动模式(Boot Mode)由 SoC 的特定引脚或 eFuse 决定。常见的启动介质包括:NOR Flash(支持 XIP,可直接执行代码)、NAND Flash(需要软件读取并复制到 RAM)、eMMC/SD 卡(通过 SD/MMC 控制器访问)、SPI NOR/NAND(通过 SPI 控制器访问)、UART/USB(用于调试和工厂烧录)。Bootloader 的第一阶段(ROM Code)由芯片厂商固化,其作用是根据启动模式从对应介质中读取 Bootloader 的第一段代码到 SRAM,然后跳转执行。

以典型的 i.MX 系列处理器为例,其启动流程如下:

  1. ROM Code 读取 Boot Mode 引脚(BOOT_MODE[1:0])和 Boot Device 配置引脚。
  2. 根据配置初始化对应的启动介质控制器(如 SDHC、QSPI、NAND Controller)。
  3. 从介质中读取 Bootloader 镜像头部(包含 DCD 数据,用于初始化 DDR)。
  4. 将 Bootloader 主体加载到 SRAM 或 DDR 中,并跳转执行。

对于 Bootloader 开发者来说,理解启动介质的关键在于:不同介质的读取方式差异巨大。NOR Flash 支持 XIP(Execute in Place),CPU 可以直接从 Flash 地址取指执行,无需复制到 RAM,但写入速度慢、容量小。NAND Flash 容量大、价格低,但坏块管理和 ECC 校验是必须处理的难题。eMMC 内置控制器,支持块读写,但需要初始化 MMC 协议栈,代码复杂度较高。

案例拆解:某基于 RISC-V 的 IoT 芯片,片内 SRAM 仅 64KB,Bootloader 需要从 SPI NOR Flash 启动。初始方案是直接将整个 Bootloader(约 120KB)复制到 SRAM,但 SRAM 容量不足。经过分析,改为分段加载:第一阶段(约 8KB)在 SRAM 中运行,负责初始化 SPI 控制器和 DDR;第二阶段从 SPI NOR 中加载到 DDR 运行。关键参数:SPI 时钟 50MHz,读取 1KB 数据约需 0.2ms,整个加载过程约 25ms,满足系统 100ms 启动时间要求。实际操作中,发现 SPI NOR 的 Quad Read 模式比 Standard Read 快 4 倍,通过配置状态寄存器启用 QE 位后,加载时间缩短至 8ms。复盘经验:启动介质的速度优化应优先考虑控制器支持的快速模式,而不是单纯提高时钟频率。

在启动介质的选择上,还需要考虑可靠性。NAND Flash 存在坏块问题,Bootloader 必须实现坏块跳过机制,并在写入时进行 ECC 校验。eMMC 虽然内置 ECC,但 Bootloader 需要处理分区表和启动分区概念。对于量产产品,建议使用 eMMC 或 SPI NOR,避免 NAND 的坏块管理复杂度。对于开发调试阶段,UART 或 USB 启动模式非常有用,可以快速下载代码到 SRAM 中运行,无需反复烧写 Flash。

经验总结:在编写 Bootloader 时,建议将启动介质抽象为统一的接口,例如定义 int storage_read(unsigned int offset, void *buf, unsigned int size) 函数,底层分别实现 NOR、NAND、eMMC 的读取逻辑。这样上层代码无需关心具体介质类型,便于移植和调试。同时,在启动介质读取函数中,务必加入超时机制,防止控制器挂死导致 Bootloader 卡死。