3、链接脚本精讲:链接脚本(.ld)结构解析、内存布局定义、段(text/data/bss)的映射规则

好,咱们进入第三讲。链接脚本,很多人觉得它神秘,甚至有点吓人。其实说白了,它就是一份给链接器的“地图”和“施工图”。你告诉链接器:“芯片里有哪些内存区域,我的代码和数据分别该放哪儿。” 就这么简单。

我刚开始做Bootloader那会儿,也踩过不少坑。有一次,APP跳转后死活跑不起来,查了三天,最后发现是链接脚本里一个地址写错了。嗯,从那以后,我对.ld文件就格外敬畏。

3.1 链接脚本的结构解析

一个标准的.ld文件,通常由三大部分组成:内存布局定义段映射规则、以及符号定义。咱们一个一个来看。

3.1.1 内存布局定义(MEMORY命令)

MEMORY命令,就是告诉链接器:“嘿,我的芯片里有哪些可用的存储区域,它们的起始地址和大小是多少。” 我个人习惯把这段放在脚本最前面,一目了然。

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

这里要注意几点:

  • ORIGIN:起始地址。对于STM32来说,Flash通常从0x08000000开始,RAM从0x20000000开始。但不同型号有差异,一定要查手册。
  • LENGTH:大小。别写大了,否则链接器会报错。
  • 属性:r=读,w=写,x=执行。Flash一般给rx,RAM给rwx。
注意: 如果你在做Bootloader,Flash的起始地址往往不是0x08000000,而是要给APP留出空间。比如Bootloader占32K,那APP的Flash起始地址就是0x08008000。这个偏移量,必须在链接脚本里体现。

3.1.2 段映射规则(SECTIONS命令)

SECTIONS命令,是链接脚本的核心。它定义了每个段(.text, .data, .bss等)该放到哪个内存区域,以及它们的排列顺序。

一个典型的SECTIONS结构长这样:

SECTIONS
{
    .text :
    {
        *(.text)        /* 所有输入文件的.text段 */
        *(.rodata)      /* 只读数据 */
        _etext = .;     /* 定义符号,标记text段结束 */
    } > FLASH

    .data : AT (ADDR(.text) + SIZEOF(.text))
    {
        _sdata = .;     /* data段起始地址(在RAM中) */
        *(.data)
        _edata = .;     /* data段结束地址 */
    } > RAM

    .bss :
    {
        _sbss = .;
        *(.bss)
        _ebss = .;
    } > RAM
}

这里有个关键点:.data段为什么需要AT? 你想想看,.data段里的变量是有初始值的,比如 int a = 100;。这个初始值100,必须存放在Flash里,否则一断电就丢了。但运行时,这些变量又必须在RAM里,因为Flash不能写。所以,链接器会把.data段的初始值放在Flash里(通过AT指定加载地址),然后在启动代码里,把它们从Flash拷贝到RAM。

核心概念: VMA(虚拟地址,运行时地址)和LMA(加载地址,存储地址)。对于.text段,VMA和LMA都在Flash。对于.data段,VMA在RAM,LMA在Flash。这个区别,是理解链接脚本的关键。

3.2 内存布局的实战考量

在实际项目中,内存布局不是随便写的。我见过不少新手,直接把默认的链接脚本拿来用,结果Bootloader和APP的地址重叠了,跳转后直接跑飞。

3.2.1 Bootloader的内存布局

假设你的Bootloader占32K,APP占剩下的Flash。那么Bootloader的链接脚本应该这样:

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

而APP的链接脚本,Flash起始地址要偏移:

MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08008000, LENGTH = 480K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

为什么是0x08008000?因为32K = 0x8000。这个计算要仔细,我曾经因为算错偏移量,导致APP的向量表指向了Bootloader的区域,一进中断就死循环。

3.2.2 向量表的重定位

这里要提一个重要的符号:_estack。它定义了栈顶地址,通常放在RAM的最高地址。在启动文件里,第一件事就是设置栈指针SP为_estack,然后跳转到Reset_Handler

对于APP来说,除了链接脚本要改,还要在代码里重定位向量表:

SCB->VTOR = 0x08008000;  // 告诉CPU,APP的向量表在这里

这一步不做,APP的中断一个都进不去。嗯,这里要注意,很多芯片的VTOR寄存器需要按128字节对齐。

3.3 段映射的细节与技巧

咱们再深入一点,看看每个段的具体映射规则。

3.3.1 .text段:代码和只读数据

.text段包含你的程序指令和常量。它必须放在可执行的区域,通常是Flash。链接器会按照你在SECTIONS里写的顺序,把各个输入文件的.text段拼接起来。

有时候,你需要把某些函数放在固定的地址,比如中断向量表。这时可以用KEEP关键字:

.isr_vector :
{
    KEEP(*(.isr_vector))  // 强制保留,不被优化掉
} > FLASH

3.3.2 .data段:已初始化的全局变量

.data段在Flash里存的是初始值,在RAM里是运行时变量。启动代码必须负责拷贝。我建议在链接脚本里定义好起始和结束符号,方便C代码引用:

extern uint32_t _sdata, _edata, _sidata;  // _sidata是Flash中的加载地址

然后在启动文件里:

for (src = &_sidata, dst = &_sdata; dst < &_edata; ) {
    *dst++ = *src++;
}

3.3.3 .bss段:未初始化的全局变量

.bss段在RAM里,不需要在Flash里占空间。启动代码只需要把它清零即可:

for (dst = &_sbss; dst < &_ebss; ) {
    *dst++ = 0;
}

小技巧: 如果你的芯片RAM很小,可以考虑把一些大的、不需要初始化的数组放到自定义段里,比如.noinit段。这样启动时不会清零,节省时间。但要注意,这些变量的初始值是随机的。

3.4 常见问题与避坑指南

最后,分享几个我实际项目中遇到的坑:

  • 符号未定义:链接时提示undefined reference to _sdata。检查链接脚本里是否定义了这些符号,以及启动文件里是否用extern声明了。
  • 地址对齐:某些段需要对齐到4字节或8字节。可以用ALIGN(4)来强制对齐。
  • 段重叠:如果Bootloader和APP的链接脚本里,Flash或RAM区域有重叠,下载时会互相覆盖。一定要仔细核对地址范围。

我曾经在一个项目里,因为链接脚本里少写了一个AT,导致.data段的初始值没有被正确加载到Flash里。结果每次上电,全局变量的值都是乱的。查了整整一天,最后发现是链接脚本的问题。嗯,从那以后,我每次写完链接脚本,都会用objdump或者IDE的map文件,仔细检查每个段的地址和大小。

好了,这一讲就到这里。链接脚本虽然看起来枯燥,但它是Bootloader和APP跳转的基石。下一讲,咱们聊聊启动文件的细节,看看CPU上电后到底干了些什么。