☰
U-Boot移植实战:从Kconfig到串口出字的完整流程与排坑指南
2026/10/8 1:04:31 网站建设 项目流程

1. 移植U-Boot,先弄明白你手里到底有什么

1.1 一块裸板怎么会走到Linux内核这一步

拿到一块全新板子,第一件事不是写应用、不是调系统,而是得让一把“钥匙”把CPU从出厂状态里接进来。这把钥匙在绝大多数ARM Linux方案里就是U-Boot。你可以把它理解成一个“二级中转站”:芯片内置的BootROM先把U-Boot拉起来,U-Boot完成硬件初始化和内核加载,再把你真正的操作系统交给CPU。

很多刚接触嵌入式的人以为移植U-Boot是“从零写一个引导程序”,其实完全不是这么回事。U-Boot在主流架构上已经支持了全球几千块板子,芯片级的东西(CPU初始化、Cache、MMU、架构相关代码、常见外设驱动)你基本不用碰。你需要做的,是把“你的板子”的信息准确地告诉U-Boot现有的框架:用哪颗DDR、跑多少频率、串口挂在哪个引脚上、SD卡挂在哪个控制器上、启动介质和内核加载地址是多少。说得再直白一些,移植U-Boot更像是在给一套标准流程填写一套半结构化的硬件档案,而不是重写流程本身。

这也解释了为什么现代U-Boot移植特别依赖Kbuild和Kconfig:硬件差异被尽可能收敛到“配置项+设备树”里,代码只能维持一份。你今天看到configs/目录下一堆xxx_defconfig,看到board/目录下的板级文件,看到arch/arm/dts/里成千上万个dts文件,这些都是同一套框架对“不同硬件档案”的实例化。理解了这个心智模型,后面所有细节都好办了。

1.2 先分清你属于哪一类移植

SoC级移植。这是最硬核的一类,工作集中在arch/<arch>/mach-xxx、arch/<arch>/cpu这样的位置,要新增整个芯片家族的支持代码,比如管理寄存器、时钟树、Cache和TLB、厂商ID识别等等。这类工作通常只有芯片方案公司和少数顶级BSP工程师在做,个人玩家极少遇到。如果你不是要给一款完全没被U-Boot支持过的处理器写支持,这段可以跳过。

板级移植。这是最常见的场景:SoC已经有人支持了,你只是把同一颗或同系列SoC做在一块全新的板子上,需要为这块板建配置、建设备树、写板级初始化代码。参考板往往就在官方支持列表里,你的绝大部分工作就是从最近的参考板“分叉”出去,改成你自己的硬件。我后面整篇文章主要讲的就是这个。

功能级移植。板子本身能启动,但你要加一个新功能:从SPI NOR启动、加一个网络引导协议、把存储从eMMC换成SD卡、支持一个新的显示面板、加上fastboot或者AB分区等等。这类工作通常只是在既有板级支持上叠加配置和驱动,风险最低,也是很多人在做完板级移植之后会遇到的第一个“舒适区外任务”。热点里那一堆“freertos移植lvgl”“lvgl移植stm32”“easylogger移植stm32”,其实就是同一套思路在不同项目上的体现:代码模块本身大概率是好的,你真正要做的是让它跑在你的硬件上下文里。

1.3 和Kbuild打交道之前,先把引导链分层画清楚

移植U-Boot的时候,脑子里一定要有这条完整的链:

芯片BootROM -> SPL(或TPL+SPL) -> U-Boot proper -> Linux内核

BootROM是芯片出厂写死的,你动不了。它根据启动引脚状态,从SD卡、eMMC、SPI NOR、USB等介质里按固定偏移读取下一级镜像,放进芯片内部的SRAM里执行。SRAM容量小,往往只有几十到几百KB,所以上一级镜像必须足够精简,这就是SPL存在的意义。SPL完成DDR初始化之后,再把完整的U-Boot主程序从存储介质读入DDR,然后跳转执行。主程序跑起来你才能看到经典串口输出:

U-Boot 2024.04 (Feb 02 2024 - 16:00:00 +0800) CPU: STM32MP157CAC Board: MYBOARD DRAM: 1 GiB MMC: sdmmc0@58005000: 0, sdmmc1@58005000: 1

在这条链路里,移植工作最关心的就三件事:第一,上一层怎么把自己加载出来;第二,DDR能不能被正确初始化;第三,串口能不能出字。后面两个直接决定你是否有能力调试,所以几乎所有U-Boot移植教程都会让你先“让串口出字”。串口就是你在黑盒世界里的眼睛,没有它,后面全是盲人摸象。

Kbuild这套构建系统要解决的,就是如何把这根链条上的每一个环节,根据你的配置,编译、链接、打包成正确的镜像。所以你看,先搞清楚引导链,再看Kbuild,一切才不突兀。

2. Kbuild移植基础:一次make的全过程

2.1 一份defconfig是怎么变成u-boot.bin的

我见过太多人第一次接触U-Boot构建时,被那一串make搞得晕头转向,其实拆开看就三步:

export ARCH=arm export CROSS_COMPILE=arm-linux-gnueabihf- make stm32mp157_myboard_defconfig make -j8

ARCH告诉构建系统你用的是哪个架构,CROSS_COMPILE告诉它编译器前缀。第一行make xxx_defconfig做的事是:读取configs/xxx_defconfig文件,把它定义的CONFIG_*项灌进.config,再通过一系列Kconfig规则把依赖项补齐。第二行make才开始真正的编译和链接。

Kbuild在这里的完整工作流大致是这样的:scripts/kconfig/conf解析Kconfig树和defconfig,生成.config;然后由conf继续生成三套中间产物——include/config/auto.conf(给Makefile用的配置)、include/generated/autoconf.mk(也是给Makefile用的,但偏符号级)、include/generated/autoconf.h(给C源码用的头文件)。往下,构建系统开始递归进入各个子目录,按Kconfig和Makefile里的规则,决定哪些.c文件要编、编成.o之后怎么归并、最后在顶层把全部目标文件链接成u-bootELF,再用objcopy产出u-boot.bin。

如果你是第一次看这个流程,一个常见的困惑是:“我改了defconfig里的一个CONFIG,为什么有时候重新make不生效?”因为make xxx_defconfig会重新生成.config,但如果你直接改.config,Kbuild不一定会检测到所有依赖变化。规范做法是改defconfig源文件,然后重新跑一次make xxx_defconfig,再加make -j。踩过的人都知道,这一步省不得。

2.2 obj-y、Kconfig和Makefile,谁指挥谁

Kbuild这套体系的核心,是三个角色各司其职:Kconfig负责“有哪些选项可选”,defconfig和menuconfig负责“选了什么”,Makefile里的obj列表负责“选中之后编什么”。

obj-y意思是“无条件编译”,比如drivers/serial/Makefile里最常见的写法:

obj-$(CONFIG_DEBUG_UART) += debug_uart.o obj-$(CONFIG_SYS_NS16550) += ns16550.o

当Kconfig配置了CONFIG_DEBUG_UART=y,obj-$(CONFIG_DEBUG_UART)就是obj-y,这个源文件被编译进U-Boot;如果不选,这条语句就变成obj-,什么都没有。你可以把obj-$(CONFIG_XXX)理解成一个“开关控制的菜篮子”,Kbuild编译时只装篮子里有的菜。

Kconfig本身是一种带依赖关系的描述语言,常用关键字就那几个:bool定义开关型选项,int/hex定义数字型选项,default给默认值,depends on限定“只有满足某个前提才显示这个选项”,select表示“选了我,就必须连同把某个其他选项也选了”,imply表示“选了我,默认建议选某个选项但不强制”。

config TARGET_MYBOARD bool "Support MyBoard STM32MP157 board" select STM32MP157 select DM_SERIAL select SYS_NS16550

select在板级Kconfig里特别常见,因为很多底层依赖你不想让用户选择,比如“你是块STM32MP157的板子,就必须允许STM32MP157这个SoC选项被选中”。这些依赖关系处理不好,最常见的报错就是Kconfig向你抱怨“aborted dependency”或者直接给你的配置里多出/少掉一堆莫名其妙的符号。

2.3 那些藏在构建过程里的关键生成物

写Makefile和Kconfig文件时,心里最好始终有这三样东西:include/config/auto.conf、include/generated/autoconf.h、以及链接脚本。

auto.conf是所有Makefile共享的真相来源。你在子目录Makefile里写的obj-$(CONFIG_...),展开时读取的就是这份文件。autoconf.h则是C代码的真相来源,你源码里写#ifdef CONFIG_DEBUG_UART,编译时其实就是在查这份生成头文件。注意,这里不是直接查.config,CONFIG_DEBUG_UART=y和#define CONFIG_DEBUG_UART 1是Kbuild替你完成的一次“翻译”。

链接脚本也是生成的。U-Boot在链接阶段会从arch/<arch>/cpu/u-boot.lds这个模板展开出真正的u-boot.lds,其中最关键的是CONFIG_SYS_TEXT_BASE——U-Boot主程序觉得自己应该被加载到哪个地址。SPL也有自己的链接脚本,对应CONFIG_SPL_TEXT_BASE。这块我在后面“常见问题”里还会讲,因为TEXT_BASE配错是新手翻车率最高的点之一。

构建完成后,你会在根目录看到一堆产物,常用的这几个要认识:u-boot(ELF,带调试信息)、u-boot.bin(纯二进制)、u-boot.img(用mkimage打包过的镜像,带header)、spl/u-boot-spl.bin(SPL阶段二进制)、u-boot.dtb(设备树二进制)。SPL镜像烧到存储介质的位置、U-Boot主程序镜像烧到存储介质的位置,都由你板卡启动方案决定,通常芯片参考手册的“BootROM”章节写得清清楚楚。

3. 从头移植一块新板:从零到串口出字

3.1 先花时间找到参考板,而不是急着抄作业

移植U-Boot最忌讳的事情,就是拿到板子直接复制一份别人的defconfig就开干。参考板的意义在于“和你足够接近,但又足够简单”,它决定了你后面所有调试工作的起点。

举例来说,假设你手里的板子用的是STM32MP157这颗异构双核Cortex-A7芯片,2GB DDR3、SD卡启动、串口UART4。我的选择顺序是这样:先看arch/arm/mach-stm32mp/Kconfig里有哪些TARGET_XXX,找到同样是STM32MP157、同样是DDR3、同样从SD/eMMC启动的官方评估板(比如stm32mp157d-dk1或stm32mp157c-ev1),用它的configs/stm32mp157d-dk1_defconfig作为种子。然后对比你的原理图,看差异点在哪里:DDR容量和颗粒型号、串口引脚、以太网PHY、LED/按键、PMIC型号。参考板不是“不用看原理图也能用”,而是“有了它,你至少可以拿着差异清单去查资料,而不是拿着空白板子拍脑袋”。

这一步还牵扯到一个被很多人忽略的问题:选一个你能轻松找到BSP参考实现的分支。如果你选的板子是某个SoC家族里冷门的衍生型号,而参考板又是官方做出来的整板方案,那中间隔着的大量寄存器差异会让你非常痛苦。宁可参考板稍微旧一点,也别选一个和你板子只有半毛钱关系的“近亲”。

3.2 建立板级目录:Kconfig、MAINTAINERS、Makefile

车子准备好了,开始动工。以我的myboard为例,需要建立的目录结构通常是:

board/mycompany/myboard/ ├── Kconfig ├── MAINTAINERS ├── Makefile └── myboard.c

Kconfig负责把这块板注册进U-Boot选择系统。最核心的是定义TARGET_MYBOARD,以及告诉构建系统板名、厂商名、配置头文件名:

if TARGET_MYBOARD config SYS_BOARD default "myboard" config SYS_VENDOR default "mycompany" config SYS_CONFIG_NAME default "myboard" endif

SYS_BOARD决定board/mycompany/myboard这个路径怎么被引用,SYS_VENDOR决定board/mycompany这一层,SYS_CONFIG_NAME决定后续include/configs/myboard.h这个名字。这三个符号不是写给人看的,是Kbuild用来拼路径的,一旦拼不上,后面构建系统根本找不到你的板级文件。

Makefile是板目录的编译入口:

obj-y += myboard.o

myboard.c则是板级初始化函数的家,常见的成员你一定认识:

int board_init(void) { gd->bd->bi_boot_params = 0xc0000000; return 0; } int board_late_init(void) { return 0; } int dram_init(void) { gd->ram_size = PHYS_SDRAM_SIZE; return 0; }

这些函数是U-Boot在引导不同阶段调用的,每个函数缺了或者返回错误,启动流程就会在对应位置提前中断。对于量产板子,board_init里往往还有IO扩展器、PMIC、板载LED之类的外设初始化和检测逻辑。

3.3 defconfig和设备树:双管齐下

板级目录建好后,要注册到全局Kconfig入口。不同架构入口位置不同,ARM平台一般要去arch/arm/Kconfig找config ARCH_STM32MP这种SoC入口,它下面有if ARCH_STM32MP ... source "board/mycompany/myboard/Kconfig" ... endif这样的结构。你可以理解为每个board/xxx/yyy/Kconfig都要被某个source语句“挂”到Kconfig树上,否则make menuconfig根本看不到你这块板。

接着创建configs/myboard_defconfig。以STM32MP157为例,最小可启动的defconfig应该有下面这些关键项:

CONFIG_ARM=y CONFIG_ARCH_STM32MP=y CONFIG_SYS_MALLOC_LEN=0x2000000 CONFIG_TARGET_MYBOARD=y CONFIG_DEFAULT_DEVICE_TREE="stm32mp157-myboard" CONFIG_DEBUG_UART=y CONFIG_DEBUG_UART_BASE=0x40010000 CONFIG_DEBUG_UART_CLOCK=24000000

CONFIG_DEFAULT_DEVICE_TREE指定了默认设备树源文件的名字,CONFIG_DEBUG_UART开启早期调试串口——这个功能能在极其早期把字符打出来,是串口出字最快的一条路。不同SoC的DEBUG_UART_BASE和DEBUG_UART_CLOCK不一样,查参考手册的UART内存映射和时钟章节即可。

设备树做出来的是arch/arm/dts/stm32mp157-myboard.dts。最省事的做法是从参考板的stm32mp157d-dk1.dts复制,然后修改model、compatible、内存节点memory@c0000000(reg要改容量)、串口别名、SD卡mmc别名等。改完别忘了把新dtb加进arch/arm/dts/Makefile:

dtb-$(CONFIG_ARCH_STM32MP) += stm32mp157-dk1.dtb dtb-$(CONFIG_ARCH_STM32MP) += stm32mp157-myboard.dtb

到这里实际上你已经完成了一大半。很多人会问“老式Board头文件还要不要”,我的建议是:跟随你参考板的风格。如果参考板已经在走“Kconfig+设备树”的纯新式路线,你就别写include/configs/myboard.h;如果参考板还在用一堆#define CONFIG_SYS_*,你也别硬扛着新式写法,另起炉灶会让调试时很难对照参考实现。适配移植不是炫技,优先保证可对比性。

3.4 让串口先出字:最小化的验证路径

一切就绪之后,构建:

export ARCH=arm export CROSS_COMPILE=arm-linux-gnueabihf- make stm32mp157_myboard_defconfig make -j8

看到产物之后,烧录方式因板而异。STM32MP157这类芯片一般是从SD卡启动:SPL放在SD卡特定偏移(通常是1KB处),U-Boot主程序放在后续偏移。很多SoC的文档里干脆提供了tools/mkimage的打包参数,照着用就行。把SD卡插入板子,串口接好,设置波特率(常见115200),上电,看串口有没有输出。

如果运气够好,你会直接看到从SPL到U-Boot的完整日志。如果运气不好,串口一片死寂,那就进入下一章——排错。严格来说,串口没有输出才是移植的“新手村”,所有老手都从这里摔过。

4. 移植中一定会踩的坑:编译、链接、运行

4.1 编译期的坑:头文件、配置同步和旧产物

编译期问题通常不是语法错误,而是“配置没同步”。我见过最多的场景:改了defconfig里的一个选项,然后直接make -j8,结果发现行为没有任何变化。原因是Kbuild的依赖跟踪没有覆盖到你改的那条路径,正确的流程我刚才已经强调过:改defconfig之后,务必重新执行make xxx_defconfig,让Kconfig重新解析并刷新auto.conf、autoconf.h和autoconf.mk。

第二个坑是“改了头文件但编译没生效”。如果你在include/configs/下面改了东西,或者改了某些头文件,Kbuild不像make直呼OK那样即时响应所有细粒度依赖。保险做法是一次干净的重新构建:

make mrproper make xxx_defconfig make -j8

mrproper会把所有生成物清掉。别嫌慢,移植阶段多花两分钟全量重编,远比在一个脏树上反复猜“这个改动到底生没生效”来得值。

第三个坑是目录Makefile写错变量名。子目录里常见的是obj-y,但你有时也会看到obj-m、lib-y、extra-y。obj-m在U-Boot里不常用,lib-y用于生成静态库,extra-y用于生成非直接链接的中间文件。新手如果从Linux内核驱动教程里复制了一个obj-m过来,编译确实能过,但链接时模块根本没进U-Boot,于是出现“函数找不到”的诡异链接错误。

4.2 链接期的坑:段重叠、TEXT_BASE和SPL大小

链接期一等一的坑是“段重叠”报错,最常见的形式是:

Error: section .text overlaps section .rodata

或者链接脚本抱怨“地址回归”。这事九成和CONFIG_SYS_TEXT_BASE有关。U-Boot主程序的镜像在链接时被安排在CONFIG_SYS_TEXT_BASE这个地址,它必须落在DDR实际存在的物理地址范围里,还要避开自己在DDR里的数据堆和栈区域。如果参考板用的是0xc0000000,你换成DDR从0x80000000开始的板子却忘了改,链接器把.text放在了一片不存在的内存上,结果要么链接报错,要么起来就挂。

SPL的大小问题同样让人头疼。SPL跑在芯片的SRAM里,SRAM就那么大,超了就是超了。报错往往长这样:

SPL image has exceeded size limit: 0x20000

处理方法有三板斧:一是看CONFIG_SPL_MAX_SIZE(或CONFIG_SPL_TEXT_BASE相关限制),确认它和芯片SRAM容量匹配;二是裁剪不需要的驱动和功能,把不用的CONFIG_SPL_*关掉;三是如果SPL本身不需要支持某些功能(比如SPL阶段不可能接显示器),就别开那些驱动。压SPL大小其实是一个很好的练习:它逼你理解哪些代码被编进了SPL,哪些只编进了主U-Boot,这是Kbuild“同一套代码不同配置编译出不同产品”的精髓所在。

4.3 运行期的坑:DDR、时钟和板级initcall

跨过编译和链接,启动过程里的问题更折磨人。串口完全没有输出时,优先排查三件事:Debug UART配置是否正确、SPL有没有被正确加载、DDR初始化是否成功。

CONFIG_DEBUG_UART_BASE和时钟如果不对,U-Boot的早期打印就出不来,但这并不代表它没在跑。你可以量一下UART TX引脚看有没有电平翻转,或者用JTAG去挂一下CPU的PC指针,看它到底卡在哪个地址。如果你的SoC支持,建议一开始就只用CONFIG_DEBUG_UART,不要依赖完整的串口驱动初始化,这样可以快速区分“UART驱动还没起来”和“卡死在DDR初始化”两种场景。

DDR初始化失败是板级移植里最经典的“无声死法”。U-Boot在SPL阶段通过dram_init和厂商DDR训练代码配置内存控制器,任何一项参数错了(时序、引脚阻抗、bank数量)都会导致CPU在访问DDR时挂死。这时候你往往连串口都看不到,只能用JTAG、逻辑分析仪、甚至是看复位引脚状态变化来推断。我自己的经验是先找官方DDR测试工具(很多厂商提供),在没有任何bootloader的情况下先把DDR调通,再回过来移植U-Boot。DDR调通,移植就算成功了70%。

initcall阶段的坑也别小看。U-Boot主程序启动时会按顺序调用一堆init_*函数,任何一个返回-EPERM都可能中断启动。常见现象是“串口打印到某一行就停住”,比如board_late_init里你加了I2C访问PMIC的代码,而I2C总线又没有上拉电阻,函数一直等总线应答,启动就永远卡在这里。这时候可以把函数里面的操作临时注释掉,逐步缩小范围,不要指望一次全通。

把这些坑踩完,你基本已经能把U-Boot“带活”了。剩下的是大量细节打磨:环境变量默认值、启动参数、bootcmd、分区表、显示、网络、fastboot等等。但主轴已经立住了。

5. 可以让你少走很多弯路的几条经验

5.1 用git和diff管理每一次验证

移植最怕的不是改错,而是“不知道自己改了什么导致现在好了/坏了”。我的习惯是:拿到参考板的源码,先原地git init并提交一版“干净的官方代码”,此后每一次尝试改动,小到调一个CONFIG_SYS_TEXT_BASE,都单独提交。每完成一个里程碑(串口出字、DDR完成、SD卡识别、进内核),就在commit message里写清楚现象。这样一旦某次改动把之前的成果搞坏了,git diff能立刻告诉你嫌疑位置;更重要的是,这种记录最终会变成你面向同事或甲方交付时的移植报告,价值远超你想象。

5.2 参考BootROM和SDK的初始化序列

很多人闷头在U-Boot里改半天,其实芯片厂商的SDK和参考代码已经把标准答案写好了。BootROM章节会画出启动介质内部的镜像布局,这是你烧录SPL时偏移量的依据;厂商的裸机或RTOS例程里有完整的DDR初始化序列、PLL频率表、电源启动时序,这些可以直接对照U-Boot里的arch/<arch>/mach-xxx代码,看厂商驱动和U-Boot的差异在哪。不要觉得“用厂商的东西是老路”,恰恰相反,移植的本质就是理解别人的实现,再把它搬进新框架里。官方BSP、参考板代码、U-Boot主线代码三份对照着看,是最快的路径。

5.3 从mainline到vendor fork的取舍

另一个现实问题是:你到底用主线U-Boot还是用SoC厂商的fork?我的建议是,如果你的SoC已经进了主线U-Boot,优先基于主线做移植。主线的好处是持续维护、工具链现代、调试手段跟得上。厂商fork往往带着完整但臃肿的BSP,有时候还锁定老版本编译器。但厂商fork里往往有主线还没有的新驱动代码(比如新DDR颗粒的支持、新显示控制器的驱动),这时候正确姿势是“从厂商fork里摘驱动,移植回主线”,而不是反过来。

这条道理同样适合同一SoC家族的新板。很多时候你需要的不是整棵树的移植,而是把参考板相关的一小块代码(板级目录、defconfig、dts、部分驱动)摘出来,嵌进一个新基线的U-Boot里。学会“摘代码”,比学会“整树搬运”重要得多。

5.4 Kbuild的能力不止适用于U-Boot

最后说点题外话。今天热点里大量“xxx移植stm32”这类工作,比如freertos移植、lvgl移植、easylogger移植stm32、nanomodbus移植裸机,表面看跟U-Boot毫无关系,但底层思维高度一致:先看懂模块的构建和配置方式,再把它嵌入目标工程。U-Boot里的Kconfig/Kbuild经验放到Linux内核、Zephyr乃至一些大型C/C++项目里都能直接复用,因为这套“Kconfig选项—Makefile变量—构建规则—生成头文件”的体系已经被行业验证了几十年。

我自己这几年的体会是,移植U-Boot最值钱的回报不是“板子能启动”那一刻的成就感,而是你被迫把芯片手册、Boot流程、构建系统、设备树、驱动模型这几座孤岛串起来。这个串的过程,是任何教程都给不了你的。下次再把一块新板从死寂的串口带到Linux shell前,你会发现自己不再慌,因为你知道每一层在干什么,也知道出了问题该去查哪里。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询