4. 启动流程与Bootloader:嵌入式系统启动流程、Bootloader的作用、U-Boot分析与移植、启动参数传递

嵌入式系统上电后,第一行代码从哪里开始?CPU怎么知道该执行什么?

很多人一上来就啃Linux内核源码,却忽略了启动流程这个地基。我见过不少工程师,内核配置玩得飞起,但板子就是起不来——最后发现是Bootloader传参传错了。说白了,启动流程没搞透,后面全是盲人摸象。

4.1 嵌入式系统启动流程全景

一个典型的嵌入式Linux系统,从上电到用户态程序运行,大致经历这么几个阶段:

  1. ROM Boot(片内引导):CPU上电后,从预设地址(通常是片内ROM)执行固化代码。
  2. SPL/第一阶段Bootloader:初始化最小硬件(时钟、DDR、串口),加载第二阶段。
  3. U-Boot完整版:初始化更多外设,加载内核镜像到内存。
  4. Linux内核启动:解压、初始化子系统、挂载根文件系统。
  5. 用户空间初始化:init进程启动,系统进入可用状态。

核心要点:整个流程就像接力赛,每一棒只做自己该做的事,然后把控制权交给下一棒。任何一棒掉链子,系统就卡死。

我当年调试一块i.MX6的板子,卡在SPL阶段整整三天。最后发现是DDR时序参数配错了,SPL初始化内存时直接挂掉。嗯,从那以后我养成了一个习惯——先确认DDR能跑通,再往下走。

4.2 Bootloader的核心作用

Bootloader到底在干什么?说白了就三件事:

  • 硬件初始化:把CPU、内存、串口、存储等基础硬件从“原始状态”带到“可用状态”。
  • 加载内核:从Flash、SD卡、网络等介质把内核镜像读到内存。
  • 传递启动参数:告诉内核“你是谁”、“你在哪”、“该怎么跑”。
功能 说明 常见坑点
硬件初始化 时钟、DDR、GPIO、串口等 DDR时序参数错误,板子直接死
加载内核 从存储介质读取镜像到内存 分区表不对,读错位置
传递参数 通过设备树或ATAG传递硬件信息 参数与内核不匹配,外设无法识别
提供交互界面 命令行接口,方便调试和烧写 环境变量配置错误,启动流程异常

个人经验:我习惯在Bootloader阶段加一个“心跳灯”——通过GPIO控制LED闪烁。这样一眼就能看出Bootloader有没有跑起来,比看串口日志快多了。

4.3 U-Boot深度分析

U-Boot是目前嵌入式领域最主流的Bootloader。为什么选它?因为支持架构多、社区活跃、功能强大。你想想看,从ARM到RISC-V,从MIPS到x86,U-Boot几乎全覆盖。

4.3.1 U-Boot的启动流程

U-Boot的启动分两个阶段:

  • Stage 1(汇编阶段):设置异常向量表、初始化CPU模式、关闭中断、初始化DDR、重定位自身到DDR。
  • Stage 2(C语言阶段):初始化板级外设、解析环境变量、进入命令行或自动启动内核。

这里有个关键点——重定位。U-Boot一开始在Flash或ROM里跑,但Flash太慢了。所以它把自己复制到DDR里,然后跳过去继续执行。我刚开始做移植时,重定位地址算错了,U-Boot一跑就飞。后来老老实实对着数据手册算偏移,再也没出过问题。

4.3.2 U-Boot的目录结构

U-Boot源码目录看着大,其实核心就几个:

u-boot/
├── arch/          # 架构相关代码(arm、riscv、x86等)
├── board/         # 板级支持代码
├── common/        # 通用功能(命令行、环境变量等)
├── drivers/       # 驱动(串口、网卡、存储等)
├── dts/           # 设备树源文件
├── include/       # 头文件
├── net/           # 网络协议栈
├── fs/            # 文件系统支持
└── cmd/           # 命令行命令实现

移植U-Boot时,你主要关注board/和arch/这两个目录。板级差异基本都在这里。

4.4 U-Boot移植实战

移植U-Boot到一块新板子,我一般按这个步骤来:

  1. 选择参考板:找一块CPU相同、硬件配置接近的板子作为起点。
  2. 配置编译:make <参考板>_defconfig,先确保能编译通过。
  3. 修改DDR参数:这是最关键的。DDR时序参数必须和硬件设计一致。
  4. 调整时钟配置:CPU频率、总线频率、外设时钟。
  5. 配置串口:确保串口能输出,这是调试的基础。
  6. 调整存储分区:告诉U-Boot内核和设备树放在哪里。
  7. 烧录测试:烧到板子上,看串口输出。

注意:移植U-Boot时,DDR参数是最大的坑。我曾经因为一个时序参数差了0.5ns,板子在高温下就随机死机。后来用示波器量了波形才发现问题。所以,DDR参数一定要和硬件工程师反复确认,最好用DDR厂商提供的计算工具生成。

4.5 启动参数传递

Bootloader和内核之间怎么通信?两种方式:

  • ATAG(老方式):用标签列表传递参数,ARM平台早期使用。
  • 设备树DTB(新方式):用二进制设备树描述硬件,现在的主流。

我个人强烈建议用设备树。为什么?因为ATAG把硬件信息硬编码在Bootloader里,换一块板子就得改Bootloader。设备树把硬件描述和内核代码分离,同一份内核镜像可以跑在不同板子上,只要换设备树就行。

U-Boot加载设备树的典型命令:

# 从SD卡读取设备树到内存
fatload mmc 0:1 0x88000000 board.dtb

# 设置fdt地址
fdt addr 0x88000000

# 启动内核
bootz 0x80000000 - 0x88000000

这里0x80000000是内核镜像地址,0x88000000是设备树地址。地址不能重叠,否则内核启动时会踩掉设备树。

避坑指南:我曾经在调试时,内核和设备树的地址只差了1MB。结果内核解压后直接覆盖了设备树,系统启动到一半就崩了。后来我养成了习惯——内核和设备树之间至少留8MB的间隔。

4.6 知识结构图

下面这张图总结了嵌入式系统从上电到内核启动的完整流程:

嵌入式系统启动流程 嵌入式系统启动流程 ROM Boot 固化代码,最小初始化 SPL 初始化DDR、时钟、串口 U-Boot完整版 加载内核、设备树 Linux内核 解压、初始化子系统 用户空间 init进程、应用程序 关键动作说明 • ROM Boot:CPU上电后从0x00000000或0xFFFF0000开始执行 • SPL:初始化DDR后,将U-Boot完整版从Flash加载到DDR • U-Boot:解析环境变量,加载内核镜像和设备树到指定内存地址 启动参数传递方式 • ATAG(旧):通过寄存器r2传递ATAG列表地址,已逐渐淘汰 • 设备树(新):通过寄存器r2传递DTB地址,硬件描述与内核分离

4.7 常见问题与调试技巧

启动流程中遇到的问题,我总结了几类:

现象 可能原因 排查方法
串口无输出 时钟配置错误、串口引脚未初始化 检查时钟树、GPIO复用配置
U-Boot启动后重启 DDR参数错误、看门狗未关闭 核对DDR时序、关闭WDT
内核无法启动 内核地址错误、设备树不匹配 确认加载地址、检查DTB内容
文件系统挂载失败 分区表错误、驱动未编译 检查分区偏移、确认驱动配置

调试技巧:我习惯在U-Boot里加几个调试打印点——SPL入口、DDR初始化完成、加载内核前。这样出问题时,看打印到哪一步就能快速定位。另外,串口波特率别设太高,115200最稳,460800在干扰大的环境下容易丢数据。

启动流程是整个嵌入式系统的基石。Bootloader虽然看起来只是“加载内核”这么简单,但里面的门道不少。把这一块吃透了,后面调试内核、驱动、应用都会顺手很多。