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 上,有两种方式实现:
- 修改 VTOR 寄存器:这是最常用的方法。VTOR(Vector Table Offset Register)位于 SCB(系统控制块)中,地址是 0xE000 ED08。写入新的向量表基址即可。
- 使用内存映射别名区:某些芯片支持通过 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() 函数,中间发生了什么?嗯,那里面坑也不少。