链接脚本与内存布局:.ld 语法详解与实战
说到嵌入式开发,很多人第一反应是写代码、调驱动。但有个东西,你平时可能不怎么碰,一旦碰了就头疼——链接脚本。
我刚开始做嵌入式那会儿,也觉得链接脚本是编译工具链的事,跟我没关系。直到有一次,程序跑起来莫名其妙死机,折腾了两天才发现——是 BSS 段没清零,全局变量全是随机值。嗯,从那以后,我再也不敢跳过链接脚本了。
说白了,链接脚本就是告诉链接器:你的代码放哪,数据放哪,堆栈怎么对齐。今天我们就把它彻底讲透。
一、链接脚本(.ld)语法详解
链接脚本的语法,其实没那么玄乎。核心就三个东西:段(SECTIONS)、内存区域(MEMORY)、符号赋值。
1.1 基本结构
一个典型的 .ld 文件长这样:
/* 最简单的链接脚本示例 */
ENTRY(Reset_Handler)
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS
{
.text :
{
*(.isr_vector) /* 中断向量表 */
*(.text) /* 代码段 */
*(.rodata) /* 只读数据 */
_etext = .; /* 代码段结束地址 */
} > FLASH
.data :
{
_sdata = .; /* 数据段起始地址 */
*(.data)
_edata = .; /* 数据段结束地址 */
} > RAM AT> FLASH
.bss :
{
_sbss = .; /* BSS段起始地址 */
*(.bss)
_ebss = .; /* BSS段结束地址 */
} > RAM
}
你看,结构很清楚:
- MEMORY:定义芯片的物理内存资源
- SECTIONS:定义各个段怎么放
- ENTRY:指定程序入口
1.2 常用关键字
| 关键字 | 含义 | 典型用法 |
|---|---|---|
ORIGIN |
内存起始地址 | ORIGIN = 0x20000000 |
LENGTH |
内存大小 | LENGTH = 128K |
AT> |
加载地址(LMA) | .data > RAM AT> FLASH |
ALIGN |
地址对齐 | . = ALIGN(4) |
KEEP |
强制保留段 | KEEP(*(.isr_vector)) |
这里有个坑:AT> 很多人不理解。你想想看,.data 段在运行时是在 RAM 里,但它的初始值存在 Flash 里。所以加载地址(LMA)是 Flash,运行地址(VMA)是 RAM。启动代码要负责把数据从 Flash 拷到 RAM。
二、代码段/数据段/BSS段布局
这三个段,是嵌入式程序的核心骨架。我画个图帮你理解:
2.1 代码段 (.text)
存放所有函数指令。包括你的 main、中断服务函数、库函数。我一般会在链接脚本里加一句:
.text : ALIGN(4)
{
*(.isr_vector)
*(.text)
*(.text*)
*(.rodata)
*(.rodata*)
_etext = .;
} > FLASH
注意 *(.text*) 这个通配符,它能捕获所有子段。有些编译器会生成 .text.main、.text.foo 这样的子段,不加通配符就丢了。
2.2 数据段 (.data)
存放已初始化的全局变量和静态变量。这里有个关键点:运行地址和加载地址不同。
uint8_t buffer[4096] 并赋了初值。结果程序在 Flash 里占了两份空间——一份是初始值,一份是 RAM 里的副本。启动时拷贝时间太长,导致看门狗复位。后来我把大数组改成运行时初始化,问题解决。
2.3 BSS 段 (.bss)
存放未初始化的全局变量和静态变量。启动代码必须把它清零,否则变量值是随机的。
.bss : ALIGN(4)
{
_sbss = .;
*(.bss)
*(.bss*)
*(COMMON)
_ebss = .;
} > RAM
启动代码里对应的清零逻辑:
extern uint32_t _sbss, _ebss;
uint32_t *p = &_sbss;
while (p < &_ebss) {
*p++ = 0;
}
三、堆栈地址对齐
堆栈对齐,说小了影响性能,说大了直接死机。ARM Cortex-M 要求栈指针必须是 8 字节对齐,否则硬件异常。
3.1 栈对齐
我习惯在链接脚本里这样定义栈:
_stack_size = 0x2000; /* 8KB 栈 */
_stack_end = ORIGIN(RAM) + LENGTH(RAM);
_stack_start = _stack_end - _stack_size;
然后在启动代码里:
ldr sp, =_stack_start
/* 确保 8 字节对齐 */
mov r0, sp
bic r0, r0, #7
mov sp, r0
__builtin_frame_address 或者调试器的栈使用统计功能看实际用量。很多 RTOS 也提供栈溢出检测,建议打开。
3.2 堆对齐
堆的起始地址也要对齐,通常 8 字节或 16 字节。malloc 返回的地址必须满足最严格对齐要求:
_heap_start = ALIGN(_ebss, 8);
_heap_end = _stack_start;
3.3 对齐检查表
| 元素 | 对齐要求 | 后果 |
|---|---|---|
| 栈指针 (SP) | 8 字节 | 未对齐触发 UsageFault |
| 堆指针 | 8 或 16 字节 | malloc 返回未对齐地址 |
| 中断向量表 | 256 或 512 字节 | 中断无法响应 |
| 段起始地址 | 4 字节 (ALIGN(4)) | 性能下降或数据错位 |
四、实战:一个完整的链接脚本模板
下面是我个人常用的模板,适用于 STM32F4 系列:
/* stm32f4.ld - 通用链接脚本模板 */
ENTRY(Reset_Handler)
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1024K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
CCMRAM (rw) : ORIGIN = 0x10000000, LENGTH = 64K
}
_estack = ORIGIN(RAM) + LENGTH(RAM);
SECTIONS
{
.isr_vector : ALIGN(256)
{
KEEP(*(.isr_vector))
} > FLASH
.text : ALIGN(4)
{
*(.text)
*(.text*)
*(.glue_7)
*(.glue_7t)
*(.eh_frame)
. = ALIGN(4);
_etext = .;
} > FLASH
.rodata : ALIGN(4)
{
*(.rodata)
*(.rodata*)
} > FLASH
.ARM.extab : { *(.ARM.extab*) } > FLASH
.ARM.exidx : { *(.ARM.exidx*) } > FLASH
_sidata = LOADADDR(.data);
.data : ALIGN(8)
{
_sdata = .;
*(.data)
*(.data*)
. = ALIGN(8);
_edata = .;
} > RAM AT> FLASH
.bss : ALIGN(8)
{
_sbss = .;
__bss_start__ = _sbss;
*(.bss)
*(.bss*)
*(COMMON)
. = ALIGN(8);
_ebss = .;
__bss_end__ = _ebss;
} > RAM
.ccmram : ALIGN(8)
{
*(.ccmram)
*(.ccmram*)
} > CCMRAM AT> FLASH
.heap : ALIGN(8)
{
_heap_start = .;
. = . + _heap_size;
_heap_end = .;
} > RAM
.stack : ALIGN(8)
{
_stack_start = .;
. = . + _stack_size;
_stack_end = .;
} > RAM
}
关键点总结:
- 中断向量表必须
KEEP,否则链接器可能优化掉 - .data 段用
AT> FLASH指定加载地址 - 所有段起始地址
ALIGN(4)或ALIGN(8) - 栈顶
_estack放在 RAM 末尾
嗯,链接脚本这东西,说难不难,说简单也不简单。你只要记住:它决定了你的程序怎么住进芯片里。地址不对,段没对齐,启动代码没拷数据——任何一个环节出问题,程序都跑不起来。
我建议你拿到一个新芯片,第一件事就是打开它的示例链接脚本,一行一行看明白。别急着改,先理解。等你真正调过几次死机问题,你就会发现——链接脚本,其实是嵌入式工程师的必修课。