直接上手改U-Boot的时候,十个人里有八个是先打开顶层Makefile看两眼,然后被那一堆ifdef、obj-y、lib-y绕晕,最后选择在板级目录里复制粘贴。我刚开始移植U-Boot时也这样,对着include/configs/下的头文件猛改,烧进去发现串口只有回车没有输出,折腾了三天,最后发现是链接脚本里TEXT_BASE和Makefile传参没对上。这篇文章不聊具体某颗芯片的寄存器配置,而是把U-Boot移植过程中和Makefile相关的这条线彻底捋一遍,从顶层构建流程到板级Makefile写法,再到链接脚本生成和常见报错排查。无论是你在做单片机裸机迁移到U-Boot、手里攥着一块新板子想跑起官方代码,还是只是想把一个已有的BSP从旧版本升级到新版本,这份经验都适用。
U-Boot的构建体系是整个移植工作的骨架。很多人把注意力放在board_init_r、dram_init这些C函数上,认为改好它们就能启动,却忽略了Makefile决定了一个文件编不编译、以什么方式编译、链接进哪个段。移植工作里碰到的所谓“怪毛病”,一大半的根源在构建系统而不是C代码。
1. 移植U-Boot,先搞清Makefile在“管”什么
1.1 从一次失败的烧录说起
我最早接触U-Boot移植是在一块国产ARM开发板上。按照网上的教程,先make xxx_defconfig,然后make -j8,顺利得到了u-boot.bin。烧进去之后,板子完全没有反应,连串口都没有任何输出。反复检查硬件、拨码开关、烧录地址,最后发现我用的defconfig里面CONFIG_SYS_TEXT_BASE定义的值和板子实际的内存映射差了0x10000000。而更隐蔽的问题在于,我改完这个宏之后,并没有重新执行配置流程,导致Makefile依然按照旧的参数去生成链接脚本。
U-Boot的构建系统和Linux内核一样,是由嵌套的Makefile体系构成的。直接执行make的时候,顶层的Makefile负责调度整个流程,但它本身并不直接编译多少代码。真正干活的,是各个子目录下的Makefile,以及被include进来的config.mk、scripts/Makefile.build这些辅助文件。理解这个分层结构,是移植工作的第一课。
1.2 U-Boot构建的三个阶段
U-Boot的构建过程大致可以拆成三个阶段:配置阶段、编译阶段、链接阶段。这三个阶段在Makefile里对应三套不同的机制。
配置阶段做的事情,是把defconfig里的CONFIG_XXX=y这样的配置项,转换成编译时真正用到的include/config.h和include/config/auto.conf。前者供C代码使用,#include <config.h>之后就能直接用CONFIG_XXX宏;后者供Makefile使用,通过CONFIG_XXX := y的形式让Makefile能够做条件判断。这一步由顶层的sinclude机制和scripts/Makefile.autoconf完成。在旧版本U-Boot里,这一步靠的是mkconfig脚本,现在则完全由Makefile体系接管。这也是很多移植教程让人困惑的地方——你看老教程说执行make xxx_config(注意是_config,不是defconfig),新代码里早就没有这个目标了。
编译阶段比较好理解,就是遍历各子目录,按需编译.c文件生成.o,再打包成built-in.o。但这里有个移植时容易忽略的点:obj-y和lib-y的写法决定了文件是直接链接进镜像,还是先归档成库。比如板级目录下放一个led.o,写obj-y += led.o,它就会被链进U-Boot主体;如果某些代码你希望按需链接、避免被垃圾回收掉,就要仔细考虑是放在lib-y还是obj-y。
链接阶段,是Makefile里最玄乎也最关键的部分。链接脚本u-boot.lds不是手写的,而是由u-boot.lds.S经过预处理器生成的。预处理器会读取CONFIG_SYS_TEXT_BASE、CONFIG_SYS_UBOOT_BASE这些宏,决定_start入口放在哪个地址。如果你改了内存布局相关的配置项,却不执行配置阶段的命令,那么生成的链接脚本还是旧的,编译出来的镜像烧进去自然跑不起来。
1.3 顶层Makefile、config.mk、scripts/Makefile.build各自“管”什么
把这几层的关系搞明白,移植的时候你就知道该去哪里改东西。
顶层Makefile是总入口,负责解析命令行参数、加载config.mk、处理defconfig目标、设置全局变量(比如CROSS_COMPILE、ARCH、CPU),然后调用子目录的Makefile。移植时最常改的就是CROSS_COMPILE,如果你用的交叉编译器叫arm-none-eabi-,就在执行make时通过参数传入:make ARCH=arm CROSS_COMPILE=arm-none-eabi-。当然现在新版U-Boot更推荐放到环境变量或者defconfig里,免得每次敲一堆参数。
config.mk是顶层Makefile在配置阶段include进来的关键文件,它会根据当前的ARCH、CPU、SOC、BOARD加载对应的arch/arm/config.mk、arch/arm/cpu/armv7/config.mk这些层级配置文件。这里定义了大量针对特定SoC的编译选项、地址参数,比如CONFIG_SYS_TEXT_BASE的默认值往往就散落在这里。有个细节:当你在defconfig里加了CONFIG_SYS_TEXT_BASE,它的优先级会覆盖config.mk里的默认值,但机制上两者是“合并”进Makefile变量的,而不是简单的“后者覆盖前者”,不同版本U-Boot对这个宏的处理方式还发生过变化,这也是移植时一定要留意版本差异的地方。
scripts/Makefile.build更像是一个“通用编译模板”。它定义了单个C文件如何编译成.o、.o如何打包成built-in.o、如何生成.d依赖文件等规则。子目录下的Makefile其实只是声明“我要编译哪些文件”,真正干活的规则都在这份模板里。所以你在板级目录的Makefile里看到obj-y += foo.o,不要觉得奇怪——它只是给模板提供输入。
2. 移植时真正要动的Makefile与配置入口
2.1 板级目录下的Makefile为什么必须改
做移植时,第一步通常是复制别人的板级目录。比如你的板子用的SoC和某款开发板接近,就复制那款板子的整个目录,改名,然后开始改。
复制完之后,板级目录下的Makefile决定了哪些文件参与编译。我见过不少人把新的板级目录加进来之后,执行make xxx_defconfig报错,提示找不到这个配置。原因往往是Kconfig里没有注册这个新板子——这其实也是Makefile体系的一部分,Kconfig被配置系统引用,你没有在arch/.../Kconfig里加source路径,配置目标就生成不了。
板级目录下的Makefile通常写成这样:
obj-y += board.o obj-y += ddr.o obj-y += clock.o移植时,你可能需要在里面加自己的flash.o、eth.o。注意obj-y里的文件名不要带路径,如果你的源码放在子目录里,要写成:
obj-y += ddr/ obj-y += ddr/ddr.o第一个写法把整个子目录编进来,第二个写法单独指定一个文件。对应地,子目录里还要有它自己的Makefile。我见过有人把源文件放在子目录里,但子目录没有Makefile,结果编译时报No rule to make target,一脸懵。这个报错在热词里也出现了,我后面会专门讲。
2.2 defconfig与Kconfig:从“配置”到Makefile变量的链路
新版U-Boot全面引入了Kconfig体系,make xxx_defconfig会读取configs/xxx_defconfig,把里面的CONFIG_FOO=y写入include/config/auto.conf。Makefile在读取这个文件之后,才能判断:
ifdef CONFIG_SPL_BUILD obj-y += spl.o endif这条链路是移植时排查问题的关键。假如你在defconfig里写了一个CONFIG_MY_BOARD_SUPPORT,然后想在这一句的#ifdef CONFIG_MY_BOARD_SUPPORT里添加代码,但编译后一点效果都没有。大概率是你改了defconfig之后没有重新执行make xxx_defconfig,或者执行了make但Makefile认为配置没有变化,没有重新生成auto.conf。养成习惯:每次改defconfig,先执行make xxx_defconfig再编译,不要偷懒。
这里还要提一下include/configs/下的头文件。老版U-Boot大量使用#define CONFIG_SYS_XXX,新版虽然迁移到Kconfig,但板级头文件仍然保留了大量板级参数。C代码里#include <configs/xxx.h>能读到这些宏,但Makefile的ifdef CONFIG_XXX读不到——Makefile只能读auto.conf,头文件里的宏对它无效。这个坑我踩过不止一次:在头文件里加了#define CONFIG_MY_FEATURE 1,然后在Makefile里写ifdef CONFIG_MY_FEATURE,结果死活不生效。正确的做法是,Makefile能感知的配置必须放到defconfig或Kconfig里。
2.3 从热词“makefile 头文件路径”说起
热搜词里有“makefile 头文件路径 rv1106”,这说明很多人碰到了编译时找不到头文件的问题。U-Boot里的头文件搜索路径主要靠-I参数传入。scripts/Makefile.build里会计算出一大堆-I路径,包括include、arch/arm/include、board/xxx/include等。
当你自己写的.c文件在板级目录里,想要#include <my_common.h>,而这个头文件放在同一个板级目录下,光靠默认路径往往找不到。正确的做法是在板级目录的Makefile里加:
ccflags-y += -I$(srctree)/board/$(BOARD)/include或者更规范一点,把头文件放到include/configs/目录下,因为U-Boot默认就会搜索这个目录。但这种做法会让板级头文件越来越臃肿,不符合现在的代码风格。我个人更喜欢把板级独有的头文件放在板级目录的一个include子目录里,然后通过ccflags-y指过去。
关于RV1106这类带NPU的SoC,移植时往往还涉及DDR初始化、电源管理这些二进制固件的加载,这时候Makefile里可能会涉及CONFIG_ROCKCHIP_*之类的配置项和对应二进制文件的复制规则。这些二进制文件放哪个目录、Makefile怎么把它们打包进最终镜像,和头文件路径问题一起,构成了瑞芯微平台移植时最常翻车的两个点。遇到这类情况,最好的办法是找官方BSP里同系列板卡的Makefile作为参照,别自己瞎写路径。
3. 链接脚本与镜像生成:Makefile里最容易被忽略的环节
3.1 u-boot.lds不是手写的,是算出来的
很多人以为u-boot.lds是像单片机工程里那样手写出来的链接脚本,其实U-Boot的链接脚本是.S文件经过C预处理器生成的。顶层Makefile里有一条规则,大意是把arch/arm/cpu/armv7/u-boot.lds.S(不同架构路径不同)通过$(CPP)处理生成u-boot.lds。
这个过程会把代码里的宏全部展开,比如:
#include <config.h> SECTIONS { . = CONFIG_SYS_TEXT_BASE; ... }最终生成的u-boot.lds里CONFIG_SYS_TEXT_BASE会被替换成具体的数字。这意味着,如果你修改了CONFIG_SYS_TEXT_BASE,它不只是影响C代码里的宏,还影响整个镜像的链接地址。而链接地址错了,烧进去之后的表现五花八门:有完全没反应的、有串口正常但go命令起不了内核的、有能起来但跑着跑着就死了的。
排查这类问题有一个实用技巧:编译之后在源码根目录下查看生成的u-boot.lds,确认里面的地址和你设想的是否一致。不要只看defconfig里写了什么,要看Makefile真正生成了什么。我在实际调试中经常执行:
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- u-boot.lds单独重新生成链接脚本,然后打开看。这样可以快速确认地址相关问题。
3.2 镜像格式生成那几行命令
make之后,你会得到u-boot(ELF文件)、u-boot.bin(裸二进制)、u-boot.img(带U-Boot头)等文件。这些格式转换的命令都写在顶层Makefile里。
主要有几个关键步骤:链接得到u-bootELF;用objcopy -O binary抠出u-boot.bin;用mkimage工具加上校验头,生成u-boot.img。如果你做的是SPL(Secondary Program Loader),还会有u-boot-spl.bin,它的生成规则单独一套。
移植过程中最常用到的操作是改mkimage的参数。比如有些平台要求镜像头的加载地址和入口地址分开设置,需要在MKIMAGE相关变量里调整-T、-A、-C、-a、-e这些参数。这里有个常见混淆:-a是加载地址,-e是入口地址,两者可以不同。很多人在SPL跳转U-Boot时卡住,检查一下u-boot.img的头信息,发现-a和-e写反了或者都写成了DDR地址,而SPL直接把U-Boot加载到了SRAM,那当然跳不过去。
查看镜像头信息用:
mkimage -l u-boot.img你会看到类似Load Address和Entry Point两行。移植时多看一眼这个输出,能省掉很多瞎猜的时间。
3.3 调试时常用的Makefile变量与技巧
U-Boot的Makefile体系里有一些变量,调试移植问题时很有用。
首先是V=1,这是我最常用的。执行make V=1,Makefile会把每条实际执行的命令完整打印出来。之前编译报错,你想知道某个.c文件到底用的什么头文件搜索路径,执行make V=1然后复制出错的那条编译命令,自己手动加-E展开预处理看宏定义,排查效率极高。
其次是O=指定编译输出目录。如果你同时维护几个不同配置的板子,强烈建议用:
make O=build/boardA xxx_defconfig make O=build/boardA -j8这样不同板子的中间文件不会互相污染,切换配置时也不用make distclean。这个习惯在我同时弄两块板子的时候帮了大忙。
还有一个容易被忽略的变量是KBUILD_VERBOSE,效果和V=1类似。另外,CROSS_COMPILE设置里可以带绝对路径,比如:
make CROSS_COMPILE=/opt/toolchains/arm-2014.05/bin/arm-none-eabi-确保没有配错工具链。这里要注意的是,U-Boot对工具链版本敏感性不低,太老或太新的编译器都可能引发奇怪的链接问题。我之前遇到过用GCC 12编译旧版U-Boot,链接阶段报lzma相关的错,换用GCC 8就没事。所以移植时记录一下验证过的工具链版本,能少很多折腾。
4. 常见报错与排查技巧实录
4.1 make: *** No rule to make target,找不到makefile怎么办
热搜词里“make没有指明目标并且找不到makefile”是很多人第一次接触U-Boot编译时的拦路虎。这个报错的原因通常有两个:一是你根本不在U-Boot源码根目录下执行make,二是当前目录没有Makefile文件。
很多初学者把u-boot.bin当成一个独立文件,下载源码压缩包后只解压了部分文件,或者直接在一个空的build目录里执行make。U-Boot的构建体系要求你在源码根目录下操作,除非你用了O=参数指定输出目录,但即便指定了输出目录,Makefile本身还是从源码根目录读取的。
如果是“编译途中出现No rule to make target”,那问题往往出在某个子目录的Makefile引用了不存在的文件。比如你加了一个obj-y += foo.o,但foo.c并不存在,或者foo.c被放在了别的目录。Makefile在解析依赖时找不到规则,就会报这个错。
我的排查步骤是:
- 确认当前目录有没有
Makefile,没有就进源码根目录。 - 执行
make V=1看完整输出,找到报错前最后处理的目录是哪个。 - 进到那个目录,检查它下面的Makefile,确认
obj-y或lib-y里写的文件名是否真实存在。 - 如果文件名存在但还报错,检查是否有大小写差异。Linux文件系统区分大小写,
FOO.c和foo.c是两个文件。
4.2 头文件路径不对与配置项搜不到
编译时报fatal error: xxx.h: No such file or directory,这个基本就是上面说的头文件搜索路径问题。我需要区分两种情况:第一种是你的源码里引用了U-Boot本身带的头文件,但名字写错了或者路径不对;第二种是你引用了自己添加的头文件,但没有把它的目录加进ccflags-y。
第一种情况,优先检查#include的写法。U-Boot的公共头文件在include/目录,可以用#include <common.h>这种尖括号写法。板级头文件在include/configs/目录,通常写作#include <configs/xxx.h>。如果你加了一个自定义头文件到include/下,直接用#include <myheader.h>是可以找到的,因为-Iinclude在默认搜索路径里。但如果你把它放在板级目录下,就一定要加-I。
第二种情况,我给一个可以直接抄的板级Makefile示例:
ccflags-y += -I$(srctree)/board/$(BOARD)/include obj-y += board.o obj-y += my_driver.o这里$(srctree)是源码根目录,$(BOARD)是板级目录名。加上这一行,board/xxx/include/myheader.h就能被找到了。
还有一个和“配置项搜不到”相关的坑:你明明在defconfig里写了CONFIG_FOO=y,但在C代码里用#ifdef CONFIG_FOO却始终不生效。原因很可能是你用了CONFIG_FOO而Kconfig里定义的是CONFIG_FOO_SUPPORT,或者auto.conf还没有重新生成。执行一下:
grep CONFIG_FOO include/config/auto.conf看这个文件中到底有没有你想要的宏。这是我和Makefile打交道时最高频的排查命令。
4.3 编译通过但链接失败
编译一行不报错,最后链接的时候报undefined reference to 'xxx',这在移植时也极常见。
通常原因有两个。第一个是obj-y里没有包含提供这个符号的文件。比如你在board.c里调用了ddr_init(),但ddr.o不在obj-y里,链接时自然找不到。第二个是函数声明和定义不匹配,常见的是inline函数在某个编译单元里被优化掉了,导致没有生成符号。这时可以看链接器的报错信息,它会指出哪个文件引用了哪个符号,顺着去找对应文件是否被编译、是否被链接进来。
另一个链接阶段的隐蔽问题是u-boot.lds要求某些段必须存在。如果你裁剪了很多功能,某些段因为内容为空被链接器丢弃,但链接脚本里还有对应的. = ALIGN(4);语句,有时会引发奇怪的address溢出问题。解法是打开u-boot.lds看具体是哪个段不对劲,再回到Makefile里决定哪个文件必须保留,比如在板级Makefile里把关键对象放obj-y,确保它不会被丢弃。
下面把上面提到过的常见排查点整理成一个速查表:
| 现象 | 可能原因 | 排查命令/手段 |
|---|---|---|
| 找不到makefile | 不在源码根目录执行make | ls Makefile,确认有文件再make |
| No rule to make target | Makefile里引用文件不存在或路径错 | make V=1定位目录,检查obj-y |
| 头文件找不到 | 缺少-I路径或文件名错误 | 查ccflags-y,手动预处理看搜索路径 |
| 配置宏不生效 | auto.conf未更新,或宏写在头文件里 | grep CONFIG_FOO include/config/auto.conf |
| 链接undefined reference | 目标文件没编进obj-y | make V=1看链接命令,查built-in.o |
| 烧录无输出 | TEXT_BASE错误或链接脚本旧 | 检查u-boot.lds实际地址 |
| 镜像头信息不对 | mkimage参数错误 | mkimage -l u-boot.img查看-a/-e |
4.4 和“新旧U-Boot版本差异”有关的坑
U-Boot版本迭代很快,不同版本的构建系统差异大到像两个项目。如果你拿着旧版移植笔记硬套新版,会踩很多坑。
旧版U-Boot(2014年之前那种)用make xxx_config生成配置,新版用make xxx_defconfig。旧版大量使用include/configs/下的头文件定义宏,新版本逐渐把这些宏迁移到Kconfig。旧版顶层的config.mk写了很多逻辑,新版把这些逻辑拆分到了arch/arm/mach-xxx/config.mk或者scripts/Makefile.spl里。
个人建议:移植之前先确定主线版本,然后只参考该版本对应的官方文档和同版本的其他板级移植案例。不要拿2013年的教程去套2020年的代码,也不要把新版代码的方式硬塞回旧版框架。我在多个项目中深有体会:版本不同,Makefile细节不同,但最顶层的那一套obj-y、lib-y、ccflags-y思想是始终沿用的。
5. 一些关于Makefile的“移植心法”
这里说的“心法”不是玄学,而是踩过足够多的坑之后沉淀下来的做事习惯。
第一,改任何配置或Makefile之后,先执行make xxx_defconfig再编译。不要觉得多余。Makefile的依赖判断并不总是可靠的,尤其当你改了Kconfig相关文件之后,有时不会自动触发重配置。强制重新生成一次include/config/auto.conf,能从源头上消除一大类“改了没生效”的问题。
第二,编译输出目录隔离。同时维护多个板型时,make O=build/boardA和make O=build/boardB分开,切换板型不需要反复distclean,也不用担心中间文件串味。这个习惯能省下大量等候编译的时间。
第三,出现诡异问题,先看V=1输出和实际生成的文件,而不是猜。make V=1会打印完整的命令行,u-boot.lds、include/config/auto.conf这些产物都在源码树里躺着。打开看,真相往往就在那里——是地址没对上,还是宏没展开,一眼就能看出来。
第四,移植时按“最小改动”原则。本来能编译通过的代码,不要因为风格问题顺手大改Makefile。一次只动一个变量,编译验证一次。我见过有人为了“优化”把板级Makefile从obj-y改成lib-y,结果因为符号链接顺序问题引入了一堆undefined reference,最后花了两天排查。构建系统这东西,稳定运行的就是好的,不要做无谓的“优化”。
第五,留意工具链版本。U-Boot对编译器的隐式依赖比想象中多,尤其涉及内嵌汇编、链接脚本预处理时。一个已知可用的工具链版本,比一个最新的工具链更值得用于移植调试。新版工具链如果出现问题,先别急着怀疑自己的代码,试着切换到官方验证过的工具链版本跑一遍,能迅速缩小排查范围。
我在实际项目中还有一个习惯:每次执行编译,都把命令行吹出来的完整log保存下来,命名带上版本和时间。比如build_20250112_v3.log。出了问题,翻log比重新编译更快,还能对比两个版本之间的编译差异。这个习惯帮我定位过好几次“之前能编过现在编不过”的回归问题。
说到底,U-Boot移植的本质不是把代码“改”到能跑,而是把Makefile背后隐藏的构建逻辑理顺。你只要搞懂配置怎么传递、文件怎么编进来、链接地址怎么确定,那些看起来神乎其神的“移植技巧”其实都是水到渠成的事情。希望这篇围绕Makefile的拆解,能让你下次面对一块新板子时,少走几步弯路。