3. 链接脚本与内存布局:.ld 文件、段布局与分区设计

链接脚本,说白了就是告诉链接器:你的代码该放哪,数据该放哪,RAM 和 Flash 怎么分。很多新手觉得这玩意儿枯燥,直接拿厂家例程改改就用了。我刚开始做 Bootloader 时也这么干,结果有一次芯片死活跑不起来,折腾了两天才发现——是链接脚本里 RAM 地址写错了。

嗯,从那以后我再也不敢轻视 .ld 文件了。今天咱们就把这块彻底讲透。

3.1 链接脚本 (.ld) 到底在干什么?

一个嵌入式项目从源码到可执行文件,要经过:预处理 → 编译 → 汇编 → 链接。前几步生成的都是目标文件 (.o),里面是分散的代码段、数据段。链接脚本的任务,就是把这些碎片拼成一个完整的镜像,并指定每个段在芯片物理地址空间中的位置。

核心概念:链接脚本 = 内存地图 + 段排列规则。没有它,你的 .bin 文件就是一盘散沙。

一个典型的 .ld 文件包含三个部分:内存区域定义 (MEMORY)、段定义 (SECTIONS)、符号赋值。咱们逐个拆解。

3.2 MEMORY 命令:划地盘

先告诉链接器芯片上有哪些可用的内存区域,以及它们的起始地址和大小。

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

这里定义了 Flash 和 RAM 两块区域。属性 (rx) 表示可读可执行,(rwx) 表示可读可写可执行。我个人习惯把 Bootloader 的 Flash 单独划一块,比如从 0x08000000 开始只给 64KB,剩下的留给应用程序。这样即使 Bootloader 写崩了,也不影响应用区。

属性 含义 典型用途
r 可读 只读数据区
w 可写 RAM 数据区
x 可执行 代码区
! 取反 排除某属性

经验之谈:Bootloader 的 Flash 区域建议放在芯片 Flash 的最开头,因为芯片复位后 PC 指针默认指向 0x08000000(或 0x00000000,取决于映射)。我曾经见过有人把 Bootloader 放在 Flash 末尾,结果每次启动都要先跳转,多了一步不说,还容易出问题。

3.3 SECTIONS 命令:排兵布阵

这是链接脚本的核心。它定义了每个段放在哪个内存区域,以及段之间的排列顺序。

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

    .data : AT(_etext)
    {
        _sdata = .;
        *(.data)
        _edata = .;
    } > RAM

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

这里有几个关键点:

  • .text 段:包含中断向量表、代码、常量。放在 Flash 里,掉电不丢失。
  • .data 段:已初始化的全局变量。运行时在 RAM 里,但初始值存在 Flash 里。启动时由 Bootloader 从 Flash 拷贝到 RAM。
  • .bss 段:未初始化的全局变量。运行时在 RAM 里,启动时清零。

你注意看 .data : AT(_etext) 这一行。AT 指定了加载地址(在 Flash 中的位置),而 > RAM 指定了运行地址。这就是所谓的「加载时在 Flash,运行时在 RAM」。启动代码必须负责把数据从 Flash 拷贝到 RAM,这个拷贝过程通常由汇编写的 Reset_Handler 完成。

踩坑提醒:我曾经遇到一个项目,.data 段拷贝没做,结果全局变量初始值全是乱的。现象很诡异——有些变量值是对的,有些是错的。后来发现是链接脚本里 _etext 符号没正确导出,启动代码拿到的拷贝地址是错的。所以一定要检查启动代码里是否引用了 _sdata、_edata、_etext 这些符号。

3.4 代码段 / 数据段 / BSS 段布局

咱们用一张图来直观理解最终的内存布局:

Bootloader 内存布局示意图 Flash (0x08000000) .isr_vector 中断向量表 (0x08000000 起始) .text 代码段 (Bootloader 程序) _etext 标记结束 .rodata 只读数据 (字符串、常量) .data 初始值 (加载地址,运行时拷贝到 RAM) 应用分区 (App) 拷贝 RAM (0x20000000) .data (运行时) 从 Flash 拷贝而来 .bss 未初始化数据 (启动时清零) 堆 (Heap) 动态内存分配 (malloc/free) 栈 (Stack) 局部变量、函数调用 (从高地址向下生长) 应用 RAM 区 注意:.data 段在 Flash 中有备份,运行时拷贝到 RAM;.bss 段不占 Flash 空间,只在 RAM 中清零

3.5 RAM 与 Flash 分区设计

做 Bootloader 时,分区设计直接决定了升级方案的可行性。我个人习惯把 Flash 分成三个区:

分区 起始地址 大小 内容
Bootloader 区 0x08000000 64KB Bootloader 代码、向量表
应用区 (App) 0x08010000 384KB 应用程序代码、向量表
备份/下载区 0x08070000 64KB 升级固件暂存

RAM 分区也要规划好:

  • Bootloader 专用 RAM:通常 4-8KB,用于 Bootloader 自身的栈和变量。这部分应用不能碰。
  • 应用 RAM:剩下的给应用程序。Bootloader 跳转到应用前,要把自己用的 RAM 清理干净,否则应用启动时可能读到脏数据。
  • 共享区域:比如升级标志、版本号,放在固定地址,Bootloader 和应用都能访问。

我的做法:在链接脚本里用 PROVIDE 关键字导出一些关键符号,比如 PROVIDE(_bootloader_size = 0x10000);。这样在 C 代码里可以直接引用这些符号,不用硬编码地址。改分区大小只需要改一处,不容易出错。

3.6 实战:一个 Bootloader 的链接脚本示例

下面是我常用的一个 Bootloader 链接脚本模板,加了详细注释:

/* Bootloader 链接脚本 - STM32F103 示例 */
ENTRY(Reset_Handler)

MEMORY
{
    /* Bootloader 占用 Flash 前 64KB */
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 64K
    /* 应用从 0x08010000 开始,384KB */
    APP_FLASH (rx) : ORIGIN = 0x08010000, LENGTH = 384K
    /* RAM 全部可用,但 Bootloader 只用前 8KB */
    RAM (rwx)   : ORIGIN = 0x20000000, LENGTH = 8K
}

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

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

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

    /* 栈顶指针,指向 RAM 末尾 */
    _estack = ORIGIN(RAM) + LENGTH(RAM);
}

这里有几个细节要注意:

  • KEEP(*(.isr_vector)):强制保留中断向量表,即使没被引用也不能被优化掉。
  • . = ALIGN(4);:4 字节对齐,ARM Cortex-M 要求向量表必须对齐到 256 字节边界,但代码段 4 字节对齐就够了。
  • _estack:栈顶指针,指向 RAM 最高地址。ARM 的栈是向下生长的,所以栈顶在 RAM 末尾。

又一个坑:我曾经在升级时发现,新固件下载完了,跳转过去却跑不起来。查了半天,原来是 Bootloader 的链接脚本里把 RAM 全占了(LENGTH = 128K),应用启动时发现自己没 RAM 可用。后来我把 Bootloader 的 RAM 限制在 8KB,剩下的留给应用,问题解决。记住:Bootloader 要「用完即走」,别占着茅坑不拉屎。

3.7 链接脚本调试技巧

链接脚本出了问题,最直接的表现就是程序跑飞或变量值不对。怎么排查?

  1. 查看 map 文件:编译时加 -Wl,-Map=output.map,生成 map 文件。里面详细记录了每个段、每个符号的地址。我遇到地址冲突时,第一件事就是看 map 文件。
  2. 检查符号值:在启动代码里加几行调试输出,打印 _sdata、_edata、_etext 的值,看是否和预期一致。
  3. 用 objdump 反汇编:arm-none-eabi-objdump -D bootloader.elf,看反汇编代码中的地址是否正确。
  4. 仿真器单步:在 IDE 里加载 .elf 文件,看反汇编窗口中的地址是否落在正确的 Flash 区域。

嗯,链接脚本这东西,说难不难,说简单也不简单。关键是要理解它背后的逻辑——你是在给芯片画一张内存地图,每个段、每个符号都要有明确的位置。搞懂了这些,后面讲启动流程、中断向量表重映射,你就知道为什么链接脚本要那么写了。