第3章:链接脚本(.ld)精讲:内存布局、段定义、符号导出、链接脚本与HAL的关联

链接脚本,说白了就是告诉编译器你的芯片内存长什么样、代码该放哪、数据该搁哪的一张地图。很多做应用层开发的同事觉得这玩意儿是编译工具链的事,跟自己没关系。但做底层移植,你要是看不懂 .ld 文件,那基本等于盲人摸象。

我记得刚入行那会儿,接手一个 STM32F4 的项目,Bootloader 死活跳不到应用程序。折腾了两天,最后发现是链接脚本里向量表偏移没设对。从那以后,我养成了一个习惯——拿到一个新平台,第一件事就是先把它的链接脚本通读一遍。

3.1 内存布局:芯片的"房产证"

每个 MCU 都有它的内存映射。Flash 多大、RAM 多少、外设寄存器在哪个地址段,这些信息都在芯片参考手册的 Memory Map 章节里。链接脚本的第一件事,就是把这些物理信息翻译成编译器能懂的语法。

看一个典型的 Cortex-M 系列链接脚本开头:

MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 512K
    RAM   (xrw) : ORIGIN = 0x20000000, LENGTH = 128K
    CCMRAM (rw) : ORIGIN = 0x10000000, LENGTH = 64K
}

这里定义了三个内存区域:

  • FLASH:只读+可执行,起始地址 0x08000000,大小 512KB。这是存放代码和只读数据的地方。
  • RAM:读写+可执行,起始地址 0x20000000,大小 128KB。这是程序运行时的主战场。
  • CCMRAM:内核紧耦合内存,速度更快,但有些型号的 DMA 访问不了。这个坑我踩过——把 DMA 缓冲区放 CCMRAM 里,结果数据死活传不过来。
个人习惯:我每次移植 Bootloader,都会在 MEMORY 块后面加一段注释,把芯片手册里的 Memory Map 截图关键信息写进去。这样半年后回来看代码,不用再翻一遍手册。

3.2 段定义:代码和数据的"房间分配"

有了"房产证",接下来就是怎么分配房间了。链接脚本里的 SECTIONS 块,就是干这个活的。

一个标准的段定义结构长这样:

SECTIONS
{
    .text :
    {
        . = ALIGN(4);
        _stext = .;
        *(.text*)
        *(.rodata*)
        _etext = .;
    } > FLASH

    .data :
    {
        . = ALIGN(4);
        _sdata = .;
        *(.data*)
        _edata = .;
    } > RAM AT> FLASH

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

这里面有几个关键点:

  • .text 段:放代码和只读常量。注意那个 . = ALIGN(4);,这是强制 4 字节对齐。ARM Cortex-M 的指令大部分是 2 或 4 字节,不对齐的话,轻则性能下降,重则直接 HardFault。
  • .data 段:放已初始化的全局变量。这里有个坑——> RAM AT> FLASH 的意思是运行时在 RAM,但初始值存在 Flash 里。启动代码必须把这段数据从 Flash 拷贝到 RAM,不然变量值全是乱的。
  • .bss 段:放未初始化的全局变量。启动代码需要把这部分清零。
我曾经踩过的坑:有一次做低功耗项目,发现休眠唤醒后某些全局变量值不对。查了半天,发现是 .bss 段在休眠期间被部分清零了——因为链接脚本里把 .bss 和某个掉电保持区域重叠了。从那以后,我每次都会用 --print-map 选项生成 map 文件,仔细检查每个段的地址范围。

3.3 符号导出:给启动代码的"路标"

你想想看,启动代码怎么知道 .data 段要从 Flash 的哪个位置拷贝?.bss 段要从哪开始清零?答案就是链接脚本里导出的符号。

上面代码里的 _stext、_etext、_sdata、_edata、_sbss、_ebss,这些就是链接器自动生成的符号。启动代码里用 extern 声明一下就能用:

extern uint32_t _sdata, _edata, _sbss, _ebss;
extern uint32_t _sidata;  // .data 在 Flash 中的起始地址

void __attribute__((naked)) reset_handler(void)
{
    // 拷贝 .data 段
    uint32_t *src = &_sidata;
    uint32_t *dst = &_sdata;
    while (dst < &_edata) *dst++ = *src++;

    // 清零 .bss 段
    dst = &_sbss;
    while (dst < &_ebss) *dst++ = 0;

    // 跳转到 main
    main();
}

这里有个细节:_sidata 并没有在 SECTIONS 里显式定义,它是通过 AT> FLASH 这个语法隐式生成的。链接器会自动计算 .data 段在 Flash 中的加载地址,并赋值给 LOADADDR(.data),我们通常用 _sidata 或 _la_data 这样的名字来引用它。

关键点:符号导出是链接脚本和 C 启动代码之间的桥梁。你可以在链接脚本里定义任意符号,然后在 C 代码里用 extern 引用。这个机制在 Bootloader 里特别有用——比如定义应用程序的起始地址、Bootloader 的版本号等。

3.4 链接脚本与HAL的关联:不只是"编译通过"

很多人觉得 HAL 库跟链接脚本没关系,直接拿官方的例程改改就能用。但实际做移植的时候,你会发现链接脚本直接影响 HAL 库的行为。

举个例子,STM32 的 HAL 库里有这么一段:

void HAL_IncTick(void)
{
    uwTick++;
}

__weak void HAL_Delay(uint32_t Delay)
{
    uint32_t tickstart = HAL_GetTick();
    while ((HAL_GetTick() - tickstart) < Delay);
}

这个 uwTick 变量,如果链接脚本没有正确分配它的位置,或者被优化掉了,整个延时函数就废了。我遇到过有人把 uwTick 定义在某个特定的内存段里,结果链接脚本没包含这个段,变量根本没分配空间。

再比如中断向量表:

__attribute__((section(".isr_vector")))
void (* const g_pfnVectors[])(void) = {
    &_estack,
    Reset_Handler,
    NMI_Handler,
    // ...
};

这个 .isr_vector 段必须放在 Flash 的起始地址(0x08000000)。如果链接脚本里没有正确处理这个段,或者地址偏移设错了,芯片上电后直接跑飞。

HAL 特性 链接脚本影响 常见问题
中断向量表 必须位于 Flash 起始地址 Bootloader 跳转后中断不响应
系统滴答定时器 uwTick 变量需在 RAM 中 HAL_Delay 卡死
DMA 缓冲区 需在 DMA 可访问的内存区域 数据传输异常
MPU 配置 内存区域属性需与 MPU 设置一致 访问违例导致 HardFault
我的建议:做 HAL 移植时,先把 HAL 库里的 __attribute__((section(...))) 全部搜一遍,看看它们都用了哪些自定义段。然后在链接脚本里为这些段预留位置。这一步做好了,后面能省 80% 的调试时间。

3.5 实战:为Bootloader定制链接脚本

Bootloader 的链接脚本和普通应用程序不一样。它需要:

  1. 把代码放在 Flash 的起始区域(比如前 32KB)
  2. 定义应用程序的起始地址符号
  3. 预留升级标志位和版本信息

一个典型的 Bootloader 链接脚本片段:

MEMORY
{
    FLASH_BOOT (rx)  : ORIGIN = 0x08000000, LENGTH = 32K
    FLASH_APP  (rx)  : ORIGIN = 0x08008000, LENGTH = 480K
    RAM        (xrw) : ORIGIN = 0x20000000, LENGTH = 128K
}

SECTIONS
{
    .boot_header :
    {
        KEEP(*(.boot_header))
        . = ALIGN(4);
    } > FLASH_BOOT

    .text :
    {
        _stext = .;
        *(.text*)
        *(.rodata*)
        _etext = .;
    } > FLASH_BOOT

    /* 导出应用程序起始地址 */
    _app_start = ORIGIN(FLASH_APP);
    _app_vector = ORIGIN(FLASH_APP);

    /* ... 其他段定义 ... */
}

这里 _app_start 和 _app_vector 就是给 Bootloader 用的路标。跳转时直接读这两个符号:

void jump_to_app(void)
{
    extern uint32_t _app_vector;
    uint32_t msp = *(uint32_t *)_app_vector;
    void (*app_entry)(void) = (void (*)(void))(*(uint32_t *)(_app_vector + 4));

    __set_MSP(msp);
    app_entry();
}
注意:跳转前一定要关掉所有中断、复位外设状态。不然应用程序一启动就撞上 Bootloader 留下的中断配置,直接 HardFault。这个坑我至少帮三个同事排查过。

3.6 本章小结:链接脚本是底层开发的"宪法"

链接脚本定义了整个固件的内存布局规则。它不像 C 代码那样有语法检查,写错了编译器不会报错,但程序跑起来就是各种诡异问题。

我个人习惯是:每拿到一个新平台,先花半小时把链接脚本从头到尾读一遍,把每个段的作用、每个符号的含义都搞清楚。然后在工程目录下放一个 memory_map.txt,把关键地址记下来。这个习惯帮我省了无数调试时间。

最后送大家一句话:链接脚本里没有"差不多",地址差一个字节,程序就是两个世界。

链接脚本核心知识体系 链接脚本 (.ld) MEMORY 内存布局 SECTIONS 段定义 符号导出 FLASH RAM CCMRAM .text .data .bss _stext _sdata _sbss 与 HAL 的关联 中断向量表 · 系统变量 · DMA缓冲区 · MPU配置