ARTICLE DETAIL

资讯详情

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

从单片机到ARM64:U-Boot启动链路与QEMU实战指南

从单片机到ARM64:U-Boot启动链路与QEMU实战指南 1. 从点灯到引导内核为什么单片机老手该碰一碰U-Boot如果你已经能闭着眼在51或者STM32上点灯、跑通DHT11、把LCD1602调出字符甚至用状态机写过几个像样的裸机项目那么你大概率正站在一个分水岭上。往前一步是Linux应用层开发往后一步是继续在裸机世界里堆功能。而横在中间那道坎就是U-Boot。很多人对嵌入式的理解停留在“单片机就是嵌入式”的阶段这话对但也不全对。单片机开发让你掌握了寄存器操作、中断向量、时序控制这些底层硬功夫但当你面对一颗Cortex-A系列的ARM64芯片比如全志、瑞芯微或者NXP的i.MX系列你会发现之前那套“上电就跑main函数”的思维完全不够用了。因为这类芯片上电后第一段执行的代码不是你的而是BootROM接着是SPL再接着才是U-Boot最后才轮到Linux内核。U-Boot就是那个承上启下的关键角色它负责初始化DDR、加载内核镜像、传递设备树、挂载根文件系统把一颗冷冰冰的芯片变成一个能跑操作系统的平台。我见过太多从单片机转过来的朋友第一次拿到ARM64开发板时一脸茫然串口打印一堆乱码SD卡插上去没反应内核根本起不来。问题往往不在内核而在U-Boot这一层没搞明白。U-Boot不是“另一个单片机程序”它是一个微型操作系统级的引导加载器有自己的命令行、环境变量、驱动模型、设备树解析逻辑。你如果还用写裸机的心态去读它的代码会非常痛苦。这篇文章就是写给那些已经玩腻了单片机、想往嵌入式Linux方向深入的人。我不会泛泛而谈“嵌入式学习路线”而是把U-Boot从编译、烧录、调试到加载内核的完整链路拆开结合ARM64架构的特点把那些文档里不会写的坑和技巧讲清楚。你不需要先精通Linux内核但你需要有C语言基础和基本的硬件概念。读完你至少能明白为什么U-Boot的启动分两阶段、设备树在U-Boot里怎么用、环境变量存哪里、以及怎么用QEMU模拟ARM64来练手而不烧板子。2. U-Boot启动链路拆解从BootROM到内核交接2.1 上电后的第一段代码为什么不是U-BootARM64芯片上电后PC指针指向的是芯片内部固化的一段ROM代码叫BootROM。这段代码是芯片原厂写死的你改不了。它的任务非常有限根据启动引脚比如SD卡、eMMC、SPI Flash去加载一小段外部代码到片内SRAM里。注意这时候DDR还没初始化所以能加载的代码大小非常受限通常只有几十KB。这段被加载的代码就是SPLSecondary Program Loader。SPL是U-Boot编译产物的一部分它的核心使命只有一个初始化DDR控制器。DDR初始化是一套非常复杂的时序训练过程涉及大量的寄存器配置和参数校准。SPL跑在片内SRAM里把DDR调通之后才能把完整的U-Boot镜像加载到DDR中运行。所以完整的链路是BootROM → SPL → U-Boot Proper → Linux Kernel。很多新手编译完U-Boot后只烧了一个u-boot.bin结果板子没反应就是因为漏掉了SPL。在ARM64平台上通常需要烧录的是u-boot-spl.bin和u-boot.itb两个文件或者一个打包好的u-boot-with-spl.bin。具体取决于芯片厂商的打包工具比如全志的mksunxi、瑞芯微的loader工具。注意不同芯片的SPL加载地址和DDR初始化参数完全不同不要拿A芯片的SPL去跑B芯片必挂。2.2 U-Boot Proper阶段到底在干什么SPL把U-Boot Proper加载到DDR后跳转过去执行。这时候你才看到串口输出那个熟悉的U-Boot 2023.xx版本信息。U-Boot Proper阶段做的事情非常多按顺序大致包括重定位把自身代码从加载地址搬到DDR的高端地址腾出低端内存给内核用。板级初始化调用board_init_r初始化串口、网口、MMC、USB等外设。驱动模型绑定U-Boot有自己的Driver Model类似Linux的设备驱动框架通过设备树匹配驱动。环境变量加载从eMMC、SPI Flash或FAT分区里读取环境变量。进入命令行或自动启动如果bootdelay时间内没有按键打断就执行bootcmd里的命令加载内核。这里有个关键点U-Boot的驱动模型和Linux内核的驱动模型是两套东西虽然都解析设备树但API完全不同。你在U-Boot里写驱动用的是U_BOOT_DRIVER宏和udevice结构体跟内核的platform_driver不是一回事。很多从内核转过来的人在这里会混淆。2.3 内核交接时到底传递了什么U-Boot启动内核的最终动作是跳转到内核入口地址同时传递三个关键参数设备树地址、内核命令行、内存布局信息。在ARM64上标准做法是使用FIT镜像Flattened Image Tree把内核Image、设备树DTB、甚至initramfs打包成一个.itb文件。U-Boot解析FIT镜像后分别加载各部分到内存然后跳转。设备树在U-Boot阶段就已经被解析过了U-Boot会根据设备树里的chosen节点决定bootargs。但U-Boot也会修改设备树比如把/memory节点的信息补全或者把kaslr-seed传给内核用于地址随机化。这些细节如果你不关注内核启动时可能会报“无法识别内存大小”或者“设备树地址非法”。我实测中遇到过一个典型问题U-Boot传给内核的设备树地址是0x40000000但内核配置的CONFIG_ARM64_VA_BITS是48位导致早期映射冲突内核直接卡死。后来把设备树地址改到0x4a000000就正常了。这种问题没有文档会写只能靠调试经验积累。3. 环境搭建与QEMU模拟ARM64的实操路径3.1 为什么建议先用QEMU跑通再碰真板如果你手头没有ARM64开发板或者板子还在快递路上完全可以用QEMU模拟一个ARM64平台来学习U-Boot。QEMU的virt机器类型支持ARM64可以模拟Cortex-A57、A72等核心配合U-Boot和Linux内核能跑通完整启动流程。这样做的好处是不需要担心烧录错误把板子变砖不需要买串口线所有调试都在终端里完成。安装QEMU和交叉编译工具链的命令如下以Ubuntu为例sudo apt install qemu-system-arm gcc-aarch64-linux-gnu make bison flex libssl-dev交叉编译工具链的prefix是aarch64-linux-gnu-后面编译U-Boot和内核都要指定这个前缀。3.2 编译U-Boot for QEMU ARM64的完整步骤先下载U-Boot源码建议用稳定版本比如v2023.10git clone https://source.denx.de/u-boot/u-boot.git cd u-boot git checkout v2023.10然后配置QEMU ARM64的默认配置make CROSS_COMPILEaarch64-linux-gnu- qemu_arm64_defconfig这个defconfig已经包含了QEMU virt平台需要的所有驱动包括PL011串口、GIC中断控制器、virtio设备等。接着编译make CROSS_COMPILEaarch64-linux-gnu- -j$(nproc)编译完成后产物是u-boot.bin。注意QEMU的ARM64 virt平台不需要SPL因为QEMU直接加载u-boot.bin到内存并跳转DDR是QEMU模拟的不需要初始化。3.3 用QEMU启动U-Boot并进入命令行启动命令如下qemu-system-aarch64 -machine virt -cpu cortex-a57 -nographic \ -bios u-boot.bin -m 1G-nographic把串口重定向到终端-bios指定U-Boot镜像-m 1G给1GB内存。启动后你会看到U-Boot的版本信息和命令行提示符。这时候你可以试试bdinfo查看板级信息printenv查看环境变量mmc list查看MMC设备QEMU virt默认没有MMC会报错正常。提示QEMU里退出U-Boot命令行用CtrlA然后按X不是CtrlC。3.4 在QEMU里加载内核和设备树的技巧QEMU可以直接通过-kernel和-dtb参数加载内核但如果你想练习U-Boot的booti命令可以先把内核Image和设备树放到一个FAT镜像里然后让U-Boot从virtio磁盘读取。具体做法是创建一个disk.img用mkfs.vfat格式化把Image和qemu-arm64.dtb拷进去然后QEMU启动时加-drive filedisk.img,formatraw,ifvirtio。U-Boot里用virtio scan、fatload virtio 0:0 0x40000000 Image加载再用booti 0x40000000 - 0x4a000000启动。这个流程走一遍你就彻底理解了U-Boot加载内核的完整逻辑比看十遍文档都管用。4. 设备树在U-Boot里的角色与常见误用4.1 U-Boot自己的设备树和内核设备树不是一回事这是最容易混淆的地方。U-Boot编译时会生成一个自己的设备树u-boot.dtb这个设备树描述的是U-Boot运行时的硬件环境用于驱动模型匹配。而内核设备树kernel.dtb是U-Boot要传给内核的描述的是内核看到的硬件。两者可以相同也可以不同。在ARM64平台上通常U-Boot的设备树和内核设备树是同一份源码编译出来的但U-Boot会在自己的设备树里加一些u-boot,dm-前缀的属性用来标记哪些节点在U-Boot阶段需要初始化。比如uart0 { u-boot,dm-pre-reloc; status okay; };u-boot,dm-pre-reloc表示这个节点在重定位之前就需要初始化通常用于串口和时钟控制器。如果你漏了这个属性串口在SPL阶段可能没有输出调试会非常痛苦。4.2 设备树覆盖机制在U-Boot中的实现U-Boot支持设备树覆盖Device Tree Overlay可以在运行时动态修改设备树。这在QEMU里特别有用因为QEMU生成的设备树可能缺少某些节点你可以用fdt命令手动添加。比如 fdt addr 0x4a000000 fdt resize fdt set /chosen bootargs consolettyAMA0 root/dev/vda rw这几条命令把设备树地址设为0x4a000000扩展空间然后设置bootargs。这样内核启动时就会用这个命令行。比起重新编译设备树这种方法在调试阶段效率极高。但要注意fdt set修改的是内存中的设备树副本不会写回存储。下次重启就没了。如果要持久化得用fdt save或者改源码重新编译。4.3 设备树地址冲突导致内核卡死的排查过程我遇到过一次内核启动到“Uncompressing Linux...”就卡住没有任何输出。排查步骤是这样的确认U-Boot能正常加载内核Image和设备树booti命令没有报错。用md 0x4a000000 0x100查看设备树头部确认magic number是0xd00dfeed。检查内核配置里的CONFIG_ARM64_VA_BITS发现是48位。计算内核早期映射范围发现0x4a000000落在了内核镜像的BSS段范围内被覆盖了。把设备树地址改到0x4b000000问题解决。这个问题的根因是U-Boot加载内核Image到0x40080000内核解压后自身会占用到0x4a000000左右设备树如果放在这个范围内就会被内核自己的代码覆盖。正确做法是把设备树放在内核镜像上方至少16MB的位置或者用booti的第三个参数明确指定一个安全地址。经验ARM64平台上内核Image加载地址通常是0x40080000设备树建议放在0x4b000000以上initramfs放在0x4c000000以上避免重叠。5. 环境变量存储与启动脚本的实战配置5.1 环境变量到底存在哪里U-Boot的环境变量可以存在多个地方eMMC的uboot-env分区、SPI Flash的特定偏移、FAT文件系统的uboot.env文件甚至编译时嵌入到二进制里作为默认值。具体用哪种由CONFIG_ENV_IS_IN_*系列宏决定。在QEMU里通常用CONFIG_ENV_IS_NOWHERE表示环境变量只存在内存里重启就丢。真板子上最常见的是CONFIG_ENV_IS_IN_MMC环境变量存在eMMC的某个偏移处。这个偏移由CONFIG_ENV_OFFSET定义必须和分区表对齐否则会覆盖其他数据。我见过一个坑有人把CONFIG_ENV_OFFSET设成了0x0结果U-Boot启动时从eMMC第0扇区读环境变量把分区表覆盖了整个存储设备报废。所以这个偏移一定要避开分区表和U-Boot自身镜像。5.2 bootcmd和bootargs的配合逻辑bootcmd是U-Boot自动启动时执行的命令序列bootargs是传给内核的命令行参数。两者配合的典型配置如下 setenv bootargs consolettyAMA0,115200 root/dev/mmcblk0p2 rw rootwait setenv bootcmd fatload mmc 0:1 0x40080000 Image; fatload mmc 0:1 0x4b000000 dtb; booti 0x40080000 - 0x4b000000 saveenv这段配置的意思是从MMC第0个设备的第1个分区FAT分区加载内核Image到0x40080000加载设备树到0x4b000000然后用booti启动。bootargs告诉内核串口是ttyAMA0根文件系统在MMC的第2个分区可读写等待设备就绪。注意booti命令的第二个参数是-表示initramfs地址为空。如果你有initramfs这里要填实际地址。5.3 用脚本简化重复启动流程U-Boot支持脚本可以把一堆命令写成一个.scr文件用mkimage工具打包然后source执行。比如创建一个boot.scrmkimage -A arm64 -O linux -T script -C none -d boot.cmd boot.scrboot.cmd内容setenv bootargs consolettyAMA0 root/dev/vda rw fatload virtio 0:0 0x40080000 Image fatload virtio 0:0 0x4b000000 qemu-arm64.dtb booti 0x40080000 - 0x4b000000然后在U-Boot里fatload virtio 0:0 0x50000000 boot.scr; source 0x50000000。这样每次启动只需要一条命令效率提升明显。6. 从U-Boot到内核那些文档不写的衔接细节6.1 内核命令行参数的优先级内核命令行可以来自三个地方U-Boot的bootargs环境变量、设备树/chosen/bootargs节点、内核编译时的CONFIG_CMDLINE。优先级是设备树/chosen/bootargs U-BootbootargsCONFIG_CMDLINE。但有个例外如果U-Boot的bootargs里包含${}变量引用会先展开再传递。我遇到过设备树里写死了bootargs导致U-Boot里改的bootargs完全不生效。排查了半天才发现是设备树覆盖了。所以如果你发现内核命令行不对先检查设备树里的/chosen节点。6.2 initramfs和根文件系统的切换逻辑U-Boot可以同时加载initramfs和根文件系统。内核启动时先用initramfs里的/init脚本做早期初始化然后通过switch_root切换到真正的根文件系统。bootargs里的root参数指定最终根文件系统rdinit指定initramfs里的初始化脚本。在ARM64上initramfs通常打包成initramfs.cpio.gz用booti的第二个参数指定地址。如果不需要initramfs就填-。我建议初学者先用initramfs跑通因为它不依赖存储设备驱动能快速验证内核是否正常启动。6.3 内核启动卡死时的分段排查方法内核启动卡死是最常见的问题排查要分段进行卡死位置可能原因排查手段无任何输出串口配置错误、内核未加载检查bootargs里的console用md确认内核镜像已加载“Uncompressing”后卡死设备树地址冲突、内存布局错误调整设备树地址检查CONFIG_ARM64_VA_BITS“Starting kernel...”后卡死内核入口地址错误、CPU模式不对确认booti地址正确检查内核编译配置内核日志输出到某处停止驱动初始化失败、根文件系统挂载失败加earlycon、initcall_debug参数earlycon参数特别有用它让内核在正式串口驱动加载之前就用早期控制台输出日志。加上earlyconpl011,0x9000000QEMU virt的串口地址能看到更多启动信息。7. 从单片机思维切换到Bootloader思维的关键认知7.1 为什么不能用写裸机的方式读U-Boot源码单片机程序通常是线性执行的上电、初始化、while(1)循环。U-Boot是事件驱动和命令驱动的它的main_loop函数等待串口输入解析命令执行命令。你如果按线性思维去读会找不到“主流程”。正确的读法是从board_init_r开始看它初始化了哪些子系统然后看main_loop怎么处理命令。U-Boot的命令用U_BOOT_CMD宏注册每个命令有名字、最大参数数、重复执行标志、帮助信息、实现函数。比如booti命令的实现函数是do_booti在cmd/booti.c里。7.2 U-Boot的驱动模型和Linux的异同U-Boot的驱动模型DM和Linux的设备模型在概念上相似都基于设备树匹配驱动但实现完全不同。U-Boot的udevice结构体代表一个设备实例driver结构体代表驱动通过U_BOOT_DRIVER宏注册。绑定过程在dm_init_and_scan里完成。关键区别U-Boot的驱动模型不支持热插拔不支持电源管理不支持复杂的子系统分层。它只求“能用”不求“好用”。所以你读U-Boot驱动代码时会发现很多驱动就是简单地把寄存器操作封装一下没有内核那么复杂的抽象。7.3 调试U-Boot的实用手段U-Boot调试主要靠串口打印和printf。但printf在SPL阶段可能不可用因为串口还没初始化。这时候可以用CONFIG_DEBUG_UART它允许在SPL阶段就用一个极简的串口驱动输出调试信息。另一个手段是gdglobal data结构体它保存了U-Boot运行时的全局状态包括内存布局、设备树地址、环境变量地址等。在命令行里用bdinfo可以打印gd的关键字段。如果U-Boot跑飞了检查gd里的relocaddr和ram_size往往能找到线索。技巧在board_init_f里加printf可以追踪重定位之前的初始化流程。但注意printf在重定位前用的是临时栈不要打印太大的字符串。8. 后续可以往哪个方向继续深入U-Boot跑通之后你面前有几条路可以走。一条是深入ATFARM Trusted Firmware这是ARM64平台上安全启动的核心组件负责EL3层的异常处理和安全监控。U-Boot在ATF之上运行两者通过SMC指令交互。如果你对安全启动、TrustZone感兴趣ATF是必经之路。另一条是研究FIT镜像签名和安全启动链。U-Boot支持对FIT镜像做RSA签名验证确保只有签过名的内核才能启动。这在产品化阶段非常重要但学习阶段可以先跳过。还有一条是回到内核驱动开发把U-Boot里学到的设备树知识用到内核驱动上。U-Boot的设备树解析逻辑比内核简单适合作为设备树的入门教材。等你把U-Boot的设备树玩熟了再看内核的设备树绑定文档会发现轻松很多。我个人在实际操作中的体会是U-Boot最大的价值不是它本身的功能而是它强迫你理解“一个完整的启动链路需要哪些组件、每个组件负责什么、它们之间怎么交接”。这种系统级的视角是单片机开发给不了的。你一旦建立了这个视角再看任何嵌入式Linux平台都能快速定位问题出在哪一层。
返回列表