4. U-Boot / Little Kernel (LK) 启动:入口、设备树加载与传递
在 Android 系统的启动链中,U-Boot 或 Little Kernel (LK) 作为 Bootloader 的第二阶段,承担着硬件初始化、内存布局建立以及最关键的任务——加载并传递设备树(Device Tree)给后续的 Linux 内核。本章将深入剖析 U-Boot 与 LK 的入口逻辑、设备树的加载机制以及传递协议。
4.1 Bootloader 第二阶段入口分析
无论是 U-Boot 还是 LK,其入口点通常由芯片厂商的 ROM Code 或 SPL (Secondary Program Loader) 跳转而来。入口代码通常使用汇编语言编写,负责设置异常向量表、初始化栈指针以及清理 BSS 段。
4.1.1 U-Boot 入口流程
U-Boot 的入口通常位于 arch/arm/lib/vectors.S 或 arch/arm/cpu/armv8/start.S。核心步骤包括:
- 设置异常向量表:将向量表基址写入 VBAR 寄存器。
- 进入 SVC 模式:确保 CPU 处于特权模式。
- 初始化栈指针:为 C 语言运行环境准备栈空间。
- 清零 BSS 段:确保全局变量初始值为 0。
- 跳转到 board_init_f:执行板级初始化前的准备工作。
关键入口符号: U-Boot 的链接脚本(u-boot.lds)定义了入口点为 _start,该符号位于 vectors.S 中。对于 ARMv8 架构,入口点通常为 _armv8_start。
4.1.2 LK 入口流程
LK 的入口相对简洁,通常从 arch/arm/crt0.S 或 platform/msm_shared/start.S 开始。其核心步骤与 U-Boot 类似,但更侧重于快速跳转到 C 语言主函数:
- CPU 模式切换:确保所有核心处于安全或非安全模式。
- MMU 与 TLB 初始化:部分平台在 LK 阶段即开启 MMU。
- 跳转到 lk_main:进入 C 语言主循环。
调试技巧: 在 GDB 中,可通过 info registers 检查 PC 是否指向 _start 或 lk_main 地址。使用 add-symbol-file 加载未剥离符号的 u-boot 或 lk.elf 文件,可进行源码级调试。
4.2 设备树 (Device Tree) 的加载机制
设备树(DTB)是描述硬件拓扑结构的数据结构。Bootloader 必须从存储介质(如 eMMC、UFS)中读取 DTB 文件,并将其放置在内存中的特定位置,以便内核能够访问。
4.2.1 DTB 的存储与加载路径
| 存储介质 | 典型分区名 | 加载方式 | U-Boot 命令/函数 |
|---|---|---|---|
| eMMC | dtb 或 dtbo |
通过 mmc read 读取到内存 |
load mmc 0:1 $fdt_addr_r dtb.img |
| UFS | dtb |
通过 ufs read 读取 |
ufs read $fdt_addr_r $dtb_offset $dtb_size |
| SPI Flash | DTB 分区 | 通过 sf read 读取 |
sf read $fdt_addr_r $dtb_offset $dtb_size |
| RAM (预加载) | 由前一阶段 BootROM 加载 | 直接使用固定地址 | 通过 fdt addr 设置工作地址 |
4.2.2 DTB 的校验与覆盖
现代 Android 设备支持 DTBO (Device Tree Blob Overlay) 机制。Bootloader 需要将基础 DTB 与多个 DTBO 片段合并,生成最终的设备树。
- 基础 DTB:包含 SoC 核心硬件描述。
- DTBO:包含板级差异(如屏幕、传感器、GPIO 配置)。
- 合并流程:U-Boot 使用
fdt apply命令或fdt_overlay_apply()函数将 DTBO 合并到基础 DTB 中。
常见问题: 如果 DTB 地址与内核解压地址重叠,会导致内核启动时解压失败。务必确保 $fdt_addr_r 与 $kernel_addr_r 不冲突。通常建议将 DTB 放置在内存末尾附近,例如 0x83000000(假设内存起始为 0x80000000,大小为 512MB)。
4.3 设备树传递协议
Bootloader 通过特定的寄存器约定将 DTB 地址传递给 Linux 内核。ARM 和 ARM64 架构的传递方式有所不同。
4.3.1 ARM32 传递协议
根据 Linux 内核的 Booting ARM Linux 文档,Bootloader 必须满足以下条件:
- r0:必须为 0。
- r1:机器类型编号(对于使用设备树的内核,此值被忽略,但建议设置为
~0)。 - r2:DTB 在内存中的物理地址(必须 4 字节对齐)。
4.3.2 ARM64 传递协议
对于 ARM64 (AArch64),协议更为严格:
- x0:DTB 的物理地址。
- x1 至 x3:必须为 0(保留)。
- MMU 状态:必须关闭。
- 缓存状态:必须禁用或刷新。
内核验证: 内核启动早期,head.S 中的 __vet_fdt 函数会检查 x0 寄存器指向的内存是否包含有效的 FDT 魔数(0xd00dfeed)。如果校验失败,内核将无法解析设备树并 panic。
4.4 启动流程与设备树传递 SVG 架构图
下图展示了从 Bootloader 入口到设备树加载、合并、传递的完整流程:
4.5 关键代码片段与调试示例
4.5.1 U-Boot 中加载并传递 DTB
以下代码展示了 U-Boot 命令行中手动加载 DTB 并启动内核的典型流程:
# 1. 从 eMMC 分区加载内核与 DTB
mmc dev 0
fatload mmc 0:1 $kernel_addr_r Image
fatload mmc 0:1 $fdt_addr_r dtb.img
# 2. 可选:应用 DTBO 覆盖
fdt addr $fdt_addr_r
fdt resize 8192
fatload mmc 0:1 $dtbo_addr_r dtbo.img
fdt apply $dtbo_addr_r
# 3. 启动内核,自动传递 DTB 地址
booti $kernel_addr_r - $fdt_addr_r
4.5.2 LK 中设置 DTB 地址并跳转
LK 中通常通过 boot_linux() 函数实现跳转,关键代码片段如下:
// LK 跳转内核示例 (arch/arm/mach-msm/boot_linux.c)
void boot_linux(void *kernel, size_t kernel_size, void *dtb, size_t dtb_size) {
// 1. 刷新缓存,确保 DTB 数据可见
arch_clean_cache_range((addr_t)dtb, dtb_size);
// 2. 设置内核入口参数
struct kernel_args args;
args.kernel = kernel;
args.kernel_size = kernel_size;
args.dtb = dtb;
args.dtb_size = dtb_size;
// 3. 调用汇编跳转函数
// 对于 ARM64,该函数会将 args.dtb 放入 x0 寄存器
arm64_linux_boot(&args);
}
4.5.3 GDB 调试 DTB 传递
在 GDB 中,可以通过以下命令验证 DTB 是否被正确加载到寄存器:
# 1. 在跳转内核前设置断点
(gdb) break *0x40080000 # 假设内核入口地址
# 2. 检查 ARM64 的 x0 寄存器
(gdb) info registers x0
x0 0x83000000 2197815296
# 3. 验证该地址是否包含有效的 FDT 魔数
(gdb) x/1wx 0x83000000
0x83000000: 0xd00dfeed
# 4. 如果魔数不正确,检查加载命令或分区内容
(gdb) x/4bx 0x83000000
0x83000000: 0x00 0x00 0x00 0x00 # 错误:全零,DTB 未加载
4.6 总结与最佳实践
| 阶段 | 关键动作 | 验证方法 | 常见陷阱 |
|---|---|---|---|
| 入口 | 设置栈、清零 BSS | GDB 单步跟踪 _start |
栈溢出导致异常 |
| DTB 加载 | 从分区读取到内存 | 检查内存地址的 FDT 魔数 | 分区偏移或大小错误 |
| DTBO 合并 | 应用 overlay 片段 | 使用 fdt list 查看节点 |
phandle 冲突或语法错误 |
| 寄存器传递 | 设置 x0/r2 | 断点检查寄存器值 | MMU 未关闭导致地址转换错误 |
| 内核跳转 | 执行 booti 或 boot_linux |
观察内核早期串口输出 | 内核与 DTB 地址重叠 |
工程建议: 在开发阶段,建议在 Bootloader 中添加 DTB 校验打印,输出 DTB 地址、大小以及魔数。这可以大幅减少因设备树问题导致的内核启动失败排查时间。使用 fdt header 命令可快速查看 DTB 头部信息。