第三章 链接脚本与内存布局

说实话,很多嵌入式开发者写了几年代码,对链接脚本还是一知半解。我刚开始做嵌入式那会儿也是这样——编译器帮我搞定了,我干嘛要管它?直到有一次,我的程序在SRAM里跑得好好的,移植到Flash就不行了,折腾了两天才发现是链接脚本里地址配错了。嗯,从那以后,我再也不敢小看这个.ld文件了。

链接脚本到底在干什么?

说白了,链接脚本就是一张地图。它告诉链接器:你的代码该放哪儿,数据该放哪儿,堆栈又该在哪儿。没有这张地图,编译器就只能按默认规则来,这在简单项目里还行,一旦涉及到Bootloader、应用程序分区、或者内存紧张的情况,你就得亲自上手了。

我个人习惯把链接脚本看作「内存的分配方案」。你想想看,MCU的内存空间就那么大,Flash和SRAM各占一块,怎么划分给代码段、数据段、BSS段、堆和栈,全由链接脚本说了算。

内存分区:代码段、数据段、BSS段、堆栈

先快速过一下这几个概念,后面写脚本时你会反复用到它们。

段名称 存储位置 内容
.text(代码段) Flash(只读) 程序指令、常量字符串
.rodata(只读数据段) Flash(只读) const修饰的全局变量
.data(数据段) Flash存储,运行时拷贝到SRAM 已初始化的全局/静态变量
.bss(BSS段) SRAM(运行时清零) 未初始化或初始化为0的全局/静态变量
堆(Heap) SRAM 动态分配(malloc)
栈(Stack) SRAM 局部变量、函数调用

这里有个关键点:.data段为什么要在Flash和SRAM各存一份?因为MCU上电时SRAM里的数据是随机的,必须从Flash把初始值拷贝过去。这个拷贝动作,通常由启动文件(startup.s)里的代码完成。我在项目中遇到过有人忘了实现这个拷贝,结果全局变量全是乱的,查了一整天。

核心要点:BSS段在运行时必须清零,.data段必须从Flash拷贝到SRAM。这两个动作缺一不可,否则程序行为不可预测。

如何自定义链接脚本?

好,现在我们来动手写一个。以STM32为例,假设芯片有512KB Flash(起始0x08000000)和128KB SRAM(起始0x20000000)。

/* memory.ld - 自定义链接脚本示例 */

MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 512K
    SRAM  (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

SECTIONS
{
    /* 代码段:放在Flash */
    .text :
    {
        *(.isr_vector)    /* 中断向量表 */
        *(.text*)         /* 程序代码 */
        *(.rodata*)       /* 只读数据 */
        _etext = .;       /* 代码段结束地址 */
    } > FLASH

    /* 数据段:Flash存储初始值,运行时在SRAM */
    .data : AT (ADDR(.text) + SIZEOF(.text))
    {
        _sdata = .;       /* 数据段起始地址 */
        *(.data*)
        _edata = .;       /* 数据段结束地址 */
    } > SRAM

    /* BSS段:运行时清零 */
    .bss :
    {
        _sbss = .;        /* BSS段起始地址 */
        *(.bss*)
        *(COMMON)
        _ebss = .;        /* BSS段结束地址 */
    } > SRAM

    /* 堆和栈 */
    .heap (NOLOAD) :
    {
        . = ALIGN(8);
        _heap_start = .;
        . = . + 0x4000;   /* 16KB堆 */
        _heap_end = .;
    } > SRAM

    .stack (NOLOAD) :
    {
        . = ALIGN(8);
        _stack_top = .;
        . = . + 0x1000;   /* 4KB栈 */
        _stack_bottom = .;
    } > SRAM
}

这段脚本里,我用了几个符号变量:_etext_sdata_edata_sbss_ebss。这些符号在启动文件里会被引用,用来实现数据拷贝和BSS清零。我曾经见过有人把符号名拼写错了,结果链接时报未定义错误,找了半天才发现是少了个下划线。

小技巧:链接脚本里的符号变量,在C代码里可以用extern声明后直接取地址。比如:extern uint32_t _sdata;,然后&_sdata就是数据段的起始地址。

避坑指南:我踩过的几个坑

写链接脚本时,有几个地方特别容易出问题。我一个个说。

  • 地址对齐问题:ARM Cortex-M要求中断向量表必须4字节对齐,有些MCU甚至要求512字节对齐。我遇到过因为没对齐,上电后直接跑飞的情况。
  • Flash和SRAM地址写反:这个错误很蠢,但我确实犯过。把代码段放到了SRAM里,结果程序下载进去一运行就死机——因为SRAM掉电不保存。
  • 堆栈大小设置不合理:默认的堆栈大小往往偏大或偏小。我建议先设大一点(比如栈8KB、堆16KB),跑起来后用__heap_limit和栈填充模式检查实际使用量,再调整。
  • NOLOAD关键字忘记加:堆和栈不需要在镜像文件中占用空间,必须加上(NOLOAD),否则生成的.bin文件会包含一堆无意义的零。

警告:千万不要在链接脚本里把不同段的地址范围重叠!比如代码段占用了0x08000000~0x08010000,数据段又映射到同一片Flash区域。链接器不会报错,但运行时数据会被代码覆盖,后果很严重。

实际项目中的应用

在我做的一个Bootloader项目中,需要把Flash分成三块:Bootloader区(64KB)、应用程序区(384KB)、参数存储区(64KB)。链接脚本就得分别写两个——一个给Bootloader用,一个给应用程序用。

Bootloader的链接脚本只包含Bootloader区的地址范围,应用程序的链接脚本则从0x08010000开始。这样两个固件互不干扰,各自独立编译、独立下载。

/* 应用程序的链接脚本片段 */
MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08010000, LENGTH = 384K
    SRAM  (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

你看,只是改了个起始地址,整个应用程序就乖乖地跑在Bootloader后面了。这就是链接脚本的威力——它决定了你的程序在内存里的「住址」。

总结一下

链接脚本不是那种天天要碰的东西,但一旦需要,它就是关键中的关键。我个人建议每个嵌入式开发者都亲手写一遍链接脚本,哪怕只是改改地址和大小。只有亲手踩过坑,才能真正理解内存布局是怎么回事。

下一章我们会讲启动文件的编写,到时候你会看到链接脚本里定义的符号是怎么被C代码和汇编代码使用的。嗯,那才是真正把整个流程串起来的时候。