搞嵌入式Linux的尤其是玩过裸机或者做过ARM平台Bring-up的朋友几乎都会在U-Boot这个“最后一道bootloader”上花不少时间。但很多人一开始接触U-Boot就被SPL和TPL这两个名字绕晕了明明是两级加载器为什么有的平台只有SPL有的平台要SPLTPL一起上它们到底从哪来、往哪去、源码在什么位置我之前在啃u-boot 2024.07这套源码的时候特意把SPL/TPL的执行路径从头到尾捋了一遍这篇就把整理的结果分享出来希望能帮到同样在这块犯迷糊的人。1. SPL与TPL是什么为什么会有这俩Loader1.1 从Boot ROM的局限性说起要搞懂SPL和TPL得先理解一个现实约束SoC内部的Boot ROM并不是万能的。芯片出厂时Boot ROM里固化了一段极小的引导代码它的主要任务是从外部存储介质比如eMMC、SD卡、SPI NOR、NAND里读取第一段可执行程序然后加载到片内SRAM并跳转执行。问题在于Boot ROM对读取介质的方式、镜像大小、甚至文件系统格式都非常挑剔往往只能读取一个固定偏移处的一小块二进制大小通常被限制在几十KB到一两百KB。而完整的U-Boot也就是我们常说的u-boot.bin或者u-boot.itb经过各种功能裁剪体量动辄几百KB甚至上MBSRAM根本塞不下而且U-Boot还要初始化DDR控制器才能让大容量内存可用。Boot ROM不可能把初始化DDR的复杂逻辑都塞进来于是就需要一个“过渡程序”先把DDR等关键硬件准备好再把完整U-Boot搬运到DDR里运行。这个过渡程序就是SPLSecondary Program Loader二级程序加载器。那TPL又是哪里冒出来的有一类情况很特殊某些SoC的SRAM空间实在太小比如只有几十KB连一个像样的SPL都放不下但U-Boot偏偏又需要做复杂的DDR训练、或者要跑安全启动校验、又或者要支持多种存储介质枚举。这时候如果把“初始化DDR”和“加载完整U-Boot”这两件事塞进一个SPL体积就不达标。解决办法是再拆一级用最小的TPLTertiary Program Loader三级程序加载器只做DDR初始化和极少量硬件配置然后从存储介质里加载SPL到DDRSPL再负责加载最大号的完整U-Boot。所以你能看到有些平台启动链是Boot ROM → TPL → SPL → U-Boot proper有些则是Boot ROM → SPL → U-Boot proper还有些干脆不需要SPL直接Boot ROM → U-Boot这种情况一般是小系统或者U-Boot本身裁剪得极小。1.2 SPL和TPL的分工明细为了更直观地看清这俩Loader的分工我整理了一张对比表这也是我在阅读源码时反复对照的维度SPLTPL中文名二级程序加载器三级程序加载器主要职责初始化DDR、时钟、串口等关键外设枚举启动介质加载下一级镜像最小化初始化尤其是DDR训练加载SPL代码规模相对较大可包含存储驱动、FIT镜像解析等极小通常只有几KB到十几KB运行位置片内SRAM一般也被Link到SRAM地址片内SRAM且比SPL占用更小典型应用大多数主流平台如i.MX、ZynqMP、部分瑞萨平台Rockchip系列、部分需要安全启动的复杂SoC编译产物u-boot-spl.binu-boot-tpl.bin核心源码位置common/spl/spl.c同样是common/spl/spl.c通过宏区分别看SPL和TPL名字差了一级它们本质上都是U-Boot这套源码编译出来的共用大量代码只是通过不同的编译宏裁剪掉不同的功能。这一点在源码里体现得特别明显——我在第2节详细给你拆。1.3 实际SoC上的部署差异举个我跑过的具体例子。Rockchip RK3399这块芯片启动顺序就是Boot ROM → TPL → SPL → U-Boot proper。为什么会这样因为RK3399上DDR初始化代码固定且体积不小TPL把DDR训练做完后SPL才能舒服地加载U-Boot。而反观Xilinx Zynq UltraScale MPSoCBoot ROM直接加载SPL到OCMSPL完成DDR初始化后再加载ATF和U-Boot并不需要TPL。这里还有个小误区要澄清很多文章把SPL称为“mini U-Boot”但其实SPL不仅仅是U-Boot的缩水版。它的启动流程虽然和完整U-Boot有共同祖先很多函数名甚至都一样比如board_init_f、board_init_r但在内部逻辑上有大量专门的实现。比如common/spl/目录下那一堆spl_mmc.c、spl_spi_nor.c、spl_nand.c、spl_fit.c才是SPL赖以“找镜像、读镜像”的本事。TPL虽然也挂在同一个目录下但TPL能用的驱动和功能被限制得更死通常连文件系统支持都被砍掉只保留最原始的“读块、加载”能力。2. 源码目录与核心文件盘点2.1 2024.07源码树相关目录梳理如果你下载的是u-boot 2024.07这个版本解开压缩包后第一件事建议先把和SPL/TPL相关的目录装进脑子里。很多新手一上来就冲进arch/arm/mach-xxx里找启动代码结果越看越乱。我建议按下面这个顺序来common/spl/这是SPL和TPL的家。几乎所有SPL/TPL特有的代码都在这里包括核心的spl.c、spl_mmc.c、spl_nand.c、spl_fit.c、spl_net.c等等。arch/arm/cpu/armv8/或armv7CPU级别启动入口比如armv8的start.SSPL的入口点也在这里不过会加上宏开关。include/spl.hSPL相关的数据结构和函数接口声明都在这看这个文件能快速了解SPL内部有哪些组件。board/厂商/具体板子/板级初始化代码很多板子的SPL配置比如板级DDR参数也会出现在这里比如rk3399-evb这类目录下就有专门的DDR初始化文件。configs/板子的defconfig板级默认配置决定编译时开不开SPL、TPL以及各自用什么镜像格式。还有一个很容易混淆的点你在编译完成后会在源码根目录看到spl/和tpl/这两个新的产物文件夹但它们不是源码目录而是编译中间文件和产物存放目录。真正的源码在common/spl/。这点最好先分清楚不然找源码时容易扑空。2.2 common/spl/spl.c —— 调度核心长什么样如果你只打算读一个文件来理解SPL/TPL那就读common/spl/spl.c。这个文件里的board_init_r()做完一系列初始化后会进入核心调度逻辑最终调用根据启动设备选出的加载函数。我摘一段简化后的关键流程大家感受一下void board_init_r(gd_t *gd, ulong dest_addr) { spl_common_init(); board_boot_order(spl_boot_list, ARRAY_SIZE(spl_boot_list)); if (IS_ENABLED(CONFIG_SPL_RAM_SUPPORT)) { spl_set_ram_size(); } if (!spl_next_phase()) { /* NOTREACHED */ } }这段代码看着简单但信息量不小。spl_common_init()会初始化堆、串口、驱动模型dm等board_boot_order()让每个板子决定优先从哪个设备启动spl__next_phase()是跳转入口它内部会先看当前是不是最后一级如果是就用jump_to_image_no_args()之类的函数跳到U-Boot proper或Linux如果还不是最后一级就加载下一级Loader。真正触发加载的核心逻辑其实是_spl_load()。它会根据boot_device来选驱动MMC、SPI NOR、NAND还是网络等。2024.07版本在加载逻辑上已经大量使用FIT镜像格式flattened image tree也就是说SPL不再单纯地从一个固定扇区地址读裸二进制而是会解析FIT里存放的多段镜像比如ATF、U-Boot proper、甚至DTB按需加载。这也是SPL里spl_fit.c越来越重要的原因。2.3 XPL_BUILD与代码复用魔法很多人在阅读源码时会看到大量CONFIG_XPL_BUILD这样的宏这个XPLeXtra Program Loader是U-Boot社区后来引入的一个统一概念把SPL和TPL“合并称呼”。也就是说只要代码在SPL或TPL环境里编译CONFIG_XPL_BUILD就会被定义。具体区分时判断CONFIG_SPL_BUILD还是CONFIG_TPL_BUILD即可。我举个例子在common/spl/spl_mmc.c这类文件顶部经常能看到#if CONFIG_IS_ENABLED(MMC) ... #endif这里的CONFIG_IS_ENABLED(...)宏特别神奇它会根据当前编译的是SPL还是TPL自动去匹配CONFIG_SPL_MMC或CONFIG_TPL_MMC。比如在TPL里编译时CONFIG_IS_ENABLED(MMC)就等价于判断CONFIG_TPL_MMC有没有定义。这个机制让同一份驱动代码可以同时在SPL和TPL里被复用同时又在配置层面严格隔离你可以让TPL不带MMC驱动SPL带MMC驱动互不干扰。理解了这一层再看源码就通透了spl_mmc.c并不是只属于SPL的它在TPL下也可能被编译进去前提是TPL使能了对应配置。所以找源码的时候别被文件名带spl前缀误导TPL用的也可能是这些文件只是编译出来的功能尺寸完全不同。3. 从ROM到Linux的执行路径拆解3.1 两级Loader的完整启动链路现在我们把视角提高到宏观层面把从芯片上电到Linux内核启动的完整执行路径画出来虽然不让你画流程图但我可以用文字描述清楚这条主线芯片上电复位CPU从SoC内固化Boot ROM的固定地址开始执行。Boot ROM读取Boot Device上固定偏移出的镜像头校验并加载TPL如果有或SPL到SRAM然后跳转。TPL开始执行它只做最基础的时钟与DDR初始化。DDR训练完成、内存可用了TPL再从存储介质加载SPL到DDR也可能是继续保持SRAM。SPL继续执行完善外设初始化比如打开串口、MMC控制器、显示板级信息随后加载完整U-Bootu-boot.bin到DDR同时可能一并加载ATFARM可信固件、DTB等。SPL跳转到完整U-Boot入口U-Boot proper接管驱动模型全面激活然后按用户环境变量启动Linux内核。对于只有SPL没有TPL的平台第2、第3步会合并成Boot ROM直接加载SPLSPL做DDR初始化后直接加载U-Boot。所以“执行路径”这个词本质上就是“控制权在哪个Loader手里以及谁负责把下一级搬进来”。3.2 board_init_f 与 board_init_rSPL也有这两阶段熟悉U-Boot proper启动流程的人肯定知道board_init_f和board_init_r这两个阶段。SPL同样沿用这两阶段设计只不过做得更大简化。在arch/arm/cpu/armv8/start.S里SPL入口会设置栈指针后调用board_init_f这一步主要是早期硬件初始化串口、时钟、重定位相关参数。由于SPL大多直接在SRAM里运行且不重定位部分平台支持重定位到DDR所以board_init_f只会非常克制地做点事。然后控制权交给board_init_r这个函数在common/spl/spl.c里实现。它负责初始化堆空间、DM驱动模型、读取启动设备顺序最后进入加载加载阶段。你可以把board_init_r看作是SPL的“主场”几乎所有核心动作都在这里完成。如果SPL串口有输出你会看到类似U-Boot SPL 2024.07这样的打印然后紧接着下一级Loader加载中。这里要特别注意TPL的board_init_f和board_init_r与SPL的并不完全相同虽然同名但TPL版本会跳过大量初始化只做最必要的事。在编译时common/spl/spl.c会根据CONFIG_TPL_BUILD调整内部逻辑比如TPL通常不会去初始化复杂的存储子系统甚至不会初始化堆直接调完DDR初始化就去加载SPL了。3.3 镜像加载逻辑与跳转细节镜像加载的核心函数是_spl_load()它内部根据boot_device走不同的分支。以MMC启动为例关键逻辑在spl_mmc.c里会根据CONFIG_SPL_MMC_BOOT_MMC等配置选择是直接从MBR分区里找FIT镜像还是跳到固定块地址去读裸镜像。代码如下示意非完整摘录static int spl_mmc_load_image(struct spl_image_info *spl_image, struct spl_boot_device *bootdev) { u32 boot_mode spl_boot_mode(bootdev-boot_device); /* Try from partition */ err mmc_load_image_raw_sector(spl_image, mmc, info, sector); /* If that fails, fallback to raw */ }加载完镜像后SPL会根据spl_image-flags、os类型等属性判断该跳到哪。如果是普通裸机U-Boot直接jump_to_image_no_args()如果带了ATF可能会用bl2_to_bl31类似的路径通过scmi接口或者smc指令把控制权转给更高安全等级的BL31。这些跳转的核心就是拿到下一级镜像的入口地址准备好参数寄存器然后清掉缓存、关掉MMU、跳转过去。这套流程里最容易出问题的地方就是“加载地址”和“执行地址”不一致。SPL把U-Boot读到了DDR里的A地址但U-Boot链接脚本里指定的运行地址是B地址一旦不同跳转过去必定死机。我调试时经常先用串口打印确认spl_image.load_addr和entry_point值再做对比定位。3.4 链接脚本与Text Base地址SPL和TPL能落在SRAM正确位置靠的是各自的链接脚本。在arch/arm/cpu/armv8/u-boot-spl.lds和arch/arm/cpu/armv7/u-boot-spl.lds里定义了输出段布局。编译时通过CONFIG_SPL_TEXT_BASE或TPL版本指定代码段的首地址。这个地址必须和SoC的实际SRAM映射地址对上否则一上电就PC跑飞。我以armv8平台举例链接脚本里最关键的几行是. CONFIG_SPL_TEXT_BASE; .text : { *(.__image_copy_start) *(.vectors) *(.text*) }CONFIG_SPL_TEXT_BASE这个值通常定义在头文件里比如include/configs/板子.h或者Kconfig配置里。Rockchip的TPL有个特点它的CONFIG_TPL_TEXT_BASE往往被安排在SRAM低地址直接映射到DDR初始化代码需要的地址这部分如果没配对DDR训练指令就跑飞到Nor Flash地址上去了后果就是板子完全无输出。4. 配置、编译与调试实操指南4.1 Kconfig体系下的SPL/TPL开关解析在u-boot 2024.07里SPL和TPL绝大多数功能都通过Kconfig来控制。打开main Kconfig你会看到CONFIG_SPL、CONFIG_TPL这两个总开关。开发时通常用make menuconfig进行图形化配置在“Boot options”下面的“SPL/TPL”选项里展开。几个我常用的关键配置CONFIG_SPL_FRAMEWORK框架总开关定义SPL会走common/spl/spl.c这套流程。CONFIG_SPL_MMC,CONFIG_SPL_SPI_MTD,CONFIG_SPL_NAND_SUPPORT控制SPL支持哪种启动设备。CONFIG_SPL_FIT_IMAGE使能FIT镜像解析复杂平台基本都是靠它同时加载多段镜像。CONFIG_TPL_DRIVERS_MISCTPL专属的小功能开关一般不会开太多。CONFIG_XPL_BUILD这是自动推断的不需要手动配但代码里经常拿它来隔离SPL/TPL编译单元。配置时经常遇到“开了SPL功能但编译报错‘undefined reference to spl_...’”这种情况根因往往是缺少对应驱动在SPL下的板级支持函数。比如SPL MMC启动时如果某个板子在SPL阶段还没有注册MCI设备就会链接失败。遇到这类报错我一般先查链接脚本有没有把需要的u_boot_list_2_...段包含进去再查dts里对应控制器节点在u-boot,dm-pre-reloc属性上有没有加标记。4.2 构建产物与编译流程说明编译U-Boot 2024.07我们看一下具体流程。以某个支持SPL/TPL的板子为例执行make board_defconfig make -j8编译完成后你会看到根目录下出现u-boot.bin、u-boot.srec等文件同时spl/目录下出现u-boot-spl.bin、u-boot-splELFtpl/目录下出现u-boot-tpl.bin等。如果平台本来就不支持TPLtpl目录就不会生成。需要注意的是很多厂商会把SPL打包到自己的镜像工具里。比如Rockchip的loader.bin通常就包含DDR初始化代码TPLSPLU-Boot。你要弄清楚自己板子最终烧写的是哪一段是spl/u-boot-spl.bin直接烧到偏移0还是生成的idbloader.img这种复合文件这个我从实际经历看非常容易搞混烧错偏移基本就白焊了。4.3 调试SPL/TPL的日常操作与排查方法调试SPL/TPL最有效的工具还是串口。保证串口输出正常的情况下我一般会分三步定位问题确认控制权有没有交到SPL如果串口完全没有输出可能Boot ROM都没读出SPL重点检查启动介质偏移、编译产物放的位置、镜像头校验。很多SoC要求SPL镜像头部带特定magic number可以用hexdump确认前几个字节。看SPL初始化进度打开CONFIG_SPL_DEBUG选项SPL内部会打印更多调试信息比如board_init_r里初始化到哪一步。如果串口有输出但止步于DDR初始化那重点查DDR参数、PMIC供电、PCB走线。看加载的下一级镜像地址对不对打开CONFIG_SPL_..._RAW打印或者直接调用debug()打印load_addr。很多板子死在“SPL加载完毕但跳转即重启”多半是地址不对或者镜像需要decompress而SPL没做。另外还有一个高效调试手段给SPL加一个新的启动选项比如从TFTP或者USB启动把镜像放到远端服务器上利用已有U-Boot的网口加载镜像。这样可以免去反复烧写启动介质缩短迭代周期。但前提是SPL阶段驱动支持网络设备一些轻量级SoC的SPL是不带网卡的那就只能老老实实烧卡了。4.4 别踩这些坑常见问题速查表最后我把平时容易翻车的点整理成了速查表希望大家在实际操作中能少走弯路问题现象可能原因排查/解决办法SPL无输出板子完全没反应Boot ROM未找到SPL镜像或镜像偏移错误用hexdump检查烧写偏移与镜像magic确认是否包含头的校验值SPL打印完“U-Boot SPL”后死机DDR初始化失败检查DDR训练参数、频率设置、供电电压读芯片手册确认延时参数SPL加载U-Boot后跳转即复位加载地址与U-Boot链接地址不一致对比CONFIG_SPL_...里的load_addr和CONFIG_SYS_TEXT_BASE编译报undefined referenceSPL驱动缺少板级支持或链接脚本缺段检查u_boot_list_2_...段是否包含确认DTS节点有u-boot,dm-pre-relocTPL能跑SPL没反应TPL加载SPL地址可能与SPL运行地址不一致查TPL的跳转目标地址与SPL的CONFIG_SPL_TEXT_BASE换存储介质后启动失败SPL只固化了一个启动设备顺序修改board_boot_order()或spl_boot_device()复位后的枚举顺序串口有乱码SPL早期时钟初始化时波特率不稳定在SPL阶段尽早初始化UART时钟避免在时钟变化期间改写控制台端口镜像太大放不进SRAM工具链优化级别不够或功能裁剪不够把SPL/TPL的-Os优化打开或者关闭无用驱动和调试符号我可能是全公司里把common/spl/spl.c读得最多的人之一。做底层Bring-up这几年最大的感受是SPL/TPL的名字看起来复杂但拆到底就是“分级加载”的思路。只要理解Boot ROM的约束、看过spl.c的核心调度、再把链接地址和加载地址这两个“命门”抓在手里整个执行路径基本就跑不出你的手掌心了。如果在你的板子上SPL/TPL还有更特殊的玩法欢迎评论区一起聊聊毕竟不同SoC的启动链差异永远是嵌入式里最折磨人也最有意思的一块。