3. Bootloader 的使命:硬件初始化、内存映射、加载操作系统内核、传递启动参数
说实话,很多人觉得 Bootloader 就是个「跳板」——把内核从 Flash 搬到内存,然后跳过去就完事了。但我在嵌入式这行干了十几年,见过太多系统死在「跳板」之前。Bootloader 的真正使命,远比你想的复杂。
它要干四件大事:硬件初始化、内存映射、加载内核、传递启动参数。这四步,一步出错,系统就起不来。我当年调试一块 i.MX6 板子,卡在内存映射上整整三天,最后发现是 MMU 页表配置错了一位。嗯,从那以后我再也不敢轻视这一步了。
3.1 硬件初始化:让芯片「活过来」
芯片上电时,大部分外设都处于复位状态。时钟没配、PLL 没锁、DDR 控制器没初始化——这时候你连个 printf 都跑不了。Bootloader 的第一件事,就是把这些基础硬件「唤醒」。
- 时钟系统:配置系统主频、PLL 倍频、外设时钟使能
- 电源管理:设置核心电压、IO 电压、DDR 电压
- 存储控制器:初始化 Flash 接口(NAND、eMMC、SPI NOR)
- 内存控制器:配置 DDR 时序、校准 ZQ、训练 DQS
- 中断控制器:关闭所有中断,设置向量表基址
- 看门狗:必须第一时间关掉,否则系统会反复复位
你想想看,如果时钟没配好,DDR 的刷新周期就全乱了。数据读出来全是错的,内核解压到一半就崩了。我有个同事曾经在量产阶段发现 5% 的板子启动失败,最后定位到是 DDR 时序参数在低温下漂移——Bootloader 里加了个温度补偿校准就解决了。
3.2 内存映射:建立 CPU 的「地址世界观」
CPU 看到的地址空间,和物理内存的地址空间,往往是两回事。Bootloader 要做的就是建立映射关系,让 CPU 能正确访问各个外设和内存区域。
这里分两种情况:
| 场景 | 说明 | 典型做法 |
|---|---|---|
| 无 MMU(MCU 类) | 物理地址直接映射,无需页表 | 配置 MPU 保护区域,设置缓存策略 |
| 有 MMU(MPU/AP 类) | 需要建立页表,开启虚拟地址映射 | 一级页表(段映射)或二级页表(细粒度) |
我个人习惯在 Bootloader 阶段尽量用物理地址,不开 MMU。为什么?因为调试方便。一旦开了 MMU,你看到的地址全是虚拟地址,出问题很难定位。等内核启动后,MMU 由内核接管,那时候再开也不迟。
但有些场景必须提前开 MMU,比如:
- DDR 地址空间不连续,需要重映射
- 需要将某个外设映射到固定地址(如 GPU 的寄存器窗口)
- 需要开启 L2 Cache 加速解压
3.3 加载操作系统内核:从存储到内存的「搬运」
这一步看起来简单——把内核镜像从 Flash 读到 DDR 里。但实际坑很多。
首先,内核镜像的存储位置你得知道。是放在 NAND 的某个分区?还是 eMMC 的某个偏移?还是从网络 TFTP 下载?Bootloader 需要根据启动介质去读取。
其次,镜像格式要识别。常见的有:
- zImage:自解压格式,需要先读到内存再解压
- uImage:U-Boot 专用格式,带头部信息
- Image.gz:纯 gzip 压缩,需要手动解压
- FIT Image:Flattened Image Tree,包含多个子镜像
我记得有一次,客户反馈设备启动到一半就卡死。我远程抓了串口日志,发现内核解压到 78% 就停了。查了两天,最后发现是 NAND 驱动在读取时发生了位翻转,而 Bootloader 没有开启 ECC 校验。从那以后,我在所有项目里都强制要求:从 NAND/eMMC 读取内核时,必须使能硬件 ECC。
// 1. 从存储设备读取内核到临时缓冲区
nand read ${loadaddr} ${kernel_offset} ${kernel_size}
// 2. 校验镜像完整性(可选)
crc32 ${loadaddr} ${kernel_size} ${checksum}
// 3. 如果是压缩镜像,解压到最终地址
unzip ${loadaddr} ${kernel_addr} ${kernel_size}
// 4. 跳转到内核入口
bootm ${kernel_addr}
3.4 传递启动参数:给内核的「交接文档」
内核启动时,需要知道很多信息:内存大小、控制台设备、根文件系统位置、IP 地址……这些信息不能硬编码在内核里,否则换个板子就得重新编译内核。Bootloader 负责把这些参数打包,传给内核。
传递方式主要有三种:
| 方式 | 架构 | 说明 |
|---|---|---|
| ATAG 列表 | ARM 32 | 链表结构,每个节点一个参数(内存、命令行、序列号等) |
| 设备树(DTB) | ARM 64 / RISC-V | 二进制描述硬件信息,内核解析后自动配置驱动 |
| bootparams | x86 | 固定结构体,包含 setup_header、cmdline 等字段 |
我个人强烈建议新项目都用设备树。为什么?因为 ATAG 列表太死板了,加一个参数就得改 Bootloader 代码。设备树是松耦合的,硬件变了只改 DTS 文件,Bootloader 和内核都不用动。
举个例子,你要传一个「console=ttyS0,115200」给内核:
// ATAG 方式(ARM 32)
atag_core(¶ms, ATAG_CORE);
atag_cmdline(¶ms, "console=ttyS0,115200 root=/dev/mmcblk0p2");
atag_mem(¶ms, 0x80000000, 0x20000000);
atag_end(¶ms);
// 设备树方式(ARM 64)
// 在 DTS 中定义:
// chosen {
// bootargs = "console=ttyS0,115200 root=/dev/mmcblk0p2";
// };
// Bootloader 只需将 DTB 地址传给内核即可
3.5 四步使命的完整流程
下面这张图,把 Bootloader 的四大使命串起来了。你可以看到,每一步都有明确的输入和输出,环环相扣。
从图上你能看到,内存映射和加载内核之间有一条直连箭头,也有折线。为什么?因为有些 Bootloader 在加载内核前需要重新配置内存映射(比如开启 Cache),这时候流程就是 1→2→3→2→4。嗯,实际项目中这种「回退」操作很常见,别被教科书上的线性流程骗了。
3.6 总结:这四步为什么缺一不可?
你想想看,如果硬件没初始化,CPU 连 DDR 都用不了,内核往哪放?如果内存映射错了,CPU 读到的指令全是乱码,解压出来的内核能跑吗?如果加载内核时没做 ECC 校验,一个比特翻转就能让系统随机崩溃。如果启动参数传错了,内核连根文件系统都挂不上。
这四步,每一步都是下一道工序的「入场券」。我见过太多「看起来能启动,但偶尔死机」的案例,最后追根溯源,都是 Bootloader 在这四步里埋了雷。所以,写 Bootloader 的时候,别急着跳进内核——先把这四步走稳了,系统才能站得直。