2. 启动流程全景:从复位向量到第一个C函数
整个启动流程,我认为可以分为三个大的阶段来理解:
- 硬件初始化阶段:关中断、设置CPU模式、初始化时钟、初始化DDR。这个阶段的目标是让CPU“活过来”,并且有一个可用的内存环境。
- 代码重定位与搬运阶段:将U-Boot自身从只读的Flash/NAND/eMMC搬运到可读写的RAM中运行。这解决了“程序存储介质运行速度慢”和“无法修改自身”的矛盾。
- 板级初始化与命令行阶段:初始化串口、网卡、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, r00xd3这个值其实包含了两层意思:低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,不要急着追求读完全部源码。按我上面给的顺序,把一条主线走通,剩下的细节会自然展开。等你把启动流程像看地图一样印在脑子里,再遇到任何“起不来”的问题,都会多一份底气。