☰
嵌入式Linux U-Boot启动流程、移植与调试实战解析
2026/10/20 6:14:07 网站建设 项目流程

1. 为什么Linux内核不能跳过U-Boot直接启动

很多刚接触嵌入式Linux的朋友都会有同一个疑问:既然U-Boot最终要做的事情就是加载内核、然后跳转过去,那为什么不直接让硬件上电后就跑内核,非要先经过一段U-Boot呢?我当年也有这个疑惑,直到自己动手在一块空板上从零点起LED、初始化DDR、加载内核,才彻底理解:内核不是谁都能随随便便启动的,它需要一个“带路人”。

以一块典型的ARM开发板为例,核心板上的CPU上电瞬间,内部SRAM可能只有几十KB到几百KB,DDR控制器处于未初始化状态,时钟源可能是慢速的外部晶振,存储介质(SD卡、eMMC、Flash)的控制器寄存器还没有配置,而Linux内核本身又依赖DDR运行、依赖设备树描述硬件、依赖存储驱动读取根文件系统。内核的代码量摆在那里,动辄十几MB甚至几十MB,没有一个能把DDR初始化的家伙在前面接应,内核代码根本没地方放。

U-Boot就是干这个的。它本质上是一段专门负责“初始化硬件、准备运行环境、加载并跳转内核”的引导程序。它不依赖内核,自己独立工作;它也谈不上“操作系统”,更像是一个处在最底层的总调度。从分工来看,它要解决的核心问题可以拆成四个:

  • 硬件还处于“未开化”阶段:DDR、时钟、串口、存储控制器全都需要有人先初始化。
  • 内核格式多样:可能是uImage、可能是Image,也可能是经过压缩的zImage,U-Boot要能识别并正确解压加载。
  • 引导策略要灵活:从SD卡启动、从网络TFTP启动、从USB启动,最好通过环境变量切换,而不是改一次代码编译一次。
  • 需要交互和调试手段:启动参数加一个“console=ttyS0,115200”,或者临时改启动顺序,总不能在每次调试时都重新烧录固件。

所以U-Boot在整个嵌入式Linux软件栈里的位置,大致是这样的:

软件层级职责典型代表
应用层业务逻辑用户的App
操作系统进程、内存、驱动框架Linux内核
引导层硬件初始化和加载内核U-Boot
固件层芯片最底层的初始引导BootROM / SPL

BootROM是芯片出厂自带的,固化在芯片内部,负责从存储介质加载第一段代码。U-Boot如果被拆成SPL和U-Boot proper两段,那SPL往往就是由BootROM加载的“袖珍引导程序”,接着再由SPL完成DDR初始化,并把完整的U-Boot加载到内存。所以你可以把U-Boot理解成BootROM和Linux内核之间的二传手,这个角色在很多场景里确实是绕不开的。

这里有人会问:那有些MCU方案不就一个bootloader直接跑RTOS吗?确实可以,MCU的Flash足够小、启动逻辑足够简单。但嵌入式Linux不一样,内核体积大、外设复杂、需要设备树做硬件描述、还要兼顾产品量产时的多种启动介质选择,这些需求叠加到一起,U-Boot几乎是Linux生态里公认的最优解。

2. 上电到内核:U-Boot启动流程的完整链路

U-Boot的启动过程看起来就是串口里一串日志,但背后是一条严谨的、分阶段的执行链路。我在调试一个模拟项目X的启动缓慢问题时,把U-Boot从BootROM到跳转内核的每个环节都梳理了一遍,这才真正建立起了完整的全局观。下面按启动顺序拆开讲。

2.1 BootROM与SPL:芯片中的“第一颗火种”

芯片上电复位后,CPU从复位向量取指令,此时DDR尚未初始化,CPU能用的只有芯片内部的SRAM(片内RAM)。BootROM就是一段固化在芯片内部的代码,它没有可修改的机会,作用是扫描启动介质(SD卡、eMMC、NAND、SPI NOR等),按照拨码开关或熔丝配置选择一个启动源,然后把该介质上固定偏移位置的代码加载到片内SRAM。

但片内SRAM空间很有限,比如某系列芯片只有128KB SRAM,完整U-Boot动辄几百KB,放不下。所以引入SPL(Secondary Program Loader)方案:BootROM只加载一小段SPL到SRAM,SPL里浓缩了最基本的外设初始化逻辑——时钟、DDR控制器、串口、存储控制器,完成DDR初始化后,再把完整的U-Boot(通常叫U-Boot proper)从存储介质拷贝到DDR的高地址处,然后跳转过去。

从执行流程来看:

  1. BootROM按启动源策略搬运SPL到SRAM并跳转。
  2. SPL执行极简初始化:开启UART、初始化时钟树、初始化DDR。
  3. SPL从同样的存储介质读取完整U-Boot映像到DDR(或从网络加载)。
  4. SPL跳转到U-Boot proper入口。

我的经验是,排查启动问题要先分清当前跑的是SPL还是U-Boot proper。SPL阶段如果挂了,串口可能连一点输出都没有——因为UART可能就是SPL里初始化的。所以SPL内部往往有一个“板级早期的串口打印”机制,用最简单的寄存器操作在UART初始化完成后立刻打印一个字符,用来判断代码跑到了哪一步。

2.2 U-Boot proper:从汇编到C世界的切换

U-Boot proper的入口通常是一段汇编代码,任务非常明确:设置CPU为SVC模式、关闭中断、设置栈指针、清BSS段、准备C语言运行环境。这一步很关键,因为C语言代码离不开栈和全局变量。如果栈指针设置错误,U-Boot可能在进入C代码后立刻触发异常,而且这个阶段串口可能已经“半工作”了,你能看到乱码或者干脆无输出。

汇编阶段结束后,U-Boot会调用board_init_f,这是一个C函数,做的是迁移前的准备工作:规划内存布局、初始化早期串口、计算代码要重定位到哪个地址。然后U-Boot把自己从当前存储位置复制到DDR中规划好的运行地址,再跳过去执行board_init_r。到这里,U-Boot才进入“完全体”状态,设备树、驱动模型、环境变量、命令系统全都就位。

这里有一个常被忽视的点:U-Boot会把自己的运行地址“重定位”到DDR高位,而内核通常加载到较低地址。例如某平台的U-Boot运行在0x4FF00000,内核加载到0x40080000。如果两个地址规划重叠,内核镜像和U-Boot的数据段会互相踩踏,轻则启动失败,重则不可复现地随机崩。

2.3 自动启动流程:bootcmd是怎么被执行的

U-Boot proper运行起来后,如果没有进入命令行交互,就会按照预配置的自动启动流程走。核心是一个环境变量bootcmd,它是一条或多条U-Boot命令的集合。比如常见的:

bootcmd=run loadimage; run mmcboot

这表示先执行loadimage(一个自定义命令序列,负责从分区读取内核和设备树到DDR),再执行mmcboot(设置启动参数并跳转)。

整个流程可以概括为七个环节:

  1. 初始化板级外设:串口、网卡、存储控制器等。
  2. 读取环境变量:从存储介质的特定分区(通常是eMMC的boot partition或SD卡末尾)读取环境变量。
  3. 执行preboot脚本:比如检查按键进入升级模式。
  4. 解析设备树:U-Boot自己也要用设备树来了解硬件布局,这和内核用设备树是同源文件。
  5. 执行bootcmd:按顺序加载内核镜像、设备树、ramdisk到内存指定地址。
  6. 设置内核启动参数:通过bootargs环境变量拼出内核命令行。
  7. 跳转内核:校验镜像格式、向内核传递设备树地址和启动参数地址,最后执行跳转指令。

我建议调试时在命令行里手动执行run bootcmd里的每一条命令,比如先手动fatload mmc 0:1 0x40080000 Image,再fdt addr 0x48000000,然后bootm 0x40080000 - 0x48000000,一条一条跑。这样哪个环节出问题,能立刻圈定范围,远比看整段日志盲猜有效得多。

3. 设备树、驱动模型与环境变量:U-Boot的三根支柱

很多开发者对U-Boot的理解停留在“它能启动内核就行”的层面,但真正要让U-Boot适配一块新板子、或者实现量产时复杂的启动逻辑,有几个知识点绕不开:设备树在U-Boot里的角色、驱动模型的设计思路、环境变量的运作机制。这三样东西,可以说是U-Boot的三大支柱。

3.1 U-Boot与内核共享设备树,但玩法不同

传统上板级信息靠头文件里的宏定义和大量#ifdef CONFIG_xxx来维护,代码里到处是板子相关的散落逻辑。现代U-Boot已经全面转向设备树,U-Boot的dts和Linux内核的dts是同一个源、同一份语法,只不过U-Boot使用的dts里通常多出一些u-boot,dm-pre-reloc、bootph-all之类的特殊属性,用来标记“在U-Boot早期阶段就需要初始化的节点”。

设备树在U-Boot里的核心作用有两个:

  • 描述板级硬件拓扑:哪些GPIO是LED、哪个I2C总线上挂了PMIC、eMMC挂在哪个控制器上、DDR的大小和时序配置,全都由设备树描述。
  • 传递给内核:U-Boot跳转内核时,会把设备树的内存地址通过寄存器传给内核,内核用这份设备树来枚举自身硬件。这就是为什么启动Linux时,命令行里明明没有写内存大小,内核却能知道DDR有多少——因为设备树里写了,或者U-Boot启动时动态修改了/memory节点。

我在实际移植中有一个习惯:先让U-Boot的设备树能在U-Boot自己的命令体系中“活”起来,比如用fdt print /soc查看节点、用fdt set临时修改某个属性,再验证内核能否识别。设备树一旦在U-Boot里通不过解析,内核那边基本也起不来。

这里有一个高频误区:有人觉得U-Boot的设备树必须和内核的设备树完全一致。实际上两者可以存在差异。U-Boot只关心早期初始化涉及的外设(DDR、时钟、串口、存储),内核关心所有硬件设备。所以有些开发商会维护两份dts,甚至用mkimage工具做DTC编译时通过不同的include来定制。为了减少维护成本,现代U-Boot的推荐做法是共用一个dts源,U-Boot特有的节点用bootph-all等标记,编译时由U-Boot的Makefile自动过滤。

3.2 为什么U-Boot要搞一套驱动模型(DM)

早年的U-Boot是没有统一驱动框架的,每块板子上的网卡驱动、串口驱动、Flash驱动都有自己的初始化函数和操作接口,加上#ifdef层层嵌套,代码可读性和复用性都很差。后来U-Boot引入了Driver Model(DM),思路和Linux内核的设备驱动模型很像:

  • 设备(device)描述一个硬件实例。
  • 驱动(driver)声明自己支持哪些设备、提供哪些操作函数。
  • 控制器(uclass)定义一类设备的标准接口,比如串口类、网络类、GPIO类。

举个例子,无论底层是哪个厂商的PHY芯片,只要驱动注册到了MDIO总线的uclass里,U-Boot的网络命令ping、tftp就能统一调用net_ops->start(),不需要关心底层驱动差异。这个设计让代码可移植性大幅提升,新板子适配时,你往往只需要提供设备树描述,U-Boot就能自动绑定驱动,省掉大量板级代码。

DM还有一个重要特性:设备和驱动是按需绑定、懒加载的。U-Boot启动早期,只初始化设备树里标记了bootph-all的节点;等到了U-Boot proper阶段,才逐步把需要的设备绑定起来。这也解释了为什么有时候U-Boot启动日志里看不到某些外设的初始化信息——因为它根本没被触发绑定。

3.3 环境变量:U-Boot的“配置中心”

环境变量是U-Boot里最容易被忽略但最有用的机制。它本质上是一份键值对集合,存储在存储介质的固定分区(通常叫U-Boot环境分区),由env驱动读写。常用的bootcmd、bootargs,还有自定义的serverip、ipaddr、loadimage、mmcboot,全都以环境变量的形式存在。

这里有一个关键设计:环境变量是优先于代码配置的。代码里通过CONFIG_BOOTCOMMAND设置的默认命令,只会在环境变量为空或损坏时生效。也就是说,生产环境下如果想临时修改启动顺序,完全不用重新编译U-Boot,直接进命令行setenv bootcmd ...,再saveenv存储即可。

环境变量有几个稍微进阶的玩法:

  • 用变量引用变量:bootcmd=run loadimage; run mmcboot里的run会展开对应的变量值。
  • 条件判断:U-Boot的if命令可以结合test命令实现简单的分支,比如通过环境变量force_update判断是否进入升级模式。
  • 脚本化:把一组命令存成一个变量,实际上就是一个小脚本,可以用来做量产测试。

不过环境变量也有坑。最常见的是环境分区损坏导致U-Boot“卡死”在读取环境阶段。由于环境变量可能有CRC校验,如果存储介质前几个字节被破坏,U-Boot会报bad CRC然后回退到默认环境,这个行为是安全的。但如果你在自定义代码里访问了未初始化环境变量,就可能读到野值。我的建议是:在U-Boot的board_init阶段不要依赖环境变量,因为此时环境变量未必已经加载。

4. 移植一套新硬件平台:从零适配U-Boot的核心步骤

把U-Boot移植到一块新板子上,是嵌入式底层开发里最有成就感、也最折磨人的事情之一。我接过一个模拟项目X,芯片是某ARM SoC,开发板完全自研,没有现成的BSP。整个过程走下来,我总结出了一套相对固定的流程,核心就四步。

4.1 选一个相近的参考板,而不是硬造轮子

U-Boot生态里几乎每个主流SoC都有若干官方开发板的支持代码。移植第一步永远是找一个和你的板子最接近的参考板。怎么看接近?看这几项:

  • 同系列SoC,或者共用DDR控制器、时钟树配置的芯片。
  • 存储介质方案接近:SD/eMMC启动、NAND启动还是SPI NOR启动。
  • 关键外设方案接近:网卡芯片型号、PMIC型号。

找到参考板后,复制整个板级目录,替换板名,然后开始修改。在U-Boot源码里,板级文件通常位于board/<厂商>/<板名>/,配置文件在configs/<板名>_defconfig,设备树在arch/arm/dts/下。

我的建议是先不要急着大改特改,而是先让U-Boot能够在新的板级目录下完成编译,哪怕它跑不起来。编译链通了,之后每次只改一个子系统,出问题能立刻定位到是DDR没起来还是串口没起来,避免一次改太多导致排查范围失控。

4.2 DDR和串口:两块必须最先趟过的“沼泽”

DDR初始化是整个移植过程里最容易让人崩溃的一环。DDR控制器时序参数非常多——频率、CAS延迟、ODT配置、刷新周期、寄存器阻抗校准,任何一个参数不对,轻则训练失败,重则内存不稳定,表现就是U-Boot能运行但随机崩溃,或者只有部分地址段可用。

我对初次移植的建议是:

  1. 先查SoC厂商提供的DDR初始化参考代码或时序表,在参考板上确认参数。
  2. 用芯片厂商的DDR训练工具在目标板上做一次存储器训练,拿到训练结果。
  3. 把训练得到的寄存器值填入dts中DDR控制器对应的节点,或者填入板级初始化数组。

这里最容易忽略的是时钟源和DDR频率的联动关系。DDR频率倍频依赖于锁相环配置,而锁相环参考时钟又依赖晶振频率。如果你把晶振从24MHz换成了25MHz,但锁相环编程没改,DDR实际跑出来的频率可能完全不是预想的值。

串口初始化相对简单,但有一个值得单列的细节:调试串口的引脚复用要提前确认。很多SoC的UART引脚默认不是UART功能,需要设置引脚复用寄存器。如果你的设备树或板级代码里没有做引脚复用配置,串口可能一直是“物理电平不对”的状态——插上USB转串口模块,电脑上一片死寂。

4.3 存储介质支持:SD卡、eMMC、SPI NOR是三个常见入口

当U-Boot能在内存里跑起来之后,下一个任务是让它从你的实际存储介质上加载自己、加载内核。不同介质的调试路径不一样:

  • SD/eMMC:通常走MMC子系统。需要确认设备树的MMC控制器节点、时钟、电源、CD引脚(卡检测)、WP引脚配置。如果SD卡完全没反应,先测量MMC CLK/CMD/DAT0三条信号的波形,再排查电源域是否供电。
  • SPI NOR Flash:走SPI子系统。SPI控制器、GPIO片选、Flash型号都要匹配。SPI NOR常见问题是四字节地址模式没有正确切换,导致大于16MB的Flash读取出错。
  • NAND:相对复杂,涉及坏块管理、ECC算法。好在现代U-Boot的NAND支持做得比较完善,主要精力通常花在匹配NAND控制器和ECC参数上。

我个人的优先级建议是:先用SD卡或eMMC把启动链路彻底跑通,再考虑SPI NOR和NAND。因为SD卡的调试效率最高——你只需要用读卡器把映像写到卡上,插到板子就能反复试,不用频繁擦写Flash、不用关心坏块,也不用买昂贵的烧录器。

4.4 定制启动流程:从“能跑”到“好用”

U-Boot跑起来只是第一步,产品的正常启动还需要定制很多细节:

  • 定制默认环境变量:bootargs里的root=UUID=...、console串口参数、bootcmd从哪个分区加载内核。
  • 定制开机LOG或品牌信息:修改板级代码的显示函数,在产品化时有价值。
  • 定制升级机制:量产阶段通常有一个update_uboot命令,从SD卡或网络固件包刷写U-Boot和内核分区。
  • 裁剪不需要的功能:用defconfig里的CONFIG_开关关掉U-Boot里用不到的命令、驱动和文件系统支持,能显著减小固件体积、降低启动时间。

我在做这部分工作时有一个很深的体会:U-Boot移植的终点不是“启动内核”,而是“让生产环境下的人不需要懂U-Boot也能安全操作”。比如给产线工人提供一个自动启动菜单,插上U盘自动升级固件,按键选择测试模式,这些全靠U-Boot的脚本化环境变量和少量板级代码就能实现。

5. 调试U-Boot时踩过的坑与排错思路

最后这部分,我整理几个自己在实际调试中反复踩过的坑。每一个都花了不止半天时间才定位,写出来希望能帮大家少走弯路。这些坑在文档里很少被系统性地总结,但对实战来说价值极高。

5.1 串口完全没有输出:别急着怀疑代码

U-Boot移植初期最常见的现象就是“串口一点反应都没有”。很多人第一时间去翻U-Boot源码,或者怀疑自己改坏了配置。我的排查顺序是这样的:

  1. 先确认硬件连接:USB转串口模块的TXD/RXD是否交叉连接?开发板的调试串口引脚是否复用正确?用示波器或万用表量一下引脚是否有电平活动。顺手检查一下模块的驱动是否安装正确,Windows下COM口号和波特率是否设置对。
  2. 确认芯片到底有没有跑起来:观察芯片主时钟是否有输出,电源轨是否正常。最简单的方法:主板上的某个LED是否按预期点亮。如果连灯都不亮,那U-Boot可能压根没被执行,问题在更上游的BootROM配置或电源。
  3. 确认SPL是否运行:如果U-Boot分了两段,SPL里会有早期串口打印。在SPL初始化UART后加一个字符打印,能立刻判断SPL是否到达UART初始化之后的代码。
  4. 确认镜像是否被正确加载:用JTAG调试器或芯片厂商的调试工具直接查看内存中SPL/U-Boot所在地址的数据,确认烧录的二进制和内存里的内容一致。

我曾经遇到一个案例:代码和镜像都对,但串口就是无输出。最后发现是调试串口的TX引脚被设备树里某个无关节点占用并配置成了GPIO输出低电平,UART信号被强制拉低。这种问题只看UART驱动代码永远找不到答案,必须回到设备树全局视角才能看出来。

5.2 内核加载后“死在”跳转前:地址冲突是最大嫌疑

U-Boot日志打到最后,眼看要跳转内核了,执行bootm之后串口要么再无输出,要么直接重启。这种情况我通常先查三件事:

  • 内核镜像是否确实加载到了指定地址:用md 0x40080000查看内存内容,确认开头几个字节和镜像文件一致。
  • 内核期望的加载地址和U-Boot实际加载地址是否一致:有的内核使用了“pKernel”的链接地址概念,如果加载地址和链接地址不一致,内核在解压或启动早期就会崩溃。
  • 设备树地址是否有效:设备树地址必须是物理内存中的有效区域,而且最好按8字节对齐。

地址冲突还有一个容易被忽略的场景:内核镜像覆盖了U-Boot自身运行时代码或环境变量区域。U-Boot跳转前会把自己“让位”,但如果内核加载地址和U-Boot重定位后的地址太接近,U-Boot在执行跳转前的最后几条指令时可能已经被内核镜像数据覆盖了。表现就是极其诡异的“有时能启动、有时不行”。排查方法:把内核加载地址往低地址挪,或者把U-Boot运行地址往高地址挪,保证两者有足够间隔。

5.3 内核启动参数对不上硬件:bootargs的暗坑

U-Boot另一个常见坑在bootargs。内核启动后崩溃,日志显示“Kernel panic - not syncing: VFS: Unable to mount root fs”。很多人第一反应是根文件系统本身问题,但我在调试中发现,很多时候是U-Boot传给内核的bootargs里的root=参数和实际根文件系统分区对不上。

比如eMMC上根文件系统在mmcblk0p5,但bootargs里写的却是root=/dev/mmcblk0p6。这种问题往往是环境变量被误改,或者不同镜像版本间分区布局调整导致参数陈旧。排查技巧:内核启动日志里会有Kernel command line: ...打印,务必先看这里确认内核实际收到的参数。如果参数里的root=明显错误,问题就不在根文件系统,而在U-Boot侧的环境变量或设备树chosen节点。

另外,还有一类问题:bootargs里的console=参数如果设置成了不存在的串口,内核启动后你会在屏幕上什么都看不到。不是内核没起来,而是内核日志输出到了一个“不存在的设备”上。这种情况可以把console=暂时去掉再启动一次,内核就会用默认的stdout输出,日志就能看到了。

5.4 环境变量反复丢失:存储介质不可靠与分区冲突

U-Boot在saveenv保存环境变量时报Error: writing environment ...或者重启后环境变量“回到出厂状态”,也是高频问题。我踩过的一个坑是:环境变量分区和内核rootfs分区重叠了。你在saveenv时把环境变量写进了分区,之后Linux启动时挂载rootfs或做分区擦写,又把它覆盖了。等到下次开机,U-Boot发现环境变量CRC错误,只好用默认环境。

解决思路:在分区的设计阶段就要给U-Boot环境变量单独划分一块区域,并确保Linux侧的MTD分区表和U-Boot侧完全一致。另外,环境变量本身有冗余备份机制(在eMMC上常设置双备份),可以通过CONFIG_ENV_OFFSET_REDUND开启。默认环境变量能帮你在环境分区损坏时兜底启动,值得认真配置。

5.5 一个冷门的坑:U-Boot日志里出现莫名其妙的乱码

U-Boot启动时串口输出大段乱码,这种问题通常有两个方向:

  • 波特率不匹配:某段代码把UART波特率重新配置了,比如从115200被某个驱动改成了57600,随后日志全部乱码。这种乱码有一个特征:前面一段正常,乱码从某个特定日志开始。
  • 时钟源被动态调整:U-Boot在初始化阶段会根据设备树或板级代码改变系统时钟(比如从低频切到高频),如果UART的波特率时钟依赖系统时钟,那切换瞬间串口输出就会乱掉。排查时可以在时钟切换代码前后各打一个字符,观察哪一段出了问题。

还有一种伪乱码:UART的TX和RX在USB转串口模块上接反了。这时主机收到的其实是“对端回的环回数据”,内容会非常奇怪。遇到乱码,我建议先量电平、换波特率、换线材,再怀疑软件。

最后分享一个小建议

关于U-Boot的学习,我个人的体会是:不要只停留在“看文档、读代码”的层面,一定要有一套最小可用的开发板,亲手走一遍编译、烧录、启动、调试的全流程。把U-Boot源代码里的board目录、arch目录、include/configs目录的结构打开来对照着读,再结合实际的启动日志看每一步,会比你刷一百道面试题都管用。

多准备几个不同SoC的开发板也能帮上大忙。比如在一颗芯片上踩过NAND坏块处理的坑,转头再碰另一个方案的eMMC启动,思维迁移会非常快。U-Boot这个项目的资料分散在官方文档、邮件列表、各个厂商的BSP包里,系统地整理自己的调试笔记,长期来看是回报很高的一件事。

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

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

立即咨询