做嵌入式开发的人,拿到一份U-Boot源码,大概率都扫过顶层目录里那个config.mk。它不像defconfig那样改一改就能配置板卡,也不像顶层Makefile那样是默认的编译入口,但如果你在移植新板卡、换交叉工具链或者追查某个链接报错时一路深挖,最后往往就会翻到这个文件。简单说,顶层config.mk是U-Boot构建体系里一个被顶层Makefile包含进来的全局配置文件,负责定义贯穿整个编译过程的平台相关变量,包括工具链前缀、平台编译选项、链接脚本查找路径等。这篇文章我想以主线U-Boot里仍然存在的顶层config.mk为对象,把它在构建系统里的位置、核心变量逻辑和常见排查手法讲清楚。不需要你精通Makefile,但最好有最基本的阅读经验,这样跟着思路走会更顺。
1. config.mk在U-Boot构建体系里的真实位置
1.1 它不是一个普通的构建规则文件
先建立一个整体印象。U-Boot的构建过程分两段:配置阶段和编译阶段。配置阶段由scripts/kconfig下的conf程序完成,读取configs目录下的_defconfig文件,解析Kconfig,最后生成include/config/auto.conf、include/generated/autoconf.h这些产物。编译阶段从顶层Makefile开始,它负责定义所有目标、调度所有子目录的编译,而顶层config.mk就在这个阶段发挥作用,被顶层Makefile通过include指令加载。include意味着什么?意味着config.mk里的内容在Makefile解析到include这一行时就被展开,它定义的变量会被后续所有规则、子make过程共享,但它本身不产生任何编译动作,也不生成目标文件。
我在实际调试中经常遇到一种误解:新手以为config.mk是一个可执行脚本,甚至会尝试./config.mk。它不是脚本,它就是一个make片段,里面没有命令,只有变量赋值、条件判断和少数探测逻辑。理解了这一点,就不会在排查问题时走错方向:想找“谁在编译时做了什么”,先看顶层Makefile;想知道“某个平台相关变量初始值是什么”,再来看config.mk。它在U-Boot构建系统中的定位更像是一个“变量总表”,由它把这些跨文件、跨目录都会用到的平台参数集中到一个地方。
再补充一个容易忽略的细节:顶层config.mk开头往往会做shell环境准备,比如把SHELL设成bash,并且探测主机架构。这些探测结果会直接影响后续的变量判断。U-Boot的构建系统大量依赖bash特性,这就是为什么在一些默认shell为dash的Linux发行版上编译U-Boot偶尔会出莫名其妙的问题,解决办法通常就是确保bash存在并把SHELL指过去。
1.2 相关构建文件的分工对照
U-Boot源码树里有多个config.mk,这是最容易混淆的地方。顶层的config.mk管全局平台变量,arch/$(ARCH)/config.mk管某一种CPU架构下的补充参数,board/$(VENDOR)/$(BOARD)/config.mk管具体板卡级参数。三层通过include形成链条:顶层Makefile先include顶层config.mk,然后不同目录的子Makefile在编译时又会include自己的config.mk,而子目录的config.mk一般会继续include顶层config.mk,从而保证平台变量一层层传递下去。
| 文件 | 职责划分 | 典型内容 |
|---|---|---|
| 顶层config.mk | 全局平台变量、工具链探测、链接脚本路径 | CROSS_COMPILE、PLATFORM_系列变量、LDSCRIPT查找 |
| arch/$(ARCH)/config.mk | 架构级编译与链接参数 | 架构特有-march、-mabi等选项 |
| board/$(VENDOR)/$(BOARD)/config.mk | 板级参数与覆盖 | 板级特有LDFLAGS、DDR参数等 |
| scripts/Makefile.lib | 汇总编译、链接、objcopy的最终参数 | KBUILD_CFLAGS、KBUILD_LDFLAGS的组装 |
| scripts/Makefile.build | 递归进入子目录执行具体编译规则 | 目标文件编译、链接、目录递归 |
这个表格基本把U-Boot构建体系里最关键的一圈文件都串起来了。我个人的经验是,绝大多数构建问题最终都发生在“变量在某个层级被赋值、在另一个层级被使用”的过程中。只看顶层config.mk是不够的,还要顺着include链条往下追。尤其是当你看到某个变量在三个层级都被追加赋值时,如果不知道追加顺序,很容易对最终值产生错误判断。make对变量的展开是“懒惰展开”,要到真正使用时才会求值,这时候“+=”的先后顺序就变得非常重要,先追加还是后追加,对最终命令行的影响天差地别。
2. 顶层config.mk核心变量逐段拆解
2.1 CROSS_COMPILE:工具链前缀的三层来源
在config.mk里,最重要的一个变量是CROSS_COMPILE。它的来源有三个:命令行、环境变量、文件内兜底。命令行最简单直接,make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf-这种方式值优先,因为make命令行变量的优先级最高。环境变量方式就是把CROSS_COMPILE提前export到shell环境,再执行make。文件内兜底主要是一段类似于“当主机架构和目标架构相同时,CROSS_COMPILE可以为空”的判断,比如在x86主机上编译x86平台的U-Boot,本机gcc就是“交叉”编译器,此时开发者不必手动指定CROSS_COMPILE。
这段逻辑看起来简单,但实际影响非常深远。我见过好几次这样的报错:有人要编译ARM板卡,只设了ARCH=arm,忘了设CROSS_COMPILE,编译时gcc直接收到-march=armv7-a这类ARM专属选项,然后报“unrecognized command line option”。从报错追根溯源,问题就出在工具链没有切换成ARM交叉编译器。如果此时去看config.mk里CROSS_COMPILE的赋值逻辑,会发现它确实没有能力帮你去“猜”一个前缀,它只能判断主机架构与目标架构一致时省略前缀。所以移植板卡时,要么在环境变量里固定配好,要么写一个小的构建脚本统一export,不要依赖make帮你自动识别。
这里还有一个比较隐蔽的细节:CROSS_COMPILE变量末尾的连字符。很多新手会写成arm-linux-gnueabihf而漏掉末尾的-,结果就是编译时命令变成了arm-linux-gnueabihfgcc,shell直接报command not found。这个错误和config.mk本身的逻辑没有关系,但因为报错信息看起来像工具链配置问题,很多人会误以为是构建脚本写错了。我建议在脚本里对这种低级配置错误做一次显式检查,至少编译开始时打印一下最终采用的CROSS_COMPILE,省得后面抓瞎。
2.2 PLATFORM系列变量:平台参数的第一级入口
顶层config.mk里还维护着一组以PLATFORM_开头的变量,主要有PLATFORM_RELFLAGS、PLATFORM_CPPFLAGS和PLATFORM_LDFLAGS。这三者的职责很清晰:PLATFORM_RELFLAGS用于和位置无关代码相关的一些选项,比如某些架构需要-fPIC;PLATFORM_CPPFLAGS用于平台相关的预处理和编译选项,汇总后进入最终的KBUILD_CPPFLAGS;PLATFORM_LDFLAGS用于平台相关的链接选项,最终会进入u-boot链接命令。这些变量在顶层config.mk只是初始化成空值,具体内容由后续架构级或板级config.mk追加。
这里有一个容易踩的认知误区:顶层config.mk写了很多变量,但它们的“最终值”往往不是在这个文件里确定的,而是由下层文件一层一层追加。U-Boot的编译参数采用的是“逐级追加”模式,不是“最终覆盖”模式。比如在arch/arm/config.mk里追加PLATFORM_CPPFLAGS += -mno-unaligned-access,这条追加的内容与顶层config.mk里的初始空值合并后,才形成真正进入编译命令的参数。所以当你看到一个编译选项行为异常时,不要只盯着顶层config.mk,要顺着“include链”和“+=链”一起看。
为什么要设计成逐级追加而不是集中赋值?我的理解是为了兼顾通用性和可移植性。U-Boot支持的架构和板卡太多了,如果把所有平台的参数都堆在顶层config.mk里,这个文件会膨胀到没人敢动。采用“顶层定义空容器、下层按需追加”的方式,每个板卡只需要关注自己要追加的那几个选项,互不干扰。这也符合Unix下“机制与策略分离”的设计思想:config.mk提供机制,各平台目录提供策略。理解这一点之后,你再看到某个PLATFORM_变量时,第一反应就不再是“去顶层找它的值”,而是“去各arch和board目录找谁在往里面追加东西”。
2.3 LDSCRIPT:链接脚本查找顺序
链接脚本u-boot.lds决定镜像的内存布局。顶层config.mk中有一段影响深远的查找逻辑,顺序大概是这样的:如果定义了CONFIG_SYS_LDSCRIPT,直接采用该路径;否则先看arch/$(ARCH)/cpu/目录下有没有u-boot.lds,有就采用;没有再看board/$(BOARDDIR)/下有没有u-boot.lds,有就采用;都没有就报错并终止编译。这个查找逻辑的意图很明显:CPU级的链接脚本优先,板级定制脚本作为兜底,同时给用户留出通过CONFIG_SYS_LDSCRIPT完全自定义的入口。
实际使用时有一个非常经典的坑:BOARDDIR这个变量在顶层Makefile里是从board/$(VENDOR)/$(BOARD)这样的路径提取的,不同版本U-Boot对VENDOR的处理不太一样。如果你的板卡目录是board/abc/def这种没有VENDOR层的,那BOARDDIR通常会退化成def;如果你的板卡有VENDOR层比如board/fsl/imx8mp_evk,那BOARDDIR就是imx8mp_evk。如果defconfig里配置的板卡名和实际路径对不上,LDSCRIPT查找就会落在错误路径上,最终报“找不到u-boot.lds”。这种问题用眼睛看很难发现,最好的办法就是2.3节里提到的打印变量法,把LDSCRIPT和BOARDDIR两个值打出来,一眼就能看出路径组合对不对。
还有一个值得注意的点:链接脚本查找用的是wildcard函数,它在make解析阶段做文件存在性判断。如果你在做out-of-tree构建,源码目录和输出目录分离,那么这里的相对路径判断容易出现“文件明明在那里但wildcard就是找不到”的情况。遇到这种问题,优先检查srctree和objtree两个变量是否指向了正确的目录,而不是怀疑链接脚本本身写错了。
3. 链接器特性探测:为什么config.mk要做“工具链体检”
3.1 -rdynamic与动态符号表
在U-Boot运行过程中,有一部分机制需要在运行时根据符号名查找函数地址,这就是-rdynamic存在的意义。u-boot.bin不带这个选项的时候,动态符号表不会导出,像source这类命令执行时就会遇到符号解析失败。config.mk并不是盲目地给所有平台都加这个参数,它会先判断工具链支不支持。具体做法是在make解析阶段,通过一个小测试文件的编译链接来验证,支持就把选项加入LDFLAGS,不支持就跳过。这个思路和Linux内核Kconfig里编译测试的探测方式非常像。直接写死的坏处很明显:不同工具链对链接选项的支持程度不一样,写死了,老工具链编译到链接环节直接报错,连绕过的手段都没有。
我在一个项目里真的遇到过这个问题。当时的交叉编译器版本比较老,对-rdynamic的部分flag组合支持不完整,链接阶段直接报错。由于整个U-Boot构建系统默认认为这个选项是可用的,报错信息又出现在链接u-boot可执行文件那一步,很难一眼看出是-rdynamic引起的。最后是把LDFLAGS里的参数逐项去掉做二分定位,才确定就是它。从那以后我形成了一个习惯:拿到一套新工具链,先在一个最小C程序上把所有要用到的链接选项都试一遍,确认没问题再往U-Boot上套。省下的时间远比测试那几下多。
3.2 架构相关的特殊链接需求
除了-rdynamic,不同CPU架构还有一些特殊链接需求。ARM在从汇编启动阶段到C语言阶段通常涉及位置无关和重定位处理,这些参数大多放在arch/arm/config.mk或cpu目录里;PowerPC早期则需要对TOC表、外部符号引用做特殊控制,历史上也出现在顶层config.mk里。我倾向于把这部分理解为:凡是会影响“顶层整体链接命令”的、跨架构差异明显的选项,都有可能在顶层config.mk里出现;而那些只影响某个子目录编译的,就会被下放到对应架构或板级目录。这样划分带来的好处是,你在移植一个全新架构时,第一件事就是先看arch/$(ARCH)/config.mk里已有的架构选项,再决定要不要改顶层文件。
还有一个我后来才想明白的设计:顶层config.mk里相当一部分关于目标文件格式处理的内容,本质上是为了服务OBJCOPY流程。U-Boot最终要生成的不只是ELF文件,还要通过objcopy转成raw binary、通过mkimage加上头部信息变成可以烧写的镜像。OBJCOPYFLAGS这个变量就是在这种情况下被引入的。如果某个板卡要求生成镜像时去掉某些section,比如去掉.note.gnu.build-id,通常会在板级config.mk里追加OBJCOPYFLAGS。这种需求看起来很简单,但如果没有理解“ELF转binary时哪些段保留、哪些段丢弃”的机制,很容易改出一个烧进去完全跑不起来的镜像。我的建议是,遇到镜像生成问题时,先不要急着改config.mk,先用readelf -S看看ELF文件里各段的属性,再决定OBJCOPYFLAGS该怎么调。
3.3 从config.mk变量到最终链接命令
最终生成u-boot这个ELF时,链接命令大概会把config.mk里定义、又被后续各级文件追加重组过的LDFLAGS全部带上,再加上目标文件和库文件,最后通过$(LD)执行。我们需要理解这一段“变量到哪里去”的过程,因为排查问题时你要从最终命令往后倒推。一个实用技巧是,在开发板的board目录下建一个config.mk,往里写KBUILD_LDFLAGS += 你想要的参数,而不是去改动顶层config.mk,因为顶层文件改动影响面大,只改板级文件则把差异限定在本板卡内,后续和主线同步时的冲突面也小得多。
这里也涉及一个关于make变量展开顺序的经典问题。很多人以为在config.mk里给LDFLAGS赋值,就能直接作用到链接命令,实际不然。U-Boot的构建体系里,LDFLAGS这个变量会在多个文件里被引用,而且scripts/Makefile.lib里很可能存在一层变量再封装。如果你在板级config.mk里写了LDFLAGS = xxx,而顶层Makefile里用的是LDFLAGS += yyy,那么赋值顺序不同,最终结果也完全不同。我吃过一次亏:在板级config.mk里用=给LDFLAGS赋值,结果把顶层Makefile里已经追加的参数全部覆盖掉了,链接出来的u-boot缺了一堆关键选项,启动阶段直接挂掉。从那以后,我给自己定了一条规矩:在U-Boot这类大型Kbuild体系里,尽量用+=追加,不要用=覆盖。除非你非常清楚你要清空某个变量。
4. 排查实战:config.mk引起的典型问题
4.1 链接脚本u-boot.lds找不到怎么办
典型的报错可能长这样:
make[1]: *** No rule to make target 'board/xxx/u-boot.lds', needed by 'u-boot.lds'. Stop.第一次见到这个问题的开发者通常会去检查顶层Makefile,但真正原因几乎都集中在两处:一是defconfig里的CONFIG_SYS_LDSCRIPT指向了一个不存在的文件;二是BOARDDIR变量拼错,导致board下的实际路径没有被正确找到。排查步骤上,我建议先用make V=1看具体命令,确认make期望的u-boot.lds路径到底是什么;然后检查board目录里文件真实存在;最后在config.mk里临时打印LDSCRIPT变量,看看它最终被解析成了什么。
# 临时调试,在config.mk末尾追加 $(warning "LDSCRIPT=$(LDSCRIPT) BOARDDIR=$(BOARDDIR)")这样重新make,make解析阶段会把这两个变量的实际值打到屏幕上。注意这种warning输出会在make每一步解析时反复出现,建议配合2>&1 | grep warning过滤,调试完一定要删掉,否则会污染构建输出。还有一个容易被忽略的细节:有时候报错信息里写的是No rule to make target ... needed by 'u-boot.lds',但真正的u-boot.lds文件可能在另一个目录里确实存在,问题反而是Makefile中这个“文件依赖”关系本身没有建立。U-Boot的顶层Makefile里有一段专门负责把LDSCRIPT复制成u-boot.lds的规则,如果这段规则因为变量为空而没有触发,也会出现上面的报错。所以排查时要把“找文件”和“找规则”两条线同时走。
4.2 换工具链后链接选项失效
有项目原本用某个厂家的交叉工具链,后来因为某些原因切换到另一个厂家的。新工具链对某些链接选项的语义不一样,结果在链接u-boot时报错。最直接的排查办法是把新旧工具链的完整链接命令放到一起对比,看差异出在哪一个选项上。具体操作是分别执行make V=1,把链接u-boot那行命令保存下来,然后逐项对比。如果问题确实出现在config.mk或PLATFORM_LDFLAGS层级的选项上,可以先用命令行临时覆盖来做A/B测试,例如:
make PLATFORM_LDFLAGS="" BOARD=xxx不过要提醒一点,make对变量的处理有一条经典规则:命令行变量会比Makefile里用=赋的值优先级更高,但如果Makefile里用了override指令,命令行覆盖就不生效。所以在临时覆盖之前,先确认相关变量在Makefile里有没有被override保护。这种问题在工具链切换时非常常见,因为不同工具链对同一选项的宽容度差别很大。比如某个选项在旧工具链下只是警告,新工具链直接报错;反过来,新工具链默认开启的特性,旧工具链完全不支持。遇到这种情况,我的建议是先不要试图在config.mk里把所有选项都改成“零”,而是确认主工具链、备用工具链之间的最小公共选项集,然后把差异部分放到板级config.mk里,这样即使后续又要换回旧工具链,也只需要改动一个文件。
4.3 打印变量与依赖缓存问题
还有一个我踩过多次的坑:修改了config.mk里的变量,重新make后却感觉没有生效。原因往往是构建系统对依赖文件的处理:U-Boot会生成一堆.cmd文件记录编译命令和依赖关系,如果变量变了但没有触发对应文件的重新编译,旧目标文件就被直接拿过来用了。所以只要动了config.mk这类全局文件,最稳妥的做法是至少执行一次make clean,必要时make mrproper完全清除重新配置。另外,想看某个变量的来源时特别推荐用$(origin),它比单纯打印值更有用,能告诉你这个变量来自命令行、环境变量还是文件内定义。
$(info CROSS_COMPILE value=$(CROSS_COMPILE) origin=$(origin CROSS_COMPILE))用origin可以快速判断你面对的变量到底是“被人为指定”的还是“默认兜底”,这在排查“为什么我明明设置了却不生效”时非常关键。我印象很深的一次问题是,某位同事在环境变量里设了CROSS_COMPILE,但Makefile里又被某个条件判断重新覆盖成了空值,导致交叉编译器前缀变成了空。当时就是靠$(origin)打印才发现CROSS_COMPILE的来源已经不再是环境变量,而是文件内的强制赋值。如果只看value不看origin,这个问题几乎不可能定位。
还有一个关于依赖缓存的经验是,很多老手习惯在修改config.mk后只重新编译单个文件来验证,比如make arch/arm/cpu/armv7/start.o,但这种做法容易被.cmd文件误导。因为变量发生变化后,某些文件确实会重新编译,但另一些依赖了旧变量值的文件可能不会。所以我的做法是:改完config.mk,先make clean,再全量编译一遍,虽然慢一点,但结果可靠。在自动化构建脚本里,如果输入参数涉及平台变量变化,我甚至会直接换一个输出目录来做干净构建。
5. 从移植经验看config.mk的改动边界
5.1 多数情况其实不需要改顶层config.mk
我见过很多同事在移植板卡时习惯性地修改顶层config.mk,其实大多数情况不需要。板级特性优先放进板级目录,CPU架构特性优先放进arch目录,真正需要动顶层config.mk的场景并不多:一是底层工具链更换,导致某些默认链接选项必须调整;二是全新架构Bring-up,需要先在顶层建立平台默认参数;三是一些比较老的历史板卡还在依赖顶层config.mk里的遗留逻辑。其他情况下,改了顶层config.mk反而会增加维护成本,尤其后续跟上主线版本同步时,每一行改动都可能变成冲突点。
从U-Boot演进的大趋势来看,传统的config.mk体系正在逐渐被Kconfig和Kbuild体系吸收。以前很多写在config.mk里的功能选项,现在都变成了Kconfig里的配置项,比如“是否支持某个命令”“是否使用某种驱动框架”。顶层config.mk的职责范围在收窄,但它作为“平台差异与编译流程之间的转接口”这个定位依然存在。理解这一层演进,你会发现很多网上老教程里关于config.mk的写法已经过时了,因为它们是基于2010年前后的U-Boot版本。现在再遇到一个构建问题,第一反应应该是去搜一下Kconfig里有没有对应的选项,而不是直接打开config.mk开改。这不是说config.mk不重要了,而是它的边界越来越清晰,越来越关注“工具链和链接脚本”这些底层事情。
5.2 一条变量追踪的实战经验
最后分享一个我自己在项目中用得很顺的排查手法。遇到“某个编译参数为什么不生效”这类问题,先别急着改,按下面这个顺序走一遍:先make V=1找到最终命令,确认参数到底有没有出现在命令行里;如果没有出现,就grep源码树看这个变量在哪些文件里被定义和引用;再用$(origin)和$(warning)打印变量来源与中间值;最后顺着include链从顶层config.mk一路追到scripts/Makefile.lib,看清它到底是在哪一段被合并进KBUILD_CFLAGS或KBUILD_LDFLAGS的。这个流程看着笨,但U-Boot构建系统变量链路本身就很长,靠“猜”往往会浪费时间。
我用这个流程定位过不少问题,印象最深的是一个“板级配置不生效”的案例。当时我在arch/arm/config.mk里加了一个架构选项,但编译时始终没进命令行。用grep一查,发现这个变量名和scripts/Makefile.lib里某个中间变量重名了,导致我在arch层追加的内容被后来的赋值覆盖。如果没有从最终命令倒推,只是盯着我修改的那个文件看,可能永远发现不了是“同名变量在不同文件里互相覆盖”的问题。这也是我坚持建议“从最终命令倒推”的原因:配置系统再复杂,最终一定会在命令行上暴露真实结果。
做嵌入式系统,很多时候就是在“看不懂为什么要这么做”的地方踩出经验。顶层config.mk就是这样:单独看它好像只是几十行变量赋值,但放进整个构建体系里,它就是平台差异与编译流程之间的那个转接口。我个人的习惯是,碰到构建问题先不动它,先用打印把变量链路摸清楚,再决定改哪里。如果你也想在U-Boot构建这块深入一点,建议拿一份主线代码,试着grep一下PLATFORM_CPPFLAGS和LDSCRIPT这两个变量的完整流转过程,再故意把一个链接脚本路径改错制造一次报错,按上面流程排查一遍。这个练习做完,你对整个构建系统的理解会上一个台阶。