
1. 先说结论Linux内核启动早期到底从哪里拿参数干过嵌入式Linux的人十有八九遇到过这样的事明明在U-Boot里用setenv设置了一个环境变量内核起来之后却怎么也读不到或者反过来U-Boot里改了bootargs内核的/proc/cmdline却纹丝不动仍然是编译内核时写死的参数。这时候大家的第一反应通常是“是不是bootargs没传进去”但很少有人能把这条传递链路从头到尾完整讲清楚。这篇文章要做的就是这件事把U-Boot跳转到Linux内核之间的参数传递机制从寄存器约定、内存布局、数据结构、启动流程到实际排查手段一层一层拆开来讲。无论你是在做BSP移植、驱动开发还是仅仅想让自己的开发板跑起来搞清楚这条链路都能省下大把调试时间。先给一个总体结论U-Boot向内核传递参数走的是物理内存里的数据块加寄存器约定的方式而不是什么高深的进程间通信。在32位ARM时代约定是r0、r1、r2三个寄存器分别传递“0、机器类型、参数块地址”在64位和现代设备树时代约定变成了x0、x1、x2分别传递“0、设备树物理地址、0”。内核启动早期会从这些寄存器指向的内存区域读取启动参数然后才逐步建立自己的运行环境。这里面的关键词有三个寄存器约定、物理地址、数据格式。只要抓住这三个词整条链路就清晰了。下面从老一代的ATAG机制开始讲因为它是理解后续一切设计动机的起点也是很多旧代码里仍然在跑的老古董。2. ATAG机制ARM Linux早期靠一段内存链表传参数2.1 为什么要设计一套“链表式”的参数块在设备树还没有大范围普及的年代ARM Linux内核面对的问题是我怎么知道内存有多大我在哪块Flash上找根文件系统控制台用哪个串口这些信息必须由Bootloader告诉内核。当年的做法是在某个固定物理地址放一串精心设计的内存数据结构这一串结构就叫做ATAGARM Tag。它的格式其实很简单本质上是一段连续的tag结构体数组每个结构体以tag标志开头后面跟着内容以ATAG_NONE结束。内核拿到起始地址后从第一个tag开始逐个向后遍历直到遇到结束标记。这就是一个典型的链表式内存块。每个tag header长这样struct tag_header { __u32 size; /* 整个tag占用的字数注意是4字节为单位不是字节 */ __u32 tag; /* tag类型比如ATAG_CORE、ATAG_MEM、ATAG_CMDLINE */ };其中size字段的设计很容易让人栽跟头。它表示的是“从tag_header开始到本tag内容结束一共有多少个32位字”而不是我们惯常理解的字节数。比如一个ATAG_CMDLINEheader占了2个字如果命令行字符串本身是64字节那就是16个字这个tag的size就是18。如果你按字节数去填这个字段内核解析时读几下就越界了直接导致无法启动。2.2 三大核心tagCORE、MEM、CMDLINE在实际的U-Boot代码里最常打交道的tag就三个Tag类型值携带信息ATAG_CORE0x54410001标志参数列表开始包含页大小、根设备号等基础信息ATAG_MEM0x54410002描述一段物理内存的起始地址和大小可以出现多次ATAG_CMDLINE0x54410009内核启动命令行就是bootargs传进去的东西ATAG_NONE0x00000000结束标记size为0U-Boot在启动旧版内核镜像时会根据这些tag类型依次往内存里写数据。写之前先要确定一块内存地址这个地址在32位ARM时代一般约定在物理内存偏移量较小的地方比如0x100或者0x2000附近。这个地址必须避开内核镜像、initrd等已占用的区域否则写进去的参数会被内核解压过程直接覆盖掉。我最早调ATAG时犯过一个典型的错直接在U-Boot里把bootargs设得很长长到整块tag区域放不下后面的tag全部被挤掉导致内核解析时找不到ATAG_CMDLINE系统起来后/proc/cmdline为空rootfs也没法挂载。这个问题查了很久才发现是内存布局重叠后来养成一个习惯给ATAG预留区域至少4KB并且做一次内存占用检查确认不与内核镜像加载地址冲突。2.3 内核侧如何消费这一串链表内核收到r2寄存器传过来的ATAG地址后在汇编启动阶段就开始遍历。这里很多人以为解析参数是C语言的parse_args在做其实不对。ATAG的解析发生在非常早期汇编代码或者early C代码里就要把ATAG_MEM的信息转交给内存初始化框架因为后续的启动过程马上就需要知道内存布局。以老版本ARM Linux为例启动阶段会调用parse_tags这个函数逐个读取tag遇到ATAG_MEM就用arm_add_memory把这块区域加入系统内存遇到ATAG_CMDLINE就把字符串指针保存下来之后交给early_param机制去处理。这个过程要求参数链表所在的内存区域映射已经建立否则直接访问会触发异常。还要注意一个细节如果你在内核配置里同时打开了CONFIG_CMDLINE和CONFIG_CMDLINE_FORCE那么即使U-Boot传来了ATAG_CMDLINE内核也会直接忽略它强制使用编译时写死的命令行。这种配置在调试时期挂载NFS根文件系统时特别坑因为你会发现自己改了半天的bootargs根本没生效第一次遇到时很容易怀疑是不是U-Boot设置变量失败了。3. 设备树DTB时代核心从“数据结构约定”变成了“地址约定”3.1 从ATAG到DTB改变了什么ATAG机制能跑但它有一个硬伤信息是扁平的没有层次。你想表达“内存控制器节点下面挂了两个bank”这种结构ATAG做不到只能靠内核里对应的机器描述代码去猜。设备树出现后Bootloader要传的东西就变成了一整棵结构化的树这棵树的二进制形式就是DTB。参数传递的方式从“传递一串tag链表”变成了“传递一个DTB的物理地址”。这个变化表面上看只是换了一种数据结构实质上是把很多硬件描述的工作从内核代码里挪到了设备树源文件里内核的板级代码被大幅精简。那么U-Boot在启动时要做什么核心动作有两步第一把DTB文件从存储介质加载到内存某个地址第二在跳转内核之前确保r2寄存器指向这个DTB地址。听起来简单但实际操作中因为内存布局冲突、DTB编译时包含的默认bootargs、U-Boot运行时修改设备树等原因麻烦事一点也不比ATAG时代少。3.2 bootm、bootz、booti三种启动方式怎么选现在的U-Boot里三种启动内核的方式各有侧重bootm kernel_addr initrd_addr fdt_addr bootz kernel_addr initrd_addr fdt_addr booti kernel_addr initrd_addr fdt_addr命令适合的镜像类型说明bootmU-Boot legacy imageuImage也兼容新格式最老牌的方式会解析镜像头信息bootz32位ARM上的zImage直接启动压缩的内核没有uImage头booti64位ARM的Image格式AArch64下常用很多高通CAF平台的U-Boot就用它很多人以为这三种命令的差别只是镜像格式不同实际上它们对参数地址的处理也有细微差别。bootm比较特别它可以从uImage头里读取内核入口地址、初始地址等然后自行计算要传的r2值bootz和booti则更依赖你给命令传入的参数地址。无论用哪种方式U-Boot内部最终都会走到bootm_boot_start这类公共逻辑把镜像地址、初始RAM盘地址、FDT地址准备好然后跳到内核入口。也就是说参数的载体不再是ATAG链表而是一个二进制设备树块。3.3 设备树里怎么放bootargschosen节点的来龙去脉设备树中参数传递的主战场是chosen节点。内核启动时会读取chosen里的bootargs属性作为启动命令行。U-Boot在启动内核时会自动检查并修正chosen节点这就是很多人会遇到的一个现象你明明在设备树源文件里写了一个bootargs但启动后看到的cmdline却完全不同。因为U-Boot使用设备树启动时有一个行为逻辑如果环境变量bootargs存在它会在跳转之前把chosen节点的bootargs属性改写为环境变量中的值。这个行为由CONFIG_OF_LIBFDT和具体板卡的ft_board_setup代码决定。如果你在设备树里写死了bootargs而U-Boot的环境变量又设置了一个不同的bootargs最终生效的会是环境变量里的那个。想验证这件事很简单启动到U-Boot后执行fdt addr $fdt_addr_r fdt print /chosen你会发现此时chosen节点里的bootargs已经不再是编译DTB时的默认值而是被U-Boot覆盖过的新值。这是很多刚接触设备树的人最容易困惑的地方也是理解U-Boot与内核参数传递机制最关键的细节之一。3.4 U-Boot运行时修改设备树fdt系列命令在实际开发中除了bootargs我们经常还要在启动前动态修改其他参数比如某颗芯片的初始化参数、内存节点信息等。U-Boot提供了一组fdt命令来操作内存中的设备树fdt addr 0x40000000 # 指定当前设备树的地址 fdt set /soc/uart10000000 clock-frequency 24000000 # 修改属性 fdt rm /soc/disabled-node # 删除节点 fdt move 0x40000000 0x42000000 # 把DTB搬到新地址这里特别提醒fdt move的用法。当内核解压后会占据较大内存区域时如果DTB的加载地址和内核解压目标地址过近启动会失败。常见做法是把DTB加载到内存靠后的地址比如内核镜像在0x40080000initrd在0x42000000DTB就放到0x48000000。这样留给解压过程足够空间也避免DTB被覆盖。4. bootargs逐字段拆解从U-Boot环境变量到内核cmdline4.1 U-Boot端bootargs的组成与拼接逻辑bootargs本身就是一个字符串U-Boot内部并不理解它的含义内核端才有完整的解析逻辑。U-Boot要做的只是把它原样传给设备树或ATAG。但工程实践中bootargs经常不是直接写死的一个字符串而是动态拼出来的。比如某块板卡的bootargs可能是这样生成的setenv bootargs consolettyMSM0,115200n8 root/dev/mmcblk0p15 rw rootwait androidboot.hardwareqcom androidboot.selinuxpermissive如果内存大小、串口端口号需要根据不同板卡变化可以拆成多段setenv bootargs_base root/dev/mmcblk0p15 rw setenv bootargs_console consolettyMSM0,115200n8 setenv bootargs_android androidboot.hardwareqcom androidboot.selinuxpermissive setenv bootargs ${bootargs_base} ${bootargs_console} ${bootargs_android}这种分段拼接的方式在厂商BSP里特别常见好处是不同需求可以只改一段。比如要临时打开某项调试开关只修改对应那一段并重新setenv bootargs即可其他部分不用动。要注意的是U-Boot环境变量展开时变量引用必须以$符号开头${变量名}的写法能避免歧义。4.2 参数到底去哪了内核cmdline的解析顺序内核拿到最终的cmdline字符串后解析过程也不是一步到位的。以现代内核为例对命令行参数的消费主要分为两个阶段early_param阶段和普通__setup阶段。static int __init early_serial_console_init(char *buf) { /* 这个阶段会非常早地注册串口 */ } early_param(earlycon, early_serial_console_init);early_param的调用时机在setup_arch前后此时很多子系统还没有初始化所以能处理的事情有限大多用于内存、控制台、中断控制器这些必须在早期准备好的功能。而普通的__setup回调函数则要等到start_kernel里的do_initcalls流程才执行比如root这种参数就需要等真实块设备驱动起来之后才能解析出根设备。这带来一个实际影响有些参数看起来在启动日志里没有打印并不代表它没被处理而是处理时对应的打印还没打开。排查这一步时光看内核日志可能不够还要去确认cmdline字符串本身是否正确传递以及对应的handler是否注册成功。4.3 多个参数来源并存时谁说了算设备树chosen节点里有bootargs内核配置里有CONFIG_CMDLINEU-Boot环境变量里也有bootargs这时候到底以哪个为准优先级大致是U-Boot环境变量通常会覆盖设备树里的bootargs如果内核配置启用了CONFIG_CMDLINE_FORCE则强制采用CONFIG_CMDLINE如果既没有U-Boot传参也没有设备树bootargs内核才回落使用CONFIG_CMDLINE。我实测过一块板卡的排查流程具体现象是无论怎么修改U-Boot环境变量内核cmdline始终带着一段“root/dev/ram0”。后来发现是内核配置里把CONFIG_CMDLINE_FORCE打开了这条配置把所有外部传入参数全部强制覆盖掉了。关掉这个配置后U-Boot传过来的参数才恢复生效。这类问题排查时第一标准动作永远是查看启动日志和/proc/cmdline# 在目标系统启动完成后执行 cat /proc/cmdline如果这个文件的内容和你预期的bootargs不一致说明问题出在U-Boot到内核的传递链路上如果一致但功能不对那才需要去排查内核侧的具体解析逻辑。5. 实测排查定位启动参数丢失或错误的完整链路5.1 一个从U-Boot到内核的完整验证流程我遇到过最典型的案例是这样的开发板通过fastboot刷机后正常开机时系统能起来但一旦需要挂载NFS根文件系统时无论如何修改U-Boot的bootargs内核始终用本地的rootfs启动。这是一条非常典型的排查链路整理成步骤给大家参考第一步在U-Boot中打印环境变量确认bootargs确实被设置了。U-Boot# printenv bootargs bootargsconsolettyMSM0,115200n8 root/dev/nfs nfsroot192.168.1.100:/srv/nfs rw ipdhcp第二步查看设备树中chosen节点的实际内容。这一步很关键因为很可能U-Boot环境变量已经设置但实际传给内核的DTB里chosen/bootargs并不是这个值。U-Boot# fdt addr $fdt_addr_r U-Boot# fdt print /chosen chosen { bootargs consolettyMSM0,115200n8 root/dev/nfs nfsroot192.168.1.100:/srv/nfs rw ipdhcp; stdout-path /serialf9000000; };第三步启动内核后在系统内查看实际生效的cmdline# cat /proc/cmdline consolettyMSM0,115200n8 root/dev/nfs nfsroot192.168.1.100:/srv/nfs rw ipdhcp如果第三步的结果和第二步的chosen节点一致说明U-Boot到内核的链路没有问题。如果第三步的结果和第二步不一致问题大概率出在bootloader后续的某个阶段又改了设备树比如某些厂商的ABL应用启动加载器或者XBL阶段会在拉起内核前覆盖chosen节点。5.2 踩过最深的坑DTB地址覆盖与解压空间冲突前面提到过fdt move的问题这里详细展开一个真实案例。某平台使用booti启动64位内核镜像内核镜像加载地址是0x80080000DTB加载地址是0x83000000initrd地址是0x84000000。看起来地址都很充裕但内核解压后镜像体积膨胀到接近64MB时解压区域会向低地址方向延伸到0x82000000附近而DTB正好放在0x83000000二者完全没有重叠。理论上讲问题不大但当内核中CONFIG_DEVTMPFS、大量驱动编入内核而非模块时镜像体积进一步增大解压后的代码段会突破0x83000000直接把DTB覆盖掉。此时启动日志里会出现类似“Starting kernel …”后就没了后续输出或者报设备树无法解析。定位这个问题的方法很简单把DTB地址再往高地址挪比如从0x83000000改到0x88000000。同时要检查initrd地址是否也受影响确保三个区域的间隔足够大。我当时总结了一套比较稳妥的内存分配习惯用途建议位置说明内核镜像内存起始偏移0x800000处避免与U-Boot自身驻留区域冲突DTB内核镜像之后与解压目标区间隔至少32MB留足解压膨胀空间initrd内存靠后位置与DTB保持足够间隔避免互相覆盖5.3 厂商BSP的额外动作高通CAF中典型的bootargs处理路径这里额外说一下高通CAF平台。CAFCodeAurora Forum是高通维护的Android内核分支广泛用于骁龙平台。这类平台虽然很多使用ABL或者LKLittle Kernel作为bootloader但也有一部分设计采用U-Boot。无论哪种它们对bootargs的处理路径都和标准主线U-Boot有一些明显差异。典型的特点是厂商ABL阶段会自行构造一部分cmdline比如androidboot.hardware、androidboot.soc型号、androidboot.bootdevice之类然后与U-Boot环境变量里的bootargs合并最终写入chosen/bootargs。这意味着你在U-Boot里通过dump环境变量看到的bootargs很可能在ABL拉起的下一阶段被追加内容。因此在高通CAF平台上排查参数问题时不能只看U-Boot这一层。遇到内核cmdline里莫名其妙多出来一段“androidboot.*”时不要惊讶这是厂商级bootloader追加进来的。同理如果发现某些U-Boot环境变量设置的内容最终没有进入cmdline也要考虑是否被ABL阶段过滤了。这种厂商差异导致的最直观的一个坑你在U-Boot里设置了一个debug开关内核起来后没有打开查遍所有配置都没问题最后发现是ABL把未知参数过滤掉了。遇到这类问题最快的办法是查看内核日志开头的Kernel command line字段它会打印实际接收到的完整cmdline对比一下就知道少了什么。6. 给不同开发场景的实操建议与扩展方向6.1 自定义参数传递的最佳实践如果你希望在U-Boot和内核之间传递自定义信息比如产品序列号、硬件版本、校准参数等建议不要直接往bootargs里塞长篇字符串。bootargs会被很多模块展开解析乱加参数可能造成意外副作用。更稳妥的做法是放在设备树里。U-Boot启动前通过fdt设置一个自定义节点内核驱动再通过设备树API读取# U-Boot侧在启动前设置一个名为vendor,device-info的属性 fdt addr $fdt_addr_r fdt set /vendor/device-info serial-number SN1234567890// 内核驱动侧通过设备树节点读取 struct device_node *np of_find_node_by_path(/vendor/device-info); const char *serial; if (np) { if (!of_property_read_string(np, serial-number, serial)) pr_info(serial: %s\n, serial); }这样做的优势是数据结构明确、解析效率高、不会污染cmdline维护起来也比字符串拼接清晰得多。6.2 内核侧读取bootargs参数的推荐方式很多驱动开发者喜欢直接去解析cmdline比如通过strstr函数查找关键子串这种办法在小项目里能跑但不够稳健。内核对cmdline的解析提供了标准的机制也就是前面提到的early_param和__setupstatic int __init my_debug_setup(char *str) { if (str !strcmp(str, 1)) my_debug_enabled true; return 1; } __setup(my_debug, my_debug_setup);这种机制的好处是参数无论来自U-Boot还是设备树内核启动时都会统一进入这一套框架你不需要关心具体来源。要注意的是__setup回调函数执行时某些子系统可能还未初始化如果你的回调函数依赖其他模块一定要选对时机否则会出现随机性崩溃。还有个容易被忽略的点cmdline参数解析时所有以相同前缀开头的参数会依次调用同一个回调。比如“foo1”和“foo2”都匹配“foo”这个handler时后写的参数会覆盖先写的。所以如果你在不同位置重复传了同一个参数结果可能和预期不同这也是调试时值得留意的细节。6.3 参数传递相关的遗留问题清单与扩展思路写到这里参数传递的主干链路已经基本完整了。但如果继续深挖还有很多方向值得展开这里列几个供后续探索第一U-Boot的FIT镜像格式。FIT把内核、DTB、initrd打包成一个镜像文件可以在里面配置多套启动配置参数启动时通过configuration来选择比手工管理多个地址变量方便得多。第二ARM64下U-Boot与内核的接口约定比32位ARM更简洁但同样存在是否启用EFI stub的差异。如果内核开启了EFI启动参数传递就走EFI的配置表机制和传统r2寄存器方式完全不同。这种路径下U-Boot只负责加载EFI内核并跳转后续参数交换由EFI运行时服务接管调试手段也要换一套。第三可信启动链对参数传递的影响。很多安全方案要求bootloader在跳转前对DTB做签名校验一旦校验通过后续所有对设备树的修改都必须发生在签名之前。如果你需要动态修改bootargs就必须在签名前完成这对启动流程设计有直接影响。我个人处理这些问题的习惯是先把U-Boot到内核这条链路的“地址视角”梳理清楚脑子里有一张内存布局图知道每一个关键参数存在哪个地址然后遇到问题就逐个环节验证而不是盲目修改内核配置。参数传递机制本质上是嵌入式系统里最难调试的一类问题因为它的失效模式往往不是报错而是“静默变化”——看起来一切正常但某个参数没生效这对定位能力的要求比普通驱动问题高得多。希望这篇文章能把这条链路的关键节点一个个标出来下次你再遇到cmdline相关的问题时能少走一些弯路。