聊到U-Boot移植,很多做嵌入式的朋友第一反应就是“水太深,坑太多”。我在这个行当里泡了十来年,从早期的AT91RM9200一路折腾到现在的ARM64平台,U-Boot移植这件事其实有非常清晰的套路。所谓基础,不是要你把整个U-Boot源码背下来,而是把一条主链路理顺:处理器怎么启动、U-Boot怎么编译、板级配置从哪里下手、串口和内存怎么先跑通、最后怎么把内核引导起来。这篇文章我打算从头到尾带你把这条链路走一遍,适合刚接触引导程序、被厂家BSP吓住的同学,也适合手上正好有一块非主流开发板、想自己动手驯服它的老手。
U-Boot(全称Das U-Boot,语义上取自德语“潜艇”的双关)是嵌入式Linux世界里使用最广的开源引导程序,它要解决的核心问题就一个:在操作系统内核“睁眼”之前,把硬件初始化到一个可用的状态,然后把内核镜像和它要用的设备树加载到内存里,跳过去执行。移植U-Boot,本质上是针对某一款具体板子,把源码里“通用逻辑”和“板级差异”之间那层胶水给配好。看起来庞杂,但拆开之后就那几块,咱们一块一块来看。
1. 移植前先搞清楚:U-Boot在启动链路里到底干多少活
1.1 引导程序的职责,用一句话就能说清
从芯片上电复位、CPU从固定地址取第一条指令,到Linux内核的start_kernel开始运行,中间有一个“硬件的冷启动期”。这段时期内,DDR可能还没初始化、时钟频率还是默认的慢速状态、串口可能只是SoC里一个不起眼的外设。操作系统的内核代码是需要内存的,设备树和根文件系统也不在内存里,所以必须先有一段代码把“执行环境”铺好。U-Boot干的就是这个活儿:初始化时钟、串口、存储介质(SD卡、eMMC、NOR Flash、NAND等)、DDR内存,把内核镜像和DTB加载到指定内存地址,设置好启动参数再跳转过去。
说得直白一点,U-Boot像一个提前上班的夜班保安。大家还没上班,他先把办公室的电闸合上、灯打开、门禁调好,再把上班要用的大楼平面图(设备树)摆在桌上,最后按门铃把老板(内核)叫醒。老板一睁眼,发现环境已经就绪,直接开始干活。
这个类比里其实藏着移植的核心难点:不同SoC、不同板子把DDR控制器、引脚复用、存储介质接法安排得都不一样,所以“合电闸”的动作没法一套代码通吃所有板子。U-Boot源码里铺天盖地的board目录、arch目录下的各种平台代码,就是为了容纳这些差异。
1.2 拿到一块新板子,第一步不是写代码而是读资料
见过太多新手一上来就make menuconfig,恨不能把每个选项都点一遍,结果折腾半宿连串口都打不出字符。正确的打开方式是先做三件事:看SoC参考手册里启动、时钟和DDR相关的章节;看官方或厂商给的同系列评估板原理图;在U-Boot源码里找一块和你板子“最像”的开发板配置。这三件事做完,你对自己移植工作量的判断基本就准了。
举个例子,你用全志V3s也好、恩智浦i.MX6ULL也好、瑞芯微RK3399也好,厂商官方基本都会维护一个对应的评估板目录。移植时绝大多数情况不是从零写一个board支持文件,而是“拷贝一个相近的板级目录,改引脚、改内存参数、改启动介质,再适配设备树”。很多新手把移植想成自己发明配置,其实正确姿势是站在已有配置的肩膀上,做最小差异的修改。
还有一个容易被忽略的准备工作:确认你的调试链路。交叉编译器、串口调试工具、TFTP服务器或者SD卡读写工具,最好再备一块逻辑分析仪或示波器。串口是最早能给你反馈的“眼睛”,没有串口反馈的移植基本等于盲人摸象。所以上电之前,先把调试串口的硬件通路确认好:如果用带USB转串口芯片的底板,先用自发自收的方式确认芯片本身工作正常,别回头板子其实已经在跑了,你却因为串口线的问题以为它没动静。
1.3 选代码树:官方主线、厂商SDK还是SoC厂商分支
这是很多人第一道坎。U-Boot官方主线维护节奏稳定,社区成体系的驱动框架、新架构支持都在这里,适合长期维护、想紧跟内核节奏的项目。厂商SDK则带着厂商的闭源初始化代码、DDR训练固件、TPL/SPL配合用的二进制载荷,适配度最高,但代码树往往比较老,而且经常和主线分叉很大。
我个人的选择逻辑是这样的:如果是生态成熟的公版SoC平台,像i.MX6ULL、全志V3s这类社区资料丰富的芯片,优先接近主线,哪怕要自己适配一两个驱动;如果是包含闭源DDR固件的新SoC,或者需要厂商加密启动链路的方案,老老实实用厂商SDK,先把系统跑起来再说,等产品稳定了再评估要不要跟随主线。记住,移植的第一目标是“让板子启动、能打印、能引导内核”,不是“追求最纯正的开源方案”。实践里“用厂商SDK跑产品、用主线把自己绑死在折腾上”的悲剧我见得太多了。
2. 板级配置从哪里下手:defconfig、Kconfig与设备树
2.1 别把defconfig当成简单的宏开关集合
U-Boot构建系统沿用Linux内核那套Kconfig机制。你执行make xxx_defconfig,实际是用arch/arm/configs/目录下对应的defconfig文件生成一个.config;执行make menuconfig,是在这个基础上做可视化调整。移植时最忌讳的就是直接在menuconfig里改来改去,改完一编译,能用,但过两天忘了自己改了什么,重做一遍又要从头试。
正确流程是这样:先找一个相近板子的defconfig作为底子,比如你要做的是i.MX6ULL板卡,那就从imx6ull相关的defconfig开始;编译验证能跑之后,再通过make savedefconfig把当前有效的配置导出一个简洁的defconfig,另存为你自己板子的名字,后续所有配置修改都以这个文件为准。这样你的配置是“可复现、可提交到版本库”的,而不是一堆不可追溯的menuconfig点击操作。
举一个真实的V3s / LicheePi Zero类项目的defconfig片段:
CONFIG_ARM=y CONFIG_ARCH_SUNXI=y CONFIG_DEFAULT_DEVICE_TREE="sun8i-v3s-licheepi-zero" CONFIG_MACH_SUN8I_V3S=y CONFIG_SPL=y CONFIG_SYS_CLK_FREQ=24000000 CONFIG_DEFAULT_FDT_FILE="sun8i-v3s-licheepi-zero.dtb" CONFIG_MMC=y CONFIG_MMC_SUNXI_SLOT=0 CONFIG_USB_EHCI_HCD=y CONFIG_USB_MUSB_HOST=y CONFIG_SYSRESET=y这里的几个关键项要解释一下。CONFIG_SPL=y决定了是否构建SPL,也就是“第一级引导程序”,后面会专门讲。CONFIG_DEFAULT_DEVICE_TREE告诉U-Boot编译时用哪个dts作为内置设备树。CONFIG_SYS_CLK_FREQ是SoC内部某个基准时钟频率的约定值,这个值错了,串口波特率就全是乱码。每一种SoC的defconfig常见字段都不一样,但思路是一致的:先搞清楚哪些选项影响“能不能启动”,哪些只影响“功能全不全”。
提示:老的U-Boot版本里很多配置写死在
include/configs/<board>.h这个头文件里,新版逐步迁移到Kconfig。如果你拿到一份老代码树,看到CONFIG_宏在头文件里,不要惊讶,那是历史遗留的“老式配置风格”,改法一样,只是位置不同。
2.2 设备树是U-Boot的“外设说明书”
自U-Boot引入CONFIG_OF_CONTROL之后,板级硬件描述越来越依赖设备树。和内核共用一套dts/dtsi的好处是:外设地址、中断号、引脚复用这些都归一了,不需要在C代码里再维护一份重复的地址表。U-Boot自己的dts处理有几个特有的点,新手经常搞混。
第一,U-Boot用的dts经常是带-u-boot.dtsi后缀的文件,比如sun8i-v3s-licheepi-zero-u-boot.dtsi。这是U-Boot独有的“附加层”,里面放着U-Boot运行时需要、但内核不需要的属性,比如串口在重定位之前就要工作,得在UART节点上标u-boot,dm-pre-reloc,让驱动模型在早期阶段就初始化它。
第二,设备树里的chosen节点用来告诉U-Boot“默认控制台是谁”,常见写法是stdout-path = &uart0;。如果你的dts里没配这一项,U-Boot可能打印到别的外设上,或者根本不知道往哪打印,裸机上调试会非常痛苦。
第三,内存节点不一定非要写死在dts里。很多SoC的DDR大小是SPL阶段通过固件探测出来的,U-Boot会把探测到的内存信息填充进设备树的memory节点,再传给内核。这时候你如果手工在dts里写死了内存大小,反而可能造成和真实硬件不匹配。所以看到一个dts里没有memory节点,别急着加,先确认SPL有没有帮忙填。
一个简化的板级dts骨架长这样:
/dts-v1/; #include "sun8i-v3s.dtsi" #include "sunxi-common-regulators.dtsi" / { model = "Lichee Pi Zero"; compatible = "licheepi,licheepi-zero", "allwinner,sun8i-v3s"; chosen { stdout-path = &uart0; }; aliases { serial0 = &uart0; mmc0 = &mmc0; }; }; &uart0 { pinctrl-0 = <&uart0_pb_pins>; pinctrl-names = "default"; status = "okay"; };移植时重点关注compatible是否和SoC的match表对得上、uart0引脚复用是否和实际原理图一致、status是否显式置为okay。很多外设不工作,追到最后就是这几行属性问题。
2.3 三大件怎么先配出来:串口、内存、时钟
串口、内存、时钟是让U-Boot“活过来”的三个必要条件,三者的调试优先级也有讲究。
串口的早期输出依赖CONFIG_DEBUG_UART相关的几个选项。以全志平台为例,你得配好CONFIG_DEBUG_UART_BASE(UART寄存器基址)、CONFIG_DEBUG_UART_CLOCK(UART供的时钟频率)、CONFIG_DEBUG_UART_BAUDRATE。很多新手只改了base和波特率,忘了clock这个值,结果打印出来的全是乱码。时钟频率这个东西没有捷径,必须从SoC手册里查UART模块的父时钟频率,或者看相近板子的配置值。
内存这块是移植里技术含量最高的地方。DDR控制器需要配置时序、位宽、密度、刷新参数,不同批次的内存颗粒还可能有细微差异。好消息是,现代SoC通常把DDR初始化藏到了厂商固化代码里:全志有FEL模式配合的DRAM初始化、瑞芯微有RK固件包里的DDR bin、NXP的i.MX系列有DDR训练固件。U-Boot这边你需要做的往往是“告诉系统DDR在哪、有多大、有几个bank”,比如CONFIG_NR_DRAM_BANKS、CONFIG_SYS_SDRAM_BASE这些。如果SoC没有厂商固件、需要纯软件初始化DDR,那工作量会陡增,这就是为什么前面建议选代码树时优先考虑带厂商DDR固件的SDK。
时钟配置在移植早期通常不用你手动精确设置,因为SPL或SoC内部启动ROM已经设置了CPU和总线的可用时钟。U-Boot会读取时钟驱动来配置外设分频,你需要验证的是“某个外设的父时钟是否如预期”。比如串口时钟配错,串口输出的波特率就会漂移;SD控制器时钟配错,mmc探测就会超时。调试时可以用逻辑分析仪测UART引脚的波形,直接数一下起始位宽度,判断实际波特率是多少,这个技巧在“串口乱码”问题上特别好使。
3. 实操:从空目录到看到U-Boot打印
3.1 交叉编译环境搭建与验证
U-Boot不是跑在开发板上编译的,你得先有一套交叉编译工具链。ARM 32位平台用arm-linux-gnueabihf-,ARM 64位平台用aarch64-linux-gnu-。Ubuntu系统下一行命令就能装:
sudo apt install gcc-arm-linux-gnueabihf gcc-aarch64-linux-gnu装完先验证工具链可用:
arm-linux-gnueabihf-gcc -v然后设置两个核心环境变量,一个是架构,一个是交叉编译前缀:
export ARCH=arm export CROSS_COMPILE=arm-linux-gnueabihf-这两个环境变量建议每次都显式设置,别写进.bashrc里指望一劳永逸。因为不同平台的ARCH不一样,配置错了会出现一堆莫名其妙的错误,比如你编译32位板子时用了aarch64-linux-gnu-,链接阶段就会报一堆unknown的机器码错误。写进项目目录下的一个build.sh脚本里反而是更稳妥的做法。
3.2 构建一个最小可启动镜像的完整命令
在U-Boot源码根目录下,整套构建流程是:
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- distclean make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- <你的板子>_defconfig make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- -j$(nproc)第一次编译我建议不要加-j并行,主要原因是新手容易把编译报错和系统无关警告混在一起,串行输出更容易核对错误行。等确认环境没问题再加-j8或者-j$(nproc)提速。
编译完的产物里,你需要关心这几个文件:
| 文件 | 用途 |
|---|---|
| u-boot.bin | 第二级U-Boot本体,通常由SPL加载 |
| u-boot.img | 带头部信息的U-Boot镜像,SPL可直接识别 |
| SPL | 第一级引导程序,由片上ROM加载 |
| u-boot.dtb | U-Boot编译出的设备树 |
| u-boot.itb | 打包了U-Boot和设备树的FIT镜像 |
如果板子支持SPL,烧录时通常要把SPL和u-boot.img拼起来,或者直接用厂商脚本打包好的u-boot-sunxi-with-spl.bin这类“带SPL的完整镜像”。确认当前生效配置的完整值,可以随时执行:
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- u-boot.cfg然后grep你关心的宏,这比用menuconfig满屏翻要快得多。
3.3 烧录介质与三种常见启动路径
U-Boot编译出来后,往哪里放、怎么放,决定你能不能看到启动打印。以SD卡烧录为例,SPL和U-Boot本体通常放在SD卡的开头区域,避开第一个分区的文件系统。这里有个千年老坑:不能用dd直接把镜像写到分区里,而是要写到“裸偏移位置”。不同SoC的偏移量不同。
以全志V3s为例,完整镜像要写到SD卡8KB偏移处,也就是512字节扇区的第16扇区:
sudo dd if=u-boot-sunxi-with-spl.bin of=/dev/sdb bs=1024 seek=8 conv=fsynci.MX6ULL因为ROM对镜像头部有特殊要求,通常写在第1扇区并带IVT头;瑞芯微平台习惯上从第64扇区开始。这些偏移值在对应板的README文件里都有,千万别凭感觉随便写,写错了板子就直接没反应,而且不会给你任何报错提示。
除了SD卡,常见的还有这些路径:
- SPI NOR Flash启动:U-Boot里用
sf probe识别Flash,然后sf erase、sf write写入。 - eMMC启动:内置eMMC的分区方式和SD卡类似,偏移一样,只是设备节点换成
mmc 1。 - TFTP网络启动:主要用于调试阶段,先把U-Boot烧进板载介质跑起来,再用
tftp命令反复加载新内核、新DTB,不用频繁换卡。
网络启动时环境变量这么配:
setenv ipaddr 192.168.1.100 setenv serverip 192.168.1.1 setenv bootcmd 'tftpboot 0x42000000 zImage; tftpboot 0x43000000 oxalis.dtb; bootz 0x42000000 - 0x43000000' saveenv这里的加载地址0x42000000不是随便写的,得确保它在物理内存范围内,且不能和U-Boot自身占用的区域重叠。ARM32平台上常见的是0x30000000往上的区域,到具体板子要看DDR的基地址。
提示:调试阶段频繁改环境变量很容易把板载存储里的env搞乱,必要时用
env default -a恢复出厂设定,或者直接把CONFIG_ENV_IS_NOWHERE打开,让它每次启动都读默认值,调试完再改回存eMMC或SPI。
4. 移植路上的高频坑与排查思路实录
4.1 串口一片寂静:先别怀疑代码,确认硬件通路
移植新手遇到“板子上电完全没打印”,第一反应往往是“代码没写对”。但说实话,我遇到的情况里至少三分之一是硬件通路问题。常规排查顺序应该是:先用万用表确认板子供电正常,核心电压有没有起来;再用示波器或者逻辑分析仪看UART TX引脚有没有波形变化;然后确认TX/RX有没有接反、电平标准是不是匹配(3.3V TTL和1.8V TTL不通用的);最后才轮到怀疑软件。
软件侧还有一个高频原因:引脚复用没配对。SoC的UART引脚往往有多种功能选项,同一个引脚默认可能是GPIO,也可能是JTAG。U-Boot在重定位前使用的早期串口,很多平台只认一个固定引脚配置,如果你在设备树里配置的pinctrl和当前复用不匹配,这个早期的print就会消失。解决手段是先把DEBUG_UART打开,它绕开驱动模型,直接往寄存器里写字符输出,能定位到“是驱动没起来”还是“引脚根本没通”。全志平台还可以借助官方FEL工具读取芯片状态,确认芯片本身有没有跑起来,这算是SoC级最底层的救命检查。
4.2 打印到一半就卡死:重定位和内存大小是重点
另一种非常典型的症状是:能看到U-Boot banner,打印几行信息,然后就没有然后了。这种时候十有八九是U-Boot发生了“重定位(relocation)”,也就是把自己从存储介质拷到内存高位去继续运行。重定位失败的原因通常是内存配置出错:要么是CONFIG_SYS_SDRAM_BASE给的基地址不对,要么是内存大小没探测准,U-Boot把自己拷到了不存在的内存区域。
遇到这种情况,可以先在U-Boot命令行里敲bdinfo,看它自己认出来的内存起始地址和大小是不是和实际硬件一致。如果不一致,先从DDR初始化链路查。另外也可以试着关闭数据缓存来排除cache一致性带来的问题:
dcache off如果关掉cache之后就正常了,说明问题大概率出在MMU/TLB相关的映射配置上,而不是DDR本身。还有一种常见情况是malloc空间预留不足,早期驱动分配内存时直接踩坏了重定位后的代码段。查一下CONFIG_SPL_SYS_MALLOC_F_LEN、CONFIG_SYS_MALLOC_LEN这类配置,把它们适当调大再试,很多莫名的“跑飞”其实都是内存布局挨得太近。
4.3 内核引导失败:load地址、bootargs和DTB三位一体
串口能进U-Boot,U-Boot也能稳定运行,但一引导内核就报Kernel panic - not syncing: VFS: Unable to mount root fs,这是最后一个高频坑。它背后通常是三件事没配合好:内核加载地址、内核启动参数、设备树的地址和内容。
先说地址。bootz和bootm对镜像格式要求不一样:bootz加载zImage,bootm加载uImage。如果你的U-Boot是较老版本,对zImage有特定加载地址约束,有些SoC还要求内核和DTB的地址满足一定对齐关系。看到Wrong image format这类报错,先检查镜像格式和命令是否匹配。
再说bootargs。常见错误是console=指定的串口号和内核实际使用的不一致,导致内核启动后黑屏,但U-Boot这边一切正常。一定要让U-Boot的console=ttyS0和内核dts里chosen的stdout-path对上。
最后说DTB。内核和设备树必须配套,同一个zImage配错了dts,会报No device tree found或者加载后直接卡死。调试时建议用fdt addr指定DTB位置,然后用fdt print /看看根节点有没有东西,确认DTB确实被正确加载到内存了。
下面整理了一张快速对照表,方便现场排查:
| 症状 | 优先检查项 | 常规解法 |
|---|---|---|
| 完全没有打印 | 串口接线、电平、引脚复用 | 确认3.3V TTL、TX/RX不反接,检查pinctrl |
| 串口全是乱码 | UART时钟配置、波特率 | 核对CONFIG_DEBUG_UART_CLOCK和实际父时钟 |
| banner后有打印但卡死 | 内存大小/基址、重定位 | bdinfo检查内存,dcache off试跑 |
| 启动时反复重启 | DDR不稳定、电源供电不足 | 提高DDR驱动电压、延长刷新参数、看电流表 |
| 引导内核报Wrong image format | 镜像格式和boot命令 | zImage用bootz,uImage用bootm |
| 挂载不了根文件系统 | bootargs里的root设备 | 核对root=/dev/mmcblk0p2这类路径是否正确 |
| 内核启动但屏幕/串口没输出 | console参数与dts不一致 | 让console=ttySX和stdout-path统一 |
4.4 几个价值极高的U-Boot“内窥镜”命令
拿到了U-Boot命令行,别急着只会tftpboot,下面这几个命令是移植排错时的宝贝。
bdinfo:看板级信息,包括DRAM起始地址和大小、各内存bank的布局、arch号、波特率,是排查内存问题第一抓手。
dm tree:U-Boot驱动模型下所有已注册设备的树状列表,能看出哪些设备probe成功了、哪些因为依赖问题被跳过。配合dm uclass可以按类查设备。
md和mw:读写内存,比如md.l 0x40000000 10查看指定地址的内容,mw.l 0x40000000 0xdeadbeef往内存里写值。可以用它在板子上现读内存颗粒的ID寄存器,确认DDR探测是否真的访问到了物理内存。
mmc info和sf probe:看存储介质有没有被正确识别,很多板子启动慢是因为mmc超时反复重试,这个命令一跑就知道该查硬件还是查驱动。
gpio set和gpio input:把某一根GPIO拉高拉低。硬件上没有可用打印的时候,我在调试板上把GPIO接到LED上,每一步初始化完成就翻转一次电平,用眼睛观察代码走到哪里了。这个土办法在SPL早期(串口还没初始化好的阶段)比插仿真器还快。
另外新版本U-Boot引入了bootflow和bootstd这套标准启动框架,看启动流程执行到了哪一步比逐个命令敲方便得多,遇到顽固的引导问题可以bootflow scan -l看完整日志。
4.5 用git bisect对付“突然变坏”的回归问题
如果你用的是主线U-Boot,今天从某个版本升到另一个版本后,板子突然起不来了,最有效的排查手段是git bisect。U-Boot主线的commit量很大,手动翻changelog根本不现实。把“能启动”的旧版本标记为good,“起不来”的新版本标记为bad,然后让git自动二分检出中间版本,每次编译烧录验证一次,几个来回就能锁定出问题的commit。
这个办法听起来笨,但在实际工程里“救过很多命”。尤其是当你发现U-Boot和内核SDK不配套、厂商悄悄改了某个配置接口的时候,二分定位出来的那个commit信息,往往直接告诉你新老版本的接口差异在哪里。配合后面加一句git log -L可以更进一步看具体那个文件哪一行变动导致的问题。
写在最后的一点个人体会
移植U-Boot做到现在,我自己最大的体会是:这活儿不是“写代码”,而是“配胶水”和“排干扰”。大多数时候你写的C代码不超过几十行,剩下全是在无数配置项和硬件细节之间找平衡。所以风格上我一直坚持最小改动原则——能用现有板级目录就绝不新造轮子,能在dts里描述清楚就绝不写驱动代码,能用一个GPIO点灯验证就绝不上仿真器。
还有一个建议送给所有想深入的人:手里留一块几十块钱的廉价款开发板,平时没事就把它当“练手沙盒”。官方主线里随便找一个有意思的板级变动,自己合进来编译烧录,跑一遍启动日志,比看十篇移植教程都管用。U-Boot的代码生态是活的,你的调试手艺也得跟着活起来。
如果你正准备做自己的第一块板子移植,希望这篇文章能让你少走几段弯路。真卡住的时候,回来翻翻这几节,先从串口和DDR查起,大概率能省下好几个通宵。