☰
嵌入式Linux U-Boot启动流程详解:从BootROM到Linux内核
2026/10/16 1:11:49 网站建设 项目流程

很多第一次接触嵌入式Linux的人,拿到一块能跑的板子,烧完镜像之后看到串口哗哗地打日志,很容易产生一种“启动不过如此”的错觉。直到有一天你要换启动介质、或者修改U-Boot的行为,才发现自己其实并没有完整掌握uboot启动流程。这篇文章算是我重读了一遍U-Boot源代码后的学习笔记,主线是从CPU上电到Linux内核收到控制权,中间经过的每一层。适合刚入门嵌入式、或者已经会烧板子但对启动流程处于“半懂不懂”状态的读者。如果你能配合一块真实开发板,一边看代码一边跑,收获会更大。

1. 为什么启动要分段:BootROM、SPL与U-Boot的三级接力

1.1 BootROM:CPU上电后第一个抢跑的角色

严格来说,U-Boot并不是所有SoC上电后运行的第一段代码。通常在芯片内部固化了一小段BootROM,它在CPU复位后自动执行。这段代码非常精简,主要工作是根据芯片的配置引脚、eFuse等判断从哪里读取下一级程序,然后加载到内部SRAM。BootROM和U-Boot的关系有点像小区门禁的自动开锁:硬件已经预先固化了一个最基础的动作,它甚至不需要DDR就能运行。

很多平台里,BootROM读出的不是完整U-Boot,而是一个SPL。因为BootROM自身能力有限,内部SRAM也小,它没有义务也没能力把一个功能繁多的Bootloader全部加载进来。设计者把完整启动过程拆成多级,每一级只负责把下一级“喂”起来。

1.2 SPL:把DDR从“未知数”变成“可用内存”

SPL是Secondary Program Loader的缩写,它是一份精简到极致的U-Boot。为什么不能直接加载完整U-Boot?一个很现实的原因是DDR控制器需要初始化,而这个初始化本身就依赖驱动代码和配置表,完整U-Boot又需要DDR来运行,这就形成了“先有鸡还是先有蛋”的问题。SPL恰好打破循环:它通常运行在片上SRAM,体积小、依赖少,先把时钟、电源、DDR控制器这些基础外设打开,再把完整U-Boot从外部介质拷贝到DDR中。

用生活里的例子类比:BootROM是门禁发的一张进门卡,SPL是屋子里的一盏小台灯,它先照亮房间;完整U-Boot则是随后的主照明系统,负责把整个房间的所有设备都接管起来。台灯虽然亮度有限,但足够让你找到主灯的开关。

1.3 三级接力带来的设计红利

这种分段设计最直接的好处是,每一级都可以针对自己的资源约束做裁剪。BootROM只需要几十KB的固定代码,SPL可以做到几百KB以内,U-Boot完整版则可以放很多驱动和命令。CPU上电的最初几百毫秒里,硬件环境并不稳定,越靠前的代码越要简单、确定、可重复执行。这也是为什么很多老工程师强调:排查启动卡死时,先确认卡在哪一级,再谈驱动问题——因为你面对的根本不是同一个调试空间。

1.4 边界不是固定的,配置宏可以裁剪

SPL和完整U-Boot的边界并不是所有平台都一致。有些SoC内部SRAM足够大,BootROM可以直接把完整U-Boot加载进去;有些平台干脆不启用SPL,让完整U-Boot自己完成DDR初始化;还有些平台存在TPL(Tertiary Program Loader),也就是在SPL之前再加一层极小的加载器,专门负责非常早期的时钟与电源初始化。所以看启动流程时,第一件事不是背代码,而是先看板级配置里开了哪些宏:CONFIG_SPL、CONFIG_TPL、CONFIG_SYS_TEXT_BASE,这些宏决定了整个启动链条的形态。版本不同、SoC不同,这个链条上的环节数量也不同,但分层思想是一致的。

2. 汇编阶段做了什么:C环境建立前的必要动作

2.1 reset入口:异常向量表和CPU模式

以常见的ARM32平台为例,U-Boot的启动代码会从一个用汇编写的reset入口开始。这是链接脚本指定的入口点,通常位于源码中的start.S。CPU上电后会从这个地址取第一条指令,紧接着做的事情之一就是建立异常向量表,并且把处理器切换到SVC模式、关闭普通中断。

这里的原因很实际:在栈和中断处理函数都还不可用的情况下,一旦来一个IRQ或FIQ,CPU会跳到一个没有任何有效处理器的向量地址,系统直接乱掉。所以启动最早期必须先把中断关掉,先把运行模式锁定在一个安全状态。很多初学者看这段汇编觉得枯燥,但这部分其实是整个U-Boot里最需要“确定性”的代码,因为C编译器的变量初始化、栈帧约定在这里都还没有建立起来。

2.2 先关MMU和Cache,再开启DDR

接着你会看到代码去关MMU、关I-Cache和D-Cache。有人会问:Cache不是能让运行更快吗,为什么要关?原因在于此刻内存系统并不完整,如果Cache缓存了某些外设寄存器的值,后续访问就会拿到脏数据;更危险的是D-Cache还没有配置好回写策略,一旦访问到暂时没有物理内存支撑的地址,会直接触发异常。MMU同理,它依赖页表,而页表还需要内存来存放——在内存初始化之前开MMU等于让CPU在一个自己都说不清楚的地图上跑。

这里的顺序要仔细体会:先关MMU/Cache,再初始化存储控制器,最后又重新开启Cache和MMU。中间那个“什么都没有”的窗口期,所有地址都是物理地址、所有访问都是直来直去,代码绝对不敢依赖任何虚拟地址映射。这个阶段如果某个外设的基地址算错了,调试器会看到CPU卡在非常诡异的总线错误上。很多新手喜欢直接把别人代码里关MMU的片段删掉,觉得“关了反而拖慢启动”,其实只要DDR时序或外设初始化顺序稍有变化,这种优化就会把整个系统带崩。

2.3 串口上的第一行打印到底是谁吐出来的

多数平台上,完整U-Boot的打印并不是最早出现的字符。在SPL阶段,代码就已经能初始化串口并输出“U-Boot SPL”之类的信息。这里涉及到U-Boot的早期调试串口机制,它不一定走完整的驱动框架,而是通过预先配置好的寄存器基地址,直接操作UART发送FIFO。

这就带来一个排查技巧:如果你在串口上只看到半句“U-Boot SPL”就停了,问题大概率出在SPL阶段之后、U-Boot完整镜像搬运之前这一小段代码里;如果完整U-Boot打印已经滚动出来,说明SPL和重定位这条路基本是通的。别小看这一行半行字符,它是你判断“代码到底走到哪一步”的第一个路标。

3. board_init_f到board_init_r:一次必须成功的“搬家”

3.1 gd数据结构与临时栈的来历

进入C语言世界后,U-Boot会先维护一个全局数据结构gd,里面保存了启动阶段的许多运行时信息:RAM布局、环境变量指针、串口波特率、设备树地址等等。gd不放在普通全局变量里,而是通过一个寄存器(ARM平台上常见用r9或者特定全局寄存器)指向它。为什么这么设计?因为此时可能还没有完整C运行时环境,代码也可能马上要被搬家,用一个寄存器保存它的地址,比依赖链接器生成的全局变量地址更稳妥。

和gd配套的是“临时栈”。在board_init_f阶段,U-Boot会先使用一个位于SRAM或RAM早期可用的栈地址,这个地址通常由CONFIG_SYS_INIT_SP_ADDR控制。它的存在让C函数调用成为可能。但注意,这个栈只是临时的,等完整U-Boot进入DDR后,栈会被重新设置到新的RAM区域中。

3.2 重定位:为什么U-Boot要把自己复制一遍

U-Boot编译时,很多代码按链接地址预先算好了绝对跳转关系。这个地址通常是CONFIG_SYS_TEXT_BASE,一般位于RAM的高地址区域。但U-Boot刚被SPL搬运进DDR时,并不会恰好落在链接地址上——它可能是被加载到某个临时地址,也可能一开始就在SRAM里。为了让程序内部的位置无关跳转都能正确指向自己,U-Boot会执行一次重定位:先把自己从当前运行位置复制到链接地址附近的最终位置上,然后立即跳转到新地址继续运行。

在源码里,这个动作体现为board_init_f最后调用relocate_code,随后进入board_init_r。如果你在源码里看到一串针对gd->relocaddr的运算,不要觉得神秘,它就是在计算“我要把镜像搬到哪一层”。重定位有点像你搬家后修改了所有通讯录里的地址,必须先把家具搬进去,再在新地址里继续生活。

3.3 从源码里找搬家过程,以及断点失效的原因

调试U-Boot时,一个典型现象是:在board_init_f阶段设的断点还能命中,但重定位之后的断点突然失效。原因很清楚——程序已经复制到了新地址,调试器里加载的符号表还指向旧地址,或者硬件断点只覆盖了某个地址范围。解决办法是:要么在重定位完成后重新加载符号,要么把断点设到重定位目标地址。更省事的方案是直接在board_init_r入口设置断点,因为它已经是新地址空间的世界。

这个阶段还有一个常见错误:修改了CONFIG_SYS_TEXT_BASE,却没有同步修改加载命令或分区布局,导致U-Boot把自己复制到了错误地址。启动时往往表现为第一条命令能执行,但执行几条后就随机死机,甚至重定位跳转时直接跳到非法地址——这类问题排查起来最头疼,因为崩溃现场很不确定。记住一条经验:改链接地址前,先确认你用的加载器/介质读取命令会把镜像放到哪个RAM地址。

3.4 在u-boot.map里确认搬家后的真实地址

每次编译结束后,U-Boot会生成u-boot.map和u-boot.sym,里面记录了每个符号的链接地址。拿到一个卡死现场后,可以先通过反汇编打开start.S附近的代码,找到重定位结束后的第一条C函数调用地址,再对照map看它是否是board_init_r。如果PC值落在map里某个函数的入口附近,就说明搬家成功、正在执行新地址代码;如果PC值完全不在任何符号附近,那多半是跳转目标算错了。这个方法比一遍遍加打印更快,尤其适合串口还没完全工作的时候。

4. main_loop之前:板级初始化序列与设备树登场

4.1 initr_xxx长名单,执行顺序的讲究

进入board_init_r之后,U-Boot会执行一大串以initr_开头的初始化函数。这些函数像排队进场一样,依次完成定时器、串口、驱动模型、环境变量、标准控制台、中断等初始化。这个顺序不是随便排的:串口要先于控制台,环境变量要先于需要读取环境变量数据的命令,驱动模型则要在很多设备驱动被使用前完成初始化。

如果你改了板级配置后发现某个功能不可用,可以先看一下initr_xxx序列里对应函数是否被调用,以及它前面是否有依赖没完成。我以前遇到过想提前打开某路GPIO控制LED,结果把初始化函数放到串口初始化之前,一运行就崩。原因就是那个GPIO控制器依赖的时钟域还没有初始化,寄存器访问直接死锁。阅读顺序的意义就在这里:它不是风格问题,而是硬件依赖关系问题。

4.2 设备树从SPL到内核的两段旅程

现代U-Boot普遍使用设备树来描述硬件。在SPL阶段,设备树可能只被用来做极小范围的解析,比如确定DDR初始化参数或启动介质;到了完整U-Boot,设备树会被更完整地解析,U-Boot的驱动模型(Driver Model)会依据它绑定设备。最终在内核启动时,U-Boot还会把设备树在内存中的地址传给Linux,Linux再根据同一个设备树去枚举驱动。

所以你会看到U-Boot里同时存在多个设备树指针:gd->fdt_blob、环境变量里的fdt_addr等等。它们分别对应“U-Boot自己用的设备树”和“将来要传给Linux的设备树”。很多新手会误以为改设备树只要改一个地方就行,实际上要分清哪个是U-Boot自身初始化阶段用到的,哪个是最终传给内核的。其实两个环节通常用同一份dtb,但一旦搞混,就会出现“U-Boot跑得好好的,内核却拿不到正确硬件信息”的怪问题。

4.3 环境变量:从哪里加载、怎么保存才不丢

环境变量是U-Boot里最常用的交互配置,像bootcmd、bootargs、loadaddr这些都是环境变量。它们不是凭空出现的:U-Boot会先有一份默认环境,通常由CONFIG_EXTRA_ENV_SETTINGS等宏拼接出来;启动过程中,如果检测到存储介质里保存了ENV数据,就会覆盖默认值。这个存储位置由CONFIG_ENV_IS_IN_MMC、CONFIG_ENV_IS_IN_NAND这类宏决定。

下表是几个常见ENV存储宏的对照,调试时先确认自己板子用的是哪一类:

配置宏存储位置典型配合参数易踩的坑
CONFIG_ENV_IS_IN_MMCeMMC/SD卡CONFIG_SYS_MMC_ENV_DEV、CONFIG_ENV_OFFSETENV分区与镜像分区重叠
CONFIG_ENV_IS_IN_NANDNAND FlashCONFIG_ENV_OFFSET、CONFIG_ENV_SIZE坏块导致写入失败
CONFIG_ENV_IS_IN_SPI_FLASHSPI NOR FlashCONFIG_ENV_OFFSET、CONFIG_ENV_SECT_SIZE扇区擦除大小问题
CONFIG_ENV_IS_NOWHERE不保存无每次重启都是默认环境

实际调试中,很多人用setenv改完参数后直接重启,发现设置没生效。原因往往是少敲了saveenv——setenv只修改了内存里的ENV副本,saveenv才会写回存储介质。还有一个更隐蔽的坑:ENV偏移和后面烧录镜像的分区重叠,saveenv时把别的东西覆盖了,下次启动系统直接变砖。所以规划分区时,ENV区域一定要和其他存储区域明确隔开。

4.4 标准控制台就绪:从“能打印”到“能交互”

早期串口能输出字符,并不代表命令行已经可用。完整控制台初始化要把stdio设备、输入缓冲、命令解释器都准备好。main_loop之前还有一步很关键:把标准输入输出都绑定到实际串口设备上。如果这步配置不对,你会看到启动日志很正常,但按任何按键都没反应,或者命令输进去了却没有任何回显。排查这种问题,可以优先检查控制台设备名是否和实际操作串口一致,尤其在多路UART的板子上,默认console用的串口号和调试串口不是同一个的情况非常常见。

5. 自动启动的倒计时:bootcmd与bootargs如何接力

5.1 按下开机键后,bootdelay数的那几秒在等什么

完整U-Boot一切初始化完成后,会进入main_loop函数。在这里,如果环境变量里的bootdelay大于0,U-Boot就开始倒计时,同时监听串口是否有输入。它的本意是给操作者一个机会,在几秒内按下按键打断自动启动,进入命令行。如果超时且没有按键,就执行bootcmd环境变量里的命令。

倒计时实现看起来简单,但实际调试中有个经典问题:你觉得“我没按键”,为什么U-Boot还是进命令行?这可能是因为串口线一直在发垃圾数据,或者调试器的虚拟串口持续输出某个字符,把倒计时“打断”了。反过来,你想在启动时按“回车”却没有反应,也要检查串口驱动是否已经完成、终端软件是否发的是换行而不是回车。

5.2 组织一次完整启动:从mmc读出内核再跳到入口

bootcmd本身就是一条或多条U-Boot命令的组合,常用分号分隔。以从eMMC/SD卡启动为例,典型的bootcmd可能长这样(具体地址要看你的平台):

setenv bootcmd 'mmc dev 0; mmc read 0x82000000 0x800 0x4000; bootz 0x82000000 - 0x88000000'

它先把MMC设备切到0号,然后从偏移0x800扇区读取0x4000个扇区到内存0x82000000,最后用bootz启动一个zImage内核。如果内核Image是ARM64的,你会用booti;如果是传统uImage,则用bootm。读数据、搬运、跳转的职责被拆得很清楚,这也便于调试——如果mmc read失败了,你看命令输出就能马上定位到介质读取问题,而不是怀疑内核本身。

这里给新手的建议是:先把命令一条条在U-Boot命令行手动敲一遍,确认每一步的输出符合预期,再把它们拼接成bootcmd。千万不要直接写好一大段自动启动脚本就烧录,那样一旦失败,排查范围会大很多。另外还要注意地址对齐:zImage通常要求4字节对齐,设备树在有些平台要求2MB对齐。地址选得不对,内核会在解压早期直接报错退出。

5.3 内存地址怎么安排才不冲突

很多新手在拼bootcmd时最头疼的就是地址:0x82000000、0x88000000这些数到底怎么选?原则很简单:先确认DDR物理范围,再给内核、DTB、ramdisk各留一块互不重叠的区域。内核镜像本身会被解压或搬运,所以加载地址最好避开U-Boot自身占用的地址段,也避开U-Boot环境变量区。一个常用技巧是使用环境变量loadaddr、fdtaddr、ramdiskaddr来标注这些区域,这样不同命令里引用时读起来更清晰,也避免出现“改了一个地址,另一个命令还在用老地址”的尴尬。

5.4 bootargs是如何交到Linux手上的

Linux内核启动参数,也就是cmdline,来源于U-Boot的bootargs环境变量。这里有个非常容易看懵的点:U-Boot的bootargs和板子上的设备树中chosen节点里的bootargs是什么关系?现代ARM平台中,U-Boot会在启动内核前,把环境变量bootargs写入内存中设备树的/chosen/bootargs属性,然后才把DTB地址和内核入口交给Linux。Linux在启动早期会检查这个属性,并把它当作最终cmdline。

所以,如果你发现setenv bootargs后内核里看到的启动参数没变化,可以先用fdt get value /chosen bootargs查看加载到的DTB里bootargs实际值,再检查是不是U-Boot每次启动时用环境变量覆盖DTB的逻辑没有执行。这个问题在同时使用FIT image和外部DTB文件时更容易踩到,因为镜像内部也可能自带一份DTB。

6. 启动流程中最容易翻车的三个现场与排查链路

6.1 串口只打印半行就卡死:先确认当前走到哪一级

现场现象往往是这样:上电后串口输出“U-Boot SPL ...”或者一行不完整的字符后,就再也没有动静。排查这种问题,不要一上来就翻驱动。先按启动分段把范围缩小:第一看是否已经过了BootROM,第二看SPL是否成功初始化DDR和介质,第三看完整U-Boot是否被加载并重定位。最简单的做法是加几个临时的LED或GPIO翻转标记。比如在SPL里打印前后分别点灭一盏灯,哪一盏没亮,问题就锁在哪一段。

串口输出不完整还有一种常见原因:波特率在中间被重新配置。有些平台早期串口工作在低频时钟、打印几条字符后,代码把PLL和UART时钟改掉了,但波特率分频没有同步更新,结果后面字符变成乱码或静默。遇到这种情况,在板级配置里确认UART的输入时钟和波特率寄存器有没有保持一致,就比盲目怀疑存储介质更有效。

6.2 改了环境变量重启不生效:ENV到底存哪了

这种坑大概每个接触U-Boot的人都遇过。你在命令行里setenv、saveenv,断电重启,printenv看到的值还是旧的。首先检查有没有保存成功:saveenv的输出通常会告诉你写入了哪个设备。如果写入报错,多半是ENV分区被擦写保护、或者存储设备初始化晚于ENV加载。如果写入成功但重启后还是旧值,就要检查ENV加载顺序是否早于你的存储设备初始化,以及设备号和偏移是否和启动读取U-Boot时一致。

另一个容易忽略的细节是冗余环境(redundant env)。有些平台为了可靠会用两个备份区交替保存ENV,读取时通过校验位选择。如果你把环境变量写进了A区,但当前有效的是B区,重启后看到的自然还是B区的内容。这类问题光看printenv不一定能发现,要结合存储介质里对应偏移的原始数据来确认。

6.3 自动启动永远不执行:bootcmd和输入缓冲的暗中博弈

有时你会遇到:bootdelay倒计时显示正常,但倒计时结束后命令行并没有自动跑bootcmd,而是进入了shell。最常见的原因是“按键按下”状态一直被识别。比如终端软件发送了空闲字符流,或者调试器串口输出了一些字符,abortboot函数收到了“非零输入”,就以为你想打断自动启动。解决办法包括:换一个虚拟串口工具、排除串口DTR/RTS线缆干扰、或者在测试时把bootdelay设成较短时间再观察。

反过来,如果倒计时结束确实执行了bootcmd,但屏幕没有任何提示,也别急着怀疑main_loop,先检查bootcmd本身是否为空、是否包含拼写错误。U-Boot执行bootcmd时,一般会在控制台回显执行细节,你可以用setenv bootcmd 'echo run bootcmd; ...'这样的写法临时观察执行路径。把启动命令可视化成“先打标记再干活”,是处理这类“看起来没执行”问题最直接的办法。

6.4 三个现场排查速查表

现场现象优先怀疑第一检查点
串口只有半行日志SPL之后搬运失败、DDR未完成检查SPL中DDR初始化后的打印标记
改env重启不生效没saveenv、ENV分区重叠、冗余ENV错位用md/dump命令查看ENV区域的原始数据
自动启动不执行串口字符干扰、bootcmd为空、bootdelay异常用空串口直接观察倒计时是否可中断

7. 给新手的调试工具箱:把启动流程变成可以观测的过程

7.1 给代码留“路标”:GPIO、LED与串口字符

如果你连串口都看不到输出,GPIO点灯是最好的定位方式。在关注的阶段位置插入一段寄存器操作,把一个GPIO拉高或翻转,就可以用示波器、万用表甚至肉眼确认代码有没有走到这里。需要注意,这个GPIO的时钟域和引脚复用可能默认没有打开,所以“点灯代码”也要放在对应控制器初始化完成之后;否则你以为代码没走到,其实它卡在配置GPIO本身。

7.2 利用编译产物定位当前代码在哪一层

U-Boot编译后会生成u-boot.map、u-boot.sym等符号表文件。通过它们可以知道某个函数被链接到哪个地址,也可以在启动流程文档里对应阶段标记出函数的地址范围。这比人眼在源码里翻找更可靠。配合反汇编,你还能看到重定位前后跳转目标的变化。

比如当系统卡死时,用调试器读出当前PC值,再在u-boot.map里搜索这个地址:如果能匹配到某个函数,说明代码还停留在已知世界;如果落在一个空白区域,说明跳转目标或栈已经坏了。这招在处理“随机死机”时尤其管用。很多随机死机本质上是代码跳到错误位置,而不是程序逻辑本身走到尽头。

7.3 用启动阶段记录量化每个环节

如果你的构建打开了CONFIG_BOOTSTAGE,U-Boot会把启动过程中的若干阶段标记和时间记录下来。到命令行后执行bootstage report,能按顺序输出每个阶段的耗时和状态。这看起来像是“性能分析”,但调试卡死时也很有用:它告诉你系统到底在哪个标记点附近停止,能大幅缩小搜索范围。可惜很多人不知道有这个功能,遇到问题只会一遍遍重新编译加打印。

7.4 推荐一条代码阅读路径

如果你打算自己动手把启动流程完整过一遍,我的建议是按这个顺序读:先看链接脚本,确定入口和内存布局;再读start.S的reset入口和lowlevel_init,理解汇编阶段;然后跟board_init_f里的init_sequence_f数组,看早期初始化;接着跳到relocate_code,确认重定位;最后读board_init_r里的init_sequence_r数组,一直跟到main_loop。这条路径走完,你对U-Boot从“能用”到“能改”的跨越就完成了。

启动流程这种东西,代码看得再细,也不如亲手把一个平台从黑暗里一步步“点亮”来得印象深刻。希望这篇笔记能把常见的主线串起来,让你下次拿到一个新板子,至少能准确说出“现在U-Boot到哪一步了”。如果你也正好在啃U-Boot,不妨把上面这些环节在你的板子上逐一验证一遍,收获会比单纯读代码大很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询