1. 从单片机到 u-boot:为什么我劝你尽早跨过这道坎
如果你现在还在用 51 单片机点灯、用 STM32 跑裸机循环,觉得嵌入式也就那么回事,那我接下来要说的话可能会让你有点不舒服:你目前接触的,只是嵌入式世界里最表层的那一小块。真正让嵌入式工程师拉开差距的,不是你会不会写GPIO_SetBits,而是你能不能把一颗 ARM64 芯片从加电那一刻开始,一步步引导到操作系统跑起来。这中间的核心角色,就是u-boot。
我大概带了不下二十个从单片机转过来的兄弟,几乎所有人第一次面对 u-boot 源码时的反应都是一样的:目录一大堆、配置文件看不懂、编译报错、烧进去串口没输出。但一旦你啃下第一块板子,后面的事情就会变得非常顺。u-boot 不只是一个 bootloader,它是你理解嵌入式 Linux 启动链路、ARM64 体系结构、设备树、内存布局、驱动模型的最佳入口。你搞懂了 u-boot,回头再看内核源码,会发现很多设计思路是一脉相承的。
这篇文章我打算把从单片机思维切换到 u-boot 开发的完整路径讲清楚。包括为什么要学、学之前要补哪些课、怎么用QEMU 模拟 ARM64环境零成本上手、u-boot 的启动流程到底分几个阶段、编译配置怎么改、常见问题怎么排查。适合有 C 语言基础、玩过单片机、想往嵌入式 Linux 方向走的同学。不需要你手上有开发板,一台能跑 QEMU 的电脑就够了。
2. 单片机思维和 u-boot 思维,差在哪里
2.1 单片机开发到底在做什么
先把你熟悉的东西捋一遍。51 单片机也好,STM32 也好,本质上你做的事情是:上电后 CPU 从固定地址取第一条指令,这条指令通常在你的启动文件里,然后跳到main函数,你在main里初始化时钟、配置外设、写业务逻辑。整个系统只有你一个程序在跑,内存是你直接操作的物理地址,没有虚拟内存,没有进程调度,没有文件系统。
这种模式的好处是确定性极强。你写P1 = 0xFE,P1 端口的电平立刻变化,中间没有任何抽象层。但问题也很明显:代码规模一大就难以维护,想跑网络协议栈、想接文件系统、想同时处理多个任务,裸机就会变得非常吃力。这就是为什么复杂嵌入式系统最终都要上 Linux。
2.2 u-boot 在系统里扮演什么角色
u-boot 全称 Universal Boot Loader,它做的事情可以类比成 PC 上的 BIOS 加上 GRUB 的组合。芯片上电后,最先运行的是一小段固化在芯片内部的 ROM 代码(BootROM),它负责从启动介质(eMMC、SD 卡、SPI Flash、网络等)加载一小段代码到片内 SRAM 执行。这段代码就是 u-boot 的SPL(Secondary Program Loader)阶段。SPL 的任务很单一:初始化 DDR 控制器,把完整的 u-boot 从存储介质搬到 DDR 里,然后跳过去执行。
完整的 u-boot 跑起来之后,它会初始化串口、网口、存储、USB 等外设,提供命令行交互界面,最后根据环境变量加载 Linux 内核和设备树,把控制权交给内核。所以 u-boot 的核心职责就三件事:初始化硬件、加载内核、传递参数。
这里有个关键点很多人一开始不理解:为什么不能直接上电就跑 Linux 内核?因为内核镜像通常好几 MB,而芯片内部的 SRAM 只有几十到几百 KB,根本放不下。所以必须有一个中间人先把 DDR 初始化好,再把内核搬到大内存里。这个中间人就是 u-boot。
2.3 从单片机转过来需要补的三块知识
第一块是ARM64 体系结构基础。你不需要把 ARM Architecture Reference Manual 几千页全看完,但至少要搞清楚异常级别 EL0 到 EL3 的区别、AArch64 的寄存器布局、MMU 页表的基本概念。这些在 u-boot 的启动汇编代码里到处都会出现。
第二块是设备树(Device Tree)。单片机时代你是在代码里硬编码硬件信息,ARM Linux 体系下硬件描述被抽离成了设备树文件。u-boot 自己也用设备树来描述板级硬件,编译时会用到.dts文件。你得能看懂一个简单的 dts 文件,知道compatible、reg、status这些属性是什么意思。
第三块是交叉编译工具链。单片机你可能用 Keil 或者 IAR 一键编译,u-boot 是在 Linux 环境下用aarch64-linux-gnu-gcc这类交叉编译器构建的。你得熟悉 Makefile 的基本规则、环境变量的设置、链接脚本的作用。
提示:不要试图先把这三块知识全部学完再动手。正确的做法是边做边学,先用 QEMU 把 u-boot 跑起来,遇到不懂的概念再回头查。这样学习效率比啃书高十倍。
3. 用 QEMU 搭建 ARM64 的 u-boot 实验环境
3.1 为什么选 QEMU 而不是买开发板
我见过太多人一腔热血买了块 ARM64 开发板,结果卡在串口驱动装不上、烧录工具不兼容、板子根本起不来,热情三天就耗光了。QEMU 的好处是零成本、可重复、随时快照。你编译出来的 u-boot 二进制直接丢给 QEMU 就能跑,串口输出直接打印在终端里,不需要任何硬件连线。
而且 QEMU 支持-d参数导出 CPU 执行日志,支持 GDB 远程调试,这些在真实硬件上要么做不到,要么需要昂贵的仿真器。对于学习 u-boot 启动流程来说,QEMU 是最合适的工具,没有之一。
3.2 环境准备的具体步骤
我用的环境是 Ubuntu 22.04,其他 Linux 发行版操作类似。先装必要的工具:
sudo apt update sudo apt install -y git build-essential flex bison \ libssl-dev libncurses-dev device-tree-compiler \ gcc-aarch64-linux-gnu qemu-system-arm gdb-multiarch这里每个包都有用:flex和bison是 u-boot 构建系统需要的词法语法分析工具,libssl-dev用于签名相关功能,device-tree-compiler提供dtc命令来编译设备树,gcc-aarch64-linux-gnu是 ARM64 交叉编译器,qemu-system-arm是模拟器本体,gdb-multiarch用于后续调试。
装完之后验证一下交叉编译器:
aarch64-linux-gnu-gcc --version能打印出版本号就说明工具链没问题。接下来拉 u-boot 源码:
git clone https://source.denx.de/u-boot/u-boot.git cd u-boot git checkout v2024.01我建议 checkout 一个稳定的 tag,不要直接用 master,因为 master 上的代码随时在变,可能引入你排查不了的编译问题。v2024.01 是我实测比较稳定的版本。
3.3 QEMU 的 ARM64 虚拟平台选哪个
QEMU 支持好几种 ARM64 虚拟板子,最常用的是qemu_arm64_defconfig对应的virt机器。这个虚拟平台模拟了一颗通用的 ARM64 CPU、一段内存、一个 PL011 串口、一个 GIC 中断控制器。u-boot 官方对它有完整的支持,编译出来直接能跑。
配置和编译命令如下:
make qemu_arm64_defconfig make CROSS_COMPILE=aarch64-linux-gnu- -j$(nproc)编译完成后,在源码根目录下会生成u-boot.bin,这就是我们要交给 QEMU 的文件。整个编译过程大概两三分钟,取决于你的机器性能。
启动命令:
qemu-system-aarch64 -machine virt -cpu cortex-a57 \ -nographic -bios u-boot.bin-nographic把串口重定向到当前终端,-bios指定固件文件。执行后你应该能看到 u-boot 的启动日志,最后停在一个=>提示符前面。恭喜你,你已经成功让 u-boot 在 ARM64 上跑起来了。
4. u-boot 启动流程的深度拆解
4.1 从第一条指令到 board_init_r
u-boot 的启动流程可以分成两个大阶段:SPL 阶段和完整 u-boot 阶段。在 QEMU 环境下,因为不需要真正初始化 DDR,SPL 阶段被简化了,但流程逻辑是一样的。
完整 u-boot 的入口在arch/arm/lib/vectors.S里的_start标号。第一条指令是跳转到reset向量,然后依次执行:设置 CPU 到 EL2 或 EL1、关闭 MMU 和 Cache、设置栈指针、清零 BSS 段、调用board_init_f。
board_init_f是在 DDR 可用之前运行的,它只能使用全局数据区(gd),不能使用全局变量。这个函数的主要工作是初始化串口(让你能看到输出)、计算内存布局、填充 gd 结构体。做完这些之后,它会调用relocate_code把 u-boot 自身重定位到 DDR 的高端地址,然后跳转到board_init_r。
board_init_r运行在 DDR 里,可以使用完整的 C 运行环境。它负责初始化所有外设驱动、扫描存储设备、设置环境变量、最后进入主循环等待用户输入。你在串口里看到的=>提示符,就是主循环的产物。
4.2 关键数据结构 gd 和 bd
u-boot 里有两个非常重要的全局数据结构:gd(global data)和bd(board data)。gd存放的是 u-boot 运行时的全局状态,比如串口设备指针、环境变量地址、重定位偏移量等。bd存放的是板级信息,比如内存起始地址、内存大小、CPU 频率等。
这两个结构体在board_init_f阶段被填充,在board_init_r阶段被大量使用。你如果要在 u-boot 里加自己的调试代码,打印gd->ram_size或者gd->relocaddr是最常用的手段。
4.3 环境变量的加载与保存
u-boot 的环境变量默认保存在存储介质的一个固定偏移处,格式是一个 CRC32 校验头加上一堆key=value字符串。启动时 u-boot 会尝试从CONFIG_ENV_OFFSET指定的位置读取环境变量,如果 CRC 校验失败就加载编译时内置的默认环境变量。
在 QEMU 环境下,默认环境变量是编译进二进制的,你修改后用saveenv保存会提示找不到存储设备。这是正常的,因为虚拟平台没有配置持久化存储。如果你想练习环境变量操作,可以给 QEMU 挂一个虚拟 SD 卡或者用-drive参数挂一个文件作为存储后端。
5. 实操:从零编译到串口交互的完整过程
5.1 编译配置的定制化修改
默认的qemu_arm64_defconfig已经够用了,但如果你想体验真实的开发流程,可以试着改一些配置。比如打开CONFIG_CMD_BOOTI支持直接启动 Linux 内核镜像,打开CONFIG_CMD_FDT支持设备树操作。
修改配置有两种方式:直接编辑configs/qemu_arm64_defconfig文件,或者用make menuconfig图形界面。我推荐后者,因为菜单界面能看到每个选项的依赖关系和帮助说明,对理解配置项很有帮助。
make CROSS_COMPILE=aarch64-linux-gnu- menuconfig进去之后你可以看到 u-boot 的配置系统有多庞大,从 CPU 架构到具体驱动,从命令支持到调试选项,几千个配置项。不要被吓到,你只需要关注Command line interface和Device Drivers这两大类就够了。
5.2 启动日志的逐行解读
当你执行 QEMU 启动命令后,串口会打印出一大段日志。我挑几个关键行解释一下:
U-Boot 2024.01 (Jan 15 2024 - 10:30:00 +0800)这是版本和编译时间。如果你改了代码重新编译,时间会更新,可以用来确认你烧进去的是不是最新版本。
DRAM: 128 MiB这是 QEMU virt 平台默认的内存大小。这个值来自设备树里的 memory 节点,u-boot 解析设备树后填充到 gd 结构体里。
Core: 12 devices, 8 uclasses, devicetree: separate这行说明 u-boot 的驱动模型扫描到了 12 个设备,归入 8 个 uclass。uclass 是 u-boot 驱动模型的核心概念,类似 Linux 的设备类,把功能相似的驱动归在一起统一管理。
Loading Environment from nowhere... OK前面说过,QEMU 环境没有持久化存储,所以环境变量是从内置的默认值加载的。
In: serial Out: serial Err: serial标准输入输出都绑定到了串口,这就是为什么你能在终端里和 u-boot 交互。
5.3 常用命令的实操演示
进入=>提示符后,你可以试试这些命令:
=> bdinfo打印板级信息,包括内存起始地址、大小、重定位后的 u-boot 地址、环境变量地址等。这个命令是排查启动问题的第一选择。
=> printenv打印所有环境变量。你会看到baudrate、bootcmd、bootargs等关键变量。bootcmd是自动启动时执行的命令序列,bootargs是传给 Linux 内核的启动参数。
=> md 0x40000000 0x10从内存地址 0x40000000 开始显示 16 个字。md是 memory display 的缩写,调试时用来查看内存内容非常方便。
=> mm 0x40000000交互式修改内存。输入地址后会提示你输入新值,回车确认,输入q退出。这个命令在验证寄存器写入时很有用。
=> help列出所有可用命令。不同配置下命令数量不同,默认的 qemu_arm64 配置大概有七八十条命令。
6. 常见问题排查与避坑经验
6.1 编译报错的高频原因
报错一:aarch64-linux-gnu-gcc: command not found
原因是你没装交叉编译器,或者装了但没在 PATH 里。用which aarch64-linux-gnu-gcc确认一下。如果确实装了但找不到,检查你的~/.bashrc里有没有把工具链路径加进去。
报错二:fatal error: openssl/ssl.h: No such file or directory
缺少libssl-dev。u-boot 的签名和加密功能依赖 OpenSSL 头文件。装上就好。
报错三:ERROR: You must install dtc
缺少设备树编译器。sudo apt install device-tree-compiler解决。
报错四:编译到一半提示multiple definition of xxx
这种通常是你的编译环境里残留了上次编译的目标文件,而源码结构发生了变化。执行make distclean彻底清理后重新配置编译。
6.2 QEMU 启动无输出的排查思路
这是新手最常遇到的问题:命令敲下去,终端一片空白,没有任何输出。排查顺序如下:
第一步,确认u-boot.bin文件存在且大小合理。正常的 ARM64 u-boot 二进制大概几百 KB 到 1 MB 左右。如果只有几 KB,说明编译没成功。
第二步,确认 QEMU 命令参数正确。-machine virt和-cpu cortex-a57是必须的,-nographic也是必须的,否则串口输出不会重定向到终端。
第三步,加-d in_asm参数看 QEMU 是否在执行指令。如果日志里全是 0 地址的指令,说明 u-boot 的入口地址和 QEMU 期望的不一致。
第四步,用 GDB 连上去看 PC 指针停在哪里。启动 QEMU 时加-s -S参数,然后用gdb-multiarch连接localhost:1234,执行info registers看 CPU 状态。
6.3 环境变量保存失败的解决
在 QEMU 里执行saveenv大概率会报错,因为默认没有配置可写的存储后端。解决办法是给 QEMU 挂一个虚拟磁盘:
qemu-system-aarch64 -machine virt -cpu cortex-a57 \ -nographic -bios u-boot.bin \ -drive file=env.img,format=raw,if=none,id=envdisk \ -device virtio-blk-device,drive=envdisk然后需要在 u-boot 配置里打开CONFIG_ENV_IS_IN_MMC或CONFIG_ENV_IS_IN_VIRTIO,并设置正确的偏移量。这个操作稍微复杂一点,但走通一次之后你就理解了环境变量持久化的完整链路。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 编译报错找不到编译器 | 工具链未安装或 PATH 未配置 | which aarch64-linux-gnu-gcc |
| 编译报错缺头文件 | 依赖库未安装 | 根据报错信息安装对应 dev 包 |
| QEMU 启动无输出 | 参数错误或二进制损坏 | 检查文件大小,加-d in_asm |
| 串口输出乱码 | 波特率不匹配 | QEMU 默认 115200,检查配置 |
| saveenv 失败 | 无持久化存储 | 挂载虚拟磁盘并配置环境变量存储 |
| GDB 连不上 | 未加-s -S参数 | 启动命令加上调试参数 |
注意:每次修改 u-boot 配置后,一定要先
make distclean再重新make xxx_defconfig,否则旧的配置缓存会导致各种诡异问题。这个坑我踩过不止一次。
7. 从 u-boot 出发,下一步该往哪走
7.1 用 u-boot 加载 Linux 内核
u-boot 跑通之后,最自然的下一步是让它加载一个真实的 Linux 内核。你需要准备三个东西:内核镜像Image、设备树文件qemu-arm64.dtb、以及一个根文件系统。QEMU 环境下可以用-kernel参数直接加载内核,但那样就绕过了 u-boot。正确的做法是在 u-boot 命令行里用booti命令手动加载。
具体操作是先用tftp或者load命令把内核镜像和设备树加载到内存,然后执行:
=> booti 0x40080000 - 0x44000000第一个参数是内核镜像地址,第二个参数是 initrd 地址(没有就写-),第三个参数是设备树地址。执行后 u-boot 会把控制权交给内核,你会看到内核启动日志刷屏。
7.2 阅读 u-boot 源码的正确姿势
u-boot 源码有几十万行,不要试图从头读到尾。正确的做法是跟着启动流程读。从_start开始,一路跟到board_init_r,把这条主线上调用的每个关键函数都看一遍。遇到不认识的函数就跳进去看实现,看完再回来继续。
我建议重点读这几个文件:arch/arm/lib/vectors.S(入口汇编)、common/board_f.c(board_init_f 实现)、common/board_r.c(board_init_r 实现)、drivers/serial/serial-uclass.c(串口驱动模型)。把这四个文件吃透,u-boot 的骨架你就掌握了。
7.3 驱动模型和 Linux 的异同
u-boot 的驱动模型(DM)和 Linux 的设备模型在设计理念上很像,都是通过设备树匹配驱动、通过 uclass/class 统一管理同类设备。但 u-boot 的 DM 更轻量,没有电源管理、没有热插拔、没有复杂的引用计数。
如果你以后要写 Linux 驱动,先在 u-boot 里写一个简单的 DM 驱动是很好的练手。u-boot 的驱动代码量小、逻辑清晰,调试也方便,适合用来理解设备树匹配、probe 流程、uclass 操作这些核心概念。
7.4 进阶方向:安全启动与固件更新
u-boot 还支持安全启动(Verified Boot)和固件更新(DFU、fastboot)。安全启动的核心是对内核镜像做签名校验,防止被篡改。固件更新则是通过 USB 或者网络把新固件写入存储介质。这两个方向在实际产品中非常重要,但学习曲线也比较陡,建议先把基础启动流程搞扎实再碰。
我个人在实际操作中的体会是,u-boot 这个东西入门确实有门槛,但它的知识密度极高。你花两周时间啃下来的东西,可能比你在单片机上折腾两个月学到的还多。而且这些知识是通用的,换任何一块 ARM64 板子,启动流程、驱动模型、环境变量机制都是一样的。最后再分享一个小技巧:把 QEMU 的启动命令写成一个 shell 脚本,每次改完代码一键编译加启动,能省下大量重复劳动的时间。