3、MCU内存映射与链接脚本:Flash与RAM布局、链接脚本(.ld)详解、中断向量表重映射

各位同学,咱们今天聊点硬核的——内存映射和链接脚本。说实话,我刚入行那会儿,觉得这玩意儿就是编译器自动搞定的,跟我没啥关系。直到有一次,我调试一个诡异的死机问题,折腾了整整三天,最后发现是链接脚本里一个地址写错了。嗯,从那以后,我再也不敢小看这个 .ld 文件了。

3.1 为什么你要懂内存映射?

你想想看,MCU 就像一个小房子。Flash 是仓库,RAM 是工作台。你的程序代码存在仓库里,运行时搬到工作台上执行。但问题是——谁来决定哪段代码放仓库的哪个货架?谁来决定工作台上的哪个区域放全局变量、哪个区域放堆栈?

答案就是链接脚本。它是一张「布局图」,告诉链接器:

  • Flash 从哪个地址开始?多大?
  • RAM 从哪个地址开始?多大?
  • 中断向量表放在哪?
  • 哪些段(.text, .data, .bss)放在哪?

我个人习惯,拿到一个新芯片,第一件事就是打开它的链接脚本和内存映射图。这比看 datasheet 里的寄存器列表还重要。

3.2 Flash 与 RAM 的典型布局

以 STM32F103 为例,它的内存映射长这样:

地址范围 区域 大小 用途
0x0800 0000 - 0x0801 FFFF Flash 128KB 存放代码、常量、中断向量表
0x2000 0000 - 0x2000 4FFF SRAM 20KB 存放变量、堆栈、堆
0x4000 0000 - 0x4002 3FFF 外设 - GPIO、USART、TIM 等寄存器

注意看,Flash 的起始地址是 0x0800 0000,而不是 0x0000 0000。为什么?因为 0x0000 0000 是「别名区」,它可以通过配置映射到 Flash 或 SRAM。这个设计是为了支持从不同介质启动。

核心要点:Bootloader 和应用程序通常共用同一个 Flash,但放在不同的地址段。比如 Bootloader 放在 0x0800 0000 ~ 0x0800 7FFF(32KB),应用程序放在 0x0800 8000 ~ 0x0801 FFFF(96KB)。

3.3 链接脚本 (.ld) 详解

链接脚本说白了就是一个「地址分配表」。我见过很多工程师,代码写得飞起,但一看到 .ld 文件就头大。其实没那么可怕,咱们拆开来看。

一个典型的 GCC 链接脚本包含这几部分:

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

/* 段定义 */
SECTIONS
{
  /* 中断向量表 */
  .isr_vector :
  {
    . = ALIGN(4);
    KEEP(*(.isr_vector))
    . = ALIGN(4);
  } > FLASH

  /* 代码段 */
  .text :
  {
    . = ALIGN(4);
    *(.text)
    *(.text.*)
    . = ALIGN(4);
  } > FLASH

  /* 只读数据 */
  .rodata :
  {
    . = ALIGN(4);
    *(.rodata)
    *(.rodata.*)
    . = ALIGN(4);
  } > FLASH

  /* 初始化数据(从 Flash 复制到 RAM) */
  .data :
  {
    . = ALIGN(4);
    _sdata = .;
    *(.data)
    *(.data.*)
    _edata = .;
  } > RAM AT> FLASH

  /* 零初始化数据 */
  .bss :
  {
    . = ALIGN(4);
    _sbss = .;
    *(.bss)
    *(.bss.*)
    *(COMMON)
    _ebss = .;
  } > RAM
}

这里有几个关键点:

  • MEMORY 块:定义物理存储器的起始地址和大小。注意 FLASH 的属性是 rx(读+执行),RAM 是 rwx(读+写+执行)。
  • SECTIONS 块:定义各个段放在哪个内存区域。
  • .isr_vector:中断向量表,必须放在 Flash 的最开头,且用 KEEP 防止被优化掉。
  • .data 段:运行时在 RAM,但初始值存在 Flash 里。所以有 > RAM AT> FLASH 这种写法——加载地址在 Flash,运行地址在 RAM。
  • .bss 段:只占 RAM 空间,不占 Flash 空间,启动时由启动代码清零。

我的经验:写链接脚本时,最容易犯的错误是忘记对齐。ARM Cortex-M 要求中断向量表必须 256 字节对齐(即地址的低 8 位为 0)。我曾经因为少写了一个 ALIGN(256),导致中断向量表错位,程序一跑就进 HardFault。嗯,那次排查花了我一整个下午。

3.4 中断向量表重映射

这是 Bootloader 开发中最关键的一环。为什么?因为芯片上电后,CPU 会从 0x0000 0000 读取栈指针,从 0x0000 0004 读取复位向量。但我们的 Bootloader 和应用程序都放在 Flash 里,怎么让 CPU 知道该跑哪个?

答案就是中断向量表重映射。说白了,就是告诉 CPU:「嘿,别去 0x0000 0000 找向量表了,去 0x0800 8000 找!」

在 Cortex-M 上,有两种方式实现:

  1. 修改 VTOR 寄存器:这是最常用的方法。VTOR(Vector Table Offset Register)位于 SCB(系统控制块)中,地址是 0xE000 ED08。写入新的向量表基址即可。
  2. 使用内存映射别名区:某些芯片支持通过 SYSCFG 寄存器将 Flash 的某段映射到 0x0000 0000。

代码实现很简单:

/* 应用程序的向量表地址 */
#define APP_VECTOR_TABLE_ADDR  0x08008000

void jump_to_app(void)
{
    /* 1. 关闭全局中断 */
    __disable_irq();

    /* 2. 设置新的向量表 */
    SCB->VTOR = APP_VECTOR_TABLE_ADDR;

    /* 3. 从应用程序的向量表中获取栈指针和复位向量 */
    uint32_t app_sp = *(volatile uint32_t *)APP_VECTOR_TABLE_ADDR;
    uint32_t app_pc = *(volatile uint32_t *)(APP_VECTOR_TABLE_ADDR + 4);

    /* 4. 设置主栈指针 */
    __set_MSP(app_sp);

    /* 5. 跳转到应用程序 */
    typedef void (*app_func_t)(void);
    app_func_t app_entry = (app_func_t)app_pc;
    app_entry();
}

注意:跳转前一定要关闭所有外设中断,并清理外设寄存器状态。否则应用程序启动时,可能会收到来自 Bootloader 遗留的中断请求,导致异常。我曾经遇到过 USART 中断没关干净,跳转后应用程序一初始化就触发中断,但中断向量表还没准备好,直接 HardFault。

3.5 实战中的布局策略

在实际项目中,Flash 的布局通常是这样:

Flash 地址空间:
+------------------+ 0x0800 0000
| Bootloader       | 32KB
| (中断向量表)     |
+------------------+ 0x0800 8000
| 应用程序         | 96KB
| (中断向量表)     |
+------------------+ 0x0802 0000
| 参数存储区       | 4KB
+------------------+ 0x0802 1000
| 固件备份区       | 剩余空间
+------------------+ 0x0801 FFFF

这里有几个设计要点:

  • Bootloader 和应用程序各自拥有独立的中断向量表。Bootloader 的向量表在 0x0800 0000,应用程序的向量表在 0x0800 8000。
  • 参数存储区:存放升级标志、版本号、校验值等。我习惯用最后 4KB 的 Flash,因为擦除次数少,不容易磨损。
  • 固件备份区:用于 OTA 升级时存放下载的新固件。如果升级失败,可以从备份区恢复。

避坑指南:我曾经在一个项目里,把参数存储区放在 Flash 的中间位置。结果每次升级都要擦除整个扇区,把参数也擦没了。后来我学乖了——参数存储区单独放在一个扇区,升级时只擦除应用程序所在的扇区。

3.6 本章小结

内存映射和链接脚本,说白了就是给 MCU 画一张「地图」。你画对了,程序就跑得顺;画错了,各种诡异问题就来了。我建议你:

  • 拿到新芯片,先看内存映射图
  • 写 Bootloader 前,先规划好 Flash 分区
  • 修改链接脚本时,注意对齐和地址计算
  • 中断向量表重映射,别忘了关中断和清外设

下一章咱们聊启动流程——从复位到 main() 函数,中间发生了什么?嗯,那里面坑也不少。


专注资料整理