3、链接脚本与内存布局:链接脚本(.ld)结构解析、ROM与RAM分区、代码段、数据段、BSS段的定位

做嵌入式开发,特别是裸机或Bootloader,第一道坎就是链接脚本。很多人觉得这玩意儿枯燥,不就是告诉编译器把代码放哪儿吗?

其实没那么简单。我见过不少工程师,代码写得飞起,一调试就崩,最后发现是链接脚本里某个段放错了位置。说白了,链接脚本就是你和硬件之间的「地契」——它决定了你的程序在芯片上到底怎么安家。

3.1 链接脚本到底在干什么?

先问个问题:你写好的 .c 文件,经过编译、汇编,变成了一堆 .o 目标文件。这些文件里,代码、数据、常量都混在一起。链接脚本的任务,就是把这些碎片按规则拼起来,然后分配到具体的地址上。

我个人习惯把链接脚本看作一张「装修图纸」:

  • ROM(Flash) —— 相当于房子的外墙和承重墙,不能动,用来放固定不变的东西(代码、常量)。
  • RAM(SRAM) —— 相当于室内的家具和摆设,可以随时搬动,用来放变量、堆栈。

一个典型的 Cortex-M 芯片,启动后从 Flash 地址 0x08000000 开始执行。你的链接脚本必须告诉链接器:

  • 中断向量表放哪儿?
  • 代码段放哪儿?
  • 初始化的全局变量怎么从 Flash 拷贝到 RAM?
  • 未初始化的全局变量(BSS)怎么清零?

核心原则: 链接脚本定义的布局,必须和芯片的启动流程严格对应。错一个字节,程序就可能跑飞。

3.2 解剖一个典型的 .ld 文件

我们拿 STM32F103 为例,看看一个标准的链接脚本长什么样。我会逐段拆解,告诉你每行代码的实际意义。

/* 入口点 */
ENTRY(Reset_Handler)

/* 内存区域定义 */
MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 512K
    RAM   (xrw) : ORIGIN = 0x20000000, LENGTH = 128K
}

/* 段定义 */
SECTIONS
{
    /* 中断向量表 */
    .isr_vector :
    {
        . = ALIGN(4);
        KEEP(*(.isr_vector))
        . = ALIGN(4);
    } > FLASH

    /* 代码段 */
    .text :
    {
        . = ALIGN(4);
        *(.text)
        *(.text*)
        *(.glue_7)
        *(.glue_7t)
        *(.rodata)
        *(.rodata*)
        . = ALIGN(4);
        _etext = .;
    } > FLASH

    /* 初始化数据段(在Flash中的拷贝源) */
    _sidata = .;

    /* 数据段(在RAM中运行) */
    .data : AT(_sidata)
    {
        . = ALIGN(4);
        _sdata = .;
        *(.data)
        *(.data*)
        . = ALIGN(4);
        _edata = .;
    } > RAM

    /* BSS段 */
    .bss :
    {
        . = ALIGN(4);
        _sbss = .;
        *(.bss)
        *(.bss*)
        *(COMMON)
        . = ALIGN(4);
        _ebss = .;
    } > RAM
}

嗯,看着有点复杂?别急,我一个个说。

3.2.1 MEMORY 区域

这里定义了芯片的物理存储资源:

区域名 属性 起始地址 大小 说明
FLASH rx(读+执行) 0x08000000 512K 存放代码和只读数据
RAM xrw(读+写+执行) 0x20000000 128K 存放变量和堆栈

注意属性里的 r、w、x。Flash 不能写(运行时),所以没有 w;RAM 可以读写执行。这个属性是给链接器做检查用的,如果你试图把可写数据放到 Flash 里,链接器会报错。

3.2.2 SECTIONS 段布局

这是链接脚本的核心。每个段都定义了一类内容的放置规则。

.isr_vector —— 中断向量表

这是芯片启动时第一个读取的地方。Cortex-M 的复位流程是:从 0x00000000 取栈顶指针,从 0x00000004 取复位入口。但实际芯片会把 Flash 映射到这两个地址。所以向量表必须放在 Flash 开头,且 4 字节对齐。

KEEP() 是关键——告诉链接器,即使这个段没有被显式引用,也不能被优化掉。我曾经遇到过因为忘记加 KEEP,链接器把向量表整个扔了,芯片上电直接跑飞。

.text —— 代码段

这里放的是你的程序指令和只读常量(const 变量、字符串字面量)。注意我用了 *(.text) 和 *(.text*),前者匹配所有 .text 段,后者匹配 .text.funcname 这样的子段。这是为了把不同编译单元生成的代码都收进来。

_etext 是一个符号,标记代码段的结束地址。这个地址在启动代码中会被用到。

.data —— 数据段

这里有个关键点:.data 段在 Flash 里有一份拷贝(_sidata),但运行时必须搬到 RAM 里。为什么?因为全局变量需要可读写,而 Flash 不能写。

AT(_sidata) 就是告诉链接器:这个段的加载地址(LMA)在 Flash 里,但运行地址(VMA)在 RAM 里。启动代码会负责把数据从 _sidata 拷贝到 _sdata。

.bss —— BSS段

未初始化的全局变量和静态变量。它们在 RAM 中占据空间,但不需要从 Flash 拷贝初始值。启动代码只需要把 _sbss 到 _ebss 之间的内存清零即可。

我的经验: 在调试时,如果发现全局变量初始值不对,先检查 .data 段的拷贝是否正常。如果发现全局变量值随机,先检查 BSS 段是否被清零。这两个问题我各踩过不下五次坑。

3.3 内存布局的完整视图

把上面的段拼起来,整个芯片的内存布局是这样的:

Cortex-M 典型内存布局(STM32F103) FLASH (0x08000000) .isr_vector 中断向量表 .text 代码段 + .rodata .data 初始值拷贝 (_sidata) 启动时拷贝 RAM (0x20000000) .data 运行时数据 .bss 未初始化数据 堆 (Heap) + 栈 (Stack) 关键说明: 1. 向量表必须放在 Flash 开头,且 4 字节对齐 2. .data 段在 Flash 中有备份,启动时由 __main 或启动代码拷贝到 RAM 3. .bss 段在 RAM 中,启动时由启动代码清零 Flash 起始: 0x08000000 Flash 结束: 0x0807FFFF (512K) RAM 起始: 0x20000000 RAM 结束: 0x2001FFFF (128K)

3.4 启动代码如何配合链接脚本

链接脚本只定义了布局,真正干活的是启动代码(startup_xxx.s)。它必须知道链接脚本里定义的符号:

; 引用链接脚本中定义的符号
EXTERN  _sidata, _sdata, _edata, _sbss, _ebss

; 复位入口
Reset_Handler PROC
    ; 1. 拷贝 .data 段从 Flash 到 RAM
    LDR     r0, =_sidata
    LDR     r1, =_sdata
    LDR     r2, =_edata
    CMP     r1, r2
    BEQ     .L_loop_data_done
.L_loop_data:
    LDR     r3, [r0], #4
    STR     r3, [r1], #4
    CMP     r1, r2
    BNE     .L_loop_data
.L_loop_data_done:

    ; 2. 清零 .bss 段
    LDR     r1, =_sbss
    LDR     r2, =_ebss
    CMP     r1, r2
    BEQ     .L_loop_bss_done
    MOVS    r3, #0
.L_loop_bss:
    STR     r3, [r1], #4
    CMP     r1, r2
    BNE     .L_loop_bss
.L_loop_bss_done:

    ; 3. 跳转到 main
    BL      main
    ENDP

这段汇编代码,说白了就是三件事:拷贝、清零、跳转。顺序不能乱——先拷贝 .data,再清零 .bss,最后才调用 main。为什么?因为 main 里可能立即访问全局变量,如果 .data 还没拷贝完,变量值就是错的。

注意: 如果你使用半主机模式(semihosting)或者 C 库初始化,启动代码会更复杂。但 Bootloader 场景下,我建议尽量精简启动流程,减少依赖。我自己做 Bootloader 时,启动代码通常不超过 50 行汇编。

3.5 常见坑与避坑指南

做 Bootloader 时,链接脚本有几个特别容易出问题的地方:

  • 向量表重定位 —— Bootloader 升级完固件后,需要跳转到新固件。新固件的向量表不在 0x08000000,而是在某个偏移地址。你需要在链接脚本里给新固件单独定义 FLASH 区域,并把 .isr_vector 放在偏移后的地址。
  • 对齐问题 —— ARM Cortex-M 要求某些段必须 4 字节或 8 字节对齐。我遇到过 .bss 段没对齐,导致某些外设 DMA 访问时出错。加 . = ALIGN(4) 能解决大部分问题。
  • 符号冲突 —— 如果你在链接脚本里定义了 _sdata,但 C 代码里也有同名变量,链接器会报错。建议用下划线前缀区分链接脚本符号和普通变量。

我的习惯: 每次修改链接脚本后,先用 arm-none-eabi-objdump -h 查看生成的 .elf 文件,确认每个段的地址和大小是否符合预期。这比直接下载到芯片调试快得多。

3.6 小结

链接脚本是 Bootloader 的骨架。你不需要背下所有语法,但必须理解:

  • MEMORY 定义物理资源
  • SECTIONS 定义逻辑布局
  • 启动代码负责执行段拷贝和清零
  • Bootloader 场景下要特别注意向量表重定位