4. 链接脚本与内存布局:分散加载文件解析、RAM/Flash分区、堆栈设置

说实话,很多刚入行的朋友写驱动,代码逻辑看着没问题,一跑就挂。查来查去,最后发现是链接脚本写错了,变量放到了不该放的地方。嗯,这活儿看着不起眼,但它是整个固件的骨架。今天我们就把它彻底讲透。

4.1 分散加载文件到底是什么?

分散加载文件,在 ARM 生态里叫分散加载描述文件(.scf),在 GCC 工具链里叫链接脚本(.ld)。名字不同,干的活一样——告诉链接器:你的代码放哪,数据放哪,堆栈放哪。

我个人习惯把链接脚本理解成一张“地图”。芯片出厂时,Flash 和 RAM 的地址范围是固定的。你的程序编译出来有 .text、.data、.bss 这些段,链接脚本就是把这些段分配到物理地址上的规则表。

核心概念:链接脚本不产生任何代码,它只决定代码和数据在存储器中的摆放位置。位置错了,程序一定跑飞。

4.2 典型 Cortex-M 内存布局

以 STM32F103 为例,它的存储器映射是这样的:

存储器区域 起始地址 大小 用途
Flash 0x0800 0000 512KB 代码、只读数据、常量
SRAM 0x2000 0000 64KB 全局变量、堆、栈
系统存储器 0x1FFF F000 2KB Bootloader(出厂固件)
外设区 0x4000 0000 0.5GB GPIO、UART、TIM 等寄存器

注意看,Flash 和 SRAM 之间有个巨大的空洞。这个空洞是留给外设寄存器和位带区域的。你写驱动时操作的外设地址,全在这个区间里。

4.3 链接脚本核心段解析

我们拿一个实际的 GCC 链接脚本片段来说话:

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

SECTIONS
{
  .text :
  {
    KEEP(*(.isr_vector))    /* 中断向量表,必须放在最前面 */
    *(.text*)               /* 所有代码 */
    *(.rodata*)             /* 只读数据 */
    _etext = .;             /* 代码结束地址 */
  } > FLASH

  .data : AT(_etext)
  {
    _sdata = .;             /* 数据区起始 */
    *(.data*)               /* 已初始化全局变量 */
    _edata = .;             /* 数据区结束 */
  } > RAM

  .bss :
  {
    _sbss = .;              /* BSS区起始 */
    *(.bss*)                /* 未初始化全局变量 */
    _ebss = .;              /* BSS区结束 */
  } > RAM

  ._user_heap_stack :
  {
    . = ALIGN(8);
    PROVIDE ( end = . );
    PROVIDE ( _end = . );
    . = . + _Min_Heap_Size;  /* 堆大小 */
    . = . + _Min_Stack_Size; /* 栈大小 */
    . = ALIGN(8);
  } > RAM
}

这里有几个关键点,我挨个说:

  • .isr_vector:中断向量表必须放在 Flash 起始地址 0x08000000。这是 Cortex-M 硬性规定的,复位后 CPU 直接从 0x00000000 取栈顶指针,从 0x00000004 取复位入口。芯片内部做了地址重映射,实际指向 Flash 起始。
  • .data 的 AT 属性:已初始化的全局变量在编译时值已经确定,但掉电后 RAM 数据会丢失。所以这些初始值要存在 Flash 里,上电后由启动代码拷贝到 RAM。AT(_etext) 就是告诉链接器:把 .data 的初始值紧跟在 .text 后面放 Flash 里。
  • _sdata / _edata:这些符号是给启动代码用的。启动代码需要知道从哪里拷贝(_etext),拷贝到哪里(_sdata),拷贝多少(_edata - _sdata)。

我曾经踩过的坑:有一次我改了链接脚本,忘了更新 _Min_Heap_Size 和 _Min_Stack_Size,结果堆栈区太小,malloc 返回 NULL 后程序直接硬 fault。从那以后我每次改链接脚本都会手动算一遍剩余 RAM 空间。

4.4 堆栈设置的艺术

堆栈设置不是拍脑袋定的。给多了浪费 RAM,给少了程序崩。我一般按这个原则来:

  • 栈(Stack):存放局部变量、函数调用上下文、中断现场。大小取决于函数调用深度和局部变量大小。裸机程序建议 1KB~4KB,带 RTOS 的每个任务栈建议 512B~2KB。
  • 堆(Heap):给 malloc/free 用的。嵌入式里我建议尽量不用动态内存分配。非要用的话,堆大小别超过剩余 RAM 的 30%。

怎么估算栈大小?我有个土办法:在启动代码里把栈区全部填充成 0xDEADBEEF,跑完所有功能后,检查栈区还有多少没被覆盖。这个值就是你的安全余量。

// 栈填充检测代码示例
#define STACK_FILL_PATTERN 0xDEADBEEF

void stack_usage_check(void)
{
    extern uint32_t _estack;  // 栈顶地址(由链接脚本定义)
    extern uint32_t _sstack;  // 栈底地址
    
    uint32_t *stack_start = &_sstack;
    uint32_t *stack_end   = &_estack;
    uint32_t used = 0;
    
    for(uint32_t *p = stack_start; p < stack_end; p++)
    {
        if(*p != STACK_FILL_PATTERN)
        {
            used++;
        }
    }
    
    uint32_t total = (uint32_t)stack_end - (uint32_t)stack_start;
    uint32_t used_bytes = used * 4;
    // used_bytes 就是实际用掉的栈空间
}

小技巧:在链接脚本里把栈放在 RAM 的最高地址,这样栈溢出时最先踩到的是未使用的 RAM 区域,不容易立刻破坏关键数据。很多芯片的启动代码默认就是这么干的。

4.5 分散加载文件的内存分区策略

实际项目中,我们经常需要把 RAM 分成多个区域。比如:

  • 一个区域放快速中断服务程序的栈(保证实时性)
  • 一个区域放普通任务的数据
  • 一个区域做 DMA 缓冲区(需要对齐到特定地址)

在 .scf 文件里可以这样分区:

; ARMCC 分散加载语法示例
LR_IROM1 0x08000000 0x00080000  ; 加载区:整个Flash
{
  ER_IROM1 0x08000000 0x00080000  ; 执行区:代码在Flash中运行
  {
    *.o (RESET, +First)
    *.o (InRoot$$Sections)
    .ANY (+RO)
  }
  
  RW_IRAM1 0x20000000 0x00008000  ; 主RAM区:64KB
  {
    .ANY (+RW +ZI)
  }
  
  RW_IRAM2 0x20008000 0x00002000  ; 快速RAM区:8KB(如果芯片有)
  {
    isr_handler.o (+RW +ZI)       ; 只放中断处理函数的数据
    dma_buffer.o (+RW)            ; DMA缓冲区
  }
}

为什么要分区?说白了就是隔离。中断服务程序的数据不会被普通任务的数据污染,DMA 缓冲区不会被编译器优化到奇怪的地方。我在做电机驱动项目时,把电流环的变量单独放在一个 RAM 区,调试起来特别清爽。

4.6 内存布局的 SVG 结构图

下面这张图把整个内存布局和段的关系画清楚了:

ARM Cortex-M 内存布局与段映射 Cortex-M 内存布局与链接脚本段映射 Flash (0x08000000) .isr_vector (中断向量表) .text (代码段) .rodata + .data 初始值 启动时拷贝 RAM (0x20000000) .data (已初始化全局变量) .bss (未初始化全局变量) 堆 + 栈 (Heap + Stack) 关键点:.data 初始值存储在 Flash 中,上电后由启动代码拷贝到 RAM 栈通常放在 RAM 最高地址,向下增长;堆向上增长 外设寄存器区 (0x40000000 - 0x5FFFFFFF) GPIO · UART · SPI · I2C · TIM · ADC · DMA · ...

4.7 常见问题与避坑指南

最后,我把这些年遇到的链接脚本相关的问题列一下,你碰到了可以直接对照:

问题现象 根本原因 解决办法
上电后程序不跑,停在 HardFault 中断向量表没放在 Flash 起始地址 检查 .isr_vector 段是否 +First
全局变量初始值不对 .data 段拷贝没执行或拷贝地址错 检查 _sdata、_etext 符号定义
malloc 返回 NULL 堆空间不足或堆栈重叠 增大 _Min_Heap_Size,检查栈是否溢出
函数指针调用崩溃 代码段放在了 RAM 但没初始化 确认 __RAM_CODE 段有拷贝逻辑
链接报错“region overflow” 代码或数据超出 Flash/RAM 大小 优化代码大小,或换大容量芯片

我曾经遇到最诡异的问题:一个同事把栈大小设成了 0,链接器没报错,启动代码也没检查。结果程序一调用函数就写到了 .bss 区,把全局变量全踩坏了。查了两天才发现是栈大小定义成了 0。所以,链接脚本里的每个数字都要认真核对。

嗯,链接脚本这块内容就这些。你写驱动时,每次修改链接脚本都要做三件事:检查段地址、确认堆栈大小、验证启动代码的拷贝逻辑。这三步走完,内存布局基本不会出问题。