3. 链接脚本深入:.ld 文件结构解析与内存布局设计
链接脚本,说白了就是告诉链接器:你的代码该往哪儿放,数据该搁在哪个地址。很多做应用层开发的同事觉得这东西可有可无,但做 Bootloader 和 RTOS 移植,你绕不开它。我刚开始接触 .ld 文件时也一头雾水,直到有一次把中断向量表放错了位置,板子死活不启动……嗯,从那以后我老老实实把链接脚本啃透了。
3.1 链接脚本的核心结构
一个标准的 GNU LD 链接脚本,通常由三大部分组成:内存布局定义、段(Section)划分、符号与重定位规则。我个人习惯把 .ld 文件看作一张“搬家清单”——告诉链接器每个“箱子”(段)应该搬到哪个“房间”(内存区域)。
核心语法速览:
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS
{
.text : { *(.text) } > FLASH
.data : { *(.data) } > RAM AT > FLASH
.bss : { *(.bss) } > RAM
}
你看,结构其实很清晰。MEMORY 定义硬件有哪些可用内存区域,SECTIONS 定义每个段该放哪儿。这里有个关键点:.data 段用了 > RAM AT > FLASH 这种写法,意思是运行时在 RAM,但初始数据存储在 FLASH 里——这就是 Bootloader 启动时要把数据从 Flash 拷贝到 RAM 的原因。
3.2 内存布局定义:MEMORY 命令详解
MEMORY 命令定义的是目标芯片的物理内存资源。每个区域需要指定:名字、属性、起始地址、长度。
| 属性 | 含义 | 典型用途 |
|---|---|---|
| r | 只读 | Flash、ROM |
| w | 可写 | RAM |
| x | 可执行 | 代码段所在区域 |
| ! | 取反属性 | 排除特定权限 |
我的经验:定义内存区域时,建议把 Bootloader 和应用程序的 Flash 区域分开。我曾经在一个项目里把 Bootloader 和 App 的 Flash 区域重叠了,结果升级一次砖一次……后来我习惯这样写:
MEMORY
{
BOOT_FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 32K
APP_FLASH (rx) : ORIGIN = 0x08008000, LENGTH = 480K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
3.3 段的划分与重定位:SECTIONS 命令实战
SECTIONS 是链接脚本的核心。每个段定义了三件事:输入哪些目标文件的内容、输出段的排列顺序、输出段放在哪个内存区域。
3.3.1 标准段划分
一个典型的嵌入式项目,至少需要这几个段:
- .text:代码段,包含函数指令和只读常量
- .rodata:只读数据段,字符串常量、const 变量
- .data:已初始化全局变量,运行时在 RAM,初始值在 Flash
- .bss:未初始化全局变量,启动时需清零
- .stack:栈空间(有些芯片需要显式定义)
- .heap:堆空间(malloc 使用)
避坑指南:我曾经遇到一个 Bug,.bss 段没有在启动代码里清零,结果全局变量初始值全是随机数。排查了两天才发现是链接脚本里 .bss 段的 AT> 写错了,导致链接器没有生成 .bss 的加载地址信息。记住:.bss 段不需要 AT 属性,它只在 RAM 中存在,没有 Flash 备份。
3.3.2 重定位机制:为什么需要 AT 属性
你想想看,芯片上电时 RAM 的内容是随机的。但 .data 段里的全局变量是有初始值的,比如 int count = 100;。这个 100 必须事先存在 Flash 里,启动时再拷贝到 RAM。这就是 AT> 的作用——告诉链接器:这个段的运行时地址在 RAM,但加载地址在 Flash。
链接器会生成两个符号:
_sdata/_edata:RAM 中 .data 段的起始和结束地址_la_data:Flash 中 .data 初始值的存放地址
启动代码里就靠这三个符号完成拷贝:
// 伪代码示意
extern uint32_t _sdata, _edata, _la_data;
for (dst = &_sdata; dst < &_edata; dst++, src++)
*dst = *src;
3.4 链接脚本中的符号与对齐
链接脚本里可以定义符号,这些符号在 C 代码里用 extern 声明就能访问。我个人常用的几个:
SECTIONS
{
. = ALIGN(4); /* 4字节对齐 */
_sdata = .; /* 当前地址赋值给符号 */
.data : { *(.data) } > RAM AT > FLASH
_edata = .;
. = ALIGN(4);
_sbss = .;
.bss : { *(.bss) } > RAM
_ebss = .;
}
这里 ALIGN(4) 很重要。ARM Cortex-M 系列要求中断向量表必须 4 字节对齐,有些外设寄存器也要求对齐访问。我见过有人忘了加 ALIGN,结果 .data 段起始地址没对齐,程序跑起来偶尔死机——这种问题最难查。
3.5 链接脚本知识体系总览
下面这张图是我自己总结的链接脚本知识结构,每次写 .ld 文件前我都会对照着检查一遍:
3.6 实战:一个完整的 Bootloader 链接脚本
最后,我分享一个实际项目中用过的链接脚本模板。这个脚本支持 Bootloader 和 App 分区,包含了中断向量表重定位:
/* bootloader.ld - Bootloader 专用链接脚本 */
MEMORY
{
BOOT_FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 32K
APP_FLASH (rx) : ORIGIN = 0x08008000, LENGTH = 480K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS
{
/* 中断向量表必须放在起始地址 */
.isr_vector :
{
. = ALIGN(4);
KEEP(*(.isr_vector))
. = ALIGN(4);
} > BOOT_FLASH
/* 代码段 */
.text :
{
. = ALIGN(4);
*(.text*)
*(.rodata*)
. = ALIGN(4);
_etext = .;
} > BOOT_FLASH
/* 已初始化数据 - 运行时在RAM,加载在Flash */
.data : AT(ADDR(.text) + SIZEOF(.text))
{
. = ALIGN(4);
_sdata = .;
*(.data*)
. = ALIGN(4);
_edata = .;
} > RAM
/* 未初始化数据 - 仅RAM */
.bss :
{
. = ALIGN(4);
_sbss = .;
*(.bss*)
*(COMMON)
. = ALIGN(4);
_ebss = .;
} > RAM
/* 堆栈定义 */
.stack (NOLOAD) :
{
. = ALIGN(8);
_sstack = .;
. = . + 0x1000; /* 4KB 栈空间 */
. = ALIGN(8);
_estack = .;
} > RAM
}
个人建议:写链接脚本时,每次修改后都用 arm-none-eabi-objdump -h 查看输出文件的段信息,确认每个段的 VMA(运行时地址)和 LMA(加载地址)是否符合预期。我每次移植芯片都会跑一遍这个检查,能避免 90% 的启动异常问题。