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 里加了个温度补偿校准就解决了。

⚠ 特别注意: 硬件初始化顺序不能乱。比如,必须先配 PLL 再配 DDR,因为 DDR 需要稳定的时钟源。我曾经见过有人把这两步写反了,结果 DDR 初始化永远超时。

3.2 内存映射:建立 CPU 的「地址世界观」

CPU 看到的地址空间,和物理内存的地址空间,往往是两回事。Bootloader 要做的就是建立映射关系,让 CPU 能正确访问各个外设和内存区域。

这里分两种情况:

场景 说明 典型做法
无 MMU(MCU 类) 物理地址直接映射,无需页表 配置 MPU 保护区域,设置缓存策略
有 MMU(MPU/AP 类) 需要建立页表,开启虚拟地址映射 一级页表(段映射)或二级页表(细粒度)

我个人习惯在 Bootloader 阶段尽量用物理地址,不开 MMU。为什么?因为调试方便。一旦开了 MMU,你看到的地址全是虚拟地址,出问题很难定位。等内核启动后,MMU 由内核接管,那时候再开也不迟。

但有些场景必须提前开 MMU,比如:

  • DDR 地址空间不连续,需要重映射
  • 需要将某个外设映射到固定地址(如 GPU 的寄存器窗口)
  • 需要开启 L2 Cache 加速解压
💡 我的经验: 内存映射表最好放在链接脚本的固定位置,不要用 malloc 动态分配。因为 Bootloader 阶段堆管理还不完善,万一分配失败,整个系统就挂了。我一般用静态数组定义页表,地址对齐到 16KB 边界。

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。

加载流程(以 U-Boot 为例):
// 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(&params, ATAG_CORE);
atag_cmdline(&params, "console=ttyS0,115200 root=/dev/mmcblk0p2");
atag_mem(&params, 0x80000000, 0x20000000);
atag_end(&params);

// 设备树方式(ARM 64)
// 在 DTS 中定义:
// chosen {
//     bootargs = "console=ttyS0,115200 root=/dev/mmcblk0p2";
// };
// Bootloader 只需将 DTB 地址传给内核即可
⚠ 避坑指南: 我曾经遇到过一个 case,内核启动后网络不通。查了三天,发现是 Bootloader 传的 MAC 地址参数格式不对——ATAG 里传的是字符串 "00:11:22:33:44:55",但内核驱动期望的是 6 字节二进制数组。这种参数格式不一致的问题,在 ATAG 时代特别常见。设备树通过规范化的属性定义,彻底解决了这个问题。

3.5 四步使命的完整流程

下面这张图,把 Bootloader 的四大使命串起来了。你可以看到,每一步都有明确的输入和输出,环环相扣。

Bootloader 四大使命流程图 Bootloader 四大使命流程图 硬件初始化 时钟 / DDR / 存储 内存映射 MMU / 页表 / 地址 加载内核 读取 / 校验 / 解压 传递启动参数 ATAG / DTB / cmdline 关键说明 • 硬件初始化是前提,不做后面全白搭 • 内存映射决定 CPU 能否正确访问外设和内存 • 加载内核时要注意 ECC 校验和镜像格式识别 • 启动参数是 Bootloader 给内核的「交接文档」,格式必须对齐

从图上你能看到,内存映射和加载内核之间有一条直连箭头,也有折线。为什么?因为有些 Bootloader 在加载内核前需要重新配置内存映射(比如开启 Cache),这时候流程就是 1→2→3→2→4。嗯,实际项目中这种「回退」操作很常见,别被教科书上的线性流程骗了。

3.6 总结:这四步为什么缺一不可?

你想想看,如果硬件没初始化,CPU 连 DDR 都用不了,内核往哪放?如果内存映射错了,CPU 读到的指令全是乱码,解压出来的内核能跑吗?如果加载内核时没做 ECC 校验,一个比特翻转就能让系统随机崩溃。如果启动参数传错了,内核连根文件系统都挂不上。

这四步,每一步都是下一道工序的「入场券」。我见过太多「看起来能启动,但偶尔死机」的案例,最后追根溯源,都是 Bootloader 在这四步里埋了雷。所以,写 Bootloader 的时候,别急着跳进内核——先把这四步走稳了,系统才能站得直。