☰
U-Boot移植实战指南:从启动链解剖到DDR初始化与BSP适配
2026/10/7 15:13:10 网站建设 项目流程

拿到一块新板子,系统起不来,第一个要解决的不是内核,而是U-Boot。U-Boot移植本质上是把处理器上电之后的前几百毫秒从头到尾搞清楚:ROM里跑完是谁接管、DDR什么时候初始化、存储介质能不能读写、环境变量放哪、最后怎么把控制权交给内核。这篇东西相当于我多年做BSP适配的U-Boot移植索引,从准备环境到跑通启动链,再到产品化收尾,把整个流程和里面最容易踩的坑都串了一遍。适合正在给板卡做bring-up的工程师,也适合想真正弄懂Linux启动链的嵌入式开发者。

1. 移植前先想明白:U-Boot在整个启动链里扮演什么角色

1.1 从复位到内核,启动链每一环都在干什么

很多人觉得U-Boot就是个引导程序,但其实它更像一个“硬件体检员加搬运工”。在典型的ARM平台里,上电后第一段代码是SoC内部ROM Code,这段代码固化在芯片里,没得改。ROM Code会从预设的启动介质里找程序:有的芯片先看SD卡,有的先看SPI NOR,有的看USB下载。找到之后,通常会把一个很小的程序加载进SRAM,这个程序叫SPL(Secondary Program Loader)。

SPL的作用很纯粹:把DDR初始化好,再把完整的U-Boot从存储介质搬进内存。为什么需要两步?因为很多SoC内部SRAM只有几十到几百KB,装不下完整的U-Boot,而DDR在上电瞬间频率和时序都还没配置,直接访问就是死机。所以必须有小程序先把DDR“激活”,才能让大个子程序有地方住。U-Boot proper启动后,会去执行bootcmd里的命令,比如从eMMC加载内核和设备树到DDR指定地址,然后用bootm/booti跳进去。到这个点,U-Boot的使命才算完成。

理解这层关系特别重要,因为你在移植时面对的很多怪问题,本质都是“某个阶段的前置条件没满足”。比如U-Boot起来了但内核不跑,先怀疑是内核加载地址不对还是设备树不对;SPL卡住不出打印,先怀疑DDR没初始化好还是波特率不对。启动链的每一环都有明确的职责边界,排查时按阶段切分就能很快缩小范围。

1.2 U-Boot移植和FreeRTOS、LVGL这类移植不是一回事

网上搜U-Boot,经常混着一堆“FreeRTOS移植LVGL”“LVGL移植STM32”“nanomodbus移植裸机”的词条。得说清楚,U-Boot移植跟这类应用层移植完全是两个维度。LVGL移植是在已有的Cortex-M平台上加一套图形库,工作重心是适配显示驱动、触摸输入和内存分配;FreeRTOS移植是让一个RTOS内核在MCU上跑起来,核心是改底层上下文切换和中断向量。

U-Boot移植属于BSP里最底层的那部分,工作对象直接影响整块板子的生存:DDR控制器寄存器配错一个数,板子可能永远黑屏;设备树里的mmc节点时钟写错,内核阶段才发现盘符找不到。它的特点是“出错了你连一点提示都没有”,因为此时串口可能还没初始化,操作系统更谈不上。所以做U-Boot移植的思维方式和做应用移植完全不同——应用移植靠日志和调试器就能推进,U-Boot移植很多时候要靠示波器、逻辑分析仪、反复阅读SoC手册来推进。

1.3 移植前必备用具清单

实战里,工具没备齐就开干,十有八九会中途卡住。我这里列个清单,都是踩过坑总结出来的:

  • SoC参考手册:查寄存器地址、时钟树、引脚复用表、启动设备优先级。
  • DDR颗粒手册:DDR初始化要填时序参数,CAS、tRCD、tRP这些不能乱写,颗粒手册上有标准值。
  • 板级原理图:确认串口引脚、SD卡检测脚、LED、电源时序对应的GPIO。
  • 厂商SDK里的DDR初始化源码:很多SoC厂商会在SDK里放DDR training代码,这是最省力的参考。
  • USB转串口工具:建议带隔离的,开发板供电不稳时隔离模块能救你的电脑USB口。
  • JTAG调试器:J-Link、OpenOCD、STM32CubeProgrammer,用来在U-Boot起不来时强制烧写和查看寄存器。
  • 示波器或逻辑分析仪:排查时钟有没有起来、SD卡CMD线有没有波形,有时比看代码更快。

有这些再做后面几步,才有底气说自己在“移植”,否则就是在瞎试。

2. 搭建环境,先把“能编译”这个伪目标跑通

2.1 工具链选型:别一上来就纠结用哪个

ARM平台U-Boot编译,工具链就两个套路:32位ARM用arm-linux-gnueabihf-,64位用aarch64-linux-gnu-。理论上用厂商SDK里自带的工具链最稳,版本经过验证,不会莫名其妙踩坑。但如果你的板子型号比较新,SDK工具链太老可能编出告警甚至报错,这时候优先考虑Linaro的gcc-arm-系列,或者直接用发行版自带的交叉工具链。

# 安装工具链(Ubuntu/Debian 系) sudo apt install gcc-arm-linux-gnueabihf # 32位ARM sudo apt install gcc-aarch64-linux-gnu # 64位ARM

需要特别说明:U-Boot本身对编译器版本不算敏感,但<linux/compiler-gcc.h>这类头文件会在新编译器下触发新的告警,个别老版本U-Boot在新gcc下是编不过的。所以如果你用的是老版本源码,尽量配老一点工具链;用新源码,工具链也别太老。这是一个“相互匹配”的关系,不是越新越好。

2.2 源码选择:主线U-Boot还是厂商BSP

主线U-Boot的好处是社区活跃,新SoC支持不断合入,代码风格统一,出了问题可以对着邮件列表找答案。坏处是它支持的板型有限,很多量产的配置不在主线里。厂商BSP(比如Rockchip、Allwinner、NXP、STM32的开源包)的好处是板级文件和DDR初始化都是现成的,你只需要在它基础上改,坏处是代码往往落后主线若干个版本,而且可能夹杂着厂商自己的一堆补丁,可读性差。

我的建议是:如果芯片厂商在主线里有对应板型或者同系列板型,直接用主线;如果厂商只有私有BSP,先以BSP为起点把系统跑起来,再考虑是否反向移植到主线。不要一上来就追求纯主线,那是大佬做的事,先让板子活下来。

# 拉取主线U-Boot git clone https://github.com/u-boot/u-boot.git cd u-boot git checkout v2024.04

分支选择上,建议挑一个稳定tag而不是最新代码。最新代码可能在你板子之外的其他平台上有夜间构建问题,稳定tag至少经过一轮验证。

2.3 用现有defconfig先“编一个能跑的”

新手移植最容易犯的错是一上来就自己建board目录、写Kconfig,结果连编译都过不了。正确姿势是先找一块跟你板子最接近的已有板型,编译烧录,确认环境本身没问题。

怎么找接近的板型?看SoC是否同型号或同系列,看存储介质是否一致,看DDR是LPDDR4还是DDR3,看网络PHY型号。比如你在做基于全志T113的板子,那就先找sun8i系列里T113相关的板型;如果你在做STM32MP157,就先找stm32mp15_*系列的defconfig。

ls configs/ | grep -i "你的芯片型号关键词" make CROSS_COMPILE=arm-linux-gnueabihf- stm32mp15_defconfig make CROSS_COMPILE=arm-linux-gnueabihf- -j8

编完先不要急着烧,去include/configs/和设备树目录里看参考板的配置,确认串口引脚、存储分区、启动命令这些跟你的板子差距大不大。差距不大就直接烧,能起来就说明环境是通的;起不来优先检查串口引脚复用和波特率。

2.4 确认“最小可观测”:串口有没有输出

很多人在这一步就卡住。U-Boot烧进去完全没有输出,其实问题不一定在U-Boot本身,有可能工具链编出来的镜像根本就没被烧到正确位置,也有可能串口接错了引脚。我第一次给A20的板子做bring-up时,折腾一晚上没输出,最后发现是USB转串口的TX/RX接反了。这种低级错误在开发初期非常普遍,所以建议在烧录前先用万用表量一下串口引脚电平、确认地线连通,甚至先用一个带loopback的例子验证串口线路本身。

如果确定线路没问题还没输出,再看启动介质和SPL。很多SoC要求在烧录时对镜像加头部(比如全志的mksunxi头部、STM32的header),直接烧裸的u-boot.bin是认不到的。厂商SDK里一般有烧录脚本,先照着脚本原封不动走一遍,不要自作聪明改镜像格式。

3. 正式移植动作:新建板级描述,过DDR这个鬼门关

3.1 不写全部代码,先把板子“登记”进构建系统

U-Boot的构建系统靠Kconfig和Makefile层层嵌套。你的新板子想被make xxx_defconfig认到,至少要在几个地方留下名字:

  • arch/arm/mach-xxx/Kconfig里增加TARGET_你的板名条目。
  • arch/arm/mach-xxx/Makefile里增加对应board/你的厂商/你的板名的编译路径。
  • 新建configs/你的板名_defconfig。
  • 新建或者复用board/你的厂商/你的板名/目录,放置Kconfig、MAINTAINERS、Makefile、xx.c。
  • 新建或者修改arch/arm/dts/你的板名.dts和对应的.dtsi。

假如你完全参考同系列的现有板子,最快的办法是直接复制整个board目录和defconfig,全局替换名字,再一点点改差异。这比从零手写靠谱得多,因为U-Boot构建体系里的隐式依赖太多了,少一个CONFIG_TARGET_XXX定义,Kconfig就会报错,而这种报错对新手来说很难看懂。

# 一个典型的参考流程 cp -r board/vendor/similar_board board/vendor/my_board cp configs/similar_defconfig configs/my_board_defconfig

3.2 Kconfig依赖和defconfig内容:你要改的其实是指纹

很多人以为defconfig里面就是CONFIG_XXX=y一行行很直白,其实它只是用户可见配置的一小部分。defconfig定义完之后,U-Boot的kconfig会根据依赖关系推导出大量默认值。你真正要确认的是:串口用的是哪个UART、环境变量保存在哪个分区、SPL要不要带DDR初始化、网络芯片用的什么驱动。

以串口为例,你需要在defconfig里指定CONFIG_SYS_MALLOC_F_LEN、CONFIG_SYS_CACHELINE_SIZE这些基础宏,还要确保CONFIG_DM_SERIAL、CONFIG_CONS_INDEX和设备树里的stdout-path匹配。U-Boot的驱动模型(DM)是从设备树获取串口信息,不再靠老式的CONFIG_SYS_NS16550_COM1硬编码。这个迁移让“换串口”从改头文件变成了改设备树,更灵活但也更容易漏——比如你只改了设备树status忘了pinctrl,串口照样没有输出。

3.3 设备树:能打印一个字符就算第一阶段的胜利

U-Boot的设备树有两个作用:一是给U-Boot自身驱动模型用,二是启动内核时传递给内核。早期可以先只关注前者,让chosen节点、/soc里的UART和GPIO节点、memory节点合法即可。

/dts-v1/; / { model = "MyBoard"; compatible = "vendor,myboard"; #address-cells = <1>; #size-cells = <1>; chosen { stdout-path = &uart0; }; memory@80000000 { device_type = "memory"; reg = <0x80000000 0x20000000>; }; };

U-Boot在早期竟然用的是CONFIG_SYS_NS16550_COM1那种硬编码,后来全面改成设备树后,很多资料还停留在老写法,特别容易误导人。强烈建议打开源码里的doc/device-tree-bindings/目录,对照你的UART型号看绑定规范,别抄别人的dts当万灵丹。

3.4 DDR初始化:U-Boot移植的第一个分水岭

DDR初始化是所有U-Boot移植里最“硬”的部分。如果SPL阶段DDR没配好,现象是卡死,甚至串口都没输出——因为串口控制器可能就在DDR所依赖的时钟域里。调试手段十分有限,只能靠JTAG读寄存器或者一行行插printf。

我自己的经验是,DDR初始化务必以厂商SDK的代码为准,不要自己发明参数。你需要的不是理解DDR控制器每个寄存器的物理意义,而是确认三组东西:

  • 硬件连接:板子上DDR的位宽(16bit还是32bit)、片选数、die堆叠数。
  • DDR颗粒类型:DDR3、DDR4、LPDDR2、LPDDR3对应不同的控制器设置。
  • 时序参数:从DDR颗粒手册里查到的CL、tRCD、tRP等,SDK一般已经按某一款颗粒调好,你换了颗粒供应商就必须重新核。

厂商SDK里常见的DDR初始化代码往往是一大段寄存器赋值,改起来风险极高。改一个位,板子可能从“完全正常”变成“偶尔启动失败”,这种不稳定问题最难查。所以尽量做到:能用SDK原厂内存配置就先不换,必须换时每次只改一个参数,并记录改动前后是否还能启动,别指望一次猜中。

3.5 从没输出到有输出,串口是怎么一步步调通的

如果SPL阶段卡在DDR初始化,串口打印会停在一个固定位置。这时检查机制是:先用JTAG确认CPU PC指针停在哪里,如果PC卡在DDR控制器初始化函数里,说明前面都过了就差这一步;如果PC根本没跑到SPL入口,那可能是SPL压根没被加载,问题在启动介质或头部信息。

一种更粗犷但很有效的方式是在DDR初始化前后各加一句原始的寄存器写操作,直接操作UART寄存器输出一个字符,不走任何驱动抽象:

static void putc_early(char c) { uart_base->THR = c; while (!(uart_base->LSR & UART_LSR_THRE)) ; }

这招在调试最早期特别好用,因为普通的printf可能依赖设备树和串口驱动,驱动本身还没初始化时什么都看不到。等能看到字符了,再去调printf和普通日志。

4. 存储和网络驱动,跑通内核前最常卡住的两个环节

4.1 eMMC/SD控制器:从识别不到卡到内核启动

U-Boot里识别存储卡的命令就三个:mmc list、mmc dev、mmc info。如果mmc list看不到设备,优先查设备树里的mmc节点有没有被U-Boot编进去,CONFIG_MMC和对应的控制器驱动开没开。

如果能看到设备但mmc dev报错,常见原因还是时钟。MMC控制器需要IO时钟和总线时钟,两个频率配错一个就握手不成功。调试时可以把时钟频率降低测试,比如SD卡跑25MHz不跑50MHz,排除是高速模式时序问题。另一个容易忽略的是电压转换芯片,很多板子SD卡电源域是独立的,如果GPIO控制的VMMC没拉高,卡供电不足同样识别不到。

内核阶段找不到rootfs,但U-Boot阶段mmc info很正常,这种问题多半是内核设备树里mmc节点和U-Boot不一致。建议先用mmc part和fatls mmc 0:1这类U-Boot命令确认分区和文件能访问,至少把问题边界确认在“U-Boot能读、内核不能读”或反过来。

4.2 SPI NOR/NAND与mtdparts分区

如果用SPI NOR启动,整个流程会更简单,体积小、随机读快,但要注意你板子上的Flash大小。U-Boot镜像如果超过Flash容量,那就是灾难;环境变量保存区和内核镜像存储区也要预留足够空间。环境变量的默认位置在include/configs/里的CONFIG_ENV_OFFSET或设备树里partitions节点指定,这个偏移量一旦和镜像重叠,后果就是启动后环境变量被自身数据覆盖,每次都像第一次启动。

NAND则复杂一些,需要处理坏块、擦除和ECC。U-Boot里NAND驱动通常由CONFIG_NAND_*控制,但厂商的NAND控制器接口千差万别,建议直接参考厂商BSP,不要通用框架上来就上,很多NAND控制器驱动没有完全接入DM模型。

分区管理上,最稳妥的方案是在设备树里明确写出partitions节点,同时在U-Boot里使用固定mtdparts命令行参数,这样内核和U-Boot对Flash分区的认知才能一致。

partitions { compatible = "fixed-partitions"; #address-cells = <1>; #size-cells = <1>; uboot@0 { label = "u-boot"; reg = <0x0 0x20000>; }; env@20000 { label = "env"; reg = <0x20000 0x20000>; }; };

4.3 网络驱动:MAC地址是个容易被忽略的产品问题

网络在开发阶段的主要用途是网络启动tftp,写内核到内存再跑起来。调网络驱动,第一件事是让ethaddr有效。很多SoC内部有mac地址寄存器,出厂可能全FFFF。如果MAC地址没配,tftp能通但设备在内网里和其他没配MAC的板子冲突。

方案分两种:一是把MAC地址烧到SoC的eFuse/OTP区,适用于正式批量产品;二是从板载EEPROM读取,适用于小批量开发。最忌讳的是把MAC地址硬编码在环境变量里然后saveenv,这种板子的MAC每个都一样,交到客户手里很容易出网络冲突。U-Boot的include/configs/里默认会有CONFIG_NET_RANDOM_ETHADDR开关,开发阶段临时用没问题,量产必须改成正式方案。

排查网络问题还有个技巧:先用md命令看看PHY寄存器的值,确认PHY没有处于复位状态。很多板子的PHY复位脚接在某个GPIO上,如果这个GPIO在上电时被拉低,PHY就一直复位,mii info根本看不到PHY ID。

4.4 排查链路:从U-Boot命令到信号完整性

U-Boot最强的地方在于它是一个交互式环境,比内核好排查太多。答题思路是分级:

  1. help看看命令列表里有没有你要用的命令,没有就加CONFIG_CMD_*。
  2. dm tree查看设备树节点有没有被驱动绑定成功。
  3. clk dump(部分平台支持)看某个外设时钟是否开启。
  4. mii info看PHY有没有被识别到。
  5. mmc info、sf probe、nand info看存储介质是否正常。
  6. 每一步都不顺手时,回到设备树和defconfig,确认驱动确实编进去了。

如果以上都正常但实际读写失败,才轮得到怀疑信号完整性和电源稳定性,这时才需要示波器去看波形。很多接触不良、过孔断裂的问题,在软件层面就是“时而能启动时而不能”,这种问题最耗时间,尽早拿出硬件工具介入才是正解。

5. 启动参数、环境变量和产品化收尾

5.1 bootargs和bootcmd,把控制权正式交给内核

U-Boot本身不启动操作系统时,它只是一个交互shell。要让Linux跑起来,必须在环境变量里组织好bootcmd。我的习惯是把这个过程写成脚本,放在环境变量里,不要用零散命令。

setenv bootcmd 'mmc dev 0; fatload mmc 0:1 0x82000000 zImage; fatload mmc 0:1 0x88000000 board.dtb; bootz 0x82000000 - 0x88000000' setenv bootargs 'console=ttyS0,115200 root=/dev/mmcblk0p2 rw rootwait panic=1' saveenv

bootargs里三个关键参数:console决定内核日志输出到哪个串口,波特率必须和你的调试口一致;root=指定根文件系统所在设备;rootwait针对mmc/ufs这类慢速设备必须加,否则内核可能在设备初始化完之前就尝试挂载根fs。mem=size一般不需要手动设,除非内核内存映射有问题,手动设了反而可能掩盖真实内存大小。

5.2 环境变量存储:为什么冗余环境变量不是可选项

U-Boot环境变量默认保存在存储介质中,你在命令行改完不saveenv,重启就丢了。这本身不是问题,问题是存储介质有坏块或者写一半掉电,环境变量就会损坏,进而导致U-Boot启动行为异常。

所以量产板子建议开启冗余环境变量,用两个存储区保存环境变量,互为备份。U-Boot里对应CONFIG_ENV_IS_IN_FLASH或CONFIG_ENV_IS_IN_MMC加CONFIG_ENV_ADDR_REDUND这类配置组合。具体配置不同平台差异很大,但原则一致:“坏了一个还有另一个可以用”。

5.3 SPL体积、启动时间和裁剪思路

产品最终是要交付的,启动时间太长、SPL体积超限都不行。这里有几个可以优化的点:

  • 裁剪驱动:用menuconfig把用不上的命令和驱动都关掉。CONFIG_CMD_NET如果量产不用网络启动,直接关,能省不少字符串和命令分发开销。
  • SPL只保留最核心功能:SPL里可以关闭设备树传递之外的大量特性。CONFIG_SPL_*里关掉不需要的驱动,体积会明显下降。
  • 减少延时:常见的CONFIG_BOOTDELAY如果是3,每次启动多等3秒。产品化后设成0或者极短。
  • 提高DDR频率:如果SPL初始化DDR在533MHz,而内核又能跑在800MHz,可以考虑让SPL就切换到最终频率,减少一次频率切换的时间。但这个操作属于高频风险区间,没有把握就不要动。

裁剪完了务必验证三件事:网络启动还能不能用,不用于生产也留条后路;saveenv保存后重启配置还在;整机冷启动和热启动反复各20次不出错。

5.4 维护一份移植索引,比代码本身更值钱

这里的“索引”对应项目标题里的“_索引”,我想强调的是:U-Boot移植不会一次结束,同一份BSP往往要支撑内核升级、DDR颗粒更换、底板迭代。如果只是“代码能跑”,三个月后你自己都看不懂当初为什么在某个寄存器上写了一个魔数。

我现在每做一个板子,都会维护一个移植索引文档,内容大致是:

  • 板级配置的初始参考来源(哪块板子的defconfig、哪个版本SDK)。
  • DDR时序参数的出处和颗粒型号。
  • 每个关键配置项改动前后的现象(比如“改了MMC时钟之后,U-Boot能识别卡但内核识别不到”)。
  • 烧录头信息格式和烧录命令,这个特别重要,因为换电脑换工具链后很容易忘。
  • 启动参数演进历史:哪些参数是量产加的、哪些是调试时临时加的。

这个习惯帮我节省了大量“重新踩一遍坑”的时间。你要是有多个项目并行,这种索引比任何代码注释都管用,因为注释只讲“代码在干什么”,索引能讲“为什么变成这样”。

最后再分享一个小经验:调U-Boot时不要“单点验证”——串口通了就继续调DDR,DDR好了就赶紧调存储,每过一个阶段就把前一阶段重复验证一遍。我就遇见过串口单独调通了,但DDR初始化代码里改了频率之后串口波特率被连带影响,结果板子起来后日志全是乱码。前后环节其实是相互耦合的,进度走得再快,也要定期回头做一次完整的冷启动验证。

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

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

立即咨询