第3章 Bootloader核心功能:硬件初始化、镜像加载与启动参数传递

Bootloader 说白了就干四件事:把硬件拉起来、把程序搬进去、告诉内核怎么跑、以及决定从哪启动。这四件事做不好,后面的系统根本跑不起来。我这些年调过的板子,十有八九的问题都出在这几个环节上。

3.1 硬件初始化——让芯片先活过来

芯片上电那一刻,内部状态其实很原始。PLL 没锁、DDR 没训练、外设时钟都没开。Bootloader 的第一口饭,就是把这些基础设施搭好。

3.1.1 时钟系统初始化

时钟是芯片的心脏。我见过一个项目,工程师把 PLL 倍频配错了,系统跑起来时序全乱,串口数据全是乱码。调了两天才找到原因。

时钟初始化的典型步骤:

  1. 选择时钟源——外部晶振还是内部 RC 振荡器
  2. 配置 PLL——设置倍频系数、分频系数
  3. 分配时钟树——CPU 核、总线、外设各走哪条支路
  4. 等待锁相——确认 PLL 输出稳定

关键指标:时钟稳定时间通常在几十微秒到几毫秒。别急着往下走,等 PLL Lock 标志位拉高再继续。

拿 ARM Cortex-A 系列举例,典型代码结构是这样的:

void clock_init(void) {
    // 1. 选择外部晶振 24MHz
    CLK_SEL = EXT_OSC;
    
    // 2. 配置 PLL: 24MHz * 25 = 600MHz
    PLL_CFG = (25 << 16) | (1 << 0);
    
    // 3. 等待 PLL 锁定
    while (!(PLL_STAT & LOCK_BIT));
    
    // 4. 切换主时钟到 PLL 输出
    CLK_SEL = PLL_OUT;
    
    // 5. 配置外设时钟分频
    PER_CLK_DIV = 4;  // 600MHz / 4 = 150MHz
}

我的习惯:时钟初始化完成后,立刻读回寄存器确认实际频率。有些芯片的 PLL 锁相标志位会虚报,读回来才放心。

3.1.2 DDR 初始化——最麻烦的一步

DDR 初始化是 Bootloader 里最坑的环节。为什么?因为 DDR 没起来之前,你连栈都用不了,代码只能跑在 SRAM 或者 Cache 里。

DDR 初始化的核心流程:

  • 上电与复位——给 DDR 供电,拉低复位引脚
  • 时钟使能——提供差分时钟对
  • CKE 拉高——时钟使能信号有效
  • 模式寄存器配置——设置 CAS 延迟、突发长度、驱动强度
  • ZQ 校准——阻抗匹配校准
  • 训练与测试——写特定数据模式,读回验证
参数 典型值 说明
CAS Latency CL=11 列地址到数据输出的延迟
Burst Length 8 一次突发传输的数据量
tRCD 11 行地址到列地址延迟
tRP 11 预充电周期
tRAS 28 行激活时间

我曾经踩过的坑:DDR 时序参数直接抄了参考设计,结果板子跑起来偶尔死机。后来发现是走线长度不匹配导致信号偏移,不得不把 tRCD 放宽了一个周期。所以,DDR 参数一定要根据实际 PCB 布局微调。

3.1.3 外设初始化

外设初始化要看具体需求。Bootloader 阶段不需要把所有外设都打开,够用就行。通常需要初始化的包括:

  • UART——打印调试信息
  • GPIO——状态指示、按键检测
  • 定时器——延时、超时控制
  • 存储控制器——NAND、eMMC、SPI Flash 接口
  • 网络接口——如果支持网络启动

我个人习惯是:先开 UART 和定时器,其他外设按需初始化。UART 能打印了,后面出问题才好定位。

3.2 镜像加载——把程序搬进内存

硬件初始化完了,接下来就是把操作系统或者应用程序的镜像从存储介质搬到 DDR 里。这一步看似简单,其实有很多门道。

3.2.1 存储介质与加载方式

存储介质 读取方式 典型速度 注意事项
NOR Flash XIP(就地执行) ~50 MB/s 可直接运行代码,但容量小
NAND Flash 页读取 + ECC ~20 MB/s 有坏块问题,需要 ECC 校验
eMMC 块设备读写 ~200 MB/s 内置控制器,使用简单
SPI Flash SPI 协议读取 ~10 MB/s 引脚少,适合小容量

3.2.2 加载流程

镜像加载的标准流程:

  1. 定位镜像——从固定偏移地址或分区表找到镜像位置
  2. 读取头部信息——获取镜像大小、校验值、入口地址
  3. 搬运数据——逐块读取到 DDR 指定地址
  4. 校验完整性——CRC32 或 SHA256 校验
  5. 跳转执行——修改 PC 指针到入口地址
int load_image(uint32_t src_addr, uint32_t dst_addr) {
    image_header_t hdr;
    
    // 1. 读取镜像头部
    flash_read(src_addr, &hdr, sizeof(hdr));
    
    // 2. 校验头部魔数
    if (hdr.magic != IMAGE_MAGIC) {
        return -1;
    }
    
    // 3. 搬运数据
    for (uint32_t offset = 0; offset < hdr.size; offset += BLOCK_SIZE) {
        flash_read(src_addr + sizeof(hdr) + offset,
                   dst_addr + offset,
                   BLOCK_SIZE);
    }
    
    // 4. CRC 校验
    uint32_t calc_crc = crc32((uint8_t*)dst_addr, hdr.size);
    if (calc_crc != hdr.crc) {
        return -2;
    }
    
    return 0;
}

我建议:加载过程中加一个进度指示。虽然 Bootloader 阶段没有图形界面,但串口打印几个点或者百分比,调试时能帮你判断是卡在加载还是卡在校验。

3.3 启动参数传递——给内核递纸条

Bootloader 加载完内核,不能拍拍屁股就走。你得告诉内核:内存有多大、根文件系统在哪、控制台用哪个串口。这些信息就是启动参数。

3.3.1 参数传递方式

不同架构传递参数的方式不一样:

  • ARM 传统方式——通过寄存器 r0/r1/r2 传递 ATAG 列表
  • ARM64 / x86——通过设备树(Device Tree)传递
  • RISC-V——通过 a0/a1 寄存器传递 FDT 地址

设备树是目前的主流方式。它用树形结构描述硬件信息,Bootloader 只需要把 DTB 地址告诉内核就行。

3.3.2 常见启动参数

参数 示例 说明
console ttyS0,115200 指定控制台串口和波特率
root /dev/mmcblk0p2 根文件系统设备
rootfstype ext4 根文件系统类型
mem 512M 可用内存大小
init /sbin/init 第一个用户态进程
// 设备树方式:修改 DTB 中的 chosen 节点
// Bootloader 在跳转前将 DTB 地址写入寄存器
void setup_boot_args(void *fdt_addr) {
    // 设置启动参数到 chosen 节点
    fdt_setprop_string(fdt_addr, "/chosen",
                       "bootargs", "console=ttyS0,115200 root=/dev/mmcblk0p2");
    
    // 将 DTB 地址传给内核
    // ARM64: x0 = DTB 地址
    __asm__ volatile("mov x0, %0" : : "r"(fdt_addr));
}

我曾经遇到的情况:设备树里忘了配串口时钟,内核启动后控制台一片黑。查了两天才发现是 DTB 里的 clock-frequency 写错了。所以,参数传递后最好在 Bootloader 里先读一遍确认。

3.4 启动模式选择——走哪条路

一个成熟的 Bootloader 不会只支持一种启动方式。你得给用户选择权:从 Flash 启动、从网络启动、还是进入烧录模式。

3.4.1 常见启动模式

  • 正常启动——从默认存储介质加载并运行
  • 恢复模式——加载最小系统,用于固件升级
  • 网络启动——通过 TFTP 或 HTTP 获取镜像
  • 串口下载——通过 XMODEM/YMODEM 协议接收数据
  • 工厂模式——运行自检程序

3.4.2 模式选择策略

启动模式的选择通常通过以下方式决定:

  1. 硬件引脚——GPIO 电平组合,简单可靠
  2. 按键检测——启动时检测特定按键是否按下
  3. 看门狗超时——正常启动失败后自动进入恢复模式
  4. 存储介质标志——特定分区内的标志位
boot_mode_t select_boot_mode(void) {
    // 1. 检测硬件引脚
    uint32_t gpio_val = GPIO_READ(BOOT_MODE_PINS);
    
    if (gpio_val == 0x01) {
        return BOOT_NETWORK;
    } else if (gpio_val == 0x02) {
        return BOOT_RECOVERY;
    }
    
    // 2. 检测按键
    if (gpio_read(KEY_UPDATE_PIN) == 0) {
        return BOOT_RECOVERY;
    }
    
    // 3. 默认正常启动
    return BOOT_NORMAL;
}

我的经验:启动模式选择一定要有回退机制。比如网络启动失败后自动切回本地存储,别让设备变砖。我曾经见过一个产品,网络启动超时后直接死循环,用户只能拆机刷写。

3.5 本章知识体系总览

下面这张图把 Bootloader 核心功能的四个模块串起来了。你可以看到,硬件初始化是基础,镜像加载是核心,参数传递是桥梁,启动模式选择是入口。

Bootloader 核心功能架构图 启动模式选择 硬件初始化 时钟系统 DDR 初始化 外设初始化 中断向量 镜像加载 定位镜像 读取头部 搬运数据 校验完整性 启动参数传递(ATAG / 设备树) 跳转执行内核

这四块功能环环相扣。硬件初始化没做好,镜像加载就会出错。镜像加载不完整,参数传递就没意义。启动模式选择错了,整个流程都白跑。所以,写 Bootloader 的时候,每一块都要稳扎稳打。

核心要点回顾:

  • 时钟初始化要等 PLL 锁定,别跳步
  • DDR 参数要根据实际 PCB 微调,别照抄
  • 镜像加载一定要做完整性校验
  • 启动参数传递前先确认 DTB 内容正确
  • 启动模式要有回退机制,防止变砖