15、ROM与RAM的地址映射:不同存储介质的初始化差异
说到嵌入式系统的启动,有个绕不开的话题——ROM和RAM的地址映射。说白了,就是CPU怎么找到代码,怎么跑起来。
我刚开始做嵌入式那会儿,总觉得这玩意儿是硬件的事,软件工程师不用管。直到有一次,我写的程序死活跑不起来,查了三天才发现是链接脚本里地址配错了。嗯,从那以后,我再也不敢轻视地址映射了。
15.1 为什么地址映射这么重要?
你想想看,CPU上电后,它第一件事是干什么?去一个固定的地址取指令。这个地址,通常指向ROM(比如Flash)。但问题是,代码执行过程中,变量、堆栈这些需要读写的东西,必须放在RAM里。
所以,地址映射要解决的核心问题就两个:
- 代码在哪里?——通常放在ROM里,掉电不丢失
- 数据在哪里?——必须放在RAM里,能读能写
但事情没这么简单。不同芯片、不同架构,ROM和RAM的地址空间怎么分配,差别很大。我见过不少新手,把STM32的Flash地址和SRAM地址搞混,结果程序跑飞了还不知道为什么。
15.2 典型存储介质的地址特性
先看看我们常用的几种存储介质,它们的地址特性完全不同。
| 存储介质 | 地址空间 | 读写特性 | 掉电保持 | 初始化需求 |
|---|---|---|---|---|
| Flash (ROM) | 通常从0x08000000开始 | 可读,写需要擦除 | 是 | 无需初始化,上电即可读 |
| SRAM | 通常从0x20000000开始 | 可读可写,速度快 | 否 | 上电内容随机,需要初始化 |
| SDRAM/DDR | 外部扩展地址 | 可读可写,速度较快 | 否 | 需要初始化控制器和时序 |
| EEPROM | I2C/SPI总线地址 | 可读可写,速度慢 | 是 | 需要配置总线接口 |
这里有个关键点:Flash上电就能读,但SRAM上电后内容是随机的。这就是为什么启动代码必须放在Flash里,而变量必须由启动代码来初始化。
核心概念:ROM(只读存储器)和RAM(随机存取存储器)的地址映射,决定了CPU如何访问代码和数据。启动代码的本质,就是把代码从ROM搬到RAM(如果需要),并初始化RAM中的数据区域。
15.3 不同架构的地址映射差异
我做过ARM Cortex-M系列,也玩过RISC-V,还碰过一些老式的51单片机。它们的地址映射方式,各有各的脾气。
15.3.1 ARM Cortex-M 的地址映射
ARM Cortex-M系列有个好处——地址映射是固定的。比如STM32,Flash从0x08000000开始,SRAM从0x20000000开始。上电后,CPU直接从0x08000000读取向量表,找到复位向量,然后跳过去执行。
我个人习惯,在链接脚本里这样定义:
/* STM32F103 的链接脚本片段 */
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 64K
}
这样写的好处是,编译器知道代码该放哪,数据该放哪。但有个坑——如果你把变量定义到了Flash地址段,写操作就会触发硬件错误。我曾经有个同事,把一个大数组定义到了Flash区,结果程序一跑就进HardFault,查了半天才发现。
15.3.2 RISC-V 的地址映射
RISC-V就灵活多了。它的地址映射完全由芯片厂商自己定。有的芯片把Flash放在0x20000000,有的放在0x80000000。我第一次接触RISC-V时,拿到芯片手册第一件事就是找地址映射表。
我记得有个项目,用的是国产的RISC-V芯片。它的Flash地址是0x20000000,但SRAM地址是0x10000000。这个顺序跟ARM完全反过来了。如果不小心把ARM的习惯带过来,程序肯定跑不起来。
/* RISC-V 芯片的链接脚本片段 */
MEMORY
{
FLASH (rx) : ORIGIN = 0x20000000, LENGTH = 1M
SRAM (rwx) : ORIGIN = 0x10000000, LENGTH = 128K
}
15.3.3 老式51单片机的地址映射
51单片机就更特殊了。它的程序存储器和数据存储器是分开编址的——哈佛架构。程序用ROM,数据用RAM,地址可以重叠。比如程序地址0x0000和数据地址0x0000,是两个完全不同的地方。
这种架构下,启动代码不需要搬移数据,因为变量直接就在RAM里。但问题是,RAM很小,通常只有128字节或256字节。我刚开始学单片机时,经常为了省几个字节的RAM,把变量定义成bit类型。
经验之谈:做地址映射时,一定要先看芯片手册的「Memory Map」章节。不同厂商、不同系列,地址分配可能完全不同。别指望靠经验猜,手册才是王道。
15.4 初始化差异:ROM vs RAM
ROM和RAM的初始化方式,差别很大。我画了一张图,帮你理清这个流程。
从这张图可以看得很清楚:ROM是「拿来就用」,RAM是「先初始化再用」。这个差异,直接决定了启动代码的结构。
15.5 启动代码中的地址映射处理
启动代码里,地址映射的处理主要集中在两个地方:
- BSS段清零:把未初始化的全局变量所在的RAM区域全部写0
- 数据段复制:把已初始化的全局变量从ROM复制到RAM
我见过一个典型的启动代码片段,是这样的:
/* 启动代码中的BSS清零 */
void __attribute__((naked)) Reset_Handler(void)
{
extern uint32_t _sbss; /* BSS段起始地址 */
extern uint32_t _ebss; /* BSS段结束地址 */
extern uint32_t _sdata; /* 数据段在RAM中的起始地址 */
extern uint32_t _edata; /* 数据段在RAM中的结束地址 */
extern uint32_t _sidata; /* 数据段在ROM中的起始地址 */
uint32_t *pSrc, *pDest;
/* 1. 清零BSS段 */
for (pDest = &_sbss; pDest < &_ebss; )
{
*pDest++ = 0;
}
/* 2. 复制数据段 */
pSrc = &_sidata;
for (pDest = &_sdata; pDest < &_edata; )
{
*pDest++ = *pSrc++;
}
/* 3. 跳转到main */
main();
}
这里有个细节:_sidata指向的是ROM地址,_sdata指向的是RAM地址。这两个地址不同,但内容相同。说白了,就是ROM里存了一份初始值,启动时把它搬到RAM里。
注意:如果芯片支持从RAM启动(比如某些调试模式),那地址映射就完全不一样了。这时候,ROM和RAM的角色互换,启动代码也要相应调整。我曾经在调试一个bootloader时,不小心把启动地址设成了RAM,结果每次复位都跑飞,后来才发现是地址映射没改过来。
15.6 实际项目中的避坑指南
做了这么多年嵌入式,我总结了几条关于地址映射的经验,分享给你:
- 一定要确认芯片的地址映射表——不同型号、不同批次,地址可能不一样。别信网上的例程,信手册。
- 链接脚本里的地址要和硬件一致——我见过有人把Flash地址写错了一位,结果程序下载进去了,但跑不起来。查了三天,发现是0x08000000写成了0x08000001。
- 注意对齐——有些芯片要求代码或数据按4字节对齐,否则会触发异常。尤其是从ROM复制数据到RAM时,如果源地址或目标地址没对齐,复制就会出错。
- 小心SDRAM的初始化时序——如果用了外部SDRAM,它的初始化需要配置控制器、设置时序参数。这个初始化必须在访问SDRAM之前完成,否则一访问就死机。
避坑指南:我曾经在一个项目中,用了外部SDRAM来存放大的数据缓冲区。结果每次上电,程序都在访问缓冲区时卡死。查到最后,发现是SDRAM初始化代码放在了main函数里,而main函数之前就有代码访问了那个缓冲区。解决方案很简单——把SDRAM初始化放到启动代码里,在main之前完成。
15.7 不同存储介质的初始化差异总结
最后,我用一个表格来总结不同存储介质的初始化差异:
| 存储介质 | 初始化时机 | 初始化内容 | 注意事项 |
|---|---|---|---|
| Flash (ROM) | 无需初始化 | 无 | 写操作需要擦除,速度慢 |
| SRAM | 启动代码中 | 清零BSS,复制数据段 | 注意对齐,避免越界 |
| SDRAM/DDR | 启动代码中(main之前) | 配置控制器,设置时序 | 时序参数必须准确,否则不稳定 |
| EEPROM | 使用时初始化 | 配置I2C/SPI接口 | 注意总线速率和地址 |
嗯,地址映射这个话题,说深了可以写一本书。但核心就一句话:ROM只管存,RAM只管用,启动代码就是那个「搬运工」。搞清楚了这一点,地址映射的问题就解决了一大半。
下次你写启动代码时,不妨多想想:我的代码在哪?我的数据在哪?它们之间的地址映射对不对?想清楚了,再动手写代码。
公众号:蓝海资料掘金营,微信deep3321