3、Bootloader阶段(上):Bootloader的作用与分类(uboot、lk)、启动分区表(boot、recovery、system等)、设备树(DTB/DTBO)的加载
好,咱们进入Bootloader阶段。这是Android启动流程中,真正由硬件跳转到软件的第一个关键环节。说白了,Bootloader就是硬件上电后,CPU执行的第一段正经的程序。
3.1 Bootloader到底在干什么?
很多人觉得Bootloader就是「按一下电源键,屏幕亮起来」之前的那个黑屏阶段。其实它的任务非常明确:初始化硬件,加载内核到内存,然后跳转过去。
我习惯把Bootloader比作「系统的大管家」。CPU刚上电时,寄存器是乱的,内存还没初始化,外设也处于未知状态。Bootloader要做的第一件事,就是把CPU、时钟、内存控制器、存储控制器这些基础硬件配置好。嗯,这一步做不好,后面全是白搭。
核心职责总结:
- 初始化硬件(时钟、内存、串口、存储等)
- 建立内存映射,让CPU能访问DDR
- 从存储介质(eMMC、UFS、NAND)读取内核镜像
- 解析设备树,传递给内核
- 跳转到内核入口,交出控制权
我在项目中遇到过一个问题:某款平板在低温环境下频繁无法开机。查了两天才发现,Bootloader里对DDR的时序配置是按常温来的,低温下DDR初始化失败。后来加了个温度补偿逻辑才解决。你看,Bootloader的硬件初始化就是这么敏感。
3.2 Bootloader的两大主流:U-Boot与LK
Android生态里,Bootloader主要分两派:U-Boot和LK(Little Kernel)。你想想看,为什么会有两个?其实跟芯片平台强相关。
| 特性 | U-Boot | LK |
|---|---|---|
| 主要平台 | TI、NXP、Rockchip、Allwinner等 | Qualcomm、MTK(部分) |
| 代码规模 | 较大,功能丰富 | 轻量,精简 |
| 启动速度 | 相对较慢(功能多) | 较快(专注启动) |
| 命令行 | 强大的交互式Shell | 有限,或通过ABL封装 |
| 设备树支持 | 原生支持,成熟 | 通过ABL间接支持 |
U-Boot,全称Universal Bootloader。它是个老牌选手,功能非常全。你可以在U-Boot命令行里做各种操作:读写内存、操作Flash、网络下载、甚至调试内核。我个人习惯在开发阶段用U-Boot,因为它调试起来太方便了。比如我经常用tftp命令直接从服务器拉取内核镜像,省去了反复烧录eMMC的麻烦。
LK,也就是Little Kernel。它更轻量,启动速度更快。高通平台基本都用LK(现在演进为ABL - Android Bootloader)。LK的设计哲学就是「够用就好」,它不提供那么多花哨的命令行功能,但启动路径非常清晰。我记得有一次帮客户优化启动时间,LK从上电到加载内核只用了不到200ms,U-Boot同样的硬件要350ms左右。
我的建议:如果你是做高通平台,老老实实用LK/ABL,别想着换U-Boot。高通对LK做了大量定制,包括签名验证、安全启动、快速启动等。换U-Boot反而会引入兼容性问题。
3.3 启动分区表:谁该待在哪里?
Bootloader要加载内核,它得知道内核镜像存在哪。这就引出了分区表的概念。Android设备上的存储(eMMC/UFS)被划分成多个分区,每个分区有固定的用途。
常见的分区布局是这样的:
| 分区名 | 内容 | 说明 |
|---|---|---|
boot |
内核 + ramdisk + 设备树 | 正常启动用的内核镜像 |
recovery |
恢复模式内核 + ramdisk | 用于OTA升级、恢复出厂设置 |
system |
Android系统文件 | 只读分区,存放/system目录 |
vendor |
厂商专有库和驱动 | 解耦系统与硬件相关文件 |
userdata |
用户数据 | 可读写,存放应用数据 |
cache |
缓存数据 | OTA升级时使用,现在逐渐被淘汰 |
misc |
杂项信息 | 存放启动模式、恢复命令等 |
这里有个关键点:Bootloader只关心boot分区和recovery分区。它不需要知道system分区里有什么,那是内核启动后的事。Bootloader的任务就是:根据启动模式(正常启动还是恢复模式),从对应的分区读取内核镜像。
我曾经踩过一个坑:某次OTA升级后设备变砖,查了半天发现是recovery分区被意外写坏了。Bootloader尝试从recovery分区启动,但读出来的数据是乱的,直接崩溃。后来我们在Bootloader里加了校验机制,启动前先检查分区头部的magic number,不对就回退到boot分区。
注意:分区表通常存储在GPT(GUID Partition Table)中,位于eMMC的LBA 0之后。Bootloader启动时,会先解析GPT,找到各个分区的起始位置和大小。如果GPT损坏,设备就彻底变砖了。所以,千万不要在运行时直接写LBA 0。
3.4 设备树(DTB/DTBO)的加载
好,现在Bootloader找到了boot分区,读出了内核镜像。但内核怎么知道它运行在什么硬件上?CPU型号?内存大小?外设地址?这些信息就靠设备树(Device Tree)来传递。
设备树本质上是一个描述硬件的数据结构。它用树形结构描述CPU、内存、总线、外设等硬件信息。Bootloader在加载内核之前,会把设备树二进制文件(DTB)放在内存的特定位置,然后通过寄存器告诉内核这个地址。
Android里设备树分两种:
- DTB(Device Tree Blob):主设备树,包含大部分硬件描述
- DTBO(Device Tree Blob Overlay):设备树叠加层,用于动态修改或补充DTB
为什么要有DTBO?你想想看,同一个SoC可能用在多个设备上,但外设配置不同。比如A设备用IMX219摄像头,B设备用OV5640摄像头。如果每个设备都编译一份完整的DTB,维护起来太麻烦。DTBO就是用来打补丁的:主DTB描述SoC公共部分,DTBO描述差异部分。Bootloader在加载时,会把DTBO合并到DTB中。
加载流程大致是这样的:
1. Bootloader从boot分区读取内核镜像(boot.img)
2. 解析boot.img头部,找到DTB/DTBO的偏移和大小
3. 将DTB加载到内存(通常是某个固定地址,如0x82000000)
4. 如果有DTBO,也加载到内存,并执行合并操作
5. 将合并后的DTB地址写入寄存器(如x0寄存器,ARM64约定)
6. 跳转到内核入口
我建议你在调试阶段,可以在Bootloader命令行里手动检查设备树是否正确加载。比如U-Boot下用fdt list命令查看设备树内容:
# 在U-Boot命令行中
# 先加载内核镜像到内存
mmc read 0x82000000 0x8000 0x8000
# 解析boot.img,提取DTB
bootm 0x82000000
# 查看设备树节点
fdt list /soc/i2c@78b6000
如果设备树加载错了,内核启动时就会报各种奇怪的错误。比如串口不工作、GPIO无法控制、甚至直接panic。我记得有一次,客户说他们的设备在某个批次上WiFi无法启用。我对比了DTB后发现,新批次的WiFi芯片地址变了,但DTB没更新。Bootloader加载的还是旧DTB,内核自然找不到新地址的WiFi芯片。
避坑指南:我曾经在合并DTBO时踩过一个坑——两个DTBO修改了同一个节点,导致合并后节点数据错乱。后来我们规定:每个DTBO只修改自己负责的硬件模块,不要交叉修改。如果确实需要修改同一个节点,用优先级机制解决。
嗯,Bootloader阶段的内容其实还有很多,比如安全启动、fastboot协议、AB系统切换等。但这些我们放到下一节再讲。这一节你只要记住:Bootloader是硬件的初始化者、内核的搬运工、设备树的传递者。这三个角色缺一不可。
下一节我们会深入Bootloader的启动流程细节,包括多阶段启动、安全验证、以及如何优化启动时间。到时候见。