2. Bootloader原理与U-Boot移植

Bootloader,说白了就是嵌入式系统的「第一口饭」。CPU上电后,它得先跑起来,把硬件初始化好,再把内核请进内存。我刚开始做嵌入式那会儿,总觉得Bootloader就是个跳板,后来踩了几次坑才明白——这玩意儿要是没弄对,后面全是白搭。

2.1 Bootloader启动流程

嵌入式系统的启动,一般分这么几步:

  1. ROM代码:芯片内部固化的启动代码,负责从指定介质加载SPL。
  2. SPL(Secondary Program Loader):初始化DDR、时钟等,加载完整的U-Boot。
  3. U-Boot:完整的Bootloader,加载内核和设备树。
  4. 内核:解压并运行,挂载根文件系统。

你想想看,为什么要有SPL?因为很多SoC的片上SRAM只有几十KB,装不下完整的U-Boot。SPL就是那个「瘦身版」的加载器,干完活就交棒。

关键点:SPL和U-Boot的分工,是嵌入式Bootloader设计的核心思想。SPL只做最少的事,把复杂逻辑留给U-Boot。

我在项目中遇到过一块板子,SPL死活加载不了U-Boot。查了半天,发现是DDR初始化时序配错了。嗯,这种问题最头疼——因为SPL阶段连串口都还没完全配好,调试全靠点灯。

2.2 U-Boot源码结构

U-Boot的源码目录,我建议你重点关注这几个:

目录 作用
arch/ 架构相关代码,比如ARM、RISC-V、x86
board/ 板级代码,每个板子一个子目录
drivers/ 驱动,包括串口、网卡、MMC等
include/ 头文件,特别是configs/下的板级配置
common/ 通用功能,比如命令行解析、环境变量
cmd/ 命令实现,每个命令一个文件

我个人习惯,拿到一个新板子,先看board/和include/configs/。这两个目录基本决定了板子的「性格」。

小技巧:U-Boot的配置系统借鉴了Linux内核的Kconfig。你可以在源码根目录执行make menuconfig,图形化地开关功能。

2.3 U-Boot配置与编译

配置和编译U-Boot,流程其实很固定:

# 清理
make distclean

# 配置(以qemu-arm为例)
make qemu_arm_defconfig

# 图形化微调(可选)
make menuconfig

# 编译
make -j4

# 编译SPL(如果板子支持)
make SPL

为什么先distclean?因为U-Boot的配置缓存很敏感,换板子不清理,编译会出各种怪问题。我曾经因为偷懒没clean,结果编译出来的U-Boot在板子上跑飞了,查了两天才发现是配置残留。

避坑指南:交叉编译时,一定要设置CROSS_COMPILE环境变量。比如export CROSS_COMPILE=arm-linux-gnueabihf-。忘了这个,编译出来的东西在目标板上跑不了。

编译完成后,你会得到u-boot.bin(完整版)和u-boot-spl.bin(SPL版)。有些SoC还需要mkimage工具把U-Boot打包成特定格式。

2.4 U-Boot常用命令

U-Boot的命令行,是调试嵌入式系统的利器。我列几个最常用的:

命令 功能 示例
printenv 打印环境变量 printenv bootargs
setenv 设置环境变量 setenv bootargs 'console=ttyS0,115200'
saveenv 保存环境变量到存储 saveenv
tftp 通过TFTP下载文件 tftp 0x42000000 uImage
mmc MMC/SD卡操作 mmc read 0x42000000 0x800 0x1000
bootm 启动内核 bootm 0x42000000
bootz 启动zImage内核 bootz 0x42000000
fdt 设备树操作 fdt addr 0x43000000

我个人最常用的是tftp和bootm的组合。开发阶段,把内核和设备树放在TFTP服务器上,U-Boot通过网络加载,省去了反复烧写存储的麻烦。

核心流程:tftp 0x42000000 uImage → tftp 0x43000000 board.dtb → bootm 0x42000000 - 0x43000000

2.5 设备树基础

设备树(Device Tree),是描述硬件信息的结构化数据。U-Boot和内核通过它来了解板子上有什么外设、怎么访问。

一个简单的设备树节点长这样:

/dts-v1/;

/ {
    model = "MyBoard";
    compatible = "vendor,myboard";

    chosen {
        stdout-path = &uart0;
    };

    uart0: serial@10000000 {
        compatible = "ns16550";
        reg = <0x10000000 0x1000>;
        clock-frequency = <24000000>;
    };
};

你想想看,为什么要有设备树?以前的做法是把硬件信息硬编码在代码里,换个板子就得改代码。设备树把硬件描述和驱动代码解耦了——换板子,换一个.dtb文件就行。

我在项目中遇到过一个问题:U-Boot启动内核后,串口没输出。查了半天,发现是设备树里chosen节点的stdout-path配错了。嗯,这种问题很隐蔽,因为U-Boot阶段串口是好的,内核阶段才出问题。

调试技巧:在U-Boot命令行下,可以用fdt list查看设备树内容,用fdt set修改节点属性。这样不用重新编译,就能快速验证硬件配置。

设备树的编译也很简单:

# 编译设备树源文件(.dts)为二进制(.dtb)
dtc -I dts -O dtb -o board.dtb board.dts

# 反编译查看
dtc -I dtb -O dts -o board.dts board.dtb

下面这张图,展示了U-Boot启动过程中,设备树是如何被传递和使用的:

U-Boot启动流程与设备树传递 CPU上电 ROM代码 SPL U-Boot 内核 设备树传递路径 U-Boot加载 .dtb → 修改 chosen 节点 → 传递给内核 图:U-Boot启动流程及设备树传递关系

这张图把启动流程和设备树的关系画清楚了。U-Boot从存储介质加载.dtb文件,可以修改chosen节点(比如设置启动参数),然后通过寄存器或内存地址把设备树指针传给内核。

避坑指南:设备树和内核版本要匹配。我曾经把新内核配旧设备树,结果网卡驱动死活不认。后来发现是设备树里compatible字符串和驱动对不上。记住,设备树是「契约」,双方都得遵守。