ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

U-Boot启动流程全景解析:从BootROM到内核交接

U-Boot启动流程全景解析:从BootROM到内核交接 我最早对 uboot 的流程两个字产生敬畏是在一块板子死活起不来的时候。串口一个字符都不打印电源正常晶振正常用示波器量 DDR 的 DQS 引脚也有活动可它就是死寂一片。后来同事走过来看了一眼我的编译配置说了一句你链接地址和烧写地址都没对上流程第一步就死了。从那一刻我才意识到uboot 的启动流程不是一段可以死记硬背的代码顺序而是一套由芯片特性、链接脚本、内存布局、环境变量共同约束的精密接力。搞懂它你才算真正入了嵌入式的门。这篇文章是 uboot 流程系列的第一章先把全景图铺开。我会从上电后第一行代码到底在哪讲起沿着汇编入口、SPL 分工、重定位、链接脚本、内核交接、板级移植这条主线往下拆把每个环节为什么必须这么设计讲透。适合刚接触 uboot、准备做板级移植或者想系统梳理启动逻辑的开发者阅读。1. 上电之后的第一公里整个启动链里 uboot 扮演什么角色很多人对 uboot 的第一个误解是开机以后第一个跑起来的程序就是 uboot。真实情况完全不是这样。芯片上电后首先执行的是芯片内部 ROM 里固化的 bootrom 代码这部分代码是芯片出厂时烧死的你改不了。bootrom 负责最基础的时钟初始化和启动介质选择——它是从 SD 卡启动、eMMC 启动、NAND 启动还是 SPI Flash 启动都由 bootrom 根据硬件电平或 fuse 配置决定。uboot 是在 bootrom 完成介质加载之后才被搬进内存执行的。1.1 从 bootrom 到 uboot芯片出厂时烧死的代码在做什么以 i.MX6ULL 为例芯片上电后 bootrom 会先做几件事初始化片内 SRAM、根据 Boot Mode 引脚的电平状态决定从哪个设备加载镜像、将外部介质特定偏移位置的数据拷贝到 SRAM 或 DDR 中然后跳转执行。注意这里有一个关键限制——bootrom 自身的能力非常有限它不认识文件系统也不理解 uboot 镜像的完整格式它只按照固定的规则去找头部数据 代码段。这个固定规则就是我们常说的镜像头。i.MX6ULL 要求 uboot 镜像在偏移 1KB 处放置 IVTImage Vector Table和 Boot Data里面记录了镜像加载地址、跳转地址、DCD 数据等。这也是为什么你会发现正点原子、野火这些开发板的 uboot 烧写脚本往往不是直接烧 u-boot.bin而是先经过 mkimage 处理生成 u-boot.imx。这个 .imx 文件就是在 uboot 镜像前面加上了 bootrom 能识别的头信息。所以整个启动链条的第一段是bootrom 读取头部信息 → 根据头部信息初始化外部存储控制器DCD 的作用→ 把 uboot 镜像拷贝到指定内存地址 → 跳转执行。此时 uboot 才真正拿到 CPU 的控制权。这个过程对开发者来说是透明的但如果你做的是 ARM 平台必须清楚这个机制否则你无法理解为什么有些板子的 uboot 不能直接从 offset 0 烧写。1.2 SPL 与 uboot 的分工为什么要有两段式启动接下来的问题来了uboot 本身也不小动辄几百 KB。对于很多芯片来说片内 SRAM 只有几十 KB 到一两百 KB根本装不下完整的 uboot而外部 DDR 在 uboot 运行之前还未完成初始化也没法用。这就形成了一个经典的先有鸡还是先有蛋困局要初始化 DDR需要代码在内存里跑但内存DDR本身还没初始化大代码进不来。解决思路就是分两步走。第一步先让一个极小的程序在片内 SRAM 里运行这个程序只做最必要的事初始化 DDR 控制器、把自己或主 uboot 从存储介质加载到 DDR、然后跳转。这就是 SPLSecondary Program Loader。在较新的 uboot 代码里SPL 已经是标配由 CONFIG_SPL_BUILD 控制编译。SPL 的体积被严格限制能裁剪的模块全部裁掉连命令行都不需要就是为了让它能在 SRAM 里跑起来。你可以在 board 目录里看到很多板级配置里会有 CONFIG_SPL_TEXT_BASE、CONFIG_SPL_MAX_SIZE 这样的宏这些就是约束 SPL 自身大小和链接地址的。SPL 跑完后的效果是DDR 已经可用、uboot 完整镜像已经被搬到 DDR 中某个位置、CPU 跳转到 uboot 入口。4412、imx6ull 这些热门移植案例很多时间其实是耗在让 SPL 阶段能稳定初始化 DDR这件事上的因为 DDR 初始化参数一旦不对后面全是黑屏。不过也要说明一点不是所有平台都强制走 SPL。比如一些老的 ARM 芯片Cortex-A8 时代片上内存比较大或者存储介质允许 uboot 直接在 XIP片上执行模式下运行就可以省掉 SPL直接 bootrom → uboot。但对于现代 ARM SoCSPL 几乎是必然出现的一环。理解这个两段式设计你就掌握了阅读 uboot 启动代码的总钥匙。2. uboot 内部流程主线从汇编到 C 语言的接力赛当 uboot 真正拿到 CPU 控制权时它面对的环境相当原始可能只有一个或多个 CPU 核处于未知状态、异常向量表还没设置、栈指针是乱的、全局变量无法访问。所以 uboot 的开头必须用汇编语言来写因为汇编可以直接操作硬件状态不需要依赖任何运行时环境。C 语言虽然可读性好但它要求先有栈、有 BSS 段清零、有可用的内存这些前置条件在启动早期都不满足。这条汇编开场C 语言接管的路径是 uboot 流程里最核心的主线。2.1 reset 向量第一条指令是怎么被找到的uboot 的入口点由链接脚本的 ENTRY 指定通常指向 arch/arm/lib/vectors.S 文件里的 _start 标号。之所以叫 vectors是因为这个文件开头放的是异常向量表——复位、未定义指令、SWI、预取 abort、数据 abort、IRQ、FIQ 这 7 个异常入口每个占 4 字节。CPU 上电或复位后硬件会强制从异常向量表的复位入口取值执行即 _start 标号处。这段汇编做的事情并不复杂但每一句都有明确目的。先是设置为 SVC 模式并关中断set_svc、cpsr 操作保证后续初始化不被中断打扰然后对 CP15 协处理器做基本设置关闭 MMU 和 Cache、清空 TLB。这里有个容易忽略的点uboot 启动早期为什么要关 MMU因为在 MMU 开启的状态下CPU 访问的是虚拟地址而此刻页表还没建立任何一次访存都可能产生 translation fault直接死机。所以第一时间必须确保 CPU 处于最原始的实地址访问模式。接下来是 cpu_init_cp15 和 cpu_init_crit后者会调用 lowlevel_init。lowlevel_init 在不同平台实现差异很大有的只做时钟和引脚复用有的还得在这里完成 DDR 的初始化如果这板子没走 SPL 的话。真正复杂、平台相关的初始化就从这里开始了。对 ARM 平台来说从 _start 到 lowlevel_init 这段代码是高度通用的基本上所有使用同系列 CPU 的板子都可以不动它。这也是为什么你会看到 uboot 源码里 arch 目录下的代码大部分不需要板级开发者操心。2.2 从 SRAM 到 DDR早期代码为什么不敢碰 DDR在 SPL 方案里SPL 的早期代码运行在 SRAM 中uboot 主镜像的早期代码则运行在 DDR 中——注意这时 DDR 已经被 SPL 初始化好了所以 uboot 主镜像可以直接从 DDR 起步。但无论哪种情况都涉及一个共性问题在内存控制器尚未配置完成之前代码不能碰外部内存。无论是取指令还是访问数据只要地址落到了 DDR 区间而控制器没准备好总线就会挂死。具体到代码层面uboot 的做法是把 start.S 前半段设计成位置无关代码PIC。位置无关的意思就是代码里不出现绝对地址跳转所有跳转都用相对跳转指令如 b、bl所有数据访问都通过当前 PC 的偏移来计算。这样做了之后这段代码放在哪里都能执行不管链接地址是 0x87800000 还是 0x80000000实际运行效果都一样。这是 uboot 里一个非常经典且必须理解的设计——它保证了 bootrom 或 SPL 把 uboot 镜像拷贝到任何合法的内存地址后这段代码都能正确跑起来。从这个位置开始uboot 才逐步建立起可以运行 C 语言的环境。首先是设置栈指针 SP。栈放在哪直接决定了后续 C 函数调用能不能工作。在 _main位于 arch/arm/lib/crt0.S里uboot 会先给栈分配一块空间然后把当前 CPU 的 ID 保存起来随后调用 board_init_f。board_init_f 的 f 是第一阶段pre-relocation的意思它做的是把开发板基本的时钟、串口、DRAM 控制器初始化完成计算后续重定位所需的参数把这些参数记录在一个叫 gdglobal_data的结构体里。2.3 重定位uboot 把自己搬家的完整逻辑重定位relocation可以说是 uboot 流程里最容易被忽略、却最致命的一步。为什么要重定位因为 uboot 主镜像在编译时有一个链接地址 CONFIG_SYS_TEXT_BASE比如 0x87800000但 bootrom 或 SPL 加载镜像时使用的加载地址未必是这个值甚至同一个镜像可能被加载到不同介质的不同偏移处。如果代码里使用了绝对地址去访问全局变量或函数而镜像实际运行地址与链接地址不一致那访问到的就是错误位置的数据行为完全不可预测。所以 uboot 在 board_init_f 阶段做了一件很巧妙的事它不是简单地把自身从当前运行地址搬到链接地址而是根据当前代码的运行位置、内存大小等因素重新计算一个目标地址通常是 DRAM 顶部往下预留一段空间然后把整个镜像搬运过去。搬运完成后还会对镜像内部的绝对地址进行修正让每个绝对跳转和数据访问都指向新位置。这就是 relocate_code 干的事它本质上是一个自举过程——程序自己把自己挪个地方还顺手修好了所有指针。完成重定位后uboot 跳转到 board_init_r 继续执行。此时代码已经运行在真正的家里可以放心访问所有 BSS 段变量、静态变量了。从这一刻起uboot 才算进入完全体状态完整的设备驱动模型可以初始化、环境变量可以加载、命令行可以启动。你会发现 board_init_f 和 board_init_r 的职责分界非常清晰前者是创造条件后者是构建完整的运行环境。搞懂这两者的分工你读任何一块板子的 uboot 启动日志都会快很多。以 imx6ull 正点原子移植为例它会定义 CONFIG_SYS_TEXT_BASE 为 0x87800000这是 DDR 映射的某个位置DDR 大小为 512MB内存顶在 0x9FFFFFFF。uboot 重定位后通常会被搬到一个很高的地址比如 0x8FF00000 附近把低地址区域留给内核和后续使用。这个地址规划的背后逻辑就是 uboot 对自身重定位位置的精妙安排。3. 链接脚本与镜像布局流程能跑起来的底层图纸很多初学者看 uboot 源码时只看 .c 和 .S 文件完全跳过 .lds 链接脚本直到某天代码一改就黑屏才回头补习。链接脚本是决定整个镜像如何排布的图纸它规定了代码段、数据段、BSS 段分别放在什么地址、什么顺序。uboot 之所以能在启动早期那段极其苛刻的环境下存活很大程度归功于链接脚本的精细布局。3.1 u-boot.lds 如何决定代码段的前世今生uboot 的主链接脚本是根目录下的 u-boot.lds由 arch/arm/cpu/u-boot.lds 或各板级目录下的 .lds 模板生成。打开它你会看到 ENTRY(_start) 这样的声明它的作用就是告诉链接器整个镜像的入口点在 _start 这个符号处。后续生成的 u-boot.map 文件里会记录所有符号的最终地址排查问题时这是最直接的线索。链接脚本里对 .text 段的排布遵循一个特殊顺序最先放的是 vectors.o异常向量表然后是 start.o启动汇编、cpu 相关代码再往后才是 common 目录下的通用代码。这样排布的目的是保证镜像最开头那几个字节就是 CPU 复位后需要执行的第一条指令。如果把 start.o 放到后面或者被其他段挤掉上电后 CPU 取的第一个指令都不是 uboot 的那就完全跑不起来了。data、rodata、bss 段的排布也有讲究。BSS 段在镜像文件里不占存储空间但运行时需要在内存里预留出来。board_init_f 之前代码虽然不依赖 BSS但一旦进入 C 语言环境BSS 清零是必须完成的。这个清零动作在 crt0.S 或 board_init_r 早期实现清零之后全局变量才能使用。你可以自己验证在 uboot 启动早期board_init_f 的参数是通过 gd 结构体传递的而不是通过全局变量因为那时候 BSS 还没准备好全局变量不可用。这个设计约束逼着 uboot 把早期能不用的功能全都往后推。3.2 烧写地址与链接地址差一个字节就黑屏链接地址和烧写地址是 uboot 开发里最容易出问题的两个概念。链接地址是编译时确定的代码里所有绝对寻址都基于它烧写地址是镜像在存储介质SD 卡、eMMC、NAND上的存放位置。bootrom 或 SPL 会把镜像从烧写地址读到链接地址所指向的内存位置然后跳转。这两者必须配合正确否则前功尽弃。以老的 4412 移植案例为例很多人从三星原厂 uboot 改起源码里链接地址是 0x43E00000对应 iRAM 区域但因为想跑进 DDR把 CONFIG_SYS_TEXT_BASE 改成了 0x43E00000 之外的某个 DDR 地址却没有同步修改 lowlevel_init 里初始化 DDR 的时序参数。结果就是代码被加载到 DDR 地址后CPU 一跳进去访存就异常串口最后只打出几个乱码甚至什么都没打出来。这种情况几乎不可能靠调试器定位最好的办法就是回头校对链接地址、加载地址、跳转地址这三个值是否一致。现在主流的 ARM 平台都引入了 FIT/ITB 镜像格式uboot 的镜像头信息里会记录加载地址和入口地址bootrom 可以自动处理搬运。但这不意味着你可以完全不管链接脚本。恰恰相反只有先理解了 u-boot.lds 的排列逻辑你才能理解为什么某个 .o 文件必须放在特定位置、为什么有的板子要在 board 目录下覆盖默认链接脚本、为什么改了内存大小后 CONFIG_SYS_TEXT_BASE 和 gd-ram_top 必须联动修改。这些知识点是一个整体拆开看哪个都觉得简单串起来才是完整流程图。4. 流程的出口bootcmd、bootargs 与内核交接uboot 启动流程的终点不是把自己跑完而是把控制权交给内核。所以流程的最后一段是交接仪式uboot 根据环境变量里的启动命令从存储介质加载内核镜像和设备树到内存设置好启动参数然后跳转执行内核。搞懂这段流程你才能真正自如地修改启动参数、调试内核启动问题。4.1 环境变量是 uboot 流程的指挥棒uboot 的行为主要受环境变量控制这些变量保存在存储介质的专用分区里比如 SD 卡上的 bootenv 分区或 eMMC 的 boot partition。上电后 uboot 会加载环境变量到内存如果没有找到有效环境变量则使用编译时默认值default_environment。这也是为什么你改了代码里 CONFIG_BOOTCOMMAND 宏定义后有时实测却不生效——因为板子上保存的旧环境变量把默认值覆盖了必须先执行 saveenv 或手动修改环境变量才能让新默认值生效。这个坑十个人有九个踩过我在调试时已经习惯性地每次烧写完先跑一次 env default -a 再 saveenv。启动命令由 bootcmd 决定它本质上是一串 uboot 命令行语句。典型的值像run distro_bootcmd会依次尝试从 USB、MMC、PXE、DHCP 等不同介质启动也可以写成具体的命令序列比如setenv bootcmd fatload mmc 1:1 0x80800000 zImage; fatload mmc 1:1 0x83000000 imx6ull.dtb; bootz 0x80800000 - 0x83000000这段命令的逻辑是从 mmc 1 设备分区 1 加载 zImage 到 0x80800000加载设备树到 0x83000000然后用 bootz 启动。凡是刷过开发板系统的朋友应该都见过类似的命令。它看起来简单但背后的每一个地址选择、每一个分区编号都对应着板级内存布局和分区表规划改错了启动必失败。4.2 从 bootm 到 kernel最后几百行代码做了什么bootm 是 uboot 最经典的内核启动命令也是 booti、bootz 的老大哥。bootm 处理的是 legacy uImage 格式带 64 字节头部和 FIT 格式bootz 专门启动 zImagebooti 启动 Image 格式ARM64 的原始内核镜像。虽然命令不同但最终流程一致校验镜像格式 → 移动到合适地址 → 设置 bootargs 环境变量 → 构造 tagged list 或设备树 → 关闭中断和 Cache → 跳到内核入口。bootargs 这个环境变量尤为重要它承载了内核启动所需的全部参数比如控制台设备、根文件系统位置、内存大小、init 进程路径等。常见值像setenv bootargs consolettymxc0,115200 root/dev/mmcblk1p2 rootwait rw这条命令指定串口控制台为 ttymxc0根文件系统在 mmcblk1p2内核挂载根文件系统前等待设备就绪。你会发现 bootargs 不只是 uboot 的事情它直接决定了内核能不能找到根文件系统、能不能输出日志。很多内核启动到一半卡死的问题追根溯源就是 bootargs 写错了导致内核无法定位根文件系统。所以调试时第一件事就是打印当前环境变量看看 bootcmd 和 bootargs 到底是什么。跳转到内核前uboot 会做一个不能省略的动作把 CPU 状态设置成内核期望的状态。具体来说要关闭 MMU 和 Cache、关中断、把寄存器 r0/r1/r2 设置成内核约定的值r0 为 0r1 为机器类型或 -1r2 为设备树地址。在支持设备树的平台r2 指向 DTB 所在内存地址内核启动后会从这个地址解析设备树。整个交接过程代码量不多但要求顺序严格任何一步出了问题内核要么完全不启动要么启动后 panic。5. 移植视角下的流程一块新板子怎么让 uboot 跑起来了解完理论很多人最关心的问题是如果我要给一块崭新的板子移植 uboot到底该从哪里下手答案其实就藏在前面的流程线里——你不需要理解 uboot 的每一个模块但必须沿着启动流程的顺序把板级相关的那几个关键节点依次打通。下面以最常见的 Cortex-A 系列 SoC 开发板为例梳理一个可执行的移植路径。5.1 拿到新板子该从哪个文件开始读首先明确板级代码都集中在 board/vendor/boardname/ 和 include/configs/boardname.h 两个位置。前者放板级初始化函数后者是配置头文件定义了大量 CONFIG_ 宏。第一步是找一块同为同一系列 CPU 的参考板把它的配置头文件复制一份改成新板子的名字然后再逐步修改。不建议从零开始写配置头文件因为里面涉及的宏可能有几百个漏一个都可能在后续流程某个环节出问题。接着要关注的是低层初始化相关的内容。对照前面讲的流程你得确认自己的板子是否有 SPL 阶段、DDR 初始化时序放在哪里、时钟树配置是否正确。对于 i.MX6ULL 这类带 DCD 的芯片DDR 初始化参数可能由 bootrom 来完成uboot 代码里反而没有复杂的 DDR 初始化代码但对于 4412、三星 Exynos 系列等平台DDR 初始化就在 lowlevel_init 里需要对照芯片手册的时序参数表逐一确认。这一步是整个移植过程中最容易卡壳的地方也是最容易出现烧进去没反应问题的根源。接下来检查运行地址配置。核对 include/configs/boardname.h 里的 CONFIG_SYS_TEXT_BASE、CONFIG_SYS_SDRAM_BASE、CONFIG_SYS_INIT_SP_ADDR 这三个关键宏第一个决定 uboot 链接地址第二个决定 DDR 起始地址第三个决定早期栈的位置。这三个值如果不匹配板子的实际内存布局后面的流程怎么调都白搭。请专门花时间把你板子的 DDR 映射关系、地址空间分配理清楚再动手改代码。5.2 串口一直不打印先查这几处串口没有任何输出是移植 uboot 时最让人崩溃的问题。但按流程顺序排查通常能快速定位。第一步是确认 bootrom 阶段有没有执行起来用示波器看对应存储介质的 CLK 引脚是否有时钟翻转如果连存储介质都没被访问说明 bootrom 在介质选择阶段就失败了检查 Boot Mode 引脚电平。第二步是确认 uboot 代码是否被加载在板级 lowlevel_init 里临时点亮一颗 LED如果 LED 亮了说明代码已经跑到了这里。第三步是检查串口引脚复用和时钟配置很多板子串口引脚默认不是 UART 功能需要在 board_init 里做 IOMUX 配置另外串口时钟源不对会导致波特率漂移表现为输出全是乱码。我在调试一块新板子时习惯用分层验证法先点亮 LED 确认 CPU 活过来了再用示波器抓 UART TX 引脚确认有电平翻转最后才接串口工具看打印。每一步的验证结果都能帮你把问题范围缩小一半。很多人喜欢一步到位直接看串口输出结果一旦没输出就完全失去方向这种做法对排错非常不利。板级移植做到后面你会发现 90% 的时间都花在了确认某个外设的时钟和引脚配置是否正确上而不是在写业务代码。这也反过来验证了 uboot 流程设计的核心思想它把复杂硬件的初始化切成了一个个明确的小阶段每个阶段都有清晰的验收标准。你不需要同时理解所有模块只需要沿着流程一个节点一个节点地验证。当一块陌生的板子在你的手里第一次打出那句熟悉的 U-Boot 2024.04 ... 时你对这套流程的理解就已经超过大多数人一半了。搞 uboot 这些年我最大的体会是它的流程设计并不算晦涩难的是你心里要始终有一条完整的主线。从 bootrom 到 SPL从汇编到 C从重定位到内核交接每一步都有它存在的必然理由。把这根线串起来之后再去看具体某个模块的源码你会发现自己不再是被代码牵着走而是能判断这段代码在整个流程里处于什么位置、缺了它会怎样。这就是我写这个系列最初的动力。下一章我会挑一个具体的板级平台从实际源码开始逐行拆解这条流程线我们到时候见。
返回列表