简介:这是一份名为《单片机程序存储空间和数据存储空间详解》的PDF文档,面向单片机初学者与嵌入式开发爱好者,系统梳理了51单片机存储器体系的组成与分工。内容以STC89C52RC单片机的8K程序存储空间、512字节数据存储空间和2K EEPROM为线索,逐一说明ROM、RAM、EEPROM的作用、数据存放特点及掉电后的行为差异,帮助读者分清程序代码、运行变量与掉电保持数据分别存放在哪里。随后延伸到哈佛结构下程序存储器与数据存储器独立编址的原则,解释EA引脚如何选择片内外ROM,以及MOVC、MOVX、MOV指令分别用于访问哪些存储空间。还覆盖了内部RAM低128B与SFR高128B的划分、工作寄存器区与位寻址区布局、中断向量入口地址等细节,配合存储空间分布图,便于对照理解。资源为单个PDF文档,大小914KB,篇幅紧凑但覆盖面完整,适合在课程学习或项目开发前快速补足存储器基础。目前已有214人学习下载,对想理清51单片机存储器寻址思路的读者来说,是一份兼顾概念与地址映射的实用参考资料。
1. 单片机的存储空间,先看这张表再往下读
很多工程师在开发单片机项目时都有过这样的困惑:同一个.c文件里定义一个全局变量,烧录后它到底被放在哪里?编译报错 "data segment too large" 时,究竟是 RAM 不够还是 Flash 不够?为什么 STM32 的const数组不占 RAM,但 51 单片机的code数组却要专门声明?这些问题的答案,都指向同一个底层知识——单片机的程序存储空间和数据存储空间是怎么划分的。
这个主题看似基础,但它是嵌入式开发中所有"存储相关"报错的根源。无论你用 51、STC、STM32 还是其他 Cortex-M 系列,理解代码段(Code)、只读数据段(RO Data)、读写数据段(RW Data)和零初始化段(ZI Data)在芯片物理存储介质上的落位,直接决定了你能否准确估算资源占用、能否写出稳定运行在极小内存上的程序。本文用一张贯穿全文的典型工程存储分配表做引子,从地址映射、编译器关键字的底层行为到调试技巧逐层展开,帮你在下一次遇到out of memory时,能直接定位是哪个段、哪个变量、哪条链接规则出了问题——这是适合每个做嵌入式开发的从业者都值得花时间理清的核心知识。
2. 程序存储空间和数据存储空间的物理区分与地址映射
2.1 什么是程序存储空间:ROM 与 Flash 在单片机里的角色
程序存储空间在单片机里通常指非易失性存储器,用于存放用户代码、常量表和中断向量表。过去 51 单片机时代多用掩膜 ROM 和 EPROM,现在基本全是 Flash 工艺。Flash 的特点是掉电不丢失,但写入需要先擦除(以扇区为单位),且擦写次数有限(一般 10 万次级别),这使得程序存储空间在使用上有一些天然约束。
程序存储空间里放的东西很明确:编译后生成的机器码(.text段)、只读常量(.rodata段,比如查表法用的正弦表、数码管段码表)、以及中断向量表。在 51 单片机上,程序存储空间还有一个容易被忽略的用途——code关键字修饰的变量可以放进程序存储区,用查表指令MOVC A, @A+DPTR访问。这在大规模查表应用(比如电磁炉的加热功率曲线、热电偶温度补偿表)里非常常见,因为 51 的片内 RAM 只有 128~256 字节,根本放不下几百字节的查找表,而程序存储空间却有 8KB~64KB。
提示:51 系列单片机的程序存储空间通过
PSEN引脚单独选通,和数据存储空间使用完全独立的地址空间,所以 64KB 程序空间 + 64KB 数据空间可以同时存在,互不干扰。这在后面讲 Harvard 结构和 von Neumann 结构的差异时会更清楚。
2.2 什么是数据存储空间:RAM 的不同层次与访问速度
数据存储空间对应易失性存储器 RAM,用于存放全局变量、局部变量、堆栈和动态分配的堆。51 单片机的数据存储空间分片内和片外两层——片内 128 字节(增强型可达 256 字节)直接寻址,速度最快;片外最多可扩 64KB,通过MOVX指令访问,速度明显变慢。这种分层直接影响了 Keil C51 编译器里data、idata、xdata这几个存储类型关键字的选用策略。
STM32 这类 Cortex-M 内核的单片机则是另一个模型。它的 RAM 分布在固定的地址区间(比如 STM32F103 系列 SRAM 起始地址是0x20000000),所有变量统一编址,没有 51 那种片内片外的概念。但 Cortex-M 的存储系统引入了 Cache 和总线矩阵,不同总线主设备(CPU、DMA)访问同一块 RAM 可能走不同的总线路径,这在高频访问场景下会带来一致性问题——这是后话,第 5 章会专门讲。
2.3 Harvard 结构与 von Neumann 结构对存储划分的底层影响
单片机分为两大体系:51 和绝大多数 Cortex-M(包括 STM32)使用 Harvard 结构,程序总线和数据总线物理分离;而早期一些 ARM7 内核芯片使用 von Neumann 结构,程序和数据共用一条总线和同一地址空间。理解这个区别非常关键,因为它决定了程序存储空间和数据存储空间是怎么"编址"的。
在 Harvard 结构下,程序存储器和数据存储器在 CPU 看来是两个完全独立的地址空间,需要的地址总线根数、访问时序、控制信号各不相同。STM32F103 的 Flash 在0x08000000起始,SRAM 在0x20000000起始,两者之间有巨大的地址空洞——这些空洞就是为总线外设、外部存储器控制器预留的。写链接脚本(.ld文件)时,LENGTH和ORIGIN分别指定了这两块物理存储的基地址和大小,链接器负责把不同段的数据放到正确的位置。
注意:用
debugger读0x08000000和0x20000000看到的内容虽然都能以uint32_t形式读取,但访问 Flash 走的是 I-Bus/D-Bus 总线,访问 SRAM 走的是 System 总线,延迟和带宽完全不同。不能把 Flash 当普通变量频繁读写,也不能指望 DMA 能直接访问所有存储区域。
2.4 一张表看懂 51 与 STM32 的存储区域对比
| 对比项 | 51 单片机(以 STC89C52 为例) | STM32F103 系列 |
|---|---|---|
| 程序空间起始地址 | 0x0000,最大 64KB | 0x08000000,典型 64KB~512KB |
| 程序空间总线 | PSEN 独立选通 | I-Bus/D-Bus 访问 Flash |
| 片内 RAM | 256B(idata),其中低 128B 可直接寻址 | 20KB~64KB SRAM,统一编址 |
| 片外 RAM | 最大 64KB,通过 MOVX 访问 | 可通过 FSMC 扩展(仅大容量型号) |
| 掉电保存 | Flash 程序区可存常量 | Flash 程序区可存常量,另有备份寄存器 |
| 地址重叠 | 无,程序和数据完全独立 | 无,但外设寄存器占据了大量地址空间 |
这张表浓缩了单片机存储体系的两条经典路线:51 靠物理引脚区分存储空间,程序大、内存小;STM32 靠统一编址区分存储空间,流量大、带宽高、RAM 资源相对充裕。理解这张表后,再看编译器的存储分配行为就有依据了。
3. 编译器如何把 C 变量映射到不同的存储区域——从段(Segment)到具体地址
3.1 嵌入式 C 程序的四类段:Code、RO、RW、ZI
任何嵌入式 C 程序编译后,都会生成若干段(Section/Segment)。以 Keil MDK 的 map 文件为例,一个工程通常包含以下内容:
- Code 段:程序机器码,存放在 Flash
- RO Data 段(只读数据):
const修饰的全局变量、字符串字面量,存放在 Flash - RW Data 段(读写数据):已初始化的全局变量,初始值存放在 Flash,运行后拷贝到 RAM
- ZI Data 段(零初始化):未初始化或初始化为 0 的全局变量,运行后在 RAM 中清零
其中 RW Data 段有一个最容易被忽略的性质:它在 Flash 和 RAM 中各占一份空间。Flash 里存的是初始值(烧录时写入),RAM 里存的是运行时的实际数据,启动代码(startup_xxx.s里的__main或Reset_Handler)负责在 main 执行前完成从 Flash 到 RAM 的拷贝。这也是为什么有些芯片明明只有 16KB RAM,但在烧录文件里却看到占用了 20KB 空间——多出来的正是 RW Data 初始值的副本。
提示:搜索"单片机 Flash 占用比代码实际大小大"这类问题的答案,九成以上都在这里——不是代码膨胀,是 RW Data 的初始值表占了一份 Flash 空间。
3.2 Keil C51 的存储类型关键字:data、idata、xdata、code
51 单片机由于存储空间的特殊性,C51 编译器提供了一套独有的存储类型关键字。这些关键字直接告诉编译器把变量放在哪个物理空间,不同关键字对应不同的汇编访问指令和性能。
| 关键字 | 物理位置 | 寻址方式 | 访问速度 | 典型容量 |
|---|---|---|---|---|
data | 片内 RAM 低 128B | 直接寻址 | 最快 | 128B |
idata | 片内 RAM 全部 256B | 间接寻址 | 较快 | 256B |
xdata | 片外 RAM 0~64KB | MOVX 间接寻址 | 慢 | 64KB |
code | Flash 程序区 | MOVC 查表 | 中等 | 64KB |
代码示例——在 Keil C51 中显式控制变量存储位置:
// 低频采样缓存放在外部RAM,省片内空间 unsigned char xdata sensor_buffer[512]; // 高频中断使用的标志位放片内直接寻址区,保证最快的访问速度 bit data irq_flag; // bit 类型只有 data 一种选择 // 数码管段码表放 Flash,不占 RAM unsigned char code seg_table[16] = { 0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8, 0x80, 0x90, 0x88, 0x83, 0xC6, 0xA1, 0x86, 0x8E }; void main(void) { // sensor_buffer 的初始化会生成 MOVX 指令 for (unsigned int i = 0; i < 512; i++) { sensor_buffer[i] = 0; } // seg_table 的访问会生成 MOVC 指令 unsigned char seg = seg_table[3]; while(1); }这段代码的核心逻辑在于:xdata告诉编译器该变量的访问走MOVX指令;code告诉编译器用MOVC查表指令访问。如果忘记写code关键字,512 字节的段码表会被放进 RAM——51 的片内 RAM 总共才 256 字节,直接溢出。这是初学 51 编程最容易踩的坑。
3.3 ARM GCC 与 Keil MDK 的 scatter 文件 / 链接脚本写法
STM32 的情况比 51 复杂。ARM GCC 使用.ld链接脚本,Keil MDK 使用.sct分散加载文件,两者的核心逻辑一致:定义各存储区域的起始地址和大小,然后告诉链接器哪些段放在哪个区域。
一个典型 STM32F103C8T6(64KB Flash,20KB RAM)的链接脚本核心片段:
MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 64K RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 20K } SECTIONS { .isr_vector : { . = ALIGN(4); KEEP(*(.isr_vector)) . = ALIGN(4); } > FLASH .text : { . = ALIGN(4); *(.text*) *(.rodata*) . = ALIGN(4); _etext = .; } > FLASH _sidata = LOADADDR(.data); .data : { . = ALIGN(4); _sdata = .; *(.data*) . = ALIGN(4); _edata = .; } > RAM AT> FLASH .bss : { . = ALIGN(4); _sbss = .; *(.bss*) *(COMMON) . = ALIGN(4); _ebss = .; } > RAM }这段脚本里最值得关注的是.data段后面的AT> FLASH——它表示.data段运行地址在 RAM,但加载地址(初始值存放位置)在 Flash。链接器会生成_sidata、_sdata、_edata三个符号,启动代码用它们完成从 Flash 到 RAM 的拷贝。理解了这个机制,你就会明白为什么修改 Flash 起始地址(比如做 Bootloader 时把 APP 偏移到0x08004000)时,需要同步修改ORIGIN,否则中断向量表会定位到错误的位置。
3.4 编译 map 文件读法:用 L618"/L691" 这类标志定位溢出问题
当编译报错时,具体信息的含义很多人没看过。以 Keil MDK 为例,L618表示某个段放不下,L691表示段与段之间存在未使用的地址空洞。ARM GCC 的报错形式则是region FLASH overflowed by xxx bytes。
实际排查步骤:
- 在编译输出目录中找到
.map文件 - 查看
Memory Map of the image部分 - 翻到
Image Symbol Table部分定位具体变量
用脚本快速统计各段的占用大小:
#!/bin/bash # 从 map 文件中提取各段大小统计 grep -E "^(Code|RO|RW|ZI) Data" build/project.map | head -20 # 从 map 文件查看最大占用 TOP 10 的量 grep -E "0x[0-9a-fA-F]+\s+0x[0-9a-fA-F]+\s+.*\.o" build/project.map | \ awk '{print $NF, $(NF-1)}' | sort -k2 -rn | head -10注意:搜"单片机 RAM 不足"的帖子时,很多答案只会建议"少用全局变量",但真正有经验的做法是打开 map 文件找出
Total RW Size (RW Data + ZI Data)这一行,看清楚到底哪个模块占用最大,往往能发现是某个库函数引入了大块缓冲,而不是你自己的业务代码。
4. 单片机程序存储空间和数据存储空间的典型应用——从 Bootloader 到掉电保存
4.1 用程序存储空间做 IAP 在线升级:C51 串口升级架构的核心思路
热词里反复出现"C51 单片机串口升级架构"和"STC 单片机 AI 在线编程",这两个方向本质上是同一件事:利用程序存储空间的分区设计,实现远程固件更新。
IAP(In-Application Programming)的基本思路是把 Flash 分成 Bootloader 区和 Application 区。Bootloader 放在起始地址(比如 51 的0x0000或 STM32 的0x08000000),上电后先执行,检查是否有新固件,如果有则通过串口接收数据写入 Application 区,然后跳转执行。
51 单片机的跳转实现——在 Keil C51 中通过函数指针跳转到指定地址:
// 跳转到 0x2000 地址处的 Application 区执行 typedef void (*jump_func)(void); void jump_to_app(void) { // 关闭全局中断,避免跳转过程中断响应 EA = 0; // 设置栈指针(应用代码有自己的栈空间,需要重新初始化) // 对 51 来说栈默认在片内 RAM 顶端即可 jump_func app_entry = (jump_func)0x2000; app_entry(); // 正常不会执行到这里 }STM32 的跳转实现加了中断向量表重定位这一步:
#define APP_ADDR 0x08008000 // 应用程序起始地址 typedef void (*pFunction)(void); void jump_to_app(void) { uint32_t app_stack_top = *(volatile uint32_t *)APP_ADDR; pFunction app_entry = (pFunction)*(volatile uint32_t *)(APP_ADDR + 4); __disable_irq(); // 重定位中断向量表到应用区 SCB->VTOR = APP_ADDR; __enable_irq(); // 设置主栈指针 __set_MSP(app_stack_top); // 跳转 app_entry(); }这段代码有讲究的地方:APP_ADDR处的前四个字节存的是应用代码的初始栈顶地址,紧接着四个字节是复位中断处理函数的地址。从 Flash 首地址读出这两个值,就能把 CPU 安全地交给应用代码。如果忘了重定位SCB->VTOR,中断向量表还指向 Flash 起始地址,应用代码一旦产生中断,CPU 会跳转到 Bootloader 的向量表而不是应用自己的,直接死机。
4.2 数据存储空间不够用时的扩展策略:STM32 用外部 RAM,51 用 xdata
数据存储空间不够,常见的扩展方案需要区分芯片平台。51 系列通过 P2 口和 P0 口外扩 SRAM,用RD、WR和ALE信号配合锁存器(74HC573)实现。STM32 则有两条路——大容量型号使用 FSMC/FMC 接口接外部 SRAM 或 SDRAM,小容量型号则把功夫下在"省着用"上。
STM32 使用 FSMC 扩展外部 SRAM 的初始化片段:
FSMC_NORSRAMInitTypeDef FSMC_NORSRAMInitStructure; FSMC_NORSRAMTimingInitTypeDef readWriteTiming; FSMC_NORSRAMTimingInitTypeDef writeTiming; // 配置 timing,访问速度为 10ns 级 readWriteTiming.FSMC_AddressSetupTime = 0x00; // 地址建立时间 1 个 HCLK readWriteTiming.FSMC_AddressHoldTime = 0x00; // 地址保持时间 readWriteTiming.FSMC_DataSetupTime = 0x05; // 数据建立时间 6 个 HCLK readWriteTiming.FSMC_BusTurnAroundDuration = 0x00; readWriteTiming.FSMC_CLKDivision = 0x00; readWriteTiming.FSMC_DataLatency = 0x00; readWriteTiming.FSMC_AccessMode = FSMC_AccessMode_A; writeTiming.FSMC_AddressSetupTime = 0x00; writeTiming.FSMC_AddressHoldTime = 0x00; writeTiming.FSMC_DataSetupTime = 0x05; writeTiming.FSMC_BusTurnAroundDuration = 0x00; writeTiming.FSMC_CLKDivision = 0x00; writeTiming.FSMC_DataLatency = 0x00; writeTiming.FSMC_AccessMode = FSMC_AccessMode_A; // Bank1 NE1 片选,对应地址 0x60000000 起始 FSMC_NORSRAMInitStructure.FSMC_Bank = FSMC_Bank1_NORSRAM1; FSMC_NORSRAMInitStructure.FSMC_DataAddressMux = FSMC_DataAddressMux_Disable; FSMC_NORSRAMInitStructure.FSMC_MemoryType = FSMC_MemoryType_SRAM; FSMC_NORSRAMInitStructure.FSMC_MemoryDataWidth = FSMC_MemoryDataWidth_16b; FSMC_NORSRAMInitStructure.FSMC_BurstAccessMode = FSMC_BurstAccessMode_Disable; FSMC_NORSRAMInitStructure.FSMC_WaitSignalPolarity = FSMC_WaitSignalPolarity_Low; FSMC_NORSRAMInitStructure.FSMC_WrapMode = FSMC_WrapMode_Disable; FSMC_NORSRAMInitStructure.FSMC_WaitSignalActive = FSMC_WaitSignalActive_BeforeWaitState; FSMC_NORSRAMInitStructure.FSMC_WriteOperation = FSMC_WriteOperation_Enable; FSMC_NORSRAMInitStructure.FSMC_WaitSignal = FSMC_WaitSignal_Disable; FSMC_NORSRAMInitStructure.FSMC_ExtendedMode = FSMC_ExtendedMode_Disable; FSMC_NORSRAMInitStructure.FSMC_AsyncWait = FSMC_AsyncWait_Disable; FSMC_NORSRAMInitStructure.FSMC_WriteBurst = FSMC_WriteBurst_Disable; FSMC_NORSRAMInitStructure.FSMC_ReadWriteTimingStruct = &readWriteTiming; FSMC_NORSRAMInitStructure.FSMC_WriteTimingStruct = &writeTiming; FSMC_NORSRAMInit(&FSMC_NORSRAMInitStructure); FSMC_NORSRAMCmd(FSMC_Bank1_NORSRAM1, ENABLE);配置完成后,外部 SRAM 就在0x60000000起始的地址空间里,可以直接定义一个指针来使用:
// 把外部 SRAM 当作大数组使用 uint16_t *external_buf = (uint16_t *)0x60000000; external_buf[0] = 0x1234; uint16_t val = external_buf[0];这里的关键是 timing 参数的设置。如果DATAST设得太短,外部 SRAM 来不及把数据放到数据总线上,读回来的数不可靠;设得太长则浪费 CPU 等待时间。搜索"FSMC 读写外部 SRAM 数据错误"的案例,绝大多数是时序参数配错,而不是芯片坏了。51 单片机的xdata、外部扩展 SRAM 的时序对 STC 单片机、基于 51 的硬件设计同样适用,只是访问速度慢一个量级,注意时序匹配即可。
4.3 掉电保存的存储选择:数据长度不同,方案完全不同
数据存储空间是易失的,断电就没有了。需要掉电保存的数据到底放哪,取决于数据量和写入频率。
- 少量关键参数(<512B):放 Flash 或 EEPROM,比如 STM32 的片上 Flash 最后几页
- 中等规模数据:外挂 SPI EEPROM(如 AT24C02)——这个方案在 51 课程设计里用了很多
- 大规模带格式数据:用 TF 卡以表格形式存储
STM32 操作片上 Flash 的简单示例:
#define FLASH_SAVE_ADDR (0x0803F800) // 主 Flash 最后一页,按实际型号取 // 写一个 32 位数据到 Flash void save_to_flash(uint32_t value) { FLASH_Unlock(); FLASH_ErasePage(FLASH_SAVE_ADDR); FLASH_ProgramWord(FLASH_SAVE_ADDR, value); FLASH_Lock(); } // 读回来 uint32_t load_from_flash(void) { return *(volatile uint32_t *)FLASH_SAVE_ADDR; }用 Flash 做掉电保存有几个必须知道的前提:Flash 擦除会磨损(10 万次寿命),所以不能用"每次上电都擦"的方式保存日志类数据;Flash 写操作必须保证电压稳定,否则写了一半掉电,数据就废了;真正可靠的掉电保存需要配合检测电路,在电压跌落到阈值时触发中断,利用电容储能完成最后的写入。这也是"基于 51 单片机汽车防盗报警""单片机智能门禁系统"这类项目里,参数存储模块的常见实现方式。
省事但不够稳的替代方案是用 STM32 的备份寄存器(BKP Domain),它由 VBAT 引脚供电,断电不丢失,且没有擦写次数限制——但只在主电源断电且 VBAT 有电时有效,且容量极小(几十字节)。做工程取舍时,这个方案的价值常被低估。
5. 深入底层:内存对齐、堆栈溢出和总线位宽对存储空间的影响
5.1 内存对齐如何影响 RAM 占用:一个结构体,三种不同布局
内存对齐是数据存储空间设计里最微妙的部分。Cortex-M 内核要求 32 位数据按 4 字节边界访问,编译器因此会在结构体成员之间插入填充字节。看下面这个结构体:
struct example { char a; // 1 字节 uint32_t b; // 4 字节 char c; // 1 字节 };按默认对齐规则,b的起始地址必须是 4 的倍数,所以a后面会填 3 个字节的 padding,c后面再补 3 个字节保证整体大小是 4 的倍数,整个结构体占 12 字节。但如果调整成员顺序,让大类型在前:
struct example_fixed { uint32_t b; char a; char c; };这个结构体只占 8 字节——填充量从 6 字节降到了 2 字节。一个小改动,400 个结构体数组就能省下 1.6KB RAM。搜索"STM32 RAM 优化"的帖子,结构体重排是每次必提的选项。在 51 单片机上因为有 3 字节的通用指针和bit类型,对齐规则略有差异,但"把大的放前面"这个原则适用。
提示:
#pragma pack(1)可以强制取消对齐,但代价是访问未对齐变量时,Cortex-M 会产生 HardFault 或性能骤降,不到万不得已不用。
5.2 栈溢出排查:在链接脚本里设置栈哨兵
数据存储空间不足的另一个隐蔽杀手是栈溢出。Cortex-M 的栈往低地址增长,堆往高地址增长,两者一旦相遇,程序会以各种诡异的方式崩溃。常用的排查手段是在栈底放一组哨兵值,定期检查它是否被改写。
代码示例——用链接脚本符号定位栈并填充哨兵:
// 在启动文件中声明链接脚本生成的栈地址符号 extern uint8_t __StackTop; extern uint8_t __StackLimit; #define STACK_SENTINEL 0xA5A5A5A5 void stack_init(void) { // 从栈顶到底部填入哨兵值 uint32_t *p = (uint32_t *)&__StackTop; uint32_t *limit = (uint32_t *)&__StackLimit; while (p > limit) { *(--p) = STACK_SENTINEL; } } void stack_check(void) { // 找到第一个哨兵值被改写的区域,大致估计最大栈深度 uint32_t *p = (uint32_t *)&__StackLimit; uint32_t *top = (uint32_t *)&__StackTop; uint32_t count = 0; while (p < top && *p == STACK_SENTINEL) { p++; count++; } // count * 4 字节就是已用的栈空间 printf("Stack used: %lu bytes\r\n", (unsigned long)(count * 4)); }这段代码的价值在于它是动态检测栈深度的通用方法。如果函数调用链变得很深(比如递归或中断嵌套),栈空间会被一点一点吃干净。使用 RTOS 时每个任务都有自己的栈,需要在任务创建时就估算好大小——56 的 STC 单片机、STM32 的典型做法是先给任务分配一个合理大小,运行时用上面的方法观测实际使用量,再做调整。网上很多"freertos 任务栈大小怎么确定"的经验帖,底层原理就是栈哨兵。
5.3 Flash 读保护与选项字节对存储空间的隐藏影响
STM32 的 Flash 存储空间里不仅有主存储区,还有系统存储区(Bootloader 出厂代码)和选项字节区。选项字节控制读保护(RDP)、写保护(WRP)等功能。
操作注意:RDP 级别从 Level 0 切到 Level 1 时,如果之前 SWD 调试口是开着的,还能连上调试器但无法读取 Flash 内容;一旦切到 Level 2,调试口和 Bootloader 同时关闭,芯片等同于锁死,没有任何软件办法恢复。做产品量产时,很多公司会设置 Level 1 防止固件被读取,但没有经验的人一旦把 RDP 设成 Level 2,整批芯片只能报废。关于选项字节的配置,"单片机烧录软件"和 "STM32CubeProgrammer" 的图形界面里都能设置,但批量生产用命令行做编程校验更省事:
STM32_Programmer_CLI -c port=SWD mode=UR -ob RDP=0xAA -w firmware.bin -v注意:只有明确了解当前产品需要的读保护等级才建议去动选项字节。用 STM32CubeProgrammer 修改
RDP=0xAA(Level 1)后,芯片的 Flash 会做全片擦除,如果 Bootloader 和 App 没备份,板子就变砖了。
5.4 用 objdump 查看程序里变量到底落在了 RAM 还是 Flash
验证变量存储位置最直接的工具是arm-none-eabi-objdump或者 Keil 的 fromelf。开发时不确定某个变量是进 RAM 还是 Flash,可以查看编译产物:
# 生成 elf 文件的段信息 arm-none-eabi-size build/firmware.elf # 查看符号表的地址分布 arm-none-eabi-nm -n build/firmware.elf | grep -E "(sensor_table|buffer|irq_flag)" # 反汇编查看具体变量访问指令(确认是 LDR/STR 还是 LDR R0, [PC, #xxx]) arm-none-eabi-objdump -d build/firmware.elf | grep -A 10 "sensor_buffer"以 STM32 为例,0x20000000开头的地址是 RAM,0x08000000开头的是 Flash。如果nm输出的某个变量地址落在0x2000xxxx,它就在 RAM。如果想把它挪到 Flash(常量),加const修饰符再编译一次,观察地址变化。搜"stm32 const 变量 不占 ram"的文章,原理就是在做这件事:C 语言层面const只是类型修饰,真正影响地址落位的是编译器的段分配规则和链接脚本里.rodata的区域归属。
5.5 常见误用误区与免责声明
- **误区一:**认为 51 的
code关键字只是"建议",不写也能编译通过。实际上不写的话变量默认进data或xdata,烧进去后看 map 文件才发现 RAM 已经爆了。规则是:51 的code关键字必须显式声明。 - **误区二:**认为
const变量一定在 Flash。在 ARM 平台,const只对编译器语义有效,只要链接脚本里没有把.rodata段放到 Flash 区域(或代码里用__attribute__((section))重新指定),const变量完全可能被放到 RAM。这也是很多 STM32 工程师的盲区。 - **误区三:**认为外部 SRAM 和片内 SRAM 速度一样。STM32 通过 FSMC 访问外部 SRAM 的速度,相比片内 SRAM 有几个数量级的差距,直接跑高频中断里的变量访问会出现明显的性能回退。
6. 一个实用技巧:把编译器的存储占用统计搬进构建流程
6.1 为什么要把存储统计搬进构建流程
开发过程中,程序存储空间和数据存储空间的占用不是一个"看看就行"的数字,而是需要持续跟踪的指标。很多项目到后期 Flash 只剩几百字节、RAM 只剩几十字节,才意识到前面的每个需求迭代都在悄无声息地消耗存储余量。与其等到编译失败,不如把存储占用统计放到每次构建的输出里,让团队每个人都能看到"上个版本相比这次多了多少"。
这个技巧的适用场景很明确:固件持续迭代的产品项目、需要严格控制内存使用的工业控制类设备、以及同时维护多个固件版本时快速核验存储是否满足规格。
6.2 基于 Keil MDK 的输出重定向与统计
Keil MDK 编译时的 Build Output 窗口里有 "Program Size" 一行,但它是人读的,不方便机器处理。更好的做法是在After Build里调用fromelf生成文本格式的 map:
fromelf.exe --text -c -d -e -s -z -o build/output.txt build/firmware.axf然后写一个 Python 脚本从output.txt中提取各段大小,生成简化报告:
import re def parse_keil_output(filepath): with open(filepath, 'r', encoding='utf-8-sig', errors='ignore') as f: content = f.read() # 匹配类似 " Code (inc. data) RO Data RW Data ZI Data Debug" # 下一行是具体数值 m = re.search(r'Code \(inc\. data\)\s+RO Data\s+RW Data\s+ZI Data\s+Debug\s*\n\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)', content) if m: code, inc_data, ro_data, rw_data, zi_data, debug = map(int, m.groups()) flash_total = code - inc_data + ro_data + rw_data # 去掉 debug 信息 ram_total = rw_data + zi_data return { 'flash_used': flash_total, 'ram_used': ram_total, 'code': code, 'ro_data': ro_data, 'rw_data': rw_data, 'zi_data': zi_data, } return None # 读取并输出 stats = parse_keil_output('build/output.txt') if stats: print(f"Flash: {stats['flash_used']} B, RAM: {stats['ram_used']} B")脚本的设计逻辑是匹配 Keil 输出中的固定格式行,计算出真正的 Flash 占用(Code 减去调试信息,加上 RO Data 和 RW Data)和 RAM 占用(RW Data 加 ZI Data)。如果你的项目用 ARM GCC 工具链,arm-none-eabi-size的输出更干净,解析起来更简单。核心思想不是工具本身,而是"每次构建产生的存储数据要有记录的意识和比较能力"。实际执行时,可以将统计结果追加到一个 CSV 文件,每次提交代码后集中查看趋势,存储空间增长曲线和代码变更记录互相对照,就很容易识别出"这个改动吃掉了 1KB RAM"的源头是谁。
本文还有配套的精品资源,点击获取