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 启动流程与内存布局的完整关系图
下面这张图,把整个启动流程和每一级的内存布局串在了一起。你可以对照着看。
4.4 链接脚本编写实战:U-Boot 的 .lds 文件
U-Boot 的链接脚本比 SPL 复杂得多。因为它需要处理重定位(relocation)。
U-Boot 的启动过程是这样的:
- SPL 把 U-Boot 加载到 DDR 的某个地址(比如 0x80000000)
- U-Boot 开始执行,初始化自身
- U-Boot 把自己重定位到 DDR 的高地址区域(比如 0x9FF00000)
- 从新地址继续执行
为什么要重定位?因为 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 项目时,第一件事就是画一张启动流程图,然后在旁边标注每一级程序的内存地址范围。这张图会贴在工位上,随时参考。嗯,这个习惯帮我避免了很多低级错误。