刚拿到一块新开发板,我的习惯不是急着照原理图,而是先把U-Boot烧进去,看串口能不能吐出一行欢迎词。U-Boot在ARM Linux开发里的地位,相当于手机上的引导程序——串口能出那行熟悉的提示符,这块板的底子基本就稳了一半。这篇文章不搞高大上的架构理论,就结合一次真实的STM32MP157开发板移植过程,把U-Boot移植的完整套路和Kbuild构建系统的门道一次捋清楚。适合刚转做ARM底层、或者做过Linux应用但第一次碰引导程序的工程师,照着文中的步骤走,基本能把U-Boot先跑起来。
我见过太多人卡在移殖这一步,不是因为代码难写,而是不理解构建系统。代码没改几行,整个移植过程却全耗在“为什么我改了配置却不生效”“为什么编译报错找不到头文件”这类问题上。所以这篇文章会把Kbuild机制放在前面讲透,再回到实操,这样你后面遇到任何一个新板子,心里都会有一条清晰的排查路径。
1. 先把移植思路拆开:U-Boot到底在干什么
1.1 上电那一刻,U-Boot解决的是什么问题
芯片上电以后,最先执行的不可能是Linux内核,而是固化在SoC内部的ROM代码。这段ROM代码会按照Boot引脚的电平状态,决定从SD卡、eMMC、NOR Flash还是USB口读取下一级程序。注意,ROM代码认的是硬件地址,它不会懂什么文件系统、分区表,它只会按固定偏移去加载数据,然后跳转过去执行。
这就要说到U-Boot的两个阶段了。绝大多数现代ARM平台采用SPL(Secondary Program Loader)加U-Boot的两级结构:SPL是个很小的程序,由ROM代码加载,负责最基础的时钟、DDR、串口和存储介质初始化,然后从存储设备读入完整的U-Boot到DDR里运行。完整版U-Boot起来以后,才有能力解析设备树、识别分区、加载内核镜像。
移植U-Boot,说白了就是让这套两段式加载在你的新板子上跑通。你不需要重新发明轮子,因为SoC厂商和U-Boot主线已经帮你覆盖了大部分硬件,你要做的是把“硬件差异”告诉构建系统,再用配置文件把它们串起来。
1.2 一次完整的移植任务,拆开看其实就四件事
我第一次做移植的时候,觉得这是个没有边界的工程,后来做多了才发现,任务拆开就四件事:
第一,Soc级的启动代码适配。这部分通常是SoC厂商已经写好的,位于arch/arm目录下,包含CPU初始化、中断向量表、安全启动等。除非你用的是很冷门的芯片,否则这块一般不用动。
第二,板级硬件差异适配。比如DDR容量和时序参数、SD/eMMC控制器引脚、串口引脚复用、网卡PHY地址、GPIO定义等。这部分散落在board目录和设备树文件里。
第三,构建配置适配。也就是defconfig文件、Kconfig选项、Makefile片段,让Kbuild知道该编译哪些文件、使用哪个设备树、支持哪些命令。
第四,验证和调试。U-Boot起来以后,要用命令验证内存、读写Flash、加载内核,确保整个链路是通的。
理解了这四件事,你再看任何一份U-Boot的BSP代码,都不会觉得乱,因为你一打开就是去这四个位置找对应的东西。
1.3 为什么说Kbuild绕不开
U-Boot从Linux内核继承了一套构建体系,也就是Kbuild。这套体系不是简单的“每个目录一个Makefile各自编译”就完事,它规定了整个项目的配置收集、依赖生成、镜像拼接方式。
很多初学者会有一种错觉:移植就是改个宏定义,那我直接往头文件里加一行不就完了?结果改完以后发现“咦,这个宏根本没有被包含进去”,那就是没有理解Kbuild的配置流。还有人在顶层Makefile里乱改目标名,结果把自己绕晕了。这些坑,都是绕不开Kbuild的。
所以我的观点一直很明确:先花半天理解Kbuild,能帮你省下一个星期的移植调试时间。下面这部分你不用死记硬背,但一定要建立完整的图景。
2. Kbuild构建系统内幕
2.1 从defconfig到.config,再到autoconf
整个Kbuild配置流程的起点是Kconfig文件。每个源码目录下都可能有Kconfig文件,里面用config、menuconfig、choice等关键字定义出一棵配置树。defconfig文件则是你这棵配置树的“快捷模板”,它只写相对于默认配置的差异项,构建时会被解析成完整的.config文件。
运行make xxx_defconfig,Kbuild会依据Kconfig树,把defconfig里的内容展开成.config。然后进入编译阶段时,再由conf工具生成两个关键产物:一个是include/generated/autoconf.h,C代码靠它知道某个宏是否开启;另一个是include/config/auto.conf,Makefile靠它决定编译哪些文件、定义哪些变量。
这里有个非常容易踩的坑:autoconf.h是构建时自动生成的,任何手动修改都会在下次make时被覆盖。我自己就吃过亏,改了半天include/generated/autoconf.h里的宏,重构以后全没了。正确的做法永远是去改defconfig或者用make menuconfig,让Kbuild重新生成。
2.2 Makefile里那几行“语法”,说穿了很简单
Kbuild的Makefile语法,核心就是几个变量:obj-y、obj-m、lib-y、lib-$(CONFIG_XXX)等。
- obj-y 表示这个目录下的文件要编译进最终镜像
- obj-$(CONFIG_XXX) + obj-y 的组合,用CONFIG开关控制某文件是否编译
- lib-y 表示构造目录内的built-in.a静态库,供上层目录链接
举个例子,假设你的板子有一个lcd驱动,在drivers/video/Kconfig里定义了CONFIG_VIDEO_LOGO,然后在Makefile里写:
obj-$(CONFIG_VIDEO_LOGO) += logo.o当defconfig里CONFIG_VIDEO_LOGO=y时,logo.o就会进入drivers/video/built-in.a,再被上级目录一步步集合成最终的u-boot二进制。
Kbuild会递归进入每个子目录执行make,这个递归过程不是简单地从上往下读Makefile,而是按照Kconfig和Makefile中声明的目录关系,生成依赖文件、编译命令,最终把所有subdir的built-in.a链接到一起。
如果你想让自己的某个文件在移植中必被编译,又不想碰Kconfig,那你可以在对应目录的Makefile里直接加一行obj-y += my_board_init.o。这种方式简单粗暴,但在开发调试阶段很有用。
2.3 SPL的那份“缩水版”构建,是怎么和主U-Boot共存的
同一个U-Boot源码目录,既能编译出完整版U-Boot,也能编译出SPL,这是Kbuild一个很精妙的设计。
SPL构建并不会为新代码另拉一个目录,而是复用同一份源码,靠CONFIG_SPL_BUILD这个宏来区分。看drivers/Makefile这类文件时你会经常见到类似写法:
obj-$(CONFIG_SPL_BUILD) += spl/ obj-$(CONFIG_SPL_BUILD) += drivers/ddr/也就是说,编译SPL时,Kbuild只收集SPL需要的目标文件;而编译完整U-Boot时,这些规则就被跳过。配合顶层Kconfig里的CONFIG_SPL、CONFIG_SPL_FRAMEWORK等开关,你就实现了“同一份代码,两个截然不同的产物”。
这带来的一个好处是,你在写板级代码时不必要特意为SPL单独开目录,只要用宏包一下即可。但同时它也是隐患来源:SPL链接出错时,报错信息往往指向一个你没听说过的.o文件,排查时要先确认你改的那个源文件到底有没有被编进SPL。这是我常提醒身边朋友的第一句话:用make spl/xxx单独编译时的V=1输出看实际参与编译的文件名,不要凭感觉。
2.4 设备树在Kbuild里是怎么被“缝”进二进制里的
现代U-Boot运行时要访问设备树,这棵树本质上是描述硬件资源的数据文件。设备树源文件存放在arch/arm/dts/目录下,Kbuild会把它们编译成dtb,再通过特定的obj-y规则嵌入U-Boot二进制。你打开arch/arm/dts/Makefile,会看到一堆类似:
dtb-$(CONFIG_TARGET_STM32MP157A_DK1) += stm32mp157a-dk1.dtb当defconfig选择了TARGET_STM32MP157A_DK1后,对应的dtb就参与构建,最终被mkimage工具封装进镜像,或者单独输出到u-boot.dtb。运行时U-Boot会去解析这个dtb,初始化串口、GPIO、网络等外设。
这里有个特别重要的细节:U-Boot用的设备树和Linux内核用的设备树,同源但不完全等同。很多U-Boot设备树会额外包含引导阶段才需要的节点,比如U-Boot专属的命令、环境变量分区、DDR训练参数等。你在移植时如果发现“Linux那边设备树明明有串口节点,U-Boot串口却不工作”,很大概率就是U-Boot自己的dtb没改对。
3. 实操过程:从空目录到串口欢迎语
3.1 环境准备,先把工具链一次配好
我建议直接下载ARM官方GNU工具链,或者用你的发行版自带的交叉编译包。以Ubuntu/Debian为例:
sudo apt install gcc-arm-linux-gnueabihf device-tree-compiler这里选择arm-linux-gnueabihf-前缀,是因为STM32MP157是Cortex-A7架构。如果你的板子是AArch64平台,需要换成aarch64-linux-gnu-前缀的工具链。交叉编译工具链的版本不要太新,也不要太老,我一般选GCC 10到12之间的版本,太低会碰到U-Boot源码里新语法不支持,太高偶尔会有链接器告警。
源码获取我通常直接拉主线仓库,再根据芯片选择带BSP的分支。比如ST的公开仓库是git clone https://source.denx.de/u-boot/u-boot.git之后,切到v2024.04这类稳定tag。如果你用的是厂商BSP,建议以厂商提供的版本为基准,因为ST、NXP这些厂商把自家芯片的初始化代码贡献到了主线,但DDR训练库、封装工具可能只在BSP里由闭源二进制提供。这个抉择很重要:想追新功能用主线,做量产用BSP。
3.2 找到“参照板”,而不是从零开始创建
新手最常犯的一个错误,是一上来就想从零创建一个board目录。实际上U-Boot移植有一大半工作是“抄作业”,关键是要抄对。
你要找的参照板应符合三点:第一,SoC核心相同或同一系列;第二,DDR、存储介质的硬件方案接近;第三,已有主线或厂商代码能正常启动。以STM32MP157为例,主线已有stm32mp15xx系列的多个板级支持,我们完全可以基于其中一个做二次开发。
操作步骤是:把configs/stm32mp15_defconfig复制成自己的configs/myboard_defconfig;把board/st/stm32mp1目录下的板级文件复制一份并改名;把include/configs/stm32mp1.h中板级相关宏调整成自己的配置。现代U-Boot已经将越来越多配置从头文件挪进Kconfig,所以defconfig和Kconfig里要重点检查两个选项:CONFIG_SYS_BOARD、CONFIG_SYS_CONFIG_NAME。Kbuild会根据它们定位板级头文件和board目录,这个定位关系一旦错了,整个编译都会乱了套。
我习惯在改名后先做一次make myboard_defconfig,确认Kbuild没有报“找不到board”之类的错误,再用make menuconfig把默认串口波特率、内存大小、环境变量存储位置逐项改掉。别跳过menuconfig这一步,它能帮你发现很多defconfig里隐藏的缺项。
3.3 设备树和板级初始化,各司其职
对于STM32MP157这类板子,设备树里至少要把三块内容确认清楚:
第一,串口。找到&uart4这样的节点,检查pinctrl配置是否和你板上实际使用的引脚一致。引脚复用写错的话,最典型的表现就是串口完全没输出,或者只在复位瞬间吐两三个乱码字符。第二,内存。U-Boot的DDR控制器初始化,在STM32MP1平台上依赖DDR training二进制,这部分板级代码会通过固件接口去调用。在设备树里需要指定内存的大小、位宽和地址映射。第三,SD/eMMC。确认sdmmc1/sdmmc2节点对应的引脚和控制器的index,这直接关系到U-Boot能否从存储介质上读取环境变量和内核镜像。
板级C代码主要补齐设备树描述不了的细节。以STM32MP1为例,你要看board/st/stm32mp1/下的board_init、dram_init、board_late_init这些函数。其中dram_init非常关键,它负责把DDR信息告诉U-Boot的Memory Bank框架:
int dram_init(void) { gd->ram_size = PHYS_SDRAM_SIZE; return 0; }另外还有一个高频坑:spi flash或eMMC的固定时序参数,在U-Boot里往往通过CONFIG_SYS_...宏配置,而不是设备树。如果你发现“设备树节点全对、但读Flash报超时”,就去include/configs目录下找对应的宏定义。
3.4 编译输出与烧写验证
配置无误以后,编译就比较简单了:
make CROSS_COMPILE=arm-linux-gnueabihf- myboard_defconfig make CROSS_COMPILE=arm-linux-gnueabihf- -j4编译成功后在根目录能找到u-boot.bin、u-boot.dtb、u-boot.img。SPL的产物在spl/u-boot-spl.bin。STM32MP157这套平台还需要ST特有的封装步骤生成u-boot.stm32,这个镜像才是烧到SD/eMMC里的最终文件。厂商通常会提供一个mkimage命令配合脚本完成封装。
烧写时我建议先用SD卡调试,因为SD卡可以随时拔下来用读卡器重烧,不用反复去连调试器。把u-boot.stm32写到SD卡的特定偏移地址上,一般以block为单位,具体偏移要去查芯片手册,STM32MP1的做法是把FSBL写到SD卡第1个block开始的偏移区。然后插入开发板,按住Boot选择按钮拨到SD启动模式,上电后看串口输出。
如果你看到串口先输出ROM代码的提示,再输出U-Boot SPL 2024.04,紧接着是U-Boot 2024.04和倒计时提示符,那就说明移植第一步成功了。如果卡在中间某一行不动,别慌,第4部分专门聊排查。
4. 常见问题与排查技巧实录
4.1 编译链接报错:先看函数符号归属
移植中最常见的编译错误,是链接时报undefined reference to 'foo'。看到这种错误,你先别急着去改代码,第一件事是确认这个函数符号属于哪个目录、该目录有没有被编进当前目标。
用grep搜源码定位函数定义,再检查定义所在目录的Makefile,看它的对象文件是否被obj-y或obj-$(CONFIG_XXX)引用。如果CONFIG开关没开,就在defconfig里补上。如果函数在SPL阶段才需要,还要确认CONFIG_SPL_BUILD下Kconfig开关是否覆盖到位。
很多时候你会遇到undefined reference toaaa_board_init',但源码里明明写了这个函数。出现这种灵异现象,十有八九是函数没有加__weak,而造成该函数定义所在的目标文件没被链接。U-Boot大量使用弱符号做板级钩子,比如board_init、board_late_init,如果你自己定义的强符号参与链接,而厂商代码里也有同名弱符号,链接器实际使用哪个取决于链接顺序,这就很容易导致“我改了没生效”。我的解决思路是直接用grep -r '__weak' board/yourboard/看有没有重复定义,用nm`工具确认最终二进制里符号的地址。
4.2 串口没输出,这是永恒的疑难杂症
串口没输出,排在排查第一位的原因往往是波特率不对。ROM阶段和SPL阶段用的波特率是芯片Boot ROM固定的,一般是115200。但如果你在defconfig里把CONFIG_BAUDRATE改成了别的值,就会出现“ROM有输出、SPL开始没输出”的现象。
第二个高频原因是引脚复用不对。U-Boot的引脚配置来源有两个:一个是pinctrl驱动解析设备树,另一个是老的board级CONFIG_SYS_...宏。如果设备树里没有pinctrl节点,有些平台会回退到board目录里的静态配置函数。建议把设备树串口节点的pinctrl-0属性单独核一遍,对照原理图把GPIO管脚确认清楚。
第三个原因比较隐蔽:SPL阶段DDR没初始化成功。内存没起来,SPL代码根本运行不了,自然串口也没反应。这种情况串口输出往往不是完全没有,而是会看到ROM打印了几行,然后卡住。排查思路是先检查DDR的电源时序和复位配置,再检查设备树里的内存映射大小是否正确,最后确认是否定义了正确的DDR控制器型号。比如STM32MP157的DDR training代码,必须与DDR颗粒型号匹配,否则会在初始化时死循环。
4.3 环境变量保存不了,问题可能在存储分区
U-Boot起来以后,用saveenv命令经常会报错,或者重启后设置全部消失。这个问题的根源是环境变量存放位置的定义与实际硬件不对应。
U-Boot用CONFIG_ENV_IS_IN_MMC、CONFIG_ENV_IS_IN_SPI_FLASH等开关决定环境变量存在哪里,再用CONFIG_ENV_OFFSET和CONFIG_ENV_SIZE决定扇区位置。很多人只改了启动介质,没改偏移地址,于是环境变量写到了设备树分区表里的FIP或内核区域,造成数据互相覆盖。
我一般会定义一个专门的empty分区专门给环境变量,哪怕只放两个扇区。这样无论怎么调试,都不会把环境变量和内核镜像挤在一处。改完这些开关后,不要忘了重新执行make myboard_defconfig,因为这类宏通常不会在增量编译时自动刷新。
4.4 配置没生效,请检查CONFIG体系里的“二重门”
最后再分享一个非常典型的“看似简单但坑死无数人”的问题:你在defconfig里加了CONFIG_FOO=y,也重新make了,代码里用#ifdef CONFIG_FOO包起来的分支就是死活不生效。
原因很多时候是:这个宏同时受另一个开关控制。比如Kconfig文件中,FOO可能被定义成depends on BAR,而BAR没有开启,那么即使defconfig写了CONFIG_FOO=y,最终的.config里也会被删掉。
排查方法很直接:用grep CONFIG_FOO .config看看最终生成的.config里到底有没有。然后再用grep -n FOO去Kconfig文件里看它的依赖项。如果你用的是menuconfig,也可以进到对应菜单看它是否变灰。多这一分钟的检查,能省下好几小时的瞎改时间。
还有一个相关的小技巧:如果编译产物里某个.o文件没有更新,往往不是Kbuild的锅,而是你改了源文件却忘了改头文件时间戳导致依赖系统没识别。这时用make clean && make一击重编是最省事的。
最后说点实在的
搞U-Boot移植这几年,我一直有个习惯:每次拿到一块新板子,先按最小配置把串口跑通,再把内存验证通过,再一步步加网络、USB、显示这些外设。每加一个功能就重编译一次、测试一次,绝不贪多。这套方法听着慢,实际速度反而是最快的。
Kbuild这个东西,你说它难,其实核心概念两只手数得过来;你说它简单,但真到排查问题时,你又会发现很多隐藏的依赖关系。我的建议是,把上面提到的defconfig到.config的流程、obj-y到built-in.a的编译链接路径、SPL与主U-Boot的区分逻辑,当成你日常排查前必过的三关,这三关顺了,什么板子到你手里都不会太折腾。
如果你现在正卡在某一块板子的移植上面,不妨按文中的顺序重新理一遍:先确认工具链版本和源码版本匹配,再确认defconfig里的板级名与board目录一致,再确认设备树里处理器节点、内存节点、串口节点都没写错,最后再上电看串口。多数问题,都出在这四个环节中间,而不是什么高深的代码。