4. 启动流程与链接脚本:芯片上电启动流程与内存布局

芯片上电后,第一行代码从哪里开始?

这个问题,我当年刚入行时也困惑了很久。说白了,嵌入式系统的启动,就像一场精心编排的接力赛。BootROM 是第一棒,SPL 是第二棒,U-Boot 是第三棒,最后才把控制权交给操作系统。每一棒都有自己的任务,也都有自己的内存布局约束。

4.1 芯片上电启动流程:BootROM → SPL → U-Boot

大多数现代 SoC 的启动流程都遵循一个固定的三段式结构。我习惯把它叫做「三级跳」。

核心流程: BootROM(固化在芯片内部)→ SPL(Secondary Program Loader)→ U-Boot(主引导程序)→ 操作系统内核。

4.1.1 第一级:BootROM

芯片上电后,CPU 会从一个固定的地址取指。这个地址通常指向芯片内部的一块 ROM,也就是 BootROM。BootROM 是芯片出厂时固化的,你改不了它。

BootROM 的任务很简单:

  • 初始化最基本的硬件(时钟、SRAM、看门狗)
  • 检测启动介质(NAND Flash、eMMC、SD 卡、SPI NOR Flash 等)
  • 从启动介质中读取下一级程序(SPL)到内部 SRAM 中
  • 跳转到 SPL 的入口地址

嗯,这里要注意。BootROM 能操作的 RAM 非常有限,通常只有几十 KB 到几百 KB 的内部 SRAM。所以它加载的 SPL 必须足够小,小到能塞进这片「弹丸之地」。

我的经验: 有一次我在调试一块新板子,死活起不来。后来发现是 BootROM 配置的启动介质顺序错了。它先去检测了空的 SPI Flash,没找到有效镜像,就卡死了。所以,启动介质的选择和优先级,是硬件设计阶段就要和 BootROM 手册仔细核对的事情。

4.1.2 第二级:SPL(Secondary Program Loader)

SPL 是 U-Boot 的一个精简版本。为什么需要它?因为 U-Boot 本身太大了,内部 SRAM 装不下。

SPL 的任务:

  • 初始化 DDR 控制器(这是最关键的一步)
  • 初始化存储控制器(以便读取更大的 U-Boot 镜像)
  • 从启动介质中加载完整的 U-Boot 到 DDR 中
  • 跳转到 U-Boot 的入口

说白了,SPL 就是一个「内存搬运工」。它把 U-Boot 从慢速的 Flash 搬到快速的内存里。

避坑指南: 我曾经在 SPL 阶段踩过一个坑。DDR 初始化时序参数是从芯片参考手册里抄的,但实际板子上的 PCB 走线长度和参考设计不一样。结果 SPL 初始化 DDR 后,读写校验一直报错。后来用示波器量了 DQS 和 CLK 的相位差,手动调整了寄存器才搞定。DDR 的初始化参数,一定要根据实际硬件调,不能无脑抄。

4.1.3 第三级:U-Boot

U-Boot 是完整的引导加载程序。它提供了命令行交互、网络下载、文件系统支持等功能。

U-Boot 的任务:

  • 初始化更复杂的硬件(网卡、USB、显示等)
  • 加载设备树(DTB)
  • 加载操作系统内核镜像(如 Linux 的 zImage/Image)
  • 设置内核启动参数(bootargs)
  • 跳转到内核入口

到这里,芯片的控制权就交给了操作系统。启动流程结束。

4.2 链接脚本(.lds)编写与内存布局

链接脚本,说白了就是告诉链接器:「你的代码应该放在内存的哪个位置,数据应该放在哪里。」

没有链接脚本,编译器就不知道 .text 段该从哪个地址开始,.data 段该紧跟在谁后面。你想想看,如果代码段和数据段重叠了,程序跑起来会是什么后果?

4.2.1 链接脚本的基本结构

一个典型的 .lds 文件包含以下几个部分:

OUTPUT_FORMAT("elf32-littlearm")
OUTPUT_ARCH(arm)
ENTRY(_start)

SECTIONS
{
    . = 0x80000000;  /* 起始地址 */

    .text : {
        *(.text)
    }

    .data : {
        *(.data)
    }

    .bss : {
        *(.bss)
    }
}

我来拆解一下:

  • OUTPUT_FORMAT / OUTPUT_ARCH:指定输出文件的格式和架构。这个一般不用动。
  • ENTRY:指定程序的入口点。通常是 _start 这个符号。
  • SECTIONS:核心部分。定义各个段的布局。
  • . = 0x80000000:设置当前地址计数器。意思是「从 0x80000000 开始放代码」。
  • .text / .data / .bss:标准的三个段。代码段、已初始化数据段、未初始化数据段。

关键点: .bss 段在镜像文件中不占空间,但运行时需要清零。所以启动代码里一定要有一段逻辑,把 .bss 段对应的内存区域全部写 0。我见过有人忘了做这一步,结果全局变量初始值全是随机数,程序行为完全不可控。

4.2.2 内存布局实战:SPL 的链接脚本

SPL 的链接脚本和 U-Boot 的链接脚本有很大不同。因为 SPL 运行在内部 SRAM 中,地址空间非常有限。

下面是一个 SPL 链接脚本的典型写法:

MEMORY
{
    sram : ORIGIN = 0x20000000, LENGTH = 64K
    ddr  : ORIGIN = 0x80000000, LENGTH = 512M
}

SECTIONS
{
    . = ORIGIN(sram);

    .text : {
        *(.text)
    } > sram

    .data : {
        *(.data)
    } > sram

    .bss : {
        *(.bss)
    } > sram

    .u_boot : {
        *(.u_boot)
    } > ddr
}

这里用到了 MEMORY 命令。它定义了两个内存区域:sram(64KB)和 ddr(512MB)。

SPL 的代码和数据都放在 sram 中。而 .u_boot 段(存放 U-Boot 镜像)则直接指定放在 ddr 中。这样,SPL 在运行过程中,可以直接把 U-Boot 从 Flash 读到 ddr 的指定位置。

我的习惯: 我一般会在链接脚本里加一个 ASSERT 语句,用来检查代码大小是否超过了 SRAM 的容量。比如:

ASSERT((. <= ORIGIN(sram) + LENGTH(sram)), "SPL too big!");

这样如果 SPL 编译后超过了 64KB,链接阶段就会报错,而不是等到运行时才崩溃。

4.2.3 内存布局的常见陷阱

陷阱 现象 原因 解决方法
代码段和数据段重叠 程序跑飞、异常 链接脚本中地址计算错误 检查 . 的赋值,确保各段不重叠
BSS 段未清零 全局变量初始值随机 启动代码中缺少 bss 清零逻辑 在 _start 中加入 bss 清零循环
栈地址设置过低 函数调用时栈溢出 栈指针指向了代码段或数据段 将栈设置在内存最高地址附近
DDR 地址未对齐 U-Boot 加载后无法执行 U-Boot 镜像地址不是 4KB 对齐 确保加载地址是页对齐的

4.3 启动流程与内存布局的完整关系图

下面这张图,把整个启动流程和每一级的内存布局串在了一起。你可以对照着看。

芯片上电启动流程与内存布局 BootROM 内部 ROM,固化代码 SPL 内部 SRAM,64KB U-Boot DDR,512MB Linux 内核 DDR,高地址区域 各阶段内存布局示意 BootROM 区域(只读,0x00000000) SRAM 区域(0x20000000,64KB) DDR 区域(0x80000000,512MB) SPL 代码 + 数据 → SRAM U-Boot 镜像 → DDR 低地址 内核镜像 → DDR 中地址 设备树 → DDR 中地址 根文件系统 → DDR 高地址 栈指针 → DDR 最高地址

4.4 链接脚本编写实战:U-Boot 的 .lds 文件

U-Boot 的链接脚本比 SPL 复杂得多。因为它需要处理重定位(relocation)。

U-Boot 的启动过程是这样的:

  1. SPL 把 U-Boot 加载到 DDR 的某个地址(比如 0x80000000)
  2. U-Boot 开始执行,初始化自身
  3. U-Boot 把自己重定位到 DDR 的高地址区域(比如 0x9FF00000)
  4. 从新地址继续执行

为什么要重定位?因为 U-Boot 在低地址运行,可能会被后续加载的内核覆盖。所以它要「挪个窝」,给自己找个安全的地方。

U-Boot 链接脚本的关键部分:

SECTIONS
{
    . = 0x80000000;

    .text : {
        *(.text)
    }

    . = ALIGN(4);
    .data : {
        *(.data)
    }

    . = ALIGN(4);
    .bss : {
        *(.bss)
    }

    . = ALIGN(4);
    .rel_dyn : {
        *(.rel*)
    }

    . = ALIGN(4);
    .end : {
        *(.end)
    }
}

这里多了一个 .rel_dyn 段。它存放的是重定位表。U-Boot 在重定位时,会遍历这个表,把所有需要修正的地址都加上一个偏移量。

避坑指南: 我曾经在移植 U-Boot 到一块新板子时,发现重定位后程序跑飞了。查了两天才发现,是 .rel_dyn 段没有正确生成。原因是编译时没有加 -fPIE 选项。没有这个选项,编译器就不会生成位置无关的代码,重定位表就是空的。U-Boot 必须用 -fPIE 编译,否则重定位会失败。

4.5 总结:启动流程与链接脚本的关系

启动流程和链接脚本,其实是同一个问题的两个面。

  • 启动流程决定了「谁先执行,谁后执行」
  • 链接脚本决定了「每一级程序放在内存的哪个位置」

两者缺一不可。你想想看,如果链接脚本把 SPL 的入口地址写错了,BootROM 跳转过去就是空指针。如果启动流程里没有正确初始化 DDR,U-Boot 加载到内存里也是一堆乱码。

我个人习惯,在开始一个新 BSP 项目时,第一件事就是画一张启动流程图,然后在旁边标注每一级程序的内存地址范围。这张图会贴在工位上,随时参考。嗯,这个习惯帮我避免了很多低级错误。