4、链接脚本深入:链接脚本(.ld)的作用、内存布局定义、段(.text, .data, .bss)的分配。

好,咱们继续往下走。上一章我们把启动代码的汇编部分捋了一遍,你可能会想:代码写好了,怎么把它放到芯片里正确的地址上?这就得靠链接脚本了。

说实话,我刚开始做嵌入式那会儿,觉得链接脚本就是个黑盒子。编译器生成的 .o 文件扔进去,出来一个 .bin,完事。直到有一次,我调试一个诡异的启动失败问题——程序在 RAM 里跑得挺好,一烧到 Flash 就死机。折腾了两天,最后发现是 .data 段没被正确拷贝到 RAM 里。嗯,从那以后,我再也不敢小看这个 .ld 文件了。

链接脚本到底在干什么?

说白了,链接脚本就是一张「地图」。它告诉链接器:

  • 你的芯片有哪些内存区域(Flash、RAM、外设寄存器空间)
  • 每个区域从哪个地址开始、有多大
  • 你的代码和数据分别该放到哪个区域

你想想看,如果没有这张地图,链接器就只能把所有的东西胡乱堆在一起。那肯定不行——Flash 和 RAM 的地址空间是分开的,代码必须放在 Flash 里才能掉电不丢失,变量必须放在 RAM 里才能读写。

核心概念:链接脚本定义了「输入段」到「输出段」的映射关系。输入段是 .o 文件里的 .text、.data、.bss,输出段是最终可执行文件里的内存布局。

内存布局:你得先知道芯片长什么样

写链接脚本之前,必须先搞清楚目标芯片的内存映射。以最常见的 STM32F103 为例:

区域 起始地址 大小 用途
Flash 0x08000000 64KB ~ 1MB 存放代码和只读数据
SRAM 0x20000000 20KB ~ 192KB 存放变量和堆栈
系统存储器 0x1FFFF000 2KB Bootloader 出厂固件
外设寄存器 0x40000000 可变 GPIO、UART 等外设

这些地址在芯片的数据手册里都能找到。我个人习惯是把它们定义成链接脚本开头的常量,方便后面引用。

三个核心段:.text、.data、.bss

咱们写 C 代码的时候,变量和函数会被编译器自动归类到不同的段里。理解这三个段,是看懂链接脚本的关键。

.text 段:你的代码和常量

.text 段存放的是程序指令和只读常量。它必须放在 Flash 里,因为 Flash 是只读的,掉电不丢失。启动后 CPU 直接从 Flash 取指令执行。

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

.data 段存放的是有初始值的全局变量和静态变量。比如 int a = 42;,这个 42 的初始值必须保存在 Flash 里,但变量 a 本身必须在 RAM 里才能被修改。所以启动代码需要把 .data 段从 Flash 拷贝到 RAM。

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

.bss 段存放的是没有初始值或初始值为 0 的全局变量。它只占 RAM 空间,不占 Flash 空间。启动代码需要把这部分 RAM 区域清零。

我的经验:很多新手会忽略 .bss 段的清零。如果你发现上电后全局变量不是 0,或者程序行为随机,十有八九是 .bss 没清。我曾经在一个电机控制项目里踩过这个坑,电机上电后随机抖动,查了两天才发现是 .bss 段没初始化。

一个完整的链接脚本长什么样?

咱们直接看代码。这是一个针对 STM32F103 的最小链接脚本:

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

/* 输出段定义 */
SECTIONS
{
    /* .text 段:放在 Flash 里 */
    .text :
    {
        . = ALIGN(4);
        _stext = .;          /* 代码起始地址 */
        *(.text*)            /* 所有 .text 子段 */
        *(.rodata*)          /* 只读数据 */
        . = ALIGN(4);
        _etext = .;          /* 代码结束地址 */
    } > FLASH

    /* .data 段:初始值在 Flash,运行时在 RAM */
    .data : AT (ADDR(.text) + SIZEOF(.text))
    {
        . = ALIGN(4);
        _sdata = .;          /* RAM 中数据起始地址 */
        *(.data*)            /* 所有 .data 子段 */
        . = ALIGN(4);
        _edata = .;          /* RAM 中数据结束地址 */
    } > RAM AT > FLASH

    /* .bss 段:只占 RAM,不占 Flash */
    .bss :
    {
        . = ALIGN(4);
        _sbss = .;           /* BSS 起始地址 */
        *(.bss*)             /* 所有 .bss 子段 */
        *(COMMON)            /* 通用符号 */
        . = ALIGN(4);
        _ebss = .;           /* BSS 结束地址 */
    } > RAM

    /* 堆栈区域 */
    . = ALIGN(4);
    _end = .;                /* 堆起始地址 */
    PROVIDE (_stack_top = ORIGIN(RAM) + LENGTH(RAM));
}

这段脚本里,有几个关键点我得跟你说明白:

  • MEMORY 块:定义了两个内存区域,FLASH 和 RAM,分别指定了起始地址和大小。
  • SECTIONS 块:定义了输出段的布局。每个段都可以指定加载地址(LMA)和运行地址(VMA)。
  • AT 关键字:.data 段用了 AT (ADDR(.text) + SIZEOF(.text)),意思是 .data 的初始值紧跟在 .text 后面存放在 Flash 里,但运行时会被拷贝到 RAM 中。
  • 符号导出:_stext、_etext、_sdata 这些符号,启动代码里会用到它们来执行拷贝和清零操作。

注意:.data 段的 LMA 和 VMA 不同,这是 Bootloader 启动代码必须处理的核心逻辑。如果你忘了在启动代码里做拷贝,.data 段里的全局变量初始值全是错的。我见过有人把这个问题误判成编译器 bug,其实只是链接脚本没写对。

段分配的可视化:一张图看懂

光说可能不够直观,我画了一张图,把 Flash 和 RAM 里的布局展示出来:

Bootloader 内存布局示意图 Flash (0x08000000) .text 段 代码指令 + 只读常量 _stext ~ _etext .data 初始值 (LMA) 紧跟在 .text 之后 剩余 Flash 空间 启动时拷贝 RAM (0x20000000) .data 段 (VMA) _sdata ~ _edata .bss 段 _sbss ~ _ebss 启动时清零 堆 + 栈 _end ~ _stack_top 栈顶 = RAM 末尾

这张图把 Flash 和 RAM 的对应关系画得很清楚。你看,.data 段在 Flash 里存了一份初始值,启动时拷贝到 RAM 里。.bss 段只占 RAM 空间,启动时清零。堆栈从 RAM 末尾往下生长。

链接脚本里的符号:启动代码的「信使」

链接脚本里定义的符号,比如 _stext、_sdata、_ebss,在 C 代码里可以用 extern 声明来引用。上一章我们写的启动代码里,就用到了这些符号:

extern uint32_t _stext, _etext;
extern uint32_t _sdata, _edata;
extern uint32_t _sbss,  _ebss;

void reset_handler(void)
{
    uint32_t *src, *dst;
    
    /* 拷贝 .data 段:从 Flash 到 RAM */
    src = &_etext;           /* .data 初始值在 .text 之后 */
    dst = &_sdata;
    while (dst < &_edata) {
        *dst++ = *src++;
    }
    
    /* 清零 .bss 段 */
    dst = &_sbss;
    while (dst < &_ebss) {
        *dst++ = 0;
    }
    
    /* 跳转到 main() */
    main();
}

这里有个细节:&_etext 指向的是 .text 段的结束地址,也就是 .data 初始值在 Flash 里的起始地址。因为我们在链接脚本里用 AT (ADDR(.text) + SIZEOF(.text)) 把 .data 的 LMA 放在了 .text 后面。

避坑指南:链接脚本里的符号,在 C 代码里取地址时一定要加 &。因为这些符号本质上是地址常量,不是变量。不加 & 的话,你拿到的是地址的值,而不是地址本身。我曾经在这个问题上浪费了半天,打印出来的地址全是 0。

链接脚本的进阶技巧

等你把基础版本跑通了,可以试试这些进阶用法:

  • 段对齐:用 . = ALIGN(4); 保证段起始地址是 4 字节对齐的。ARM 指令要求对齐,不对齐会触发异常。
  • KEEP 关键字:KEEP(*(.isr_vector)) 告诉链接器,即使这个段没有被引用,也要保留。中断向量表必须放在最开头,不能被优化掉。
  • PROVIDE 关键字:PROVIDE(_stack_top = ...) 定义了一个弱符号。如果其他地方没有定义 _stack_top,就用这个默认值。
  • 多个输入段合并:*(.text*) 匹配所有 .o 文件里的 .text 子段,把它们合并成一个输出段。

调试链接脚本的小技巧

链接脚本写好了,怎么验证对不对?我一般用这几个方法:

  1. 查看 map 文件:编译时加 -Wl,-Map=output.map,链接器会生成一个 map 文件,里面详细列出了每个段的地址和大小。
  2. 用 objdump 反汇编:arm-none-eabi-objdump -h output.elf 可以查看各段的 VMA、LMA 和大小。
  3. 检查符号表:arm-none-eabi-nm output.elf | grep _sdata 可以确认链接脚本里定义的符号是否正确。

我个人习惯在启动代码里加一段调试输出,把 _sdata、_edata、_sbss、_ebss 的地址通过串口打印出来。如果地址和预期不符,说明链接脚本有问题,赶紧回头检查。

重要提醒:链接脚本的语法很严格,少一个分号、多一个空格都可能导致链接失败。我建议你从官方的示例脚本开始改,不要自己从头写。ST 官方提供了 STM32 系列的链接脚本模板,直接拿来用,改一下 Flash 和 RAM 的大小就行。