第3章:链接脚本与内存布局
链接脚本,说白了就是告诉编译器:「你的代码该放哪儿,数据该放哪儿」。很多做Bootloader的朋友,遇到启动不起来的问题,最后查来查去,发现是链接脚本写错了。我当年就吃过这个亏,折腾了两天才找到原因。
3.1 链接脚本(.lds)到底在干什么
链接脚本的核心任务就三个:
- 把目标文件(.o)合并成可执行文件
- 给每个段分配内存地址
- 生成符号表,让代码能正确跳转
你想想看,Bootloader的入口地址、中断向量表的位置、栈顶指针的初始值,全得靠它来定。一旦定错了,CPU上电后第一条指令就跑飞了。
核心概念:链接脚本定义了「虚拟地址」和「加载地址」的映射关系。对于Bootloader这种裸机程序,两者通常相同,但有些场景(比如从Flash拷贝到RAM执行)就需要区分。
3.2 代码段/数据段/BSS段的定位
一个典型的Bootloader,内存布局大概长这样:
/* 一个简化的Bootloader链接脚本示例 */
ENTRY(Reset_Handler)
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 128K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 64K
}
SECTIONS
{
/* 中断向量表 + 代码段 */
.isr_vector : ALIGN(4)
{
_svector = .;
KEEP(*(.isr_vector))
_evector = .;
} > FLASH
.text : ALIGN(4)
{
_stext = .;
*(.text*)
*(.rodata*)
_etext = .;
} > FLASH
/* 初始化数据段 - 运行时在RAM,初始值在Flash */
.data : ALIGN(4)
{
_sdata = .;
*(.data*)
_edata = .;
} > RAM AT> FLASH
/* BSS段 - 零初始化 */
.bss : ALIGN(4)
{
_sbss = .;
*(.bss*)
*(COMMON)
_ebss = .;
} > RAM
/* 堆与栈 */
.heap : ALIGN(8)
{
_sheap = .;
. = . + HEAP_SIZE;
_eheap = .;
} > RAM
.stack : ALIGN(8)
{
_sstack = .;
. = . + STACK_SIZE;
_estack = .;
} > RAM
}
个人经验:我习惯在链接脚本里给每个段都定义起始和结束符号(比如_sdata、_edata),这样C代码里可以直接用extern声明来访问这些地址,做数据拷贝和零初始化特别方便。
3.3 各段的作用与初始化时机
| 段名 | 存储位置 | 运行时位置 | 初始化方式 |
|---|---|---|---|
| .isr_vector | Flash起始 | Flash | 编译时确定 |
| .text | Flash | Flash | 编译时确定 |
| .rodata | Flash | Flash | 编译时确定 |
| .data | Flash | RAM | 启动时从Flash拷贝 |
| .bss | 无 | RAM | 启动时清零 |
| .heap | 无 | RAM | 启动时设定边界 |
| .stack | 无 | RAM | 启动时设定SP指针 |
这里有个关键点:.data段在Flash里存了一份初始值,运行时却要搬到RAM里。为什么?因为RAM读写快,但掉电丢失。Flash存得久,但直接执行效率低。所以Bootloader启动时,得先把.data从Flash拷贝到RAM,再把.bss清零。
我曾经踩过的坑:有一次忘了在启动代码里做.data段的拷贝,结果全局变量全是默认值0,程序跑起来各种诡异。查了两天才发现,原来.data段在RAM里根本没初始化。从那以后,我每次写启动代码都会反复确认拷贝逻辑对不对。
3.4 栈与堆的初始化
栈和堆的初始化,是Bootloader启动流程里最容易出问题的地方之一。
栈的初始化
栈指针SP的初始值,通常由链接脚本提供。在ARM Cortex-M系列里,CPU上电后会自动从0地址加载SP和PC:
/* 启动文件中的栈指针设置 */
__Vectors:
.word _estack /* 栈顶地址,由链接脚本定义 */
.word Reset_Handler /* 复位入口 */
.word NMI_Handler
.word HardFault_Handler
/* ... 其他中断向量 */
这里要注意:栈是向下生长的。所以_estack实际上是RAM的最高地址,而不是最低地址。我见过有人把栈顶设在RAM中间,结果栈一增长就把数据段给覆盖了。
堆的初始化
堆用于动态内存分配(malloc/free)。在Bootloader里,堆用得不多,但如果有复杂的数据结构(比如解析FAT32文件系统),堆就派上用场了。
堆的初始化其实很简单:
- 在链接脚本里定义堆的起始地址和大小
- 在启动代码里调用一个堆初始化函数(通常是__heap_init或类似名字)
- 设置堆管理器的内部数据结构
我的建议:Bootloader里尽量少用动态内存分配。如果非用不可,一定要在链接脚本里给堆留够空间,并且做好内存泄漏检查。我一般会在堆的末尾放一个「哨兵值」,每次分配后检查哨兵有没有被踩坏。
3.5 内存布局的SVG结构图
下面这张图展示了典型的Bootloader内存布局:
3.6 常见链接脚本错误与排查
我整理了几个最常见的链接脚本问题,你在调试Bootloader时肯定会遇到:
- 段重叠:两个段的地址范围有交集,导致数据互相覆盖。用
objdump -h查看各段的地址范围就能发现。 - 符号未定义:链接脚本里引用了某个符号,但没在任何目标文件里定义。检查
_estack、_sdata这些符号有没有正确定义。 - 对齐问题:ARM指令要求4字节对齐,如果链接脚本没加ALIGN(4),可能会生成非对齐访问,触发HardFault。
- 加载地址与运行地址混淆:.data段用
AT> FLASH指定加载地址在Flash,但运行地址在RAM。如果忘了这个区别,拷贝逻辑就会写错。
重要提醒:修改链接脚本后,一定要做三件事——1) 重新编译所有文件;2) 用objdump检查生成的elf文件的内存布局;3) 在调试器里单步验证SP和PC的初始值是否正确。少一步都可能出问题。
3.7 实战:手写一个最小链接脚本
假设你用的是STM32F103,Flash从0x08000000开始,RAM从0x20000000开始。一个最小可用的链接脚本长这样:
ENTRY(Reset_Handler)
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 64K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 20K
}
_estack = ORIGIN(RAM) + LENGTH(RAM);
SECTIONS
{
.isr_vector : { *(.isr_vector) } > FLASH
.text : { *(.text*) *(.rodata*) } > FLASH
.data : { *(.data*) } > RAM AT> FLASH
.bss : { *(.bss*) *(COMMON) } > RAM
}
这个脚本虽然简单,但五脏俱全。你可以在它的基础上,根据实际需求添加堆、栈、自定义段等。
个人习惯:我每次新建一个Bootloader项目,都会先从这个最小脚本开始,然后逐步添加功能。这样万一出了问题,回退起来也方便。
嗯,链接脚本这块儿就讲这么多。记住一句话:内存布局是Bootloader的骨架,链接脚本就是骨架的设计图。设计图画错了,骨架肯定长歪。多花点时间在链接脚本上,后面调试能省一半的功夫。