
1. 参数传递的前世今生U-Boot到底给kernel传了什么干过几年嵌入式Linux开发的人大概率都遇到过这种情况kernel启动到一半卡死或者外设驱动报奇怪的错误查来查去最后发现是U-Boot传给kernel的参数不对。这个问题的核心就是U-Boot和kernel之间的参数传递机制。要理解这套机制先得明白一个基本事实U-Boot和kernel是两个完全独立的程序它们之间没有共享内存、没有函数调用关系kernel是被U-Boot用一条跳转指令“扔”进内存的。那问题来了——kernel启动后怎么知道自己该用哪个串口从哪里挂载根文件系统内存有多大这些信息总得有地方传递过去。实际上U-Boot向kernel传递参数有两条路一条是历史悠久的ATAGS另一条是现在主流的设备树Device TreeDTB。大部分现代ARM平台都已经转向设备树但我建议不管是新人还是老手两条路都搞明白。原因很简单你会遇到老的BSP、老的板子、甚至是某些特殊场景必须用ATAGS只看设备树不看ATAGS遇到老平台就抓瞎了。另外还有一个隐藏在日常背后的机制——bootargs。这东西虽然不是物理上的“传递通道”但它是参数的“内容来源”。U-Boot把环境变量里的bootargs字符串准备好然后通过ATAGS或设备树里的chosen节点传给kernel。所以完整链条是bootargs内容→ ATAGS/设备树载体→ kernel解析消费。我用一句话总结这套机制的实质**U-Boot负责整理硬件信息和用户启动意图kernel负责在启动早期把这些信息“认领”走之后双方各干各的互不干扰。**这个流程从ARM Linux诞生起就存在经过这么多年演进核心思想没变过。2. 两条传递路线的核心原理对比2.1 ATAGS老牌通道简单粗暴ATAGS的全称是ARM Tags最早是ARM Linux kernel定义的一套参数传递约定。U-Boot在跳转kernel之前会在内存的特定位置存放一系列“标签”——每个标签都是一个结构体有tag、size、data三个部分data里存具体参数。这套机制的关键地址是0x100或0x100之前的某个位置不同平台有差异更准确地说ATAGS列表的起始地址通常存放在寄存器r2里。U-Boot跳转kernel时会设置好r0、r1、r2三个寄存器r0必须为0r1machine type ID机器类型编号r2ATAGS列表的物理地址或者设备树二进制DTB的物理地址这就解释了一个现象——为什么老代码里经常能看到machine_is_xxx()这种判断。因为早期版本里U-Boot和kernel通过machine type ID来匹配平台后来有了设备树这个ID的作用就逐渐弱化了但仍然保留在代码里以兼容老内核。ATAGS里面都存什么呢从实际的tag定义来看主要有这几类ATAG_CORE固定起始标记类似文件的魔数ATAG_MEM内存块起始地址和大小ATAG_CMDLINE命令行参数实际就是bootargs的值ATAG_SERIAL板级序列号ATAG_REVISION板级版本号这些信息对驱动来说非常重要。拿ATAG_MEM举例在设备树普及之前kernel就是靠这个tag拿到内存大小从而建立页表、管理物理内存。你要是没传或者传错了kernel能用的内存就不是实际大小系统稳定性直接完蛋。2.2 设备树现在的标准答案设备树机制本质上改变了参数传递的“粒度和结构”。U-Boot不再是往内存里塞一堆平铺的tag而是把一整棵描述硬件信息的树状结构——DTB文件——放到内存指定位置然后通过r2寄存器把地址传给kernel。kernel启动早期会用early_init_dt_scan()系列接口去解析这棵树。之所以ARM Linux要从ATAGS转向设备树直接原因就是硬件越来越复杂板级文件已经管不住了。一块SoC会有几十种外设组合同一个内核要支持多块板子靠“每个板子一个board文件”的做法代码量爆炸而且维护困难。设备树把硬件描述从内核代码里剥离出去板子差异全在dtb文件里体现内核源码反而干净了很多。设备树传递参数有几个关键节点我单独说一下chosen节点存放bootargsbootargs ...、stdout-path控制台路径等这是U-Boot最常改动的地方memory节点描述物理内存布局device_type memoryreg属性里写起始地址和大小aliases节点给串口、i2c、spi等外设起别名方便驱动按名字查找一个很有意思的细节是U-Boot在启动kernel之前会先把设备树里chosen节点的bootargs覆盖成U-Boot环境变量里的bootargs。也就是说dtb文件里原本写好的bootargs是“默认值”真正生效的往往是U-Boot传进去的“实际值”。2.3 为什么说设备树更“聪明”ATAGS传的是“扁平参数”设备树传的是“结构参数”差异表面看是格式本质是数据组织能力。ATAGS每个参数都是一个单独的tag内存多大、串口地址多少、中断号是多少……这些都是分散的平铺数据kernel得靠标签类型一个个找。设备树则把这些信息挂在了树状结构里外设A的中断号就挂在外设A的节点下面驱动查找时按节点路径遍历即可。打个比方ATAGS是一张写了各种信息的纸条设备树是一本带目录的说明书。纸条方便但信息量有限说明书结构清楚且能无限扩展。这是当前开发者的认知基础再往下走就得看U-Boot到底是怎么把这些信息拼装起来并递出去的。3. bootm、booti、bootz三种启动命令背后的细节3.1 三条命令的适用场景老手都知道U-Boot能引导kernel但具体用哪条命令很多人其实没有细想过。我先说结论再展开讲bootm引导uImage格式的kernel镜像兼容性最好支持脚本和ramdisk老平台大量使用booti引导非压缩的Image格式kernel镜像通常是64位ARMAArch64平台在用bootz引导zImage格式的kernel镜像32位ARM平台最常见选择哪条命令不是拍脑袋。uImage是传统格式它会在普通内核镜像头部加一个64字节的U-Boot头部uImage头里面记录了镜像的加载地址、入口地址、CRC校验值等信息。U-Boot引导uImage时会先解析这个头部确认镜像完整性和加载位置然后做必要的搬运再把控制权交出去。zImage是自解压内核镜像它自带解压代码。U-Boot把zImage加载到内存某处跳转过去后zImage内部代码会把自己解压到合适位置再运行。Image则是已经链接好、可以直接运行的原始内核镜像不需要解压U-Boot跳过去就能跑。3.2 bootm的完整工作流程bootm流程值得拆开细讲因为它是三者里面流程最完整、逻辑最清晰的。大致步骤是这样检查uImage头部获取镜像类型、加载地址、入口地址如果镜像带CRC校验先校验一下损坏就停止启动将镜像搬运到加载地址如果还没在如果有ramdisk同样处理并放置在指定位置准备设备树或ATAGS放到约定地址设置寄存器r0/r1/r2或在64位下用x0/x1/x2跳到内核入口地址实际执行时U-Boot内部会调用bootm_os_get_boot_func()、boot_get_kernel()、boot_get_fdt()等函数把镜像解析和设备树准备分开做。这个设计思路其实值得借鉴——每个环节独立出了问题能单独查。3.3 为什么64位平台要用booti在AArch64架构上入口要求比32位更严格——内核要求Image必须位于2MB对齐的地址上。booti命令正是在这个前提下设计的它比bootm少了头部解析直接把Image镜像放上去检查入口地址对齐后就跳转。这也是很多新手踩坑的地方在64位平台上用bootm引导Image格式的内核U-Boot会尝试按uImage格式去解析头部结果发现前64字节根本不是uImage头直接报“Bad Magic Number”起不来。所以判断当前平台的启动约定很重要先看U-Boot源码里的CONFIG_CMD_BOOTI是否打开或者直接敲一遍booti试试。三条命令的共性是无论哪条最终都会进入一个“准备参数并跳转”的收尾阶段。也就是接下来我要重点说的寄存器约定和地址规划。4. 寄存器与内存布局参数传递的物理细节4.1 ARM32与ARM64的参数传递约定U-Boot跳转kernel时不是简单跳过去就完事还必须保证寄存器里放了正确的值。ARM32的约定是r0 0r1 machine type IDr2 ATAGS/DTB物理地址ARM64的约定略有变化x0 32位DTB物理地址高32位清零x1 0保留x2 0保留x3 0保留uint64传给32位环境时PSCI固件接口和U-Boot会对DTB地址做严格限制。如果你在64位平台上把dtb加载到4GB以上地址那启动很可能失败因为x0只能传32位地址。这个问题在带有大内存的服务器级ARM平台上尤其常见得主动把dtb放在4GB以内的区域。4.2 内存布局的经典规划举个例子在一块典型的32位ARM平台上常见的内存规划如下内容地址大小U-Boot0x00000000 - 0x00080000512KBkernel zImage0x00080000 - 0x003000002.5MBDTB0x00300000 - 0x0031000064KBramdisk0x00320000 - 0x008000005MB左右这些地址不是固定的需要根据具体平台的内存大小和U-Boot配置来调整。但有个原则必须记住kernel、DTB、ramdisk三者的加载地址不能重叠而且必须给kernel的解压过程留出足够的空间。zImage自解压时会把解压后的内核放到“当前运行地址之后的安全区域”。如果DTB正好放在这个区域里解压过程会把DTB覆盖掉轻则启动报错重则直接死机。这个问题我在调试时遇到过不止一次规避办法是把DTB放在内存的高地址区域比如RAM末尾留出几百KB或者把DTB放在kernel镜像之前。经验值是DTB尽量离zImage远一点越远越安全。4.3 DTB加载地址的选择策略很多平台的U-Boot默认会用fdt_addr环境变量来指定DTB加载地址。实际项目中我建议把DTB放在DDR的末尾附近比如内存为512MB可以放在0x1FF00000这种位置。原因有两个一是避免被内核解压覆盖。zImage解压过程是从低地址往高地址扩展放在高地址区域的DTB相对安全。二是给kernel的初始页表和启动早期的数据结构留出低地址空间。这些结构通常会占用几MB内存如果DTB在低地址可能被覆盖。64位平台同理虽然有了更大的地址空间但核心原则不变——DTB地址要在内核可以访问的范围不能跨越设备树访问权限的边界。同时考虑到U-Boot的$fdt_addr变量、booti命令的第三个参数dtb地址要保持一致不然就会出现“命令里指定的地址和实际地址不符”的诡异问题。4.4 一个经典的启动命令实例在线下培训或实际调试中我最常给的模板是下面这种# 假设U-Boot环境变量如下 # loadaddr0x80008000 (kernel加载地址) # fdt_addr0x88000000 (dtb加载地址) # ramdisk_addr0x84000000 (ramdisk加载地址) setenv bootargs consolettyS0,115200 root/dev/mmcblk0p2 rw rootwait load mmc 0:1 ${loadaddr} zImage load mmc 0:1 ${fdt_addr} board.dtb load mmc 0:1 ${ramdisk_addr} ramdisk.img bootz ${loadaddr} ${ramdisk_addr}:${filesize} ${fdt_addr}这里注意bootz的语法是bootz kernel地址 [ramdisk地址:大小] [fdt地址]。三个参数刚好对应用户要传的“主程序”“附件”“配置表”。如果不加ramdisk可以写成bootz ${loadaddr} - ${fdt_addr}中间用-占位。很多初学的人卡在这觉得少个参数就不知道怎么写了。4.5 64位平台启动命令实例对于ARM64平台典型的启动命令是# loadaddr0x40080000 (Image加载地址) # fdt_addr0x40000000 (dtb加载地址) setenv bootargs consolettyAMA0,115200 root/dev/nfs rw nfsroot192.168.1.10:/srv/nfs ipdhcp load mmc 0:1 ${loadaddr} Image load mmc 0:1 ${fdt_addr} board.dtb booti ${loadaddr} - ${fdt_addr}AArch64的Image加载地址要求2MB对齐上面的0x40080000就是一个常见且满足要求的地址2MB 0x2000000x40080000对0x200000取模为0。如果你的地址没对齐U-Boot会报错说Image not aligned。解决办法就是重新选一个对齐的加载地址或者确认内核配置里CONFIG_LINUX_KERNEL_IMAGE_HEADER是否让U-Boot帮你做了对齐调整。5. 设备树覆盖机制与U-Boot的fdt命令5.1 fdt命令到底能做什么正常情况下dtb文件在编译时就确定了硬件描述U-Boot引导kernel时原样传过去就行。但实际项目里往往需要在启动时动态改一些内容改内存大小、换串口、调mac地址。这时候就需要U-Boot的fdt命令了严格说是“设备树覆盖”机制。我常用的几个fdt操作场景修改内存大小fdt set /memory reg 0x0 0x40000000修改bootargsfdt set /chosen bootargs consolettyS0,115200设置mac地址fdt set /soc/ethernet local-mac-address [xx xx xx xx xx xx]启用或禁用节点fdt set /soc/usb status disabled5.2 设备树覆盖overlay的现代玩法传统做法是启动前用fdt命令手动改dtb现代做法则是设备树覆盖Device Tree Overlay, DTO。这种机制允许你在不改原dtb的情况下通过一个overlay dtbo文件把额外的硬件配置“叠加”上去。这在FPGA动态加载、外设模块更换的场景中特别实用。U-Boot对overlay的支持主要体现在fdt apply命令。用法大致是这样先把基础dtb加载到内存把overlay dtbo加载到另一块内存执行fdt addr 基础dtb地址让U-Boot识别当前dtb执行fdt apply overlay地址把overlay叠加进去再用booti或bootz启动kernel这个流程最大的好处是板级差异与基础硬件解耦。同一块核心板搭配不同底板只需要切换不同的overlay文件无需维护多份完整dtb。我在实际项目中用这种方案解决过一个机器上要兼容三种不同显示屏的问题非常灵活。5.3 启动过程中dtb被修改的真相还有一个常见疑问U-Boot启动kernel前到底会不会改dtb里面的内容答案是肯定会。具体改动取决于配置最常见的是chosen节点的bootargs和linux,initrd-start、linux,initrd-end。如果你的启动命令带了ramdiskU-Boot会在dtb的chosen节点下新增这两个属性告诉kernel ramdisk的物理地址范围。内核启动后从chosen节点去找initrd找到就加载找不到就跳过。这个机制也解释了调试时的一个现象dtb文件在磁盘上是“干净”的但在内存中运行时已经包含了一些U-Boot注入的信息。如果你的kernel就是找不到initrd先别急着怀疑内核配置先检查U-Boot的CONFIG_OF_LIBFDT是否打开以及bootm/booti命令有没有正确传入ramdisk地址。5.4 实际案例动态修改设备树后启动失败曾经调试过一块板子现象是fdt命令修改完设备树后kernel启动到一半直接panic报Unflattening device tree failed。排查过程是先确认fdt命令是否成功写入——fdt print /能看到修改后的完整树结构说明写入没问题再对比修改前后的dtb二进制发现修改时内存越界破坏了后续的其他数据。后来定位到问题关键**fdt set命令在U-Boot里操作的是内存中的dtb副本如果这个副本本身太小比如dtb文件预留的空闲空间不足扩展节点时会溢出到邻近内存。**解决办法是编译dtb时预留足够空间比如在dts里加上/ { reserved-memory { #address-cells 2; #size-cells 2; ranges; }; };或者更直接的做法——在生成dtb时用dtc工具指定空余空间大小dtc -I dts -O dtb - -p 1024 board.dts -o board.dtb其中-p 1024表示预留1024字节的空闲空间用于后续扩展。6. kernel启动早期的参数解析路径6.1 从入口函数到setup_arch参数在内存里放好了kernel是怎么消费的这个流程对定位问题很重要我按启动时间线来梳理。以ARM32为例U-Boot跳转到zImage后zImage自解压代码会先接管控制权解压完成后跳转到解压后的内核入口stext。在stext里内核会做一件关键的事保存r0、r1、r2的值即机器ID、ATAGS/DTB地址存到临时变量里然后关中断、切换页表、建立早期MMU映射。接着进入start_kernel()里面会调用setup_arch(command_line)这是参数解析的重头戏。6.2 设备树解析的关键函数设备树解析路径的函数比较固定我来列一下主流内核里会依次调用的函数栈setup_machine_fdt()根据传入的DTB地址检查有效性解析model和compatible属性early_init_dt_scan_nodes()扫描根节点、memory节点、chosen节点early_init_dt_scan_chosen()解析chosen节点提取bootargs、initrd地址、stdout-pathearly_init_dt_scan_memory()解析memory节点和reg属性建立内存块列表arm_memblock_init()把memory信息转换成memblock数据结构形成物理内存管理的基础这个流程里如果dtb地址无效或格式错误内核会直接报Error: invalid device tree而终止启动。所以dtb地址传错是启动失败的常见原因必须排查U-Boot传给r2/x0的值。6.3 command_line和bootargs的关系这里有个容易混淆的概念bootargs是U-Boot环境变量里的字符串而command_line是内核解析后的全局变量。两者不一定是同一个值。内核拿到bootargs后还会叠加自身编译时的CONFIG_CMDLINE配置、以及可能从ATAGS中提取的tag_cmdline最终拼接成完整的command_line。所以你可能遇到这种情况明明U-Boot里设置了bootargs但内核启动时打印的命令行里多出一截没见过的参数——那大概率是内核的CONFIG_CMDLINE默认值被拼进去了。这种拼接行为可以通过内核配置的CONFIG_CMDLINE_FORCE来强制覆盖一般不会这么干但要清楚有这回事。6.4 常见调试打印语句启动时如果想快速确认参数是否传递成功可以关注内核打印的几行日志Kernel command line: ...打印最终生效的command_lineMachine model: ...打印设备树根节点的model属性Memory: ...打印检测到的物理内存大小和布局如果这三行里的内容和预期不一致说明参数传递链路的某个环节出了问题。再结合U-Boot侧打印的Kernel image ...、FDT ...等日志能快速锁定是镜像地址问题、dtb内容问题还是bootargs拼接问题。7. 常见启动异常与排查实录7.1 内核收到错误bootargs导致的串口无输出现象是U-Boot正常打印完跳转kernel后串口上什么都没有像死机了一样。这种情况下首先要排除是“真死机”还是“串口没配置对”。如果kernel默认从ttyS0输出而bootargs里写的是ttyAMA0那就可能看到不到任何输出——内核在printk早期就找不到控制台日志全丢进了早期缓冲。排查办法是先清空bootargs再启动setenv bootargs bootz ${loadaddr} - ${fdt_addr}如果清空后能看到kernel启动日志虽然会因为没有rootfs而最终panic就说明bootargs里的console参数有问题。正常操作时应该保证bootargs和内核CONFIG_DEBUG_LL的调试口配置一致。7.2 DTB地址被zImage覆盖导致启动失败这是非常经典的问题我前面提过原理这里讲一个真实的数据。某平台DDR从0x80000000开始大小256MB即到0x8FFFFFFF。当时把DTB放在0x82000000kernel zImage放在0x80008000。启动到一半内核报“FDT address 0x82000000 is out of memory”或者直接死机。因为zImage解压后的内核大小可能达到20-30MB而解压位置会紧跟在zImage运行地址之后的“安全区域”附近。0x82000000正好在0x80008000附近20MB左右的范围内被解压覆盖了。后续调整方案是将DTB挪到0x8F000000内存末尾附近问题解决。所以我建议DTB地址最好放在RAM末尾附近至少预留约16MB-32MB的安全距离。如果RAM紧张或者地址空间有限至少也要确保zImage的实际解压范围不会覆盖DTB。7.3 uImage与zImage混用的陷阱很多从旧项目拷贝过来的U-Boot环境变量里还写着bootm但实际kernel镜像是zImage。这时U-Boot按uImage格式解析zImage的头部看到的魔数不匹配直接报错启动流程中止。反过来也一样你配置了bootz却给了uImageU-Boot虽然能识别但引导逻辑会不一样。一个比较稳妥的排查方式是先用U-Boot的iminfo命令检查镜像信息iminfo ${loadaddr}这个命令会打印镜像类型、格式、加载地址等。看到输出里的“Image Type: ARM Linux Kernel Image”时后面会标出是uncompressed还是zImage还是multi类型。确认实际格式后再选择正确的启动命令能少踩很多坑。7.4 设备树里没写对compatible导致根文件系统挂载失败还有一个隐蔽问题U-Boot传递dtb没问题bootargs里root/dev/mmcblk0p2也写得没错但kernel始终报找不到rootfs。这种问题常见原因是设备树中存储节点如SDHCI节点的compatible属性与实际硬件不符导致对应的驱动没有加载因此/dev/mmcblk0p2这个设备根本没有被创建。排查思路是先看启动日志里有没有sdhci、mmc相关的打印。常见的内核配置里如果驱动匹配失败日志里会有一段“no compatible match”之类的话。解决办法是打开设备树的存储节点核对compatible字符串是否与内核驱动匹配也可以临时通过fdt set手动修改节点状态来验证。7.5 常见问题速查表现象可能原因排查/解决启动后串口无输出bootargs里的console参数不对或U-Boot没传dtb地址清空bootargs对比内核源码里调试口配置内核报“Bad Magic Number”用bootm引导了非uImage格式的镜像用iminfo查看镜像类型切换到bootz/booti内核报“(FDT address) is not word-aligned”DT地址未对齐检查内存地址是否满足4字节对齐内核报“provided dtb is not in usable memory”DTB地址错误或超出DDR范围检查fdt_addr是否在合法内存区间内启动后内存变小一半memory节点reg不匹配或ATAG_MEM错误用fdt print查看memory节点核对reg值kernal启动后initrd找不到chosen节点未正确注入initrd地址检查是否在bootm/booti传入ramdisk地址启动时提示kernel panic “unable to mount root fs”bootargs的root参数与实际存储节点不匹配检查mmc/SATA/USB节点是否正常枚举核对root参数7.6 一个完整的启动log片段分析最后给一个典型的正常启动日志片段配合注释方便对照U-Boot 2020.04 (Apr 20 2023 - 15:33:08 0800) DRAM: 256 MiB NAND: 128 MiB MMC: mmcf8000000: 0 setenv bootargs consolettyS0,115200 root/dev/mmcblk0p2 rw rootwait load mmc 0:1 0x80008000 zImage load mmc 0:1 0x8f000000 board.dtb bootz 0x80008000 - 0x8f000000 Kernel image 0x80008000 [ 0x000000 - 0x5b8d90 ] FDT 0x8f000000 Starting kernel ... [ 0.000000] Booting Linux on physical CPU 0x0 [ 0.000000] Linux version 5.10.120 (builderbuildhost) [ 0.000000] Kernel command line: consolettyS0,115200 root/dev/mmcblk0p2 rw rootwait [ 0.000000] Memory: 254MB 2MB reserved [ 0.000000] Machine model: MyBoard V2.1 [ 0.000000] earlycon: uart0 at MMIO32 0xf8000000 (options 115200)看到“Kernel command line”和“Machine model”里的信息和预期一致就基本可以确定参数传递链路没有问题。后面如果根文件系统挂载失败问题就集中在存储驱动和root参数匹配上可以往那个方向深挖。8. 个人经验总结三个关键调试技巧参数传递机制说到底是“地址约定格式约定内容约定”三件事。三个约定里任何一个出错kernel就起不来或跑不稳。从实操角度我总结了三个最常用的调试技巧希望能帮你少走弯路。第一个技巧是打印U-Boot的内存布局。不管什么平台启动前先执行bdinfo命令它会打印当前板子的内存起始地址、大小、各个加载地址的约定值。对照着看bootcmd里的启动命令是否和bdinfo显示的内存范围一致很多“起不来”的问题一眼就能看出来。第二个技巧是善用fdt print。这个命令能看到当前内存中设备树的具体内容。如果你不确定chosen节点有没有被正确注入直接执行fdt print /chosen里面有没有bootargs、有没有initrd地址打印出来清清楚楚。比盲猜高效得多。第三个技巧是准备一个“最小启动配置”。平时调试时我习惯先把系统剪到最简串口不变、rootfs为空或直接挂init/bin/sh这样能把变量数降到最低。当最小配置能启动并进入shell再逐步加bootargs参数、加设备树节点每加一步都能立刻知道是哪一步引入的问题。这比一次性配好所有东西、出了问题无从下手要高效很多。说句实在话U-Boot和kernel之间的参数传递机制是整个嵌入式Linux启动链路里最容易“玄学化”的部分。很多开发者遇到启动异常第一反应是怀疑内核配置或驱动问题但排查到最后往往发现是参数根本没传对。把这个机制彻底吃透等于掌握了启动链路中最基础、最底层的那些约定——这些约定看似简单但每一行背后都是无数前辈踩坑换来的经验。我写这篇文章也是希望后来的开发者能少重复这些弯路。