4. Bootloader原理与移植:从上电到内核启动的第一公里

大家好,我是你们的嵌入式系统移植课讲师。今天我们来聊聊Bootloader——这个嵌入式系统里“第一个醒来的家伙”。

很多刚入行的朋友觉得Bootloader就是一段“把内核拷贝到内存然后跳转”的代码。其实没那么简单。我做过不下二十个板子的移植,每次最头疼的往往不是Linux内核本身,而是这个“看门人”能不能把硬件伺候舒服了。

4.1 Bootloader的作用与启动流程

Bootloader说白了就干三件事:初始化硬件、加载镜像、跳转执行。但每一件都藏着坑。

核心职责清单:

  • 硬件初始化:关看门狗、设置时钟、初始化DDR/Flash、配置串口
  • 加载镜像:从Flash/NAND/SD卡/网络把内核和DTB读到内存
  • 传递参数:给内核传递启动参数(ATAGS或设备树)
  • 跳转执行:跳转到内核入口,交出控制权

典型的启动流程分两个阶段,我画了张图帮你理清脉络:

U-Boot 两阶段启动流程 Stage 1:汇编阶段(SPL/TPL) 关看门狗 · 初始化时钟 初始化 DDR 控制器 从启动介质加载 Stage 2 Stage 2:C语言阶段(U-Boot proper) 初始化串口 · 网卡 · 存储 解析环境变量 · 进入命令行 加载内核 · DTB · 跳转 💡 现代 SoC 常用 SPL(Secondary Program Loader) SPL 大小通常限制在 32KB 以内,由片上 SRAM 直接执行 U-Boot proper 则放在 DDR 中运行,功能完整

为什么分两阶段?因为片上SRAM太小了,装不下完整的U-Boot。SPL就是那个“瘦身版”的加载器,它只做最必要的事——初始化DDR,然后把真正的U-Boot从Flash搬到内存里。

我的经验:调试SPL阶段是最痛苦的,因为串口还没初始化,你根本看不到任何输出。我习惯在SPL里加GPIO翻转,用示波器看电平变化来判断程序跑到哪了。这招救过我很多次。

4.2 U-Boot源码结构分析

U-Boot的源码目录乍一看很吓人,其实有规律可循。我带你快速过一遍核心目录:

目录/文件 作用 移植关注点
arch/ CPU架构相关代码(arm、riscv、x86等) 启动汇编、异常向量、MMU设置
board/ 板级特定代码 移植主要战场,DDR参数、GPIO复用
drivers/ 外设驱动(串口、网卡、MMC、NAND等) 根据板载外设选配
include/configs/ 板级配置头文件 定义内存地址、外设基址、环境变量
dts/ 设备树源文件 新板子需要新建.dts文件
tools/ 镜像打包工具(mkimage等) 生成U-Boot可执行镜像

我个人觉得,board/和include/configs/是移植时改动最频繁的地方。你想想看,每块板子的DDR颗粒不一样、GPIO分配不一样,这些差异全得在这两个目录里体现。

4.3 U-Boot的配置与编译(make menuconfig)

U-Boot从2014年开始引入了Kconfig配置系统,跟Linux内核的配置方式一模一样。这意味着你可以用make menuconfig来图形化地开关功能。

标准编译流程是这样的:

# 1. 清理
make distclean

# 2. 配置板级默认配置
make <board_name>_defconfig

# 3. 自定义配置(可选)
make menuconfig

# 4. 编译
make -j$(nproc)

举个例子,如果你要为树莓派4编译U-Boot:

make rpi_4_defconfig
make menuconfig  # 可以在这里打开或关闭驱动
make -j4

⚠️ 注意:我曾经遇到过一个坑——make menuconfig保存后,有些配置项跟include/configs/里的宏定义冲突。U-Boot的规则是:Kconfig的优先级高于头文件。如果你在头文件里定义了CONFIG_XXX,但Kconfig里没开,那这个宏实际上不会生效。

编译完成后,你会得到几个关键文件:

  • u-boot.bin:完整的U-Boot镜像
  • u-boot.img:带U-Boot头部的镜像(用于SPL加载)
  • spl/u-boot-spl.bin:SPL镜像
  • u-boot.dtb:编译好的设备树

4.4 U-Boot的板级文件修改

这才是移植的核心。假设你要给一块新的ARM板子移植U-Boot,需要动哪些文件?我按优先级列出来:

  1. 新建板级目录:board/yourvendor/yourboard/,里面放Kconfig、Makefile、board.c
  2. 新建配置头文件:include/configs/yourboard.h,定义DDR大小、基地址、环境变量
  3. 新建设备树:arch/arm/dts/yourboard.dts,描述硬件拓扑
  4. 修改顶层Kconfig:在arch/arm/Kconfig里添加你的板子选项
  5. 修改Makefile:在arch/arm/dts/Makefile里添加设备树编译目标

这里我重点说一下board.c里最关键的dram_init()函数:

// board/yourvendor/yourboard/board.c
#include <common.h>
#include <asm/arch/ddr.h>

int dram_init(void)
{
    // 设置DDR控制器参数
    // 不同颗粒的时序参数完全不同
    gd->ram_size = PHYS_SDRAM_1_SIZE;
    return 0;
}

int dram_init_banksize(void)
{
    // 告诉U-Boot内存布局
    gd->bd->bi_dram[0].start = PHYS_SDRAM_1;
    gd->bd->bi_dram[0].size = PHYS_SDRAM_1_SIZE;
    return 0;
}

避坑指南:我曾经在一块i.MX6ULL板子上折腾了两天,U-Boot死活起不来。最后发现是DDR的ODT(片上端接)配置不对。不同频率下ODT的阻值要求不一样,DDR3 800MHz和DDR3 1066MHz的ODT设置完全不同。建议你拿到新板子后,先找DDR颗粒的数据手册,把时序参数一个一个对清楚。

配置头文件里最常改的是这些宏:

// include/configs/yourboard.h
#define CONFIG_SYS_SDRAM_BASE       0x80000000
#define CONFIG_SYS_SDRAM_SIZE       (512 * 1024 * 1024)  // 512MB

#define CONFIG_SYS_LOAD_ADDR        0x82000000   // 内核加载地址
#define CONFIG_SYS_TEXT_BASE        0x87800000   // U-Boot运行地址

#define CONFIG_BOOTCOMMAND \
    "fatload mmc 0:1 0x82000000 zImage; " \
    "fatload mmc 0:1 0x83000000 imx6ull.dtb; " \
    "bootz 0x82000000 - 0x83000000"

#define CONFIG_BOOTARGS \
    "console=ttymxc0,115200 root=/dev/mmcblk0p2 rootwait"

嗯,这里要注意CONFIG_SYS_TEXT_BASE。这个地址必须跟链接脚本里的地址一致,否则U-Boot会跑飞。我见过有人改了DDR大小但忘了改这个地址,结果U-Boot加载到DDR范围之外,直接死机。

最后总结一下移植的检查清单:

检查项 常见问题 验证方法
DDR初始化 时序参数错误、ODT配置不对 串口打印DRAM大小,md命令读写测试
串口输出 波特率不对、GPIO复用没配 上电看有没有U-Boot启动信息
存储驱动 MMC/NAND时序、分区表错误 mmc list、fatls命令测试
网络驱动 PHY地址不对、MAC地址没配 ping命令测试
内核启动 DTB地址不对、bootargs错误 看内核启动log,停在哪个阶段

移植U-Boot就像搭积木,每个模块都要对上。别指望一次成功,我每次移植至少烧写三五十次Flash。但只要你把基础打牢了——DDR时序、时钟树、GPIO复用——后面的事情就顺了。