3、Bootloader深度解析:Little Kernel(LK)与U-Boot的启动流程,设备树(Device Tree)的加载与传递,内核启动参数的准备。

好,我们继续往下走。上一章我们聊了系统上电后,ROM Code 是怎么把 Bootloader 请进门的。这一章,咱们就深入 Bootloader 内部,看看它到底干了哪些脏活累活。

说白了,Bootloader 就是个「引路人」。它的任务很明确:把硬件初始化好,把内核镜像找到,然后一脚把内核踢进内存里跑起来。在 Android 世界里,最常见的两位选手就是 Little Kernel(LK)和 U-Boot。

3.1 Little Kernel(LK)的启动流程

LK 是 Google 为 Android 专门设计的轻量级 Bootloader。我最早接触它是在做高通平台的项目时,那时候就被它的简洁震惊了——整个代码量比 U-Boot 小了一个数量级。

LK 的启动流程,我习惯把它拆成四个阶段:

  1. 汇编入口阶段:CPU 刚加电,还处于最原始的状态。LK 的汇编代码会先设置异常向量表,配置栈指针,然后跳转到 C 语言的世界。
  2. 平台初始化:进入 C 语言后,第一个调用的函数是 platform_early_init()。这里会做最基本的硬件初始化,比如时钟、UART 串口。嗯,这里要注意,此时内存控制器还没配好,所以代码只能在 SRAM 或者 Cache 里跑。
  3. 内存初始化platform_init_mmu()platform_init_mmu() 这两个函数负责把 DDR 内存初始化好。我遇到过一个问题,某次内存时序参数配错了,导致系统在 30% 的概率下启动失败,排查了整整两天。
  4. 设备树与内核加载:最后一步,LK 会从存储介质(eMMC、UFS)里把内核镜像和设备树读到内存,然后跳转过去。

核心要点:LK 的启动速度极快,从上电到进入内核,通常只需要几百毫秒。代价是功能比较单一,不支持复杂的文件系统,也不支持网络启动。

3.2 U-Boot 的启动流程

U-Boot 是另一条路线。它比 LK 重得多,但功能也强大得多。你想想看,U-Boot 甚至能跑一个简单的 Shell,让你在命令行里操作。

U-Boot 的启动流程大致是这样的:

  1. SPL(Secondary Program Loader):这是 U-Boot 的第一阶段。因为 U-Boot 本身太大了,ROM Code 那点 SRAM 装不下。所以先加载一个极小的 SPL,它只做一件事:初始化 DDR 内存,然后把完整的 U-Boot 从存储设备里搬进内存。
  2. 完整的 U-Boot:SPL 跳转到 U-Boot 后,U-Boot 会做更全面的初始化。包括网卡、USB、文件系统支持等等。
  3. 环境变量与启动命令:U-Boot 会读取环境变量(比如 bootargsbootcmd),然后执行预设的启动命令。我个人习惯把启动命令写成这样:
# 从 eMMC 读取内核和设备树
mmc read 0x40000000 0x800 0x4000
mmc read 0x42000000 0x4800 0x100

# 设置启动参数
setenv bootargs 'console=ttyS0,115200 root=/dev/mmcblk0p5 rw'

# 启动内核
bootm 0x40000000 - 0x42000000

避坑指南:我曾经在调试 U-Boot 时,发现内核总是启动到一半就挂掉。查了半天,原来是 bootm 命令的第三个参数(设备树地址)写错了。U-Boot 不会报错,它只会默默地传一个错误的地址给内核,然后内核就崩溃了。所以,每次修改地址后,建议用 md 命令检查一下内存里的数据是否正确。

3.3 设备树(Device Tree)的加载与传递

设备树,说白了就是一份硬件配置清单。它告诉内核:CPU 有几个核、内存多大、I2C 控制器挂在哪里、GPIO 怎么映射……

为什么需要设备树?因为 Linux 内核要支持各种不同的硬件平台。如果没有设备树,每换一个板子就得重新编译内核。有了设备树,内核是通用的,只需要换一份 .dtb 文件就行。

在 Bootloader 里,设备树的加载流程是这样的:

  1. 读取 .dtb 文件:Bootloader 从存储设备(eMMC、UFS)或者从某个分区(比如 dtbo 分区)里读取设备树二进制文件。
  2. 加载到内存:把 .dtb 文件加载到一块特定的内存地址。在 LK 里,这个地址通常是 0x83000000 附近;在 U-Boot 里,你可以自己指定。
  3. 传递给内核:Bootloader 在跳转到内核之前,会把设备树的地址放在一个约定的寄存器里(比如 ARM 平台的 x0 寄存器)。内核启动后,第一件事就是从这个寄存器里拿到设备树地址,然后解析它。

重要提醒:设备树在内存中的位置不能和内核镜像重叠。我见过一个案例,有人把内核加载到 0x80000000,设备树也放在 0x80000000,结果内核启动时直接把自己覆盖了——这就像你坐在椅子上,然后试图把椅子举起来。

3.4 内核启动参数的准备

内核启动参数(也叫 bootargscmdline)是 Bootloader 传给内核的一串文本。内核会根据这些参数来决定怎么挂载根文件系统、用什么控制台、要不要开启某些调试功能。

一个典型的启动参数长这样:

console=ttyS0,115200 root=/dev/mmcblk0p5 rw rootfstype=ext4 init=/init androidboot.selinux=permissive

我来拆解一下每个参数的含义:

参数 含义 我的经验
console=ttyS0,115200 指定串口控制台,波特率 115200 调试时必加,否则看不到内核日志
root=/dev/mmcblk0p5 根文件系统所在的分区 分区号千万别搞错,我曾经把 p5 写成 p6,系统直接 panic
rw 以读写方式挂载根文件系统 生产环境建议用 ro,更安全
androidboot.selinux=permissive 关闭 SELinux 强制模式 调试阶段用,发布前一定要改成 enforcing

在 LK 里,启动参数通常硬编码在代码里,或者从 cmdline 分区读取。在 U-Boot 里,你可以通过 setenv bootargs 命令动态修改。我个人习惯在 U-Boot 的环境变量里维护一份默认参数,然后在烧录脚本里根据产品需求做微调。

警告:启动参数的长度是有限制的。Linux 内核默认支持的最大 cmdline 长度是 2048 字节。如果你塞了太多参数,超出的部分会被截断,而且内核不会给你任何提示。我曾经在调试一个车载项目时,因为加了一长串 androidboot.serialno=... 参数,导致后面的 init=/sbin/init 被截掉了,系统启动后直接卡在 kernel panic。排查了三个小时才发现是这个问题。

3.5 小结

这一章我们聊了 Bootloader 的两个代表选手:LK 和 U-Boot。LK 轻快但功能少,U-Boot 功能全但启动慢。它们都要做三件事:初始化硬件、加载设备树、准备启动参数。

下一章,我们会进入内核的世界。内核是怎么从 Bootloader 手里接过控制权的?它又是怎么一步步把 Android 系统拉起来的?嗯,到时候再细聊。