☰
U-Boot启动流程全解析:从复位向量到第一个C函数
2026/10/16 1:18:07 网站建设 项目流程

2. 启动流程全景:从复位向量到第一个C函数

整个启动流程,我认为可以分为三个大的阶段来理解:

  1. 硬件初始化阶段:关中断、设置CPU模式、初始化时钟、初始化DDR。这个阶段的目标是让CPU“活过来”,并且有一个可用的内存环境。
  2. 代码重定位与搬运阶段:将U-Boot自身从只读的Flash/NAND/eMMC搬运到可读写的RAM中运行。这解决了“程序存储介质运行速度慢”和“无法修改自身”的矛盾。
  3. 板级初始化与命令行阶段:初始化串口、网卡、Flash等外设,然后进入一个死循环,等待用户输入命令或自动启动内核。

我画了一张粗粒度的流程图(纯文字版),方便你对照着看:

复位入口 (_start) | |----> 设置SVC模式,关IRQ/FIQ中断 | |----> 初始化关键寄存器 (如CPU频率、L2 Cache等) | |----> 初始化DDR (内存控制器) | |----> 设置栈指针 (SP) | |----> 代码重定位 (复制自身到RAM,修复链接地址) | |----> 跳转至RAM中的C语言入口 (board_init_f / main_uboot) | |----> 初始化串口、网卡、Flash、环境变量等 | |----> 进入main_loop,等待命令或加载内核

这张图可能看着有点抽象,下面我一步步拆开细讲,重点说清楚每个环节“是什么”、“为什么要做”、“怎么做”。

2.1 硬件初始化阶段:让CPU先活过来

这段代码是汇编写的,也是移植U-Boot时几乎必改的地方。它最核心的几件事:

(1)设置CPU工作模式,关闭中断

CPU上电后,默认可能有各种未知的中断状态。如果不先把中断全关了,一旦配置内存到一半来了个中断,程序跑飞,后面的初始化步骤全白费。所以第一步往往是:

/* 设置SVC模式,关IRQ和FIQ */ mrs r0, cpsr bic r0, r0, #0x1f orr r0, r0, #0xd3 msr cpsr, r0

0xd3这个值其实包含了两层意思:低5位0x13表示 SVC模式,0xc0这两位表示关掉IRQ和FIQ。这两个一位都不要省。

在汇编里加注释是极度重要的习惯。很多项目代码看起来像天书,不是因为它复杂,而是因为根本没有注释。启动代码前20行看懂了,后面基本是类似的套路。

(2)初始化时钟

CPU主频和总线频率如果不匹配,外设访问就会出现时序问题。比如I2C、SPI这类低速外设,如果时钟源没配好,数据根本传不出来。

规范的平台通常会有lowlevel_init.S或clock_init.c,里面分门别类地把ARM核时钟、总线时钟、外设时钟逐项设好。单独提个醒:很多平台在调试DDR时明明时序对了仍不稳定,大概率就是时钟没先配好,DDR控制器和高频外设是强相关的。

(3)初始化DDR内存

这一步完成后,C语言环境才有地方放栈和变量。DDR初始化通常包括:

  • 配置内存控制器基地址和地址映射
  • 设置时序参数,如CAS延迟、刷新周期、读写时序
  • 设置DDR电源和终端电阻

有些平台会把DDR参数做成宏或表格放在include/configs/xxx.h或设备树中。如果是自己移植的板子,DDR参数通常由板级厂商提供,或参考芯片手册一步步计算。

下面举个非常简化的DDR初始化示例(伪代码风格,实际寄存器名请对照手册):

void ddr_init(void) { /* 使能DDR控制器时钟 */ writel(0x01, DDR_CLK_ENABLE); /* 设置内存块映射 */ writel(0x1122, DDR_BASE_ADDR); /* 设置时序参数 */ writel(0x0A0A0A0A, DDR_TIMING_PARAM); /* 启动DDR控制器 */ writel(0x80000001, DDR_CTRL_START); /* 等待就绪 */ while (!(readl(DDR_STATUS) & 0x1)) ; }

这里的数值没有通用含义,不同芯片完全不同。重点是想表达一个过程:DDR初始化本质是给控制器写一组配置参数,然后启动,再确认状态。

2.2 代码重定位:把U-Boot搬进RAM

这一步有许多初学者容易卡住。U-Boot最初是从 Flash/NAND/SD 卡里启动的,这些介质访问速度慢,而且有些介质(如NOR Flash)不能随机写。如果直接在上面运行代码,整体性能会被严重拖慢,同时也没法更新自身。

所以U-Boot会在 DDR 初始化完成后,把自身代码从启动介质复制到内存中。

这里有个关键概念叫“链接地址”。编译器在编译U-Boot时,会预设一个运行地址(比如0xC3E00000),所有函数调用、全局变量访问都基于这个地址生成机器码。但在上电初期,U-Boot实际是先从低地址的Flash里跑起来的,这时CPU取指地址和链接地址不匹配,怎么办?

答案就在“位置无关码”和“重定位修复”这两个机制:

  • 位置无关码:启动早期,汇编代码里只使用相对跳转(b/bl),不依赖绝对地址,代码放在哪里都能跑。
  • 重定位修复:在把代码复制到RAM链接地址后,需要把所有绝对地址引用的地方修复成实际链接地址对应的数值。U-Boot中有专门一段代码负责遍历并修正.rel.dyn段中的重定位表。

我拿一个生活例子来解释:

想象你搬新家,搬家前所有家具上贴的便签写的是“新家地址+房间号”。搬家车开到新家后,你需要根据便签把家具一一放到对应房间。便签就是链接地址,新家就是RAM,而搬运工就是重定位代码。

理论上,如果代码包含位置无关码,不修复也能跑,但因为有绝对地址访问(比如全局变量、函数指针),不修复就会出现“指针指错内存”的诡异问题。这就是为什么启动流程里必须有一整套重定位逻辑。

2.3 从汇编跳入C语言世界

在汇编完成了最基本的初始化和重定位后,U-Boot会设置好栈指针,然后跳进一个C函数——通常是board_init_f或main_uboot(具体名字视版本和平台而定)。

栈指针(SP)为什么必须在这个阶段设定?因为C代码第一次调用函数时,需要用栈保存局部变量、返回地址和现场。没有栈的C语言就像没有桌子的厨师,菜都放不到台面上。

从跳入C函数那一刻起,U-Boot的绝大部分可移植代码就开始起作用了。它会使能串口、初始化定时器、扫描总线上的设备、解析环境变量、配置网络……这一系列操作让U-Boot从“裸机程序”逐渐变成一个“微小型操作系统”。

3. U-Boot 的启动阶段划分:两个关键的init函数

老版U-Boot(2014年之前)的启动路径是board_init_f -> board_init_r,新版虽然转向了initr_*系列函数,并在不少平台上引入了设备树(FDT)驱动模型,但核心逻辑仍然保留了两个阶段的思想。

我把这两个阶段分别展开讲。

3.1board_init_f:前期的静态初始化

这个阶段整体上还在板级代码里,主要做这么几件事:

  • 初始化DRAM(有些平台已提前,但这里会再次确认)
  • 把DRAM大小、栈位置等存入全局变量gd结构体
  • 分配后续阶段使用的内存空间,为重定位做准备
  • 执行早期的板级初始化,比如点亮调试LED

gd结构体是U-Boot里的重要结构体,全称global_data,它保存了内存大小、启动标志、波特率、环境变量地址等信息。许多函数都依赖它。

这里有个常见问题:有人问为什么有了全局变量还要搞个gd?因为启动早期,全局变量所在的地址可能还没准备好(链接地址对应的RAM还没有初始化完成),所以U-Boot暂时把关键信息保存在一个固定的寄存器(如r9)指向的区域,等内存好了再复制到全局变量中。这是一种阶段化设计的智慧。

3.2board_init_r:重定位完成后的全面初始化

当代码重定位到RAM并跳转到正确的链接地址后,U-Boot会进入board_init_r,执行全面的板级初始化。

在这个阶段,U-Boot会:

  • 初始化完整的内存分配器
  • 创建设备树(如果使用FDT)
  • 初始化串口控制台
  • 初始化I2C、SPI、GPIO等总线控制器
  • 初始化Flash / NAND / MMC 驱动
  • 初始化网络接口
  • 初始化环境变量(包含bootcmd、ipaddr、serverip等)
  • 执行initr_*系列函数,让各子系统逐步工作起来

这一阶段结束时,U-Boot就准备好进入命令行交互了。

3.3 启动内核前的最后一公里

当用户在命令行执行boot,或者U-Boot依据bootcmd自动启动时,它会做几件非常关键的事:

  • 获取内核镜像地址(通常从环境变量kernel_addr或loadaddr获得)
  • 检查镜像格式与CRC校验
  • 如果有设备树,会把它加载到指定内存地址
  • 设置内核启动参数(如bootargs,包括控制台、根文件系统位置等)
  • 跳转到内核入口点

之前我踩过的一个坑:bootargs里忘了写root=/dev/mmcblk0p2,结果内核启动后挂不上根文件系统,一直 kernel panic。排查这问题比想象中费时间,因为那个阶段连串口打印都是正常的,你根本想不到是U-Boot传参错了。

所以,bootargs一定在移植初期就固定好规范模板,一句一句拆,别嫌麻烦。

4. 从移植者视角看几个关键源码文件

很多人说我看了启动代码但不知道从哪里改起。下面几个文件是移植时最高频修改的,建议你在工程里先定位出来,逐个过一遍:

4.1include/configs/<board>.h或设备树

这个文件被称为“板级配置头文件”,它定义了大量宏,包括内存大小、环境变量默认值、启动命令、控制台参数等。

如果是新版U-Boot+FDT,很多配置从头文件挪到了设备树里,但环境变量默认值和一些编译开关仍在这里。改配置、加命令都要这里动手。

4.2board/<vendor>/<board>/目录

这里放的是板级代码,比如board.c、lowlevel_init.S、Kconfig。这是每个板子最独特的部分,也是与其他板卡拉开差距的地方。

4.3arch/arm/cpu/armv7/start.S(或其他架构对应文件)

这是汇编启动的入口。虽然一般不会大改,但理解它非常必要。如果你要用自定义的启动方式(比如从特殊介质启动),就要动这个文件。

4.4drivers/目录

各类外设驱动。串口、网卡、存储、显示等。驱动模型(DM)是U-Boot现代化的核心,不理解DM就去配置设备树,你会不断撞墙。

移植U-Boot时有一个非常朴素的建议:拿到一块新板子后,先查看它的defconfig里开启了哪些子系统,再通过make menuconfig逐项核对。启动流程跑不通,往往不是启动代码本身的问题,而是某个外设初始化失败导致整体卡死。

5. 启动流程常见问题与排查技巧实录

这部分我觉得最实用,整理几个我在调试中真实踩过的坑:

5.1 串口只打印几个字符就卡死

这种现象通常发生在lowlevel_init之后的DDR初始化阶段。原因不外乎:

  • DDR时序参数不对,初始化不稳定,代码跑着跑着就挂了
  • 时钟配置错误,DDR工作频率超出规格
  • 代码重定位时地址映射错误,导致跳转后执行了错误数据

排查手段:

  • 在汇编代码的关键节点添加串口打印(用最原始的putc函数)
  • 用示波器量DDR时钟引脚,看频率是否正常
  • 检查内存控制器寄存器配置值与芯片手册对照

5.2 启动时U-Boot没有输出

如果串口一点点输出都没有,优先怀疑:

  • 串口引脚复用或波特率不对
  • U-Boot源码中的串口基地址与板子实际地址不一致
  • 晶振频率偏差太大,导致波特率失调
  • 代码根本没有跑起来(比如引导介质里没烧写成功)

以前调试一块板子,因晶振频率配错,串口输出的全是一堆乱码,最后通过读回U-Boot二进制确认烧写无误后,才想到去检查晶体配置。

5.3 内核启动时崩溃或不能加载根文件系统

这个阶段往往不是U-Boot本身的问题,而是传给内核的启动参数不正确。

典型错误:

  • bootargs中缺少console=ttySAC0,115200导致内核无控制台输出,看起来像“死机”
  • root=参数指定的分区号与实际不符
  • mtdparts分区定义与内核分区表不一致

遇到这类问题,不要上来就怀疑U-Boot代码。先在U-Boot命令行手敲bootargs,确认能启动后再去分析为什么自动化流程失败。这个步骤能节省大量时间。

5.4 如何精准定位卡死在哪个环节

最笨但最有效的方法:在汇编和C入口函数里临时加点串口打印,打印“我到了第N步”,一步步定位。只要串口是好的,这种方法就像给代码挂了个GPS。

有个小技巧需要强调:调试打印别随手乱加,都打完了记得统一移除,或者用调试宏包起来,不然版本发布后用户看到满屏调试信息,体验会非常差。

5.5 小总结:启动流程排查清单

现象优先排查方向常用手段
无任何输出烧写、串口、复位检查烧写地址、串口配置
输出乱码波特率、晶振测波形、核对配置
输出后卡死DDR、时钟寄存器对照、打印定位
U-Boot能跑但内核起不来bootargs、内核地址手敲命令验证
能启动但外设失灵设备树、驱动逐个外设排查

6. 启动流程的现代化演进:设备树与驱动模型

如果你看的是2016年之后的U-Boot,你会发现启动流程里到处是dm_*函数和fdt_*函数。这背后其实是两个大变化:

第一,设备树(FDT)全面取代了板级头文件里的硬件描述。

旧的方式是把各种硬件参数写成宏,编译进U-Boot二进制,改硬件就要重新编译。设备树则把硬件的描述分离出去,同一份U-Boot二进制可以配合不同设备树启动不同板子,这对产品线繁多的厂商来说便利性极大。

在启动代码里,U-Boot会解析设备树,为每个节点绑定驱动。没有设备树,就panic。所以移植的第一步往往是把基础设备树创建好。

第二,驱动模型(DM)重写了驱动架构。

旧的驱动各自为战,初始化顺序靠函数表写死。新的驱动模型用U_BOOT_DRIVER宏注册驱动,用设备树节点自动匹配,并提供了层级化的probe顺序。驱动模型更优雅,但也意味着你要多了解一点uclass、udevice这些概念,才能排查驱动初始化问题。

我对刚接触U-Boot的同学有一个真诚的建议:先不要急着追求最新版本代码细节,把经典启动流程理解透彻,再去看新机制,你会发现很多所谓的新概念,本质还是为了解决老问题演进出来的。

7. 手把手梳理一份实战启动日志

这一节我打算用一个模拟的串口启动日志,带你过一遍。这里我用虚构的平台日志,但流程与真实情况基本一致。

U-Boot 2024.10 (Jan 13 2025 - 10:00:00 +0800) CPU: ARMv7 Processor [410fc051] rev 1 (ARMv7) Core: ARMv7 Cortex-A9 Clock: CPU 800 MHz, DDR 400 MHz DRAM: 512 MiB MMC: sdhci@12340000: 0 Net: eth0: ethernet@12350000 In: serial Out: serial Err: serial Hit any key to stop autoboot: 3

我来拆解一下每一行背后的含义:

  • U-Boot 2024.10 ...:版本号,这是环境变量和代码分支的重要参考。排查问题第一步先看版本。
  • CPU...:CPU类型和版本。这行是CPU驱动通过读取CP15寄存器得到的。
  • Clock...:CPU和DDR频率。频率异常时这行会很明显地暴露出来。
  • DRAM: 512 MiB:检测到的内存大小。这里如果显示错误,说明DDR初始化参数或地址映射有问题。
  • MMC: sdhci@12340000: 0:注册了一个MMC控制器实例,名字是设备树节点名。如果你这个板子有两个MMC控制器,这里可能会显示0,1。
  • Net: eth0...:网络接口。没有这一行说明网卡驱动没起来,或者网卡不在线。
  • Hit any key to stop autoboot: 3:倒计时3秒,按任意键进入命令行,否则执行bootcmd。

从这几行,你已经能看出U-Boot做了多少事:CPU识别、时钟配置、DDR探测、MMC驱动、网卡驱动、串口控制台、环境变量加载。每一个子系统都是上面提到的启动流程中对应“initr_*”函数完成的。

如果其中某一行缺失,你可以顺着对应的初始化函数去追,效率会高很多。

8. 两个必须掌握的命令和一个小技巧

最后分享两个我在调试启动流程时几乎天天用的命令,以及一个实用小技巧。

8.1bdinfo命令

它打印当前板子的内存布局、波特率、环境变量地址等信息。每当你怀疑内存映射有问题,或者环境变量指针被破坏时,先敲这个。

8.2printenv/setenv命令

启动流程最终很大程度上由环境变量掌控。你可以用printenv bootcmd看看默认启动命令是怎么设置的,用setenv临时修改后saveenv保存。很多启动问题就是环境变量里某个地址或参数字符串写错了。

8.3 调启动时间的技巧

接上串口后,从第一条输出到命令行出现的时间,可以用秒表量一下。如果发现启动太慢,可以查一下U-Boot是否在等待某个外设超时。例如网卡自动协商没完成时会等待,MMC初始化超时也会拖慢启动。把不需要的驱动在defconfig里关掉,是缩短启动时间最直接的手段。

9. 结尾的一点个人体会

踏踏实实把U-Boot启动流程过一遍,远比在网上搜各种“疑难杂症”的答案有用。因为很多坑之所以难排查,本质上就是对“启动顺序”和“依赖关系”缺乏全局把握。

我自己在调试过程中最大的收获,不是记住某个寄存器的值,而是养成了“按阶段画图、按顺序推演”的习惯。拿到一块新板子,第一步看start.S,第二步看DDR初始化,第三步看重定位,第四步看设备树和外设驱动,每一层都有清晰的边界。这个习惯在后面调试根文件系统、调试内核启动、做产品量产时,都帮了大忙。

如果你刚开始接触U-Boot,不要急着追求读完全部源码。按我上面给的顺序,把一条主线走通,剩下的细节会自然展开。等你把启动流程像看地图一样印在脑子里,再遇到任何“起不来”的问题,都会多一份底气。

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

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

立即咨询