3. 链接脚本与内存布局:.ld 文件深度解析

做 MCU 底层开发,链接脚本(.ld 文件)是绕不开的一道坎。我见过不少工程师,写应用代码溜得很,一碰到链接脚本就头大,直接拿现成的改两下完事。结果呢?芯片跑起来莫名其妙死机,或者 RAM 不够用,查半天发现是内存布局出了问题。

说白了,链接脚本就是告诉编译器:你的代码放哪,数据放哪,栈和堆又该划多大。今天咱们就把这层窗户纸捅破。

3.1 链接脚本结构解析

一个标准的 .ld 文件,核心就三部分:内存区域定义、段定义、符号导出。我习惯把它看作一张「房产证」—— 芯片的 Flash 和 RAM 就是地皮,你得告诉编译器哪块地盖什么楼。

3.1.1 MEMORY 命令:划地盘

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

这里定义了 Flash 和 RAM 的起始地址和大小。注意那个 (rx) 和 (rwx),分别表示读-执行、读-写-执行。我在 STM32F4 项目上就吃过亏 —— 把 RAM 属性写成了 (rw),结果代码里想从 RAM 执行函数,直接 HardFault。

⚠️ 避坑指南: 我曾经在移植 Bootloader 时,把 FLASH 的 LENGTH 写小了 4K,结果链接器一声不吭,编译通过,下载后程序跑飞。后来用 arm-none-eabi-size 一查,代码段已经超出 Flash 范围,但链接器没报错。所以,一定要在编译脚本里加 --check-sections 选项。

3.1.2 SECTIONS 命令:分房间

这是链接脚本的核心。每个段(section)就像房间,你得告诉编译器哪些内容搬进哪个房间。

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

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

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

这里有个关键点:.data 段用了 AT(_etext)。什么意思?数据段在运行时放在 RAM,但初始值存在 Flash 里。启动代码需要把这段数据从 Flash 拷贝到 RAM。嗯,这个拷贝动作,就是 Bootloader 或 CRT 启动文件干的活。

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

咱们把这三个段拆开细说。你想想看,一个程序编译完,最终在芯片里长什么样?

段名 存储位置 内容 特点
.text Flash 指令、常量、中断向量表 只读,掉电不丢失
.data RAM(初始值在 Flash) 已初始化的全局/静态变量 可读写,启动时从 Flash 拷贝
.bss RAM 未初始化或初始化为 0 的变量 可读写,启动时清零

我记得有一次做低功耗项目,发现休眠电流比预期大了 200μA。查了半天,原来是 .bss 段里有个大数组没清零,里面残留了随机值,导致外设引脚状态不对。从那以后,我每次都会检查启动文件里的 __bss_start 和 __bss_end__ 符号是否正确。

💡 个人习惯: 我通常会在链接脚本里显式导出这些符号:
_sflash = ORIGIN(FLASH);
_eflash = ORIGIN(FLASH) + LENGTH(FLASH);
_sram   = ORIGIN(RAM);
_eram   = ORIGIN(RAM) + LENGTH(RAM);

这样在 C 代码里可以直接用 extern uint32_t _sflash; 来获取地址,方便做内存校验和 CRC 计算。

3.3 栈与堆的配置策略

栈和堆的配置,是 Bootloader 开发中最容易翻车的地方。我见过有人把栈设成 1KB,结果函数嵌套深一点就栈溢出;也有人把堆设得巨大,结果 .bss 段没空间放变量了。

3.3.1 栈(Stack)配置

栈用于局部变量、函数调用、中断现场保存。在 Cortex-M 系列中,栈指针由 MSP(主栈指针)或 PSP(进程栈指针)控制。Bootloader 通常只用 MSP。

/* 在汇编启动文件中定义栈大小 */
.equ Stack_Size, 0x400    /* 1KB */

.section .stack
.align 3
_stack_start:
    .space Stack_Size
_stack_end:

我个人建议:Bootloader 的栈不要小于 512 字节。为什么?因为 Bootloader 要处理 Flash 擦写、校验、通信协议,这些操作都有一定的函数调用深度。我曾经在某个项目里把栈设成 256 字节,结果在擦除 Flash 时调用了回调函数,栈直接爆了,程序卡死在擦除循环里。

📐 栈大小估算公式:

栈大小 ≈ 最大中断嵌套深度 × 每个中断的栈帧 + 最大函数调用链的栈使用 + 安全余量(20%)

对于 Bootloader,我通常取 1KB ~ 2KB,够用又不浪费。

3.3.2 堆(Heap)配置

堆用于动态内存分配(malloc/free)。但在 MCU Bootloader 里,我强烈建议:能不用堆就不用堆。

为什么?

  • 堆管理有额外开销(碎片、链表维护)
  • Bootloader 环境简单,用静态数组完全可以替代
  • 一旦堆溢出,定位问题非常困难

如果实在要用,比如需要动态接收变长固件包,那堆大小可以这样设:

/* 堆大小 = 最大固件包大小 × 2 + 协议缓冲区 */
.equ Heap_Size, 0x800    /* 2KB */

我记得有个项目,应用层用了 FreeRTOS,堆由操作系统管理。但 Bootloader 里我直接关了堆,所有缓冲区都静态分配。这样既省了 RAM,又避免了堆碎片问题。

3.4 完整内存布局图

下面这张图,是我做 Bootloader 时经常参考的内存布局。你一看就明白:

MCU Bootloader 内存布局图 Flash (512KB) 中断向量表 (.isr_vector) 0x08000000 ~ 0x080000C0 代码段 (.text) Bootloader 主程序 Flash 驱动、通信协议 CRC 校验、加密解密 只读数据 (.rodata) 字符串常量、查表数据 .data 初始值备份 启动时拷贝到 RAM 应用固件区 存放待更新的用户程序 RAM (128KB) .data 段(运行时) 已初始化全局变量 0x20000000 起始 .bss 段 未初始化/零初始化变量 启动时清零 堆 (Heap) 动态分配(可选) 栈 (Stack) 向下生长 通信缓冲区 接收/发送 FIFO 注意:栈和堆的生长方向相反,防止相互覆盖。建议在链接脚本中设置栈在堆之后。

3.5 实战配置建议

说了这么多,咱们总结几条实战经验:

  1. Bootloader 的 .text 段尽量放在 Flash 开头,因为中断向量表必须从 0x08000000 开始(除非你用了 VTOR 重映射)。
  2. .data 段的 LMA(加载地址)和 VMA(运行地址)要分开,这是 Bootloader 启动拷贝的基础。
  3. 栈大小设为 1KB 起步,如果用到文件系统或加密库,建议 2KB~4KB。
  4. 堆能不用就不用,非要用的请把堆放在栈前面,防止栈向下生长时踩到堆的数据。
  5. 在链接脚本末尾加 PROVIDE 符号,方便启动文件和 C 代码引用:
PROVIDE(_stack_top = ORIGIN(RAM) + LENGTH(RAM));
PROVIDE(_heap_start = _ebss);
PROVIDE(_heap_end = _stack_top - Stack_Size);
🔧 调试小技巧: 我每次写完链接脚本,都会用 arm-none-eabi-objdump -h 查看各段的地址和大小,确认是否符合预期。再用 arm-none-eabi-nm 查看关键符号地址,比如 _sdata、_ebss 等。这一步能提前发现 90% 的内存布局问题。