链接脚本与内存布局:.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:指定程序入口
我的习惯:每次新建项目,我都会先确认芯片的 Flash 和 RAM 地址范围。别信默认值,一定要看数据手册。我有一次就是用了默认的 0x08000000,结果芯片是 0x08010000 起始,程序直接跑飞。

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段布局

这三个段,是嵌入式程序的核心骨架。我画个图帮你理解:

嵌入式程序内存布局 Flash (0x08000000) .isr_vector (中断向量表) .text (代码段) 函数指令、内联汇编 .rodata (只读数据) .data 初始值 (LMA) RAM (0x20000000) .data (运行时 VMA) .bss (未初始化数据) 堆 (Heap) malloc / new 分配 栈 (Stack) 局部变量、函数调用 启动时拷贝 注意:.data 段在 Flash 和 RAM 中各有一份,启动代码负责从 Flash 拷贝到 RAM

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
我的经验:栈大小别拍脑袋定。我一般先给 2KB,跑起来后用 __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 末尾

嗯,链接脚本这东西,说难不难,说简单也不简单。你只要记住:它决定了你的程序怎么住进芯片里。地址不对,段没对齐,启动代码没拷数据——任何一个环节出问题,程序都跑不起来。

我建议你拿到一个新芯片,第一件事就是打开它的示例链接脚本,一行一行看明白。别急着改,先理解。等你真正调过几次死机问题,你就会发现——链接脚本,其实是嵌入式工程师的必修课。