2. Bootloader基础

大家好,我是你们的Bootloader讲师。今天咱们聊一个特别基础、但又特别容易被忽视的话题——嵌入式系统从上电到操作系统启动,中间到底经历了什么?

很多同学一上来就盯着U-Boot源码看,结果越看越懵。为什么?因为你不知道ROM Code在干嘛,不知道SPL和TPL到底解决了什么问题。说白了,你缺一张完整的地图。

这一章,我就带你把这整条链路走一遍。我会结合我这些年踩过的坑,把每个阶段该干什么、不该干什么,都给你讲清楚。

一、上电瞬间:CPU在干什么?

按下电源键的那一刻,CPU内部发生了什么?

我刚开始做嵌入式时,以为CPU上电后直接就去执行Flash里的程序了。后来被一个老工程师骂了一顿——「你连复位向量都不看,调什么Bootloader?」

实际上,CPU上电后做的第一件事,是去固定的地址取第一条指令。这个地址由芯片设计时硬编码决定,你改不了。

核心要点:

  • ARM Cortex-A系列:通常从0x00000000或0xFFFF0000开始
  • ARM Cortex-M系列:从0x00000000取栈指针,0x00000004取复位向量
  • RISC-V:从0x80000000或芯片自定义地址开始
  • x86:从0xFFFFFFF0(复位向量)开始

这里有个坑,我当年就栽过。有一次做i.MX6ULL的项目,板子死活起不来。查了三天,最后发现是启动介质选择引脚的电平没拉对,CPU根本没去读Flash,一直在ROM Code里打转。

注意:很多SoC在上电后会先执行芯片内部固化的ROM Code,而不是直接执行你的Bootloader。ROM Code负责初始化最基本的硬件(时钟、DDR控制器等),然后根据启动引脚的电平状态,决定从哪个介质加载下一级程序。

二、ROM Code:芯片出厂自带的「迷你Bootloader」

ROM Code是什么?它是芯片出厂时固化在ROM里的一段小程序。你没法改它,也没法擦除它。

它的任务很简单:

  1. 初始化最基本的硬件——比如PLL、时钟树、DDR控制器
  2. 检测启动引脚——决定从NAND、eMMC、SD卡还是UART启动
  3. 加载下一级程序——把SPL或第一级Bootloader从存储介质搬到SRAM或DDR里
  4. 跳转执行——把控制权交给加载进来的程序

我见过一个团队,ROM Code阶段就出了问题。他们用的芯片ROM Code默认DDR频率是400MHz,但板子上的DDR颗粒只能跑333MHz。结果ROM Code初始化DDR后,SPL一加载就崩溃。最后只能改硬件,把DDR颗粒换成能跑400MHz的型号。

经验之谈:ROM Code的代码质量通常很高,毕竟是芯片原厂写的。但它有个致命问题——不灵活。一旦芯片流片出来,ROM Code就定死了。所以ROM Code只做最基础的事,复杂的初始化留给后面的SPL去做。

三、SPL与TPL:为什么需要两级加载?

好,现在ROM Code把控制权交给了SPL。SPL全称是Secondary Program Loader,中文叫「二级程序加载器」。

你可能会问:为什么不能一步到位,直接加载完整的Bootloader?

原因很简单——SRAM太小了。

我举个例子。TI的AM335x芯片,内部SRAM只有64KB。U-Boot完整版编译出来动辄几百KB,根本放不下。所以必须分两步:

  • SPL:体积小(通常几十KB),放在SRAM里运行。它的任务就是初始化DDR,然后把完整的Bootloader从Flash搬到DDR里。
  • 完整的Bootloader(比如U-Boot):体积大,放在DDR里运行。它负责加载操作系统内核。

那TPL又是什么?TPL是Tertiary Program Loader,三级程序加载器。有些芯片的SRAM实在太小,连SPL都放不下,怎么办?那就再加一级:

阶段 运行位置 典型大小 主要任务
ROM Code 芯片内部ROM 4KB - 32KB 初始化时钟、检测启动介质、加载SPL
TPL SRAM 8KB - 16KB 初始化DDR控制器(如果SRAM太小,SPL放不下)
SPL SRAM 或 DDR 32KB - 128KB 初始化DDR(如果TPL没做)、加载完整Bootloader
完整Bootloader DDR 200KB - 1MB+ 加载操作系统内核、设备树、ramdisk

你看,这个层级关系就像剥洋葱。每一层只做最少的事,然后把控制权交给下一层。

四、完整启动链路:一张图看懂

说了这么多,不如直接画张图。下面这张SVG图,把整个启动链路串起来了:

嵌入式系统启动链路图 嵌入式系统启动完整链路 上电 / 复位 CPU从复位向量取指令 ROM Code 初始化时钟 / 检测启动介质 TPL(可选) 初始化DDR(SRAM太小) SPL 初始化DDR / 加载Bootloader 完整Bootloader 加载内核 / 设备树 / ramdisk 操作系统内核 Linux / RTOS / 裸机程序 💡 说明: TPL只在SRAM太小、连SPL都放不下时才需要。大多数Cortex-A系列芯片(如i.MX6、AM335x)不需要TPL,直接从ROM Code跳到SPL。 存储介质与加载路径: NAND Flash eMMC / SD SPI NOR Flash UART / USB ROM Code根据启动引脚电平,选择从哪个介质读取SPL。不同介质的读取方式不同,但SPL的格式是固定的。

五、各阶段的内存布局

搞清楚了流程,咱们再看看内存是怎么分配的。这个特别重要——我见过太多人因为内存布局搞错,导致SPL覆盖了ROM Code的数据,或者Bootloader把内核加载地址给占了。

以典型的ARM Cortex-A7芯片为例:

内存地址空间(示意):
0x00000000 - 0x0000FFFF  (64KB)  SRAM
  ├─ 0x00000000 - 0x00000FFF  (4KB)   ROM Code使用的栈和全局变量
  ├─ 0x00001000 - 0x00002FFF  (8KB)   SPL代码段
  ├─ 0x00003000 - 0x00003FFF  (4KB)   SPL数据段/BSS
  └─ 0x00004000 - 0x0000FFFF  (48KB)  空闲(可用于临时缓冲区)

0x80000000 - 0x8FFFFFFF  (256MB) DDR
  ├─ 0x80000000 - 0x8001FFFF  (128KB) 完整Bootloader(由SPL加载)
  ├─ 0x82000000 - 0x8200FFFF  (64KB)  设备树(DTB)
  ├─ 0x83000000 - 0x83FFFFFF  (16MB)  内核映像(zImage/Image)
  └─ 0x88000000 - 0x8FFFFFFF  (128MB) ramdisk/根文件系统

避坑指南:我曾经在一个项目里,SPL把Bootloader加载到了0x80000000,但Bootloader自己又把内核加载到了0x80000000。结果内核一加载,就把Bootloader的代码覆盖了。虽然Bootloader已经完成了它的使命,但如果你还想在跳转内核前做点收尾工作(比如关闭MMU、刷新cache),那就全完了。

所以我的习惯是:给每个阶段留足安全间距。Bootloader放在0x80000000,内核放在0x82000000,中间留2MB的缓冲。

六、实际项目中的启动时间分配

最后,咱们聊聊启动时间。很多同学做启动优化时,一上来就盯着内核优化,却忽略了前面的阶段。

我给大家看一组真实数据(来自一个i.MX6ULL的项目):

阶段 耗时 占比 优化空间
ROM Code ~50ms 5% 几乎无(固化在芯片里)
SPL ~150ms 15% 大(DDR初始化、时钟配置可优化)
完整Bootloader ~300ms 30% 大(驱动初始化、环境变量加载)
内核启动 ~500ms 50% 中(驱动、文件系统)

你看,SPL和Bootloader加起来占了将近一半的时间。很多人只优化内核,却忽略了前面这两个阶段。其实SPL的DDR初始化、Bootloader的驱动加载,都有很大的优化空间。

我有个客户,产品要求启动时间小于1秒。他们一开始只优化内核,折腾了两个月,从500ms降到了400ms。后来我帮他们看了SPL和Bootloader,把DDR初始化参数调优、去掉不必要的驱动、用快速启动模式,SPL从150ms降到了60ms,Bootloader从300ms降到了120ms。加起来省了270ms,比优化内核的效果好多了。

我的建议:做启动优化时,先拿示波器或逻辑分析仪把各阶段的耗时量出来。哪个阶段占比最大,就先优化哪个。不要凭感觉,要凭数据。