拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

嵌入式Linux入门:u-boot引导程序从原理到QEMU实战

嵌入式Linux入门:u-boot引导程序从原理到QEMU实战

1. 从点灯到引导系统:为什么单片机之后要啃 u-boot

很多人学嵌入式的路径都差不多:买块 51 或 STM32 开发板,照着教程点个 LED、读个 DHT11 温湿度、在 LCD1602 上显示两行字符,跑通了就觉得自己“入门”了。这个阶段确实有成就感,但它离真正的嵌入式系统还有一段不小的距离。单片机开发里,你写的代码直接跑在裸机或者一个极简的调度器上,程序入口就是main,内存布局、时钟、外设全由你手动配置,整个系统里只有你这一份代码说了算。

而当你转向嵌入式 Linux 时,情况完全变了。芯片上电后,最先跑起来的不是你写的应用,而是一段叫u-boot的引导程序。它负责初始化 DDR、配置时钟、把内核从存储介质搬到内存里、给内核传参,最后把控制权交出去。可以说,u-boot 是嵌入式 Linux 系统的“第一棒”,它跑不通,后面内核、根文件系统、应用全都无从谈起。这也是为什么很多从单片机转过来的朋友,第一次接触 u-boot 会觉得门槛陡增——它不像点灯那样所见即所得,涉及的概念多、链路长、出错后现象还很隐晦。

我自己的体会是,u-boot 是嵌入式 Linux 学习路上第一个真正的分水岭。跨过去,你对“一个系统是怎么从零启动起来”的理解会上一个台阶;跨不过去,就只能停留在改改设备树、抄抄配置文件的层面。这篇内容就是写给那些已经玩过单片机、想往嵌入式 Linux 深入的朋友,我会把 u-boot 的核心机制、上手路径、以及用 QEMU 模拟 ARM64 来练手的完整方法讲清楚,尽量把每个“为什么”都说明白,让你不只是会敲命令,而是真正理解这套东西在干什么。

需要先明确一点:u-boot 不是唯一的选择,但它是目前嵌入式领域事实上的标准引导程序,支持 ARM、ARM64、RISC-V、MIPS 等大量架构,社区活跃、板级支持丰富。学它,投入产出比很高。

2. u-boot 到底在系统启动链路里扮演什么角色

2.1 从上电到内核接管,中间发生了什么

要理解 u-boot,得先把整个启动链路串起来。以一块典型的 ARM64 开发板为例,上电后的顺序大致是这样的:

  1. 芯片内部固化的 BootROM 代码先运行。这段代码是芯片出厂就烧死的,你改不了。它会根据引脚电平或熔丝位,去固定的地方(比如 SPI Flash、eMMC、SD 卡)找下一段引导代码。
  2. SPL(Secondary Program Loader)被加载进来。因为 BootROM 能加载的代码体积很小,而完整的 u-boot 往往有几百 KB 甚至更大,DDR 这时候还没初始化,没地方放。所以需要一个精简版的 u-boot,也就是 SPL,先把 DDR 初始化好,再把完整的 u-boot 搬到 DDR 里。
  3. 完整的 u-boot(U-Boot Proper)运行起来。这时候内存可用了,u-boot 可以做各种复杂操作:读文件系统、加载内核镜像、解析设备树、设置启动参数。
  4. 加载并跳转到 Linux 内核。u-boot 把内核镜像(Image 或 zImage)和设备树(dtb)放到内存指定位置,然后跳过去执行,自己功成身退。

这个链路里,SPL 和 U-Boot Proper 是同一份源码编译出来的两个阶段,通过配置区分。很多初学者搞不清为什么编译 u-boot 会产出好几个文件,其实就是这个原因。

2.2 它和单片机的启动代码有什么本质区别

单片机里也有启动代码,通常是startup_xxx.s那个汇编文件,负责设置栈指针、初始化中断向量表、跳到main。但它的职责非常单一,而且和你的应用是编译在一起的。u-boot 不一样,它是一个独立的、通用的、可配置的引导程序,和内核、应用是分开编译、分开部署的。

这个区别带来的直接影响是:u-boot 需要一套自己的命令行交互环境、自己的驱动模型、自己的文件系统读取能力。它本质上是一个“微型操作系统”,只不过它的使命是给真正的操作系统铺路。理解了这一点,你就不会奇怪为什么 u-boot 里会有mmc read、fatload、bootm这些命令,也不会奇怪它为什么要支持网络、USB、显示这些看起来“不属于引导程序”的功能。

2.3 为什么值得花时间学它,而不是跳过

有人会问:现在很多芯片厂商提供了打包好的镜像,直接烧录就能启动,我为什么还要学 u-boot?我的回答是:只要你需要做产品定制,就绕不开它。举几个实际场景:

  • 需要修改启动参数,比如调整内核命令行里的console、root参数;
  • 需要支持多种启动介质,比如优先从 SD 卡启动,失败后回退到 eMMC;
  • 需要实现快速启动,裁剪 u-boot 功能、优化加载流程;
  • 需要做固件升级,在 u-boot 阶段实现 A/B 分区切换;
  • 需要调试硬件,用 u-boot 的命令行直接读写寄存器、内存、外设。

这些需求,厂商的通用镜像都满足不了,必须自己动手改 u-boot。而且从学习角度讲,u-boot 源码结构清晰、驱动模型规范,是理解嵌入式系统底层运作的绝佳教材。

3. 用 QEMU 模拟 ARM64 搭一套零成本练手环境

3.1 为什么选 QEMU 而不是先买开发板

学 u-boot 最怕的就是“环境没搭好,先卡三天”。买开发板当然好,但初期你连编译、烧录、串口连接这些流程都不熟,硬件一旦出问题,你根本分不清是代码错了还是板子坏了。QEMU 的好处是:纯软件模拟,不花钱,环境可复现,出错了好排查,而且支持 ARM64 的virt机器,u-boot 官方就带了这个板级的配置。

我建议的路径是:先在 QEMU 上把 u-boot 编译、启动、命令行交互这一整套跑通,建立信心和直觉,再去碰真实硬件。这样你在板子上遇到问题时,至少知道“正常应该是什么样”。

3.2 交叉编译工具链的准备

ARM64 的 u-boot 需要在 x86 主机上交叉编译。工具链的选择上,我推荐用发行版自带的gcc-aarch64-linux-gnu,省去自己编译工具链的麻烦。以 Ubuntu 为例:

sudo apt update sudo apt install gcc-aarch64-linux-gnu bison flex libssl-dev \ device-tree-compiler swig python3-dev

这里几个包的作用值得说明一下:bison和flex是语法分析器生成工具,u-boot 的配置系统依赖它们;libssl-dev用于签名和加密相关功能;device-tree-compiler提供dtc,用来编译设备树;swig和python3-dev是给 u-boot 的构建脚本用的。少装任何一个,编译到一半就会报错,这是新手最常见的卡点。

装完后验证一下:

aarch64-linux-gnu-gcc --version

能打印出版本号就说明工具链就绪。

3.3 拉取源码与选择版本

u-boot 源码可以从官方仓库获取。版本选择上,我建议用较新的稳定版本,比如v2024.01这类带年份的 tag,太老的版本对 QEMU 的支持可能不完善。

git clone https://source.denx.de/u-boot/u-boot.git cd u-boot git checkout v2024.01

如果你网络条件受限,也可以下载官方发布的 tarball。源码目录结构里,arch/arm放架构相关代码,board/放板级支持,drivers/放驱动,configs/放各种板子的默认配置。后面我们会重点和configs/qemu_arm64_defconfig打交道。

3.4 配置与编译 QEMU 专用镜像

QEMU 的 ARM64virt机器在 u-boot 里有现成配置:

make qemu_arm64_defconfig make CROSS_COMPILE=aarch64-linux-gnu- -j$(nproc)

编译完成后,目录下会生成u-boot.bin和u-boot(ELF 格式)。u-boot.bin是纯二进制,可以直接被 QEMU 加载;u-boot带符号信息,方便用 GDB 调试。

这里有个细节:qemu_arm64_defconfig默认可能不生成 SPL,因为 QEMU 的加载方式不需要 SPL 那一套。真实硬件上你往往需要 SPL,但练手阶段先不纠结这个,把主线跑通更重要。

3.5 启动 QEMU 并进入 u-boot 命令行

启动命令如下:

qemu-system-aarch64 \ -machine virt \ -cpu cortex-a57 \ -nographic \ -bios u-boot.bin

参数逐个解释:-machine virt指定虚拟的通用平台;-cpu cortex-a57指定 CPU 型号,u-boot 的 virt 配置默认按这个来;-nographic表示不用图形界面,串口直接接到当前终端;-bios u-boot.bin把 u-boot 当作固件加载。

执行后你应该能看到类似这样的输出:

U-Boot 2024.01 (Jan 01 2024 - 00:00:00 +0000) DRAM: 128 MiB Core: 51 devices, 14 uclasses, devicetree: board Flash: 64 MiB MMC: Loading Environment from nowhere... OK In: serial Out: serial Err: serial Net: No ethernet found. Hit any key to stop autoboot: 0 =>

看到=>提示符,恭喜你,u-boot 跑起来了。这时候你可以敲help看看支持哪些命令,敲bdinfo看板级信息,敲printenv看环境变量。这一步的成就感,不亚于当年点亮第一个 LED。

4. 把 u-boot 命令行当成你的硬件调试台

4.1 环境变量:u-boot 的“配置中心”

u-boot 的环境变量是一组键值对,存在存储介质里(QEMU 里默认没存,所以显示Loading Environment from nowhere)。它决定了 u-boot 启动时做什么,比如bootcmd定义自动启动时执行哪些命令,bootargs定义传给内核的命令行参数。

在命令行里操作环境变量:

=> printenv bootcmd => setenv myvar hello => printenv myvar => saveenv

setenv设置,printenv查看,saveenv保存。注意在 QEMU 默认配置下saveenv可能失败,因为没有可写的环境存储,这不影响练手。

环境变量的价值在于,它把“启动策略”从代码里抽离出来,变成可配置的数据。产品出厂后想改启动行为,往往只需要改环境变量,不用重新编译 u-boot。这是它设计上的一个关键取舍。

4.2 内存操作命令:直接读写物理地址

u-boot 提供了一组内存操作命令,调试硬件时极其有用:

=> md 0x40000000 16 # 从 0x40000000 开始显示 16 个字 => mw 0x40000000 0x12345678 # 写入一个值 => mm 0x40000000 # 交互式修改内存 => cp 0x40000000 0x50000000 0x1000 # 拷贝 4KB

md是 memory display,mw是 memory write,mm是 memory modify,cp是 copy。这些命令让你能像调试器一样直接观察和修改内存。在真实硬件上,如果怀疑某段内存被踩了,或者想验证 DDR 是否正常工作,这几条命令就是你的第一把工具。

4.3 加载与启动内核的完整流程

虽然 QEMU 里我们暂时没有内核镜像,但把流程讲清楚很重要。典型的内核加载启动分三步:

=> load mmc 0:1 0x40000000 Image => load mmc 0:1 0x48000000 virt.dtb => booti 0x40000000 - 0x48000000

第一行从 MMC 设备 0 的分区 1 读取内核镜像到内存0x40000000;第二行读取设备树到0x48000000;第三行booti是启动 ARM64 内核的命令,参数依次是内核地址、initrd 地址(-表示没有)、设备树地址。

这里的内存地址不是随便选的。ARM64 内核通常要求加载地址满足 2MB 对齐,且不能和 u-boot 自身、设备树重叠。0x40000000是很多平台的常规选择,但具体要看芯片的内存映射。选错地址的典型现象是内核解压后跑飞或者直接卡死,排查起来很痛苦,所以一开始就养成查手册确认地址的习惯。

4.4 用 GDB 单步调试 u-boot

QEMU 配合 GDB 可以单步调试 u-boot,这对理解启动流程帮助极大。启动 QEMU 时加上-s -S:

qemu-system-aarch64 -machine virt -cpu cortex-a57 -nographic \ -bios u-boot.bin -s -S

-s表示在 1234 端口开 GDB server,-S表示启动时暂停,等 GDB 连接。另开一个终端:

aarch64-linux-gnu-gdb u-boot (gdb) target remote :1234 (gdb) break board_init_f (gdb) continue

这样你就能在board_init_f、board_init_r这些关键函数上下断点,一步步看 u-boot 是怎么初始化的。我强烈建议至少完整跟一遍board_init_f到board_init_r的流程,跟完之后你对“引导程序到底做了什么”会有完全不一样的认识。

5. 从源码层面拆解 u-boot 的启动阶段

5.1 board_init_f 与 board_init_r 的分工

u-boot 的启动被清晰地切成两个阶段,分界点就是内存是否可用。

board_init_f运行在内存还没初始化好的阶段。它做的是“打地基”:初始化串口(这样你才能看到输出)、设置全局数据结构gd、计算内存布局、准备重定位。这个阶段能用的内存极其有限,所以代码要非常克制。

board_init_r运行在重定位之后,此时 u-boot 已经把自己搬到了内存高端,完整的 C 运行环境可用。它做的是“盖房子”:初始化各种驱动、扫描设备、进入主循环、执行bootcmd。

理解这个分界,你就明白为什么有些代码放在board_init_f里会出问题——那时候内存还没准备好,动态分配、复杂数据结构都用不了。

5.2 重定位:u-boot 为什么要“搬自己”

重定位(relocation)是 u-boot 里一个容易被忽略但很关键的机制。简单说,u-boot 最初可能被加载到内存低端,但它希望最终运行在内存高端,把低端空间让给内核。所以它要把自己的代码和数据整体搬到高端地址,并修正所有绝对地址引用。

这个过程的难点在于:搬的时候,代码还在运行,不能把自己踩坏。u-boot 用了一套精巧的机制,先计算好目标地址和偏移,再逐段拷贝,最后跳转到新地址继续执行。你在 GDB 里跟一遍重定位过程,会看到程序计数器突然跳到一个很高的地址,那就是搬完了。

5.3 驱动模型(DM)与设备树的关系

现代 u-boot 引入了驱动模型(Driver Model,简称 DM),用设备树来描述硬件,用 uclass 来组织驱动。这套机制和 Linux 内核的设备树思路一脉相承。

设备树里描述一个串口:

uart0: serial@9000000 { compatible = "arm,pl011"; reg = <0x0 0x9000000 0x0 0x1000>; clocks = <&clk 0>; };

u-boot 启动时解析设备树,找到compatible匹配的驱动,实例化设备。这样同一份驱动代码可以适配不同板子,板级差异全在设备树里。这个设计的好处是显而易见的:驱动复用率高,板级适配成本低。代价是启动时多了一层解析开销,对启动时间敏感的场景需要权衡。

6. 那些年我在 u-boot 上踩过的坑

6.1 编译报错:工具链和依赖的连环坑

最常见的编译失败是缺依赖。bison: command not found、libssl headers not found、dtc not found,这些报错信息其实很明确,照着装就行。麻烦的是一些隐晦的错误,比如工具链版本不匹配导致的汇编语法错误,或者 Python 版本不对导致的脚本执行失败。

我的经验是:先把官方文档里列出的依赖一次性装全,别等报错了再一个个补。另外,交叉编译前缀一定要写对,CROSS_COMPILE=aarch64-linux-gnu-末尾那个横杠不能少,少了会找不到编译器。

6.2 QEMU 启动后没有任何输出

这是新手最慌的情况:命令敲下去,终端一片空白。排查顺序建议这样:

  1. 确认u-boot.bin真的生成了,且大小合理(通常几百 KB);
  2. 确认 QEMU 命令里的-bios路径正确;
  3. 试试加-d guest_errors看 QEMU 有没有报错;
  4. 换一个 CPU 型号试试,比如-cpu cortex-a53。

我遇到过一次,是因为编译出来的 u-boot 配置里串口驱动没使能,导致没有任何输出。这种情况用 GDB 连上去看,会发现程序其实在跑,只是没打印。所以“没输出”不等于“没运行”,学会用调试器判断程序状态很重要。

6.3 环境变量保存失败与启动卡死

saveenv失败通常是因为没有配置可写的环境存储。在 QEMU 里这很正常,不用管。但在真实硬件上,如果环境存储的偏移地址配错了,可能会把 u-boot 自身覆盖掉,导致下次启动直接变砖。所以改环境存储配置时,一定要对照芯片手册确认 Flash 分区布局。

启动卡死则常见于bootcmd配置错误,比如引用了不存在的设备或文件。这时候可以在自动启动倒计时时按任意键进入命令行,手动一步步执行bootcmd里的命令,定位是哪一步卡住的。

6.4 内存地址选错导致的诡异现象

前面提过内核加载地址的问题,这里再强调一次。地址选错的症状五花八门:有时内核能启动但设备树解析失败,有时直接无输出,有时打印到一半乱码。根因往往是内存区域重叠,u-boot 或设备树被内核覆盖了。

规避方法:画一张内存布局图,把 u-boot 自身、内核、设备树、initrd、保留内存区都标出来,确认互不重叠。这张图在调试任何启动问题时都用得上,值得花十分钟画。

7. 从 u-boot 出发的嵌入式进阶路线

把 u-boot 跑通只是开始,接下来可以沿着几条线继续深入。第一条线是内核移植:让 Linux 内核在你的板子上跑起来,理解设备树如何描述硬件、内核如何初始化驱动。第二条线是根文件系统构建:用 BusyBox 或 Buildroot 做一个最小根文件系统,理解 init 进程、系统服务、库依赖。第三条线是驱动开发:从字符设备驱动入手,逐步深入到平台驱动、设备树匹配、中断处理。

这三条线不是孤立的,它们共同构成一个完整的嵌入式 Linux 系统。而 u-boot 是串起它们的第一环——你在这里建立的“系统从零启动”的直觉,会在后面每一环里反复用到。

如果让我给一个具体的下一步建议,我会说:在 QEMU 上把 u-boot 加载一个真实的 Linux 内核并启动到 shell。这个目标一旦达成,你就真正跨过了嵌入式 Linux 的门槛。过程中你会遇到设备树不匹配、内核命令行参数错误、根文件系统挂载失败等各种问题,每解决一个,你对整个系统的理解就深一层。

最后分享一个我自己的习惯:每次调通一个启动流程,就把关键的命令、地址、配置记到一个笔记里,标注清楚“为什么这么配”。嵌入式这东西细节太多,隔几个月不用就会忘,一份带解释的笔记比任何教程都管用。u-boot 的命令行、环境变量、内存布局这些,尤其值得记下来,因为它们在不同板子之间既有共性又有差异,记多了你自然就能看出规律。

返回列表