
干嵌入式这行谁还没被U-Boot那堆缩写折磨过。SPL、TPL这两个名字看着像一对兄弟可真到移植板子、调启动的时候分不清到底谁先跑、谁后跑、各自管哪一段代码就会看得一头雾水。我最早调一块带DDR training的板子时就是在SPL和TPL之间反复横跳翻了一整晚源码才把整个链条理顺。这篇内容我以u-boot 2024.07版本为例把SPL和TPL的职责、构建方式、执行路径拆开揉碎讲清楚同时结合我在实际调试中沉淀下来的排查顺序和配置参考。正在做板级移植、启动流程优化或者刚准备读U-Boot源码的人都可以把这篇当个引路地图。1. 先搞懂SPL与TPL在整个启动链里的角色1.1 从BootROM到内核启动是如何一级一级接力要理解SPL和TPL先得把整个启动链路放在眼前。拿一颗常见的ARM SoC来说芯片上电复位后第一段代码并不是U-Boot而是芯片出厂固化在ROM里的BootROM。它做的工作很基础根据启动引脚的配置到指定的介质比如SD卡、eMMC、SPI NOR去读一段镜像放进片上SRAM然后跳转进去执行。这里的一段镜像正常情况下就是SPL。SPL是Secondary Program Loader的缩写它是从U-Boot源码裁剪出来的极简引导程序大小通常控制在几十KB到两百KB之间。它要完成的事情包括初始化时钟、串口必要时初始化DDR内存再从启动介质读取下一级镜像。注意SPL的这一级下一级镜像既可能是U-Boot proper也可能是TPL。TPL是Tertiary Program Loader是这套接力里的第三棒。它比SPL更精简出现在一部分需要分两步走的平台上。它的工作时序通常是从SPL手里接管控制权在DDR环境里做进一步准备然后加载U-Boot proper并跳转。说得直白一点SPL负责把环境搭起来TPL负责把正主接进来而U-Boot proper才真正进入我们熟悉的命令行、环境变量、网络、文件系统等功能世界。拿开车来类比BootROM相当于点火那一瞬间SPL相当于把发动机打着让车热起来TPL相当于挂上挡把车开出去U-Boot proper则是你坐进驾驶室调整各种设置最后才把Linux内核这一段长途路程交给自动驾驶。这个链条层层递进每一级都比前一级更大、更复杂同时每一级的工作又都被刻意做窄目的就是保证前一级能在SRAM的狭小空间里跑完自己的任务。1.2 本不需要TPL为什么又多了一级引导看到这里你可能会想问既然SPL已经能加载下一级镜像为什么还要设计一个TPL答案是很多平台上SPL根本塞不下那么多代码。原因在于DDR初始化。DDR控制器和PHY的初始化尤其是DDR3、DDR4、LPDDR4这些需要做training训练校准的型号代码量非常大。常见的情况是一套带training的DDR初始化代码编译出来就有上百KB。而很多SoC集成在片内的SRAM只有64KB、128KBBootROM对镜像大小的限制又很死SPL必须压缩到几十KB才能塞得下。引导代码因此面临一个经典的鸡生蛋问题要初始化DDRDDR初始化代码得先有地方运行而DDR没初始化代码只能挤在SRAM里跑。解决办法就是把这个任务拆成两棒SPL只负责DDR初始化哪怕代码大一点任务单一勉强能塞进SRAM等DDR起来之后内存空间一下子大了几个数量级此时由SPL加载TPLTPL再把U-Boot proper搬进DDR。这就是因为放不下所以多一级的朴素逻辑。你可以把TPL理解为一种架构上的妥协方案它并不是每个平台都必须存在而是当SPL镜像体积击穿上限时用来拆分工序的备用跑道。我见过不少成熟的板子SPL只有50KB不到一跳就到U-Boot完全没有TPL的影子也见过一些DDR training特别重的平台SPL和TPL必须同时存在否则启动流程就走不完整。这个差异也解释了为什么很多新手看不同开发板的启动日志时会产生困惑有的板子log里只有SPL阶段输出然后直接进入U-Boot有的板子则会出现两个独立的加载阶段。其实不是代码框架不同而是这套三级模型在做取舍时把某一级裁剪掉了而已。2. 从2024.07源码看SPL/TPL的构建与配置2.1 Kconfig总开关两种Loader的门牌号对照u-boot 2024.07源码SPL和TPL并不是两个独立的版本分支也不是两个完全隔开的源码树而是在同一份U-Boot源码里通过构建系统裁剪出来的两份不同产物。裁剪的依据主要是Kconfig配置最常见也最关键的一批选项我列在下面。配置项作用影响范围CONFIG_SPLSPL总开关启用后生成spl/u-boot-spl.bin整个SPL阶段CONFIG_TPLTPL总开关启用后生成tpl/u-boot-tpl.bin整个TPL阶段CONFIG_SPL_TEXT_BASESPL镜像的链接地址决定SPL代码被放到哪个内存地址运行CONFIG_TPL_TEXT_BASETPL镜像的链接地址决定TPL代码的运行地址CONFIG_SPL_MAX_SIZESPL镜像体积上限超限编译报错防止SRAM爆仓CONFIG_TPL_MAX_SIZETPL镜像体积上限防止目标内存空间不足CONFIG_SPL_STACKSPL阶段使用的栈地址运行时数据空间CONFIG_TPL_STACKTPL阶段使用的栈地址运行时数据空间CONFIG_SPL_SYS_MALLOC_F_LENSPL早期malloc池大小影响driver model等子系统的内存分配CONFIG_TPL_SYS_MALLOC_F_LENTPL早期malloc池大小同上这套配置在对应平台的defconfig文件里就能看到典型写法大概是下面这样。注意地址和大小只是示例具体数值必须以芯片手册和板级设计为准。CONFIG_SPLy CONFIG_TPLy CONFIG_SPL_TEXT_BASE0x100000 CONFIG_TPL_TEXT_BASE0x0 CONFIG_SPL_MAX_SIZE0x4000 CONFIG_TPL_MAX_SIZE0x8000 CONFIG_SPL_STACK0x200000 CONFIG_TPL_STACK0x200000需要注意SPL与TPL的配置不是完全独立的它们之间存在传导关系。比如你开启CONFIG_TPLy之后Kconfig会要求你同时把与TPL阶段对应的底层驱动选项补齐比如TPL是否需要串口、是否需要MMC、是否需要DMdriver model等。如果这些子项没配好编译阶段可能不会报错但生成出来的TPL镜像会缺这少那到了运行时才暴露出问题。这也是SPL/TPL配置最让人头疼的地方主开关只是入口真正决定产物内容的是那一大串CONFIG_SPL_XXX和CONFIG_TPL_XXX子项。2.2 同一个U-Boot源码如何变出两套小Loader构建的时候U-Boot的顶层Makefile会根据Kconfig配置决定是否进入spl/和tpl/目录去构建子镜像。产物上你会看到顶层目录下的u-boot.bin是完整版spl/目录下的u-boot-spl.bin是SPLtpl/目录下的u-boot-tpl.bin是TPL。这三个bin的生成过程都调用同一套源码但编译选项不同链接脚本也不同。链接脚本是理解构建差异的关键。完整版U-Boot的链接脚本在arch/arm/cpu/armv8/u-boot.lds以ARM64为例里面把启动向量、代码段、数据段、bss段完整排布SPL的链接脚本是spl/u-boot-spl.ldsTPL的链接脚本是tpl/u-boot-tpl.lds。后者通常由同一套脚本根据配置模板生成但裁剪掉了很多段比如不需要完整的环境变量区、不需要独立的device tree区域等。SPL和TPL里的裁剪和完整U-Boot也不是一个量级。完整U-Boot为了兼容各种命令、文件系统、网络协议代码量动辄几MB。SPL的镜像通常只有几十KB到一两百KB而TPL更要精简很多时候只有几十KB甚至更小。这种裁剪是怎样落到代码层面的核心思想其实就一句话同一个源文件在不同的构建阶段只编译其中一部分代码。举一个最典型的文件common/board_f.c。这个文件在U-Boot、SPL、TPL三个构建阶段都会被编译但文件里插满了条件编译开关。完整版U-Boot里initr_xxx系列函数几乎全部启用SPL阶段只启用跟时钟、串口、DDR相关的函数TPL阶段又把范围进一步缩小可能连环境变量、board info这类都砍掉。你现在去读2024.07源码在common/board_f.c里就能看到大量这样的代码片段。这种一套源码多阶段复用的设计能极大减少代码维护工作量但也给阅读源码增加了难度——你经常会遇到一个疑问这段逻辑到底在哪个阶段生效此时必须回到Kconfig看对应的CONFIG_IS_ENABLED宏到底在哪个阶段被赋值成真。2.3 CONFIG_IS_ENABLED一源三编的隐藏逻辑提到CONFIG_IS_ENABLED这是理解SPL/TPL源码绕不开的一个宏。它的作用是在当前编译阶段自动选择合适的配置前缀在U-Boot proper阶段解析为CONFIG_XXX在SPL阶段解析为CONFIG_SPL_XXX在TPL阶段解析为CONFIG_TPL_XXX。换句话说你在源码里看到这种写法if (CONFIG_IS_ENABLED(SERIAL)) { /* 初始化串口 */ }在SPL构建时实际编译进去的是CONFIG_SPL_SERIAL在TPL构建时变成CONFIG_TPL_SERIAL在完整U-Boot构建时就是CONFIG_SERIAL。这个机制对源码阅读者非常友好因为它把三个阶段的差异封装成了一个统一表达。但反过来也导致一个常见的阅读障碍你看到某段代码里写着CONFIG_IS_ENABLED(MMC)如果你不去区分当前正在编译的是哪个产物你无法确定它到底是在判断CONFIG_MMC还是CONFIG_SPL_MMC。我调板子时踩过这个坑。当时在SPL阶段改了cmd相关的配置却始终不生效最后查了半天才发现改的是完整版U-Boot的CONFIG_CMD_XXXSPL阶段根本不看那些选项。正确操作是同时在defconfig里把SPL对应的子项也打开。同样的TPL阶段很多功能选项前都要加CONFIG_TPL_前缀漏掉一个就可能出现TPL编译进去了但某个外设驱动没编译进去这种奇怪现象。看懂CONFIG_IS_ENABLED之后这三份源码如何共用同一套文件就完全不神秘了。3. 执行路径追踪从复位向量到跳转U-Boot proper3.1 第一行代码向量表与入口地址配置层面的迷雾散开之后接下来把视角转到CPU实际执行路径上。以ARM平台为例芯片复位后BootROM跳转到SPL镜像入口而SPL镜像的第一行代码不是C代码而是汇编。ARM的入口通常从arch/arm/lib/vector.S或者arch/arm/cpu/armv8/start.S开始入口符号在链接脚本里明确指定整个代码段被链接到CONFIG_SPL_TEXT_BASE指定的地址上。这个TEXT_BASE地址不是随便定的。它必须落在BootROM跳转约定好的地址范围内同时要避开BootROM自身使用的SRAM区域、栈区和下一级镜像即将占用的区域。很多平台习惯把SPL_TEXT_BASE设成0x0因为BootROM就是固定跳到0地址取指令也有平台设成SRAM中某个固定偏移。如果TEXT_BASE设置得和实际加载地址不一致CPU就会取到乱码指令表现就是上电后毫无反应或者直接跑飞进HardFault。汇编阶段做的事情看着琐碎但极其关键设置异常向量表、把CPU设为SVC模式、关MMU和cache、初始化栈指针、清除bss段。这些准备做完才会调用board_init_f进入C语言世界。很多人调试SPL时第一反应是看C代码我建议反过来先确认汇编跳转没问题因为绝大多数SPL完全没输出的问题根源都在这一步之前。比如栈指针没设置好C函数的第一个push指令就会把数据写到非法地址后面再怎么查都查不出逻辑错误。3.2 SPL主流程spl.c里的接力逻辑进入C语言之后SPL主流程集中在common/spl/spl.c里。在这个文件里最核心的框架函数是board_init_r它做的事情可以概括成一个标准的接力流程。虽然具体函数名在不同版本里会有微调但2024.07版本的整体路径仍然很清晰。第一步是基础环境准备。spl_common_init()会完成driver model的初始化如果配置了FDT还会解析设备树。这一步很重要因为SPL阶段的代码与完整版U-Boot一样大量外设访问都是通过DM框架完成的DM没起来后面的MMC、SPI、NAND驱动全部白搭。第二步是板级初始化。spl_board_init()这类回调会做平台相关的准备工作比如配置电源域、GPIO复用、复位外设等。不同平台差别很大有的板子在这里还要做一次时钟重新配置把CPU频率从BootROM的默认值提上去。第三步是查找启动设备。SPL会通过bootdev框架或者传统的spl_boot_device()函数确定当前是从哪个介质启动的。这个判断通常依赖BootROM传入的参数、启动引脚、或者eFuse熔丝。确定介质后就会调用对应的加载函数——spl_mmc_load_image()、spl_nand_load_image()、spl_spi_load_image()等。第四步也是最关键的一步加载下一级镜像并跳转。加载逻辑会读取镜像头或者FIT镜像的描述信息把下一级镜像放到指定的加载地址最后清理cache和TLB跳转到下一级入口。在2024.07源码中这个跳转动作由jump_to_image_no_args()完成。整个board_init_r的流程实际代码里穿插了大量CONFIG_IS_ENABLED的裁剪简化后的调用关系如下。board_init_r() - spl_common_init() // DM与FDT初始化 - spl_board_init() // 板级准备 - spl_probe_find_boot_device() // 确定启动介质 - spl_load_image() // 加载下一级镜像 - jump_to_image_no_args() // 清cache并跳转这个函数调用链基本就是SPL源码的地图。在24.07版本源码里顺着它往下走一天之内就能把SPL阶段的全数流程理清。我自己的经验是先不用管每个函数的具体实现把上面这四步在spl.c里找到位置理解每一步的输入输出再往细节里钻效率最高。如果一开始就扎进某个驱动函数的实现里很容易被宏开关带偏方向。3.3 TPL阶段加载U-Boot前还要做什么TPL的执行路径与SPL非常相似因为它复用了整个SPL框架在common/spl/tpl.c里也存在对应的入口逻辑。但TPL有两个显著特点第一它的代码规模被裁剪得更狠很多在SPL里正常工作的功能在TPL里可能连编译都不编译进去第二它的职责被限定得很窄通常只负责存储介质访问和U-Boot proper加载。在典型的SPL只初始化DDRTPL负责搬U-Boot方案里执行流程是这样走的BootROM跳转到SPLSPL完成时钟、串口和DDR初始化后将TPL镜像从启动介质读入DDR然后跳转到TPL入口。TPL运行在已经可用的DDR环境中再次通过bootdev或者spl_boot_device确定启动介质读取U-Boot proper镜像头把完整版U-Boot加载到DDR的固定位置。TPL清理运行环境跳转到U-Boot proper入口之后开始完整的初始化流程进入命令行。注意这里有一个容易混淆的地方SPL加载TPL之后TPL的存储介质访问其实是在DDR环境下进行的此时内存充足理论上可以做更多事情。但由于TPL本身被设计为最小化的搬运工它不会去跑命令、不会加载文件系统、也不会解析复杂的启动脚本。它的目的只有一个把U-Boot proper搞进来然后功成身退。在实际源码里TPL阶段也会使用spl_load_image()加载镜像只是镜像的存储介质和加载地址可能与SPL阶段不同。如果你在板子的启动log里看到类似Trying to boot from MMC出现了两次别奇怪一次是SPL在加载TPL另一次是TPL在加载U-Boot。这种重复log本身就是判断当前处于哪一级的直观信号。3.4 链接脚本与内存布局SRAM里的极限生存说完了代码路径还得聊聊空间问题。SPL和TPL的链接脚本虽然模板类似但会因为各自TEXT_BASE和MAX_SIZE的差异呈现出完全不同的内存布局。在一个典型平台上内存布局大概是这样的BootROM占用芯片ROM和部分SRAM这部分不能被覆盖。SRAM余量划分给SPL镜像、SPL栈、SPL的bss段、DDR训练代码临时数据区。如果引入TPL那么在DDR初始化完成后TPL镜像和U-Boot proper镜像会被陆续加载到DDR的不同地址段。SPL阶段只在SRAM里活动TPL阶段跳到DDR里活动。链接脚本里常常会有一些软约束来兜底比如用ASSERT语句确保镜像大小不超过MAX_SIZE配置。编译时如果SPL超了你会看到一个明确的报错提示镜像体积超过了CONFIG_SPL_MAX_SIZE。这时有几个处理思路一是裁剪SPL里不需要的功能比如关闭SPL_FIT_SUPPORT、关闭多余的串口选项二是优化链接脚本把一些大段数据挪到镜像末尾三是引入TPL从架构上把DDR初始化和介质访问拆开。我在项目里遇到过一次典型的超限SPL需要同时支持MMC启动和NAND启动加上DDR training代码镜像体积直奔96KB而SRAM只剩80KB怎么裁都下不去。最后干脆引入了TPLSPL裁剪到40KB只做DDR初始化和MMC访问剩下的NAND加载逻辑全部丢给TPL。改动很小但空间压力瞬间解决。这也证明了TPL存在的现实意义——它不只是理论设计而是解决SRAM容量焦虑的实用手段。4. 实战避坑SPL/TPL配置排查与最小方案参考4.1 如何判断板子该不该上TPL很多开发者在拿到一块新板子时第一反应是照抄参考板卡配置结果经常在SPL阶段反复编译失败或者启动卡死。我建议根据下面几个问题来判断是否需要TPL。第一SPL镜像的大小是否接近SRAM容量。一个直接的方法是在编译完成后查看spl/u-boot-spl.bin的实际字节数对比芯片SRAM可用空间。如果镜像已经超过SRAM容量的80%就该考虑裁剪或者加TPL了。第二DDR初始化代码是否复杂。如果板子用的是DDR3、DDR4、LPDDR4并且需要进行完整training校准DDR初始化代码体积很容易超过几十KB。这种情况下DDR初始化不放TPL里几乎不现实。第三芯片BootROM对镜像大小的限制。有些芯片手册会直接写明BROM最大加载长度比如64KB或128KB超过这个长度的SPL根本无法被加载。这个限制也是引入TPL的直接原因。第四启动介质是否多样。如果你的产品需要同时支持eMMC、SPI NOR、NAND等多种启动介质每种介质对应的加载代码都会占空间。SPL放不下的部分交给TPL去加载U-Boot proper是比较经典的解法。场景启动方案SRAM 200KBDDR初始化简单BootROM - SPL - U-BootSRAM在64~128KBDDR初始化复杂BootROM - SPL - TPL - U-BootSRAM很小且BootROM能跳过SPLBootROM - TPL - U-Boot现代平台里第二种情况非常常见。尤其带secure boot或带FIT镜像校验的平台SPL阶段还要留出校验算法和密钥存储的空间镜像体积更紧张TPL几乎成了必选项。判断是否引入TPL本质上是在算一笔空间账SPL能塞进SRAM就直接两段式塞不下就拆三段式。思路就这么简单。4.2 启动卡死的排查三板斧调SPL/TPL最容易遇到的问题就是启动卡死而且往往没有系统日志只能靠排除法。我实际调试中总结了一套三层排查顺序遇到问题先按这个顺序走能省很多时间。第一板斧确认串口log是否打开。很多人卡死半天发现是CONFIG_SPL_SERIAL_SUPPORT或者CONFIG_SPL_LIBCOMMON_SUPPORT没打开SPL阶段根本没有任何输出。先把串口初始化相关的配置全部打开至少能看到SPL的打印信息问题就能定位一半。注意TPL阶段如果也需要log记得检查CONFIG_TPL_SERIAL_SUPPORT是否同样开启。第二板斧看最后一条log。如果SPL阶段有输出最后一条log就是最好的定位线索。比如在加载MMC镜像之前卡死多半是DM、MMC驱动、电源配置的问题在jump之前卡死可能是镜像头格式错误、加载地址不对、安全启动校验失败跳到U-Boot之后立即卡死则可能是U-Boot镜像的TEXT_BASE和链接地址不匹配或者DDR配置和实际硬件不相符。第三板斧验证SIZE_LIMIT和TEXT_BASE。用size或者readelf查看编译产物对比配置的MAX_SIZE。再确认TEXT_BASE是否与实际加载地址一致。实际调试中最常见的反模式是BootROM把SPL加载到0x10000000结果链接脚本里写的TEXT_BASE是0x0代码一运行就乱跳。如果三板斧还没定位到问题就得上JTAG仿真器在spl.c的board_init_r里一行一行确认走到了哪里。这种做法虽然慢但对疑难问题几乎百试百灵。我自己碰到过最刁钻的一个问题是DDR training代码里某个寄存器的时序参数写错导致SPL阶段一切正常一跳转到DDR里的下一级代码就随机死机。这种问题光看log根本看不出来最后是靠着在跳转点打断点、单步调试才找到根因。4.3 一套可复用的最小SPL/TPL配置参考最后给出一套可复用的最小配置参考适用于常见的ARM SoC带MMC启动场景。注意这不是某个具体平台的标准配置实际使用时需要根据芯片手册和板级设计调整地址和大小。# 基础部分 CONFIG_SPLy CONFIG_TPLy CONFIG_SPL_TEXT_BASE0x0 # 根据平台实际BootROM跳转地址设置 CONFIG_TPL_TEXT_BASE0x10000 # 通常是DDR初始地址偏移 CONFIG_SPL_STACK0x1ff000 # 栈顶设在SRAM末梢 CONFIG_TPL_STACK0x1ff000 CONFIG_SPL_MAX_SIZE0x8000 # 32KB按实际SRAM调整 CONFIG_TPL_MAX_SIZE0x10000 # 64KB CONFIG_SPL_SYS_MALLOC_F_LEN0x2000 CONFIG_TPL_SYS_MALLOC_F_LEN0x2000 # 串口与log CONFIG_SPL_SERIAL_SUPPORTy CONFIG_SPL_LIBCOMMON_SUPPORTy CONFIG_SPL_LIBGENERIC_SUPPORTy CONFIG_TPL_SERIAL_SUPPORTy CONFIG_TPL_LIBCOMMON_SUPPORTy CONFIG_TPL_LIBGENERIC_SUPPORTy # 存储介质 CONFIG_SPL_MMCy CONFIG_TPL_MMCy CONFIG_SPL_MMC_SUPPORTy CONFIG_SPL_FS_FATy # 按实际文件系统调整 CONFIG_TPL_FS_FATy # DM框架 CONFIG_SPL_DMy CONFIG_TPL_DMy CONFIG_SPL_DM_MMCy CONFIG_TPL_DM_MMCy调试过程中先不要急着开FIT镜像、secure boot、network这些重量级功能等基本启动链路跑通再加入。每次只改一处配置编译、烧录、看log是调试SPL/TPL最稳妥的节奏。一旦改了两三处都不生效很难判断是哪一步引入了问题。另外U-Boot 2024.07源码里doc/develop/spl.rst和对应平台docs目录都有比较详细的说明遇到吃不准的配置项先翻文档再动代码比盲目试错省时间得多。我个人调试SPL/TPL的一点体会是刚开始别急着翻具体函数先把当前阶段加载谁、从哪里加载、跳到哪个地址这三件事搞清楚再顺着spl.c和tpl.c的调用链往下读整个框架很快就通透了。给刚开始看U-Boot源码的朋友一个建议遇到SPL和TPL分不清的时候别急着背概念先在板子上把启动log打出来对照log里每一个加载动作再去源码里找对应函数。串口log就是最好的源码地图。