3. 链接脚本(.ld)深度解析:内存布局定义、段映射与移植要点
链接脚本,说白了就是告诉编译器:「你的代码该放哪儿,数据该放哪儿」。很多做MCU开发的朋友,写了好几年代码都没碰过.ld文件。但做Bootloader,这关你必须过。
我刚开始移植U-Boot时,就栽在链接脚本上。板子怎么都跑不起来,调试了整整两天,最后发现是中断向量表放错了位置。嗯,从那以后,我每次移植第一件事就是先读透.ld文件。
3.1 链接脚本到底在干什么?
一个MCU的Flash、RAM地址是固定的。你的代码编译出来是一堆二进制段,链接脚本就是告诉链接器:
- .text(代码段)→ 放到Flash的哪个偏移
- .rodata(只读数据)→ 紧跟在代码后面
- .data(初始化数据)→ 运行时从Flash拷贝到RAM
- .bss(未初始化数据)→ 在RAM中清零
你想想看,如果没有链接脚本,编译器怎么知道你的STM32F4的Flash是从0x08000000开始的?它不知道。所以这个脚本就是MCU的「地址地图」。
3.2 内存布局定义——先画好地盘
一个典型的链接脚本开头,会先定义内存区域。我习惯用MEMORY命令来画地盘:
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1M
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
这里定义了:
- FLASH:起始0x08000000,大小1MB,属性可读可执行
- RAM:起始0x20000000,大小128KB,属性可读可写可执行
对于Bootloader场景,我们通常需要预留一块区域给APP。所以内存定义会变成这样:
MEMORY
{
BOOT_FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 32K
APP_FLASH (rx) : ORIGIN = 0x08008000, LENGTH = 992K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
这样Bootloader和APP的地址空间就彻底分开了,互不干扰。
3.3 段(Section)的映射——把代码和数据放到正确的位置
定义好内存区域后,接下来就是告诉链接器:每个段该放到哪个区域。这是链接脚本的核心部分。
3.3.1 .text段:代码和只读数据
SECTIONS
{
.text :
{
. = ALIGN(4);
_stext = .;
*(.isr_vector) /* 中断向量表 */
*(.text*) /* 代码 */
*(.rodata*) /* 只读数据 */
. = ALIGN(4);
_etext = .;
} > BOOT_FLASH
}
这里有个关键点:中断向量表必须放在段的最开头。MCU上电后,从Flash的起始地址读取栈指针和复位向量。如果向量表没放在开头,芯片直接跑飞。
_stext和_etext这两个符号。这样在C代码里可以用extern声明它们,方便计算代码大小和校验和。做固件升级时特别有用。
3.3.2 .data段:初始化数据
.data段比较特殊。它存储的是全局变量的初始值,这些值在编译时放在Flash里,但运行时必须拷贝到RAM中才能读写。
.data : AT (ADDR(.text) + SIZEOF(.text))
{
. = ALIGN(4);
_sdata = .;
*(.data*)
. = ALIGN(4);
_edata = .;
} > RAM
注意这里的AT指令。它告诉链接器:这个段的加载地址(LMA)在Flash里,紧跟在.text后面;但运行地址(VMA)在RAM里。启动代码需要把数据从LMA拷贝到VMA。
我曾经在一个项目里忘了加AT,结果.data段的LMA和VMA都指向RAM。下载程序后,RAM里的数据是随机的,全局变量全乱套了。排查了好久才发现是链接脚本的问题。
3.3.3 .bss段:未初始化数据
.bss :
{
. = ALIGN(4);
_sbss = .;
*(.bss*)
*(COMMON)
. = ALIGN(4);
_ebss = .;
} > RAM
.bss段不需要从Flash拷贝,但启动代码必须把它清零。否则全局变量初始值可能是随机的。
3.4 链接脚本移植要点——避坑指南
移植链接脚本时,有几个坑我踩过,分享给你:
| 要点 | 说明 | 我的经验 |
|---|---|---|
| 向量表偏移 | APP的向量表必须偏移,否则中断会跑到Bootloader的向量表 | 记得在APP代码里设置SCB->VTOR寄存器 |
| 栈顶地址 | 链接脚本第一行通常定义栈顶,必须指向RAM末尾 | 我习惯用_estack = ORIGIN(RAM) + LENGTH(RAM); |
| 对齐问题 | ARM Cortex-M要求4字节对齐,否则可能触发硬件错误 | 每个段前后都加. = ALIGN(4); |
| LMA与VMA | .data段的加载地址和运行地址不同,必须用AT指定 | 忘了AT的话,启动代码拷贝时会读到错误的数据 |
| 符号导出 | 用PROVIDE导出关键符号,方便C代码引用 |
比如PROVIDE(_sflash = .); |
3.5 一个完整的Bootloader链接脚本示例
下面是我常用的一个Bootloader链接脚本模板,你可以直接参考:
/* 栈顶地址:RAM末尾 */
_estack = ORIGIN(RAM) + LENGTH(RAM);
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 32K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS
{
.isr_vector :
{
. = ALIGN(4);
KEEP(*(.isr_vector))
. = ALIGN(4);
} > FLASH
.text :
{
. = ALIGN(4);
_stext = .;
*(.text*)
*(.rodata*)
. = ALIGN(4);
_etext = .;
} > FLASH
.data : AT(ADDR(.text) + SIZEOF(.text))
{
. = ALIGN(4);
_sdata = .;
*(.data*)
. = ALIGN(4);
_edata = .;
} > RAM
.bss :
{
. = ALIGN(4);
_sbss = .;
*(.bss*)
*(COMMON)
. = ALIGN(4);
_ebss = .;
} > RAM
}
3.6 链接脚本与固件升级的关系
做固件升级时,链接脚本直接决定了升级包的生成方式。我画了一张图来说明它们的关系:
从这张图可以看出,链接脚本是整个固件升级链条的起点。它决定了:
- APP的起始地址(决定了升级包该烧录到哪个位置)
- 中断向量表的位置(决定了跳转后中断能否正常工作)
- RAM的使用情况(决定了Bootloader和APP能否共用RAM)
_app_start和_app_size这样的符号,然后在Bootloader代码里用extern引用。这样即使以后调整了Flash分区,Bootloader代码也不需要修改,只需要改链接脚本就行。
3.7 总结
链接脚本看起来复杂,其实核心就三件事:
- 画地盘:用MEMORY定义Flash和RAM的起始地址和大小
- 分房间:用SECTIONS把.text、.data、.bss放到正确的位置
- 留门牌:用符号导出关键地址,方便C代码引用
做Bootloader移植时,我建议你先从芯片厂商提供的示例链接脚本改起。不要自己从头写,容易漏掉细节。改完之后,用objdump -h查看各段的地址,确认它们都在你期望的位置上。
嗯,链接脚本这块就讲到这里。记住一句话:链接脚本错了,代码写得再好也跑不起来。