1. 从一条命令到内核跑起来,中间到底发生了什么
很多人第一次接触嵌入式Linux启动流程时,都会有一个疑问:我在U-Boot命令行里敲下bootm 0x80008000,然后内核就起来了,这中间到底发生了什么?看起来只是一条命令,实际上背后是一条完整的启动链路——从镜像格式识别、头部校验、内存搬运、设备树传递,到最终跳转到内核入口。这条链路上任何一环出问题,你看到的就是黑屏、卡死或者一串看不懂的报错。
bootm、booti、bootelf这三个命令是U-Boot里最常用的启动命令,分别对应不同的镜像格式。搞嵌入式的朋友基本天天跟它们打交道,但真正把它们的启动链路吃透的人并不多。大部分人的做法是:板子能启动就行,命令能跑通就不深究。但一旦遇到启动失败、镜像加载地址冲突、设备树传参不对、内核解压后跑飞这些问题,就只能靠反复试参数来碰运气。
这篇文章面向的是有一定嵌入式基础的开发者,不管你是刚接触U-Boot的新手,还是已经用过一段时间但没系统梳理过启动链路的老手,都能从中找到有用的东西。我会把bootm、booti、bootelf三条命令的完整启动链路拆开讲清楚,包括它们各自适用什么场景、内部做了哪些事、关键参数怎么传、踩过哪些坑。内容基于U-Boot通用实现来写,不同SoC厂商的U-Boot分支可能有细微差异,但核心逻辑是一致的。
2. 三条启动命令的适用边界:别拿bootm去启动ARM64内核
2.1 镜像格式决定了你用哪条命令
U-Boot的启动命令不是随便选的,它跟镜像格式强绑定。选错了命令,轻则报"Bad Magic Number",重则跳转后直接跑飞。
bootm是历史最悠久的启动命令,它处理的是U-Boot自己的镜像格式(uImage)。uImage是在原始内核镜像前面加了一个64字节的头部,里面包含魔数、加载地址、入口地址、CRC校验等信息。这个格式在ARM32时代非常流行,因为U-Boot可以自己解析头部,知道该把镜像搬到哪里、从哪里开始执行。
booti是后来为ARM64(AArch64)引入的命令,它处理的是raw Image格式,也就是内核编译出来的Image文件,没有U-Boot头部。为什么ARM64不继续用uImage?因为ARM64的启动协议变了,内核要求设备树地址通过寄存器传递,而且镜像本身不需要U-Boot再做搬运和校验,直接用booti指定内核地址、ramdisk地址、设备树地址就行。
bootelf处理的是ELF格式的可执行文件。这个命令用得相对少,但在一些裸机程序加载、RTOS启动、或者某些特殊固件的场景下会用到。ELF格式的好处是段信息完整,U-Boot可以按照ELF头里的program header把各个段加载到正确的地址。
下面这张表可以帮你快速判断该用哪条命令:
| 命令 | 适用镜像格式 | 典型架构 | 是否需要U-Boot头部 | 设备树传递方式 |
|---|---|---|---|---|
| bootm | uImage (legacy) | ARM32, MIPS, PowerPC | 是 | 通过bootargs或fdt_addr |
| booti | raw Image | ARM64 | 否 | 通过第三个参数显式指定 |
| bootelf | ELF | 通用 | 否 | 取决于程序本身 |
2.2 一个常见的误判:ARM64上用bootm会怎样
我见过不少人在ARM64平台上习惯性地敲bootm,结果报错。原因很简单:ARM64的内核Image没有uImage头部,bootm去读头部魔数的时候读到的是一堆无效数据,直接判定格式错误。
更隐蔽的情况是:有人用mkimage工具把ARM64的Image重新打包成uImage,然后用bootm启动。这样做理论上可行,但实际很容易出问题——因为ARM64内核对启动参数的要求跟ARM32不同,uImage头部里的入口地址和设备树传递方式可能跟内核预期不匹配。所以ARM64平台就老老实实用booti,别绕弯子。
2.3 bootelf的使用场景比你想的要窄
bootelf在标准Linux启动流程里基本用不到,它的主战场是:
- 加载裸机测试程序,比如内存测试、外设初始化验证
- 启动某些RTOS或实时固件
- 在U-Boot阶段加载一个ELF格式的二级引导程序
用bootelf的时候要注意,ELF文件的加载地址是编译时确定的,U-Boot会按照program header里的p_paddr来搬运各个段。如果编译时指定的地址跟实际内存布局冲突,加载就会失败。所以用之前一定要用readelf -l看一下段的物理地址。
3. bootm的完整启动链路:从头部校验到跳转内核
3.1 第一步:镜像头部解析与CRC校验
当你敲下bootm 0x80008000,U-Boot做的第一件事是读取这个地址开始的64字节头部。头部结构大致是这样的:
typedef struct image_header { uint32_t ih_magic; // 魔数 0x27051956 uint32_t ih_hcrc; // 头部CRC uint32_t ih_time; // 时间戳 uint32_t ih_size; // 数据大小 uint32_t ih_load; // 加载地址 uint32_t ih_ep; // 入口地址 uint32_t ih_dcrc; // 数据CRC uint8_t ih_os; // 操作系统类型 uint8_t ih_arch; // 架构类型 uint8_t ih_type; // 镜像类型 uint8_t ih_comp; // 压缩类型 uint8_t ih_name[32]; // 镜像名称 } image_header_t;U-Boot首先检查ih_magic是不是0x27051956,不是就直接报"Bad Magic Number"。然后校验头部CRC,再校验数据CRC。这两步校验很关键——如果镜像在传输或烧录过程中损坏,这里就会拦住,不会让一个坏镜像跑到跳转阶段才崩溃。
提示:如果你确认镜像没问题但一直报CRC错误,先检查加载地址对不对。有时候你把镜像下载到了错误的地址,读到的头部自然是一堆乱码。
3.2 第二步:镜像搬运与解压
校验通过后,U-Boot看ih_load和当前镜像所在地址是否一致。如果不一致,就把镜像数据搬到ih_load指定的地址。这一步很多人会忽略,但它恰恰是很多启动失败的根源。
举个例子:你用TFTP把内核下载到0x81000000,但uImage头部里记录的ih_load是0x80008000。U-Boot会自动把数据从0x81000000搬到0x80008000。如果0x80008000这块区域被其他东西占用了(比如正在运行的U-Boot自身代码),搬运就会覆盖掉关键数据,导致各种奇怪的问题。
搬运完成后,如果ih_comp显示镜像是压缩的(比如gzip、lzma),U-Boot会调用对应的解压函数把镜像解压到ih_load地址。解压后的数据大小可以通过ih_size推算,但实际解压后的大小要看压缩格式。
3.3 第三步:设备树处理与bootargs传递
对于ARM32平台,bootm处理设备树有几种方式:
第一种是bootm <kernel_addr> - <fdt_addr>,用短横线占位ramdisk,第三个参数指定设备树地址。U-Boot会把设备树地址写到合适的位置,并在跳转前通过寄存器传递给内核。
第二种是通过环境变量fdt_addr或fdtcontroladdr来指定。如果命令行没给设备树地址,U-Boot会尝试用环境变量里的值。
第三种是设备树已经跟内核一起打包在uImage里(multi-image格式),U-Boot会自动拆分。
bootargs环境变量是内核命令行参数,U-Boot在跳转前会把它放到设备树的/chosen/bootargs节点里。这里有个细节:如果你在命令行里用bootm ... bootargs=xxx这种方式传参,它会覆盖环境变量里的bootargs。但更推荐的做法是提前用setenv bootargs设置好,避免命令行太长出错。
3.4 第四步:跳转到内核入口
所有准备工作完成后,U-Boot做最后几件事:
- 关闭中断和缓存(或者按内核要求保留某些缓存)
- 把机器ID(ARM32)或设备树地址(ARM64)放到指定寄存器
- 跳转到
ih_ep指定的入口地址
对于ARM32,寄存器约定是:r0=0,r1=机器ID,r2=设备树地址。对于ARM64,x0=设备树地址。这些约定是内核启动协议规定的,U-Boot必须严格遵守,否则内核起来后找不到设备树,直接卡死。
注意:跳转前U-Boot会执行
cleanup_before_linux之类的清理函数,不同架构实现不同。如果你在移植U-Boot时改动了这部分,一定要确认没有破坏内核要求的寄存器状态。
4. booti的启动链路:ARM64下的精简与规范
4.1 booti的参数格式与地址规划
booti的命令格式是:
booti <kernel_addr> [<ramdisk_addr> [<fdt_addr>]]三个参数分别是内核Image地址、ramdisk地址(用-表示没有)、设备树地址。跟bootm不同,booti不需要解析头部,它直接认为你给的地址就是raw Image的起始位置。
地址规划是ARM64启动的关键。典型的内存布局是这样的:
- 内核Image加载地址:
0x80080000或类似(取决于具体平台) - 设备树加载地址:通常在内核之后,比如
0x83000000 - ramdisk加载地址:再往后,比如
0x84000000
这些地址不能重叠,也不能跟U-Boot自身占用的内存冲突。我一般会在U-Boot里用bdinfo看一下内存布局,确认可用区域后再定地址。
4.2 设备树在booti里的传递细节
ARM64内核对设备树的要求比ARM32严格。booti在跳转前会做这几件事:
首先,检查设备树头部魔数是不是0xd00dfeed。不是的话直接报错。然后,U-Boot会把bootargs写入设备树的/chosen节点。如果设备树里没有/chosen节点,U-Boot会创建一个。
接着,U-Boot会处理设备树里的memory节点,确保内存信息跟实际硬件匹配。有些平台还需要U-Boot修正设备树里的CPU频率、时钟等参数。
最后,跳转时把设备树地址放到x0寄存器。ARM64内核启动协议规定x0必须指向设备树,x1到x3保留为0。如果x0传错了,内核在early_init阶段就会崩。
4.3 booti启动失败的几个典型原因
实际调试中,booti启动失败最常见的原因有这么几个:
内核地址不对。有些人把Image下载到了0x80008000,但实际应该用0x80080000。ARM64内核的加载地址通常有对齐要求,不对齐的话解压或跳转会出问题。
设备树地址跟内核重叠。如果设备树加载地址离内核太近,内核启动过程中可能会覆盖设备树数据。一般建议设备树跟内核之间留至少16MB间隔。
bootargs里的console参数不对。这个不会导致启动失败,但会导致你看不到任何输出,误以为启动卡死了。确认console=ttyS0,115200之类的参数跟实际串口匹配。
内存节点信息不对。设备树里的memory节点如果写的地址范围跟实际内存不符,内核起来后会访问非法地址。这个在移植新板子时特别容易遇到。
5. bootelf的加载逻辑:按段搬运与入口跳转
5.1 ELF头部解析与program header遍历
bootelf的工作方式跟bootm、booti完全不同。它不关心什么uImage头部或raw Image,而是按照标准ELF格式来解析。
U-Boot首先读取ELF头部,确认魔数0x7f454c46(即\x7fELF)。然后根据e_phoff找到program header table,遍历每一个program header。对于类型为PT_LOAD的段,U-Boot会按照p_paddr指定的物理地址,把p_filesz大小的数据从文件偏移p_offset处搬过去。如果p_memsz大于p_filesz,剩余部分清零(BSS段)。
这个过程跟操作系统加载ELF程序很像,但U-Boot是在裸机环境下做的,没有虚拟内存映射,所以直接按物理地址搬运。
5.2 bootelf的入口地址与参数传递
所有段加载完成后,U-Boot跳转到ELF头部里的e_entry指定的入口地址。跟Linux内核不同,ELF程序的入口不需要遵循什么启动协议,寄存器状态取决于程序本身的约定。
如果你用bootelf加载的是Linux内核(虽然不常见),那就要自己确保寄存器状态符合内核要求。但更常见的用法是加载裸机程序或RTOS,这时候入口地址和寄存器约定都是你自己定的,灵活度很高。
提示:用
bootelf之前,务必用readelf -h和readelf -l确认入口地址和段地址。我遇到过有人编译时没指定链接脚本,段地址默认从0开始,结果U-Boot往地址0搬运数据,直接把异常向量表覆盖了,系统当场挂掉。
5.3 bootelf与bootm/booti的本质区别
从链路角度看,bootelf比bootm和booti都要"底层"。bootm和booti是为Linux内核量身定做的,内部做了很多针对内核的优化和约定处理。而bootelf是一个通用的ELF加载器,它不关心你加载的是什么,只负责把段搬到正确位置然后跳转。
这也意味着bootelf的容错性更低。bootm会帮你校验CRC、处理压缩、传递设备树,bootelf这些都不管。所以除非你确实需要加载ELF格式的程序,否则启动Linux还是用bootm或booti更省心。
6. 启动链路中的踩坑实录与排查思路
6.1 镜像加载地址冲突:一个反复出现的坑
这个坑我踩过不止一次。现象是:U-Boot里bootm命令执行后没有任何输出,串口直接卡死,也不报错。
排查过程是这样的:首先确认镜像下载地址和uImage头部里的ih_load是否一致。如果不一致,U-Boot会做搬运。搬运的目标地址如果落在U-Boot自身代码或数据区域,就会把正在运行的U-Boot破坏掉,导致跳转后行为异常。
怎么确认?在U-Boot里用bdinfo查看内存布局,找到U-Boot的代码段和数据段范围。然后确认ih_load不在这个范围内。如果冲突,要么改镜像的加载地址重新打包,要么把镜像下载到正确地址避免搬运。
6.2 设备树地址传错导致的静默卡死
ARM64平台上,booti的第三个参数是设备树地址。如果这个地址传错了,比如指向了一块未初始化内存,内核在early_init阶段解析设备树时会读到无效数据,然后卡死。串口可能连一行输出都没有。
排查方法:在U-Boot里用fdt addr <addr>和fdt print确认设备树内容是否正确。如果fdt print能正常输出,说明设备树本身没问题,那就要检查booti命令里的地址参数是否跟fdt addr用的一致。
另一个容易忽略的点是:设备树地址必须是8字节对齐的。ARM64内核要求设备树地址对齐到8字节,不对齐的话可能解析失败。这个在手动指定地址时容易忘。
6.3 bootargs参数错误引发的"假死"
有时候内核其实已经启动了,但因为bootargs里的console参数不对,你看不到任何输出,以为卡死了。这种情况最容易被误判。
我的做法是:先在U-Boot里用printenv bootargs确认参数内容,重点检查console=后面的设备名和波特率。然后可以用setenv bootargs ${bootargs} earlycon加上earlycon参数,让内核在更早的阶段输出调试信息。如果earlycon能输出但正常console不行,那就是console驱动或参数的问题。
6.4 压缩镜像解压后跑飞
用bootm启动压缩内核时,U-Boot会先解压再跳转。如果解压后的数据大小超过了预留内存区域,就会覆盖后面的数据。这个问题的隐蔽性在于:解压过程本身不报错,跳转后才崩溃。
确认方法:在U-Boot里用iminfo <addr>查看镜像信息,它会显示镜像大小、压缩类型、加载地址等。然后根据压缩类型估算解压后的大小(gzip一般能压到30%左右,lzma压缩率更高但解压后更大)。确保加载地址往后预留足够空间。
7. 几条实战中总结的配置建议
7.1 地址规划要留足余量
不管是bootm、booti还是bootelf,地址规划都是第一位的。我的习惯是:
- 内核加载地址:根据SoC手册推荐值,通常在内存的低地址区域
- 设备树地址:内核地址 + 内核最大可能大小 + 16MB余量
- ramdisk地址:设备树地址 + 1MB余量
这样规划虽然浪费一些内存,但能避免绝大多数地址冲突问题。嵌入式设备的内存通常够用,没必要为了省几MB去冒险。
7.2 善用iminfo和fdt命令做预检
在正式启动前,用iminfo检查uImage头部信息,用fdt print检查设备树内容,用md查看内存数据。这几步花不了几秒钟,但能提前发现大部分问题。
特别是iminfo,它会输出镜像的加载地址、入口地址、数据大小、校验结果。如果加载地址跟你预期的不一样,这里就能发现。
7.3 保留一份可用的启动参数
调试过程中很容易把环境变量改乱。我的做法是:在一切正常的时候,用printenv把关键变量(bootargs、bootcmd、fdt_addr等)记录下来,存到本地文件。一旦改乱了,直接对照恢复。
另外,U-Boot支持env export和env import命令,可以把环境变量导出到内存或从内存导入。调试时用这个功能做备份很方便。
7.4 不同命令的调试输出要会看
bootm启动失败时,U-Boot通常会打印具体原因,比如"Bad Magic Number"、"Bad Header Checksum"、"Bad Data CRC"。这些信息直接指向问题所在,别忽略。
booti的报错相对少一些,因为它不做头部校验。如果booti失败,大概率是地址问题或设备树问题,需要自己用其他命令排查。
bootelf失败时,U-Boot会打印段加载信息。如果某个段加载失败,会显示具体地址和大小。根据这个信息去检查ELF文件的链接脚本。
7.5 跳转前的最后检查清单
在敲下启动命令之前,我一般会快速过一遍这几个点:
- 镜像地址是否正确,是否跟U-Boot自身区域冲突
- 设备树地址是否正确,是否8字节对齐
bootargs里的console参数是否匹配实际串口- 内存节点信息是否跟实际硬件一致
- 如果是压缩镜像,解压后空间是否足够
这几项确认完,启动成功率会高很多。嵌入式调试没有捷径,把链路搞清楚,把细节做到位,问题自然就少了。