U-Boot移植实战:Kbuild构建系统与STM32MP157开发板移植全解析
刚拿到一块新开发板我的习惯不是急着照原理图而是先把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平台采用SPLSecondary 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_defconfigKbuild会依据Kconfig树把defconfig里的内容展开成.config。然后进入编译阶段时再由conf工具生成两个关键产物一个是include/generated/autoconf.hC代码靠它知道某个宏是否开启另一个是include/config/auto.confMakefile靠它决定编译哪些文件、定义哪些变量。这里有个非常容易踩的坑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_LOGOy时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单独编译时的V1输出看实际参与编译的文件名不要凭感觉。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_COMPILEarm-linux-gnueabihf- myboard_defconfig make CROSS_COMPILEarm-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_FOOy也重新make了代码里用#ifdef CONFIG_FOO包起来的分支就是死活不生效。原因很多时候是这个宏同时受另一个开关控制。比如Kconfig文件中FOO可能被定义成depends on BAR而BAR没有开启那么即使defconfig写了CONFIG_FOOy最终的.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目录一致再确认设备树里处理器节点、内存节点、串口节点都没写错最后再上电看串口。多数问题都出在这四个环节中间而不是什么高深的代码。