ARTICLE DETAIL

资讯详情

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

从单片机到u-boot:用QEMU零成本入门ARM64嵌入式Linux

从单片机到u-boot:用QEMU零成本入门ARM64嵌入式Linux 1. 从单片机到 u-boot为什么我劝你尽早跨过这道坎如果你现在还在用 51 单片机点灯、用 STM32 跑裸机循环觉得嵌入式也就那么回事那我先说一句可能不太中听的话你摸到的只是嵌入式世界的门口地毯真正的客厅还在后面。我当年也是从 51 单片机入门焊过 3461BS 数码管、接过 LCD1602、调过 DHT11 温湿度那种我让硬件听我话的成就感确实很爽。但当我第一次面对一块 ARM64 开发板串口里刷出几百行启动日志、最后停在一个提示符前时我才意识到单片机和嵌入式 Linux 之间隔着一个叫 u-boot 的东西而它才是真正把你从写代码的人变成懂系统的人的分水岭。这篇内容我想聊的就是这个跨越。核心关键词是u-boot、嵌入式、单片机、ARM64、QEMU。我会讲清楚 u-boot 到底是个什么东西、它解决了什么问题、为什么值得你从单片机阶段主动跳出来学它以及怎么用 QEMU 在电脑上零成本模拟一块 ARM64 板子把 u-boot 从源码编译到跑起来、再到加载内核的完整链路走一遍。适合谁看适合已经会点 C 语言、玩过至少一款单片机、想往嵌入式 Linux 方向走但一直卡在不知道从哪下手的朋友。也适合那些面试被问到u-boot 启动流程就卡壳、想补上这块短板的人。我不打算写成教科书。市面上讲 u-boot 的资料要么是芯片原厂的几百页手册要么是抄来抄去的博客真正把为什么这么设计讲透的不多。我想用从业者之间聊天的口吻把踩过的坑、绕过的弯、以及那些文档里不会写的经验一次性摊开讲。你不需要一块真实的开发板一台装了 Linux 的电脑或者一个虚拟机就够了QEMU 会帮我们把 ARM64 环境搭起来。往下看我们从为什么开始。2. 单片机思维和 u-boot 思维差的到底是什么2.1 单片机到底帮你屏蔽了什么先别急着否定单片机。51、STM32 这些芯片教会你的东西是实打实的寄存器操作、中断、定时器、GPIO 时序、外设驱动。你写一个 LCD1602 显示程序本质上是在跟时序图打交道你调一个 DHT11是在跟单总线协议死磕。这些经验非常宝贵因为它们让你对硬件是活的、会不听话这件事有肌肉记忆。但单片机有一个巨大的善意谎言它把整个系统的控制权都交给了你一个人。上电之后CPU 从固定地址取第一条指令你的main函数就是世界的中心内存是你随便用的没有操作系统跟你抢资源没有别的程序在后台跑。你写的代码就是唯一的代码。这种独裁式的编程模型简单、直接、可控但也让你对一个系统如何被组织起来缺乏概念。当你换到一块跑 Linux 的 ARM64 板子情况完全变了。上电那一刻DDR 内存还没初始化时钟还没配好存储设备还没被识别CPU 甚至可能还在一个非常原始的频率上跑。这时候没有任何操作系统能帮你你需要一段比操作系统还早的代码把硬件从刚通电的砖头状态一步步带到可以交给 Linux 内核的状态。这段代码就是bootloader而 u-boot 是目前嵌入式领域用得最广、生态最成熟的那一个。2.2 u-boot 不是高级单片机程序很多人第一次接触 u-boot会下意识把它当成一个功能更多的单片机程序。这个理解会让你走很多弯路。u-boot 确实是用 C 写的、确实操作寄存器、确实跑在裸机上但它和单片机程序在目标上有本质区别。单片机程序的目标是完成一个具体功能——点灯、测温、驱动电机。u-boot 的目标是把系统启动起来然后把自己让出去。它是一个过渡者一个接力赛里的第二棒。它要做的事情包括初始化 DDR 和时钟、把内核镜像从存储介质搬到内存、准备设备树、给内核传参、最后跳转到内核入口。做完这些u-boot 的使命就结束了它甚至可以从内存里消失。这个让出去的思维是单片机阶段很难体会到的。你写单片机程序程序是主角你写 u-bootu-boot 是配角主角是内核。理解这一点你才能理解为什么 u-boot 的代码结构那么奇怪——为什么有那么多板级配置、为什么有board_init_f和board_init_r两个阶段、为什么要有 SPL 这种东西。这些设计全都是为了在资源极度受限的早期阶段稳妥地把接力棒交出去。2.3 为什么 ARM64 让这件事更有意思ARM64也叫 AArch64是 64 位 ARM 架构和你在 STM32 上玩的 Cortex-M 系列完全不是一个量级。它支持更大的地址空间、更复杂的异常等级Exception LevelEL0 到 EL3、更规范的启动约定。ARM64 的启动流程里CPU 上电后通常从 EL3 或 EL2 开始需要逐级往下切到 EL1 才能跑内核。u-boot 在 ARM64 上要处理这些异常等级的切换还要处理 PSCI电源状态协调接口这类固件层的约定。听起来很吓人其实对学习者来说ARM64 反而是个好消息。因为它的启动约定比 32 位 ARM 规范得多文档也齐全而且 QEMU 对 ARM64 的模拟支持非常成熟。你不需要买一块几百上千块的开发板用 QEMU 就能模拟出一台virt机器把 u-boot 跑起来。这就是我推荐从 QEMU ARM64 入手学 u-boot的核心原因门槛低、可复现、不烧钱、不怕把板子刷成砖。3. 用 QEMU 搭一个 ARM64 的 u-boot 实验台3.1 为什么选 QEMU 而不是真板子先说结论学 u-boot 的前期QEMU 比真板子好。原因有三条。第一可复现。真板子刷坏了要救砖救砖要另一套工具链新手很容易卡在这一步就放弃了。QEMU 里你把镜像删了重来就行零成本试错。第二可观测。QEMU 支持-d参数导出 CPU 执行日志、中断日志配合 GDB 可以单步调试 u-boot 的每一条指令这在真板子上要么做不到要么需要昂贵的仿真器。第三干净。真板子的启动链路里往往还夹着厂商的闭源固件比如各种 BL1/BL2你看不到也改不了QEMU 的virt机器是开源的从第一条指令开始全透明。当然QEMU 也有短板它模拟不了真实硬件的时序问题、电源管理、以及各种玄学的硬件 bug。所以我的建议是用 QEMU 把 u-boot 的启动流程、命令、加载内核的机制吃透然后再上真板子去啃那些硬件相关的坑。顺序反了你会被硬件问题淹没根本学不到 u-boot 本身。3.2 环境准备工具链和依赖我用的环境是 Ubuntu 22.04其他发行版大同小异。先装交叉编译工具链和 QEMUsudo apt update sudo apt install -y gcc-aarch64-linux-gnu \ qemu-system-arm \ bison flex libssl-dev \ device-tree-compiler \ git make bc这里解释几个关键包。gcc-aarch64-linux-gnu是 ARM64 的交叉编译器前缀是aarch64-linux-gnu-因为我们在 x86 主机上编译出 ARM64 能跑的代码。qemu-system-arm里其实包含了qemu-system-aarch64能模拟 ARM64 机器。device-tree-compiler提供dtc命令用来编译设备树。bison和flex是 u-boot 的 Kconfig 和构建系统需要的。注意不要用发行版自带的u-boot包去学那个是给特定板子预编译好的你改不了源码也看不到构建过程。一定要自己从源码编译。验证一下工具链aarch64-linux-gnu-gcc --version qemu-system-aarch64 --version两条命令都能输出版本号环境就算齐了。3.3 拉取 u-boot 源码并选对版本u-boot 的源码在官方 git 仓库。我建议用稳定 tag不要用 master因为 master 天天变你照着教程做可能对不上。git clone https://source.denx.de/u-boot/u-boot.git cd u-boot git checkout v2024.01为什么选v2024.01因为它是 LTS 性质的版本QEMU 的qemu_arm64_defconfig在这个版本上很稳定社区文档也全。你如果追新遇到构建报错会很难查。3.4 配置和编译一条命令背后的门道QEMU 的 ARM64virt机器在 u-boot 里有现成的配置make qemu_arm64_defconfig make CROSS_COMPILEaarch64-linux-gnu- -j$(nproc)编译完你会得到u-boot.bin和u-bootELF 格式。这里有个细节值得说qemu_arm64_defconfig默认会生成SPLSecondary Program Loader吗在较新版本里QEMU 的 ARM64 配置通常直接生成一个能在0x40000000加载的u-boot.bin不需要 SPL因为 QEMU 的virt机器上电后 DDR 已经是可用的不需要 u-boot 自己去初始化内存控制器。这是 QEMU 和真板子的一个重要区别——真板子上你几乎一定要处理 SPL 和 DDR 初始化QEMU 帮你省了这一步。实操心得如果你编译时报No rule to make target qemu_arm64_defconfig说明你的 u-boot 版本太老或者太新配置名变了。用ls configs/ | grep qemu看一下实际有哪些 QEMU 配置选对应的那个。3.5 第一次启动看到提示符启动命令qemu-system-aarch64 \ -M virt \ -cpu cortex-a57 \ -nographic \ -bios u-boot.bin逐参数解释。-M virt指定模拟一台通用的虚拟 ARM64 机器这是 QEMU 专门为跑虚拟化/引导程序设计的硬件布局固定且文档齐全。-cpu cortex-a57指定 CPU 型号cortex-a57 是经典的 ARM64 核u-boot 对它的支持很成熟。-nographic把串口重定向到当前终端这样你能直接在终端里看到 u-boot 的输出并输入命令。-bios u-boot.bin把 u-boot 当作固件加载QEMU 会从它开始执行。如果一切顺利你会看到类似这样的输出U-Boot 2024.01 (Jan 01 2024 - 00:00:00 0000) DRAM: 128 MiB Core: 51 devices, 14 uclasses, devicetree: board Flash: 64 MiB Loading Environment from nowhere... OK In: serial Out: serial Err: serial Net: No ethernet found. Hit any key to stop autoboot: 0 看到了吗恭喜你已经站在 u-boot 的命令行里了。这个提示符意味着 u-boot 完成了板级初始化正在等你下命令。它本来会尝试自动启动内核但因为我们没提供内核镜像它超时后落到了命令行。4. u-boot 命令行那些你必须亲手敲一遍的命令4.1 先学会看信息类命令进了 u-boot 命令行第一件事不是急着启动内核而是学会观察。bdinfo打印板级信息包括内存起始地址、大小、环境变量位置 bdinfo boot_params 0x0000000000000000 DRAM bank 0x0000000000000000 - start 0x0000000040000000 - size 0x0000000008000000 ...这里start 0x40000000、size 0x08000000128MB就是 QEMU 给的内存布局。记住这个0x40000000后面加载内核要用。printenv打印所有环境变量这是 u-boot 最核心的机制之一。环境变量存在一块独立的存储区通常是 flash 或 eMMC 的一个分区保存着启动参数、启动命令、IP 地址等。你可以用printenv bootcmd单独看启动命令 printenv bootcmd bootcmdrun distro_bootcmddistro_bootcmd是 u-boot 的发行版启动脚本它会依次扫描各种存储设备MMC、USB、网络找符合规范的启动文件。这个机制让同一份 u-boot 能启动不同发行版是 u-boot 生态强大的原因之一。4.2 内存操作md、mw、mm学 u-boot 一定要会操作内存因为加载内核本质上就是往内存里写数据。mdmemory display读内存 md 0x40000000 10 40000000: 00000000 00000000 00000000 00000000 ................mwmemory write写内存 mw 0x40000000 0xdeadbeef 4 md 0x40000000 4 40000000: deadbeef deadbeef deadbeef deadbeef ................mmmemory modify是交互式修改会一个个地址问你新值。这些命令看起来简单但它们是理解u-boot 如何加载内核的基础——内核镜像就是被cp或load命令搬到内存某个地址然后跳过去执行。注意在 QEMU 里你随便写内存没事但在真板子上往错误地址写数据可能直接导致总线错误甚至锁死。养成先bdinfo确认内存范围再操作的习惯。4.3 存储与文件系统从哪加载内核u-boot 支持从 MMC、NAND、SPI Flash、网络TFTP、USB 等多种介质加载。QEMU 的virt机器上最方便的是用-drive挂一个虚拟 SD 卡或者用-kernel直接加载。但为了学 u-boot 的加载流程我建议用虚拟 SD 卡走一遍fatload或ext4load。先看设备 mmc list mmca0030000: 0如果 QEMU 启动时加了-drive ifnone,filesd.img,formatraw,idhd0 -device virtio-blk-device,drivehd0你会看到 virtio 块设备。u-boot 里用virtio相关命令操作。这里不展开因为 QEMU 的存储配置本身就能写一篇。更简单的学习路径是用-kernel让 QEMU 直接加载内核先跳过 u-boot 的加载环节专注理解 u-boot 的启动流程。等你把启动流程吃透了再回来折腾存储加载。4.4 环境变量的保存与环境分区前面说环境变量存在独立存储区。在 QEMU 上如果你没配置环境分区u-boot 会提示Loading Environment from nowhere... OK意思是它用了默认环境改动不会保存。想保存的话需要给 QEMU 挂一块带环境分区的存储并在 u-boot 配置里指定CONFIG_ENV_IS_IN_...。这个机制值得单独说因为它是新手最容易困惑的地方之一为什么我setenv之后重启就没了因为环境变量默认在 RAM 里saveenv才会写回存储。而写回的目标存储取决于编译时的配置。真板子上通常是 eMMC 的某个偏移或 SPI Flash 的某个扇区。理解这一点你才能理解为什么不同板子的 u-boot 环境变量存活行为不一样。5. 从 u-boot 到内核启动流程的完整拆解5.1 u-boot 的两阶段启动board_init_f 和 board_init_ru-boot 的启动分两个大阶段这是理解它的关键。第一阶段board_init_f跑在重定位之前。此时 u-boot 可能还在只读存储如 SPI Flash 的 XIP 模式里执行可写内存还没准备好。这个阶段做的事情很有限初始化串口好让你能看到输出、初始化时钟、设置早期的内存分配器、然后重定位——把 u-boot 自己从当前位置搬到 DDR 的高端地址。为什么要重定位因为 u-boot 最终要加载内核到 DDR 的低端地址比如0x40080000如果 u-boot 自己占着低端内存就会跟内核冲突。所以它先把自己搬到高端把低端腾出来给内核。这个自己搬自己的操作是 bootloader 里最精妙的设计之一。第二阶段board_init_r跑在重定位之后此时 u-boot 已经在 DDR 里可以正常使用内存。这个阶段初始化各种外设驱动MMC、USB、网络、解析环境变量、执行bootcmd。你看到的提示符就是在这个阶段出现的。5.2 加载内核镜像格式和加载地址ARM64 Linux 内核编译出来通常是Image未压缩或Image.gz压缩。u-boot 加载内核的典型流程是把内核镜像从存储读到 DDR 的某个地址比如0x40080000。把设备树.dtb读到另一个地址比如0x4a000000。用booti命令启动booti 0x40080000 - 0x4a000000。booti是专门启动 ARM64 Linux 内核的命令bootz是 32 位 ARM 的bootm是老的 uImage 格式。中间的-表示 initrd 地址没有就填-。为什么内核加载地址是0x40080000而不是0x40000000因为 ARM64 Linux 内核要求自己加载在内存起始地址 0x80000512KB的位置前面那 512KB 留给内核自己的页表和其他用途。这个偏移是内核约定的写死在Documentation/arm64/booting.rst里。你如果加载到错误地址内核启动会直接挂掉而且报错信息往往很隐晦。5.3 设备树内核怎么知道硬件长什么样设备树Device Tree是 ARM Linux 的核心机制。x86 平台有 ACPI 和 BIOS 帮你枚举硬件ARM 平台没有统一的固件标准所以用设备树来描述这块板子上有什么硬件、寄存器地址在哪、中断号是多少。u-boot 的职责之一就是把正确的设备树传给内核。在 QEMU 上设备树可以由 QEMU 自己生成-machine dumpdtb也可以由 u-boot 提供。u-boot 里可以用fdt系列命令操作设备树 fdt addr 0x4a000000 fdt print /fdt print会把设备树的内容打印出来你能看到compatible、reg、interrupts这些属性。理解设备树你就理解了 ARM 嵌入式 Linux 一半的玄学——为什么换个板子要换 dtb、为什么驱动匹配不上、为什么内存大小识别错了。5.4 用 QEMU 完整跑一遍u-boot 加载内核要完整跑通你需要一个 ARM64 内核镜像和一个根文件系统。最省事的办法是用现成的发行版内核比如从 Ubuntu 的 ARM64 cloud image 里提取vmlinuz和initrd。或者你自己用make defconfig make Image编译一个最小内核需要装gcc-aarch64-linux-gnu。假设你有Image和qemu-arm64.dtb把它们放进一个目录用 QEMU 的-drive挂成 virtio 块设备然后在 u-boot 里 virtio scan ls virtio 0:0 / load virtio 0:0 0x40080000 Image load virtio 0:0 0x4a000000 qemu-arm64.dtb booti 0x40080000 - 0x4a000000如果内核配置正确你会看到内核启动日志刷屏最后出现登录提示。这一刻你亲手走完了上电 → u-boot → 内核 → 用户空间的完整链路。这种感觉和当年点亮第一个 LED 是一样的但层次完全不同。实操心得booti启动失败最常见的原因是设备树不匹配。内核启动早期如果卡在Starting kernel ...之后没有任何输出八成是串口地址在设备树里不对或者内核根本没配对应的串口驱动。用 QEMU 的-d int看异常日志或者用 GDB 连上去单步能快速定位。6. 踩坑实录那些文档不会告诉你的问题6.1 编译报错Kconfig 和 Python 依赖u-boot 的构建系统依赖 Python 3 和一堆 Python 包。如果你编译时报ModuleNotFoundError: No module named setuptools装一下python3-setuptools和python3-pyelftools。较新版本的 u-boot 还需要swig和libpython3-dev。这些依赖在官方文档里散落在各处新手很容易卡在第一步。另一个常见坑是dtc版本太老。u-boot 对设备树编译器的版本有要求Ubuntu 自带的通常够用但如果你从源码装了老版本dtc会报语法错误。用dtc --version确认建议 1.6.0 以上。6.2 QEMU 启动无输出串口没配对第一次跑 QEMU最常见的现象是命令执行了但终端一片空白。原因通常是串口没配好。QEMU 的virt机器默认串口是ttyAMA0u-boot 的qemu_arm64_defconfig默认也用它。如果你自己改了配置或者用了别的机器类型串口就对不上了。排查方法加-serial mon:stdio明确把串口和监视器都接到标准输入输出或者用-serial telnet:localhost:4321,server,nowait把串口转到 telnet用另一个终端连上去看。后者在调试时更方便因为不会和 QEMU 的监视器抢输入。6.3 环境变量保存失败存储没配对前面提过saveenv失败通常是因为没配置环境存储。在 QEMU 上如果你想保存环境变量需要给 u-boot 配置一个可写的环境后端。最简单的是用CONFIG_ENV_IS_IN_FAT配合一个 FAT 分区或者用CONFIG_ENV_IS_NOWHERE不保存仅内存。真板子上更常见的是CONFIG_ENV_IS_IN_MMC环境变量存在 eMMC 的某个偏移。这个偏移如果和分区表冲突会导致环境变量损坏甚至启动失败。我踩过一次坑环境偏移设在了分区表所在扇区结果saveenv把分区表覆盖了板子直接起不来。后来用CONFIG_ENV_OFFSET和CONFIG_ENV_SIZE仔细算避开分区表才解决。6.4 常见问题速查表现象可能原因排查方向QEMU 启动无任何输出串口未配对检查-nographic和 u-boot 串口配置编译报 Python 模块缺失依赖未装装python3-setuptools、pyelftoolsbooti后卡在 Starting kernel设备树或加载地址错确认内核加载在0x80000dtb 匹配saveenv失败环境存储未配置检查CONFIG_ENV_IS_IN_*内核识别内存大小错误设备树 memory 节点错fdt print /memory核对u-boot 找不到 MMC驱动未编入检查 defconfig 里的 MMC 相关配置6.5 独家避坑技巧第一永远保留一份能工作的配置。我习惯在改 defconfig 之前先cp .config .config.bak改崩了直接恢复比重头配快得多。第二用make menuconfig而不是手改 defconfigmenuconfig 会帮你处理依赖关系手改容易漏掉select的项。第三QEMU 的-d日志是你的朋友-d int,cpu_reset能让你看到异常和复位很多莫名其妙重启的问题一看日志就明白了。7. 从会用到懂u-boot 之后的路怎么走把 u-boot 跑起来、能加载内核只是第一步。真正让你从会用变成懂的是去读它的源码。我建议的路径是先看board_init_f和board_init_r的调用链理解启动流程再看drivers/mmc和drivers/net理解 u-boot 的驱动模型UCLASS/UDATA 那套最后看common/bootm.c和cmd/booti.c理解启动内核的完整逻辑。u-boot 的驱动模型是它和单片机程序最大的区别之一。它引入了类似 Linux 的uclass概念把驱动按类别组织支持设备树匹配。这套模型一开始会觉得绕但理解了之后你会发现它让 u-boot 能支持成百上千种板子而不至于代码爆炸。这正是系统级思维的体现——不是把所有功能堆在一个文件里而是用抽象和分层来管理复杂度。我个人在实际操作中的体会是学 u-boot 最大的收获不是学会了一个工具而是学会了一个系统如何从零被组织起来的思维方式。这种思维你回头再看单片机项目会发现很多以前没想过的问题——比如启动顺序、资源竞争、错误恢复。它甚至会改变你写应用层代码的习惯让你更在意边界和异常。最后再分享一个小技巧如果你觉得 QEMU 太干净、学不到真板子的坑可以找一块便宜的 ARM64 开发板比如基于 Allwinner 或 Rockchip 的方案把 QEMU 上学到的流程在真板子上复现一遍。你会发现真板子上的 u-boot 往往还夹着厂商的 SPL 和闭源 DDR 初始化这时候你之前学的两阶段启动知识就派上用场了——你能看懂那些日志在说什么也能判断问题出在哪一层。这个内容后续还可以这样扩展往上是 ATFARM Trusted Firmware和 PSCI往下是 Linux 内核的head.S和早期初始化整条链路打通你就真正跨过了嵌入式的门槛。
返回列表