刚接触Keil MDK那会儿,我最喜欢看Build Output里那句“0 Error(s), 0 Warning(s)”,至于它上面那行Program Size,扫一眼就关掉了。后来帮一个朋友调板子,一个非常简单的小工程,下载进去却没有任何反应。查了半天才意识到,编译输出的RW-data加ZI-data,早就超过了那款芯片的RAM容量。可链接器当时并没有直接报错,程序下载进去之后跑飞了。从那以后,我养成了一个习惯:每次编译完,先看那行数字,再去看启动代码和链接脚本。
这篇就把Code、RO-data、RW-data、ZI-data一次性讲透:它们分别存在哪、怎么影响Flash和RAM、出问题时怎么用.map文件定位,以及从哪些方向给它们瘦身。主要基于Cortex-M平台上使用Keil MDK的常见场景,懂一点C和单片机基础的读者都能跟上。就算你用的是其他IDE,只要底层还是ARMCC或armclang,思路完全通用。
1. 编译输出窗口那张表,到底在说什么
1.1 这一行数字从哪里来
先看一个典型的编译输出:
Program Size: Code=12000 RO-data=800 RW-data=120 ZI-data=2400这行内容不是某个编译器插件生成的装饰信息,而是链接器在完成链接后,对最终镜像的段(section)大小做的统计。C/C++源文件先被编译成.o目标文件,链接器再把所有目标文件、启动文件、C库合并成一个可执行镜像,同时把各个段放到约定好的Flash和RAM区域里。最终该放在哪、占多大,全都在这一步定下来。
所以,这行数字描述的是“最终烧录镜像”的构成,和代码行数没有关系,和“用了多少栈”也不是一回事。它更接近一份货物的装箱清单:有多少CPU指令、多少只读数据、多少需要初始化拷贝的变量、多少上电清零的缓冲区。想看到更细的分配,得去打开.map文件,这个后面专门讲。
1.2 四个字段速记表
我先把四个字段直观地列一下,再逐个展开:
| 字段 | 全称 | 通俗解释 |
|---|---|---|
| Code | Executable Code | CPU要执行的机器指令 |
| RO-data | Read Only Data | 只能读、不能改的数据,如const常量、字符串字面量 |
| RW-data | Read/Write Data | 已初始化且初值非零的全局/静态变量 |
| ZI-data | Zero Init Data | 未初始化或初值为0的全局/静态变量 |
一个很容易记的口诀:Code和RO-data是“烧进Flash里不用改的东西”;RW-data是“上电要从Flash拷到RAM、之后在RAM里改的东西”;ZI-data是“上电后在RAM里清成0的东西”。
1.3 我建议新手怎么记
很多教科书喜欢直接抛术语,新手记不住。你可以把MCU想象成两间屋子:Flash是仓库,RAM是操作台。
- Code是一叠图纸,放在仓库里,CPU按图纸干活。
- RO-data是仓库里的固定模具,只需要读,不需要拿到操作台上去改。
- RW-data是耗材,仓库里存了一份初始状态,开工前被搬到操作台,之后操作台随便改。
- ZI-data是操作台上的素白纸板,开工前先用抹布清干净,再往上写东西。
这样记,后面理解“为什么RW-data既占Flash又占RAM”就很自然:因为仓库里那份初始备份不能丢,操作台上那个可写的副本也不能少。
2. 逐项拆解:Code、RO-data、RW-data、ZI-data在单片机内部对应的“位置”
2.1 Flash与RAM的分工
在深入每项之前,先把硬件存储器的脾气讲清楚:
- Flash:掉电不丢,但写入慢、不能按字节无限擦写,适合放指令和常量。
- RAM:掉电就丢,读写快,适合放变量、栈、堆。
Cortex-M内核从Flash取指令、从Flash读常量都很高效,所以只读的东西没必要占用RAM。代码和常量老老实实待在Flash里,等运行时直接访问即可。只有真正要“写”的变量才需要RAM。
2.2 Code里到底装了什么
Code代表最终固件中所有机器指令。你的main函数、中断服务函数、驱动库、RTOS调度器、C运行时启动代码,全都会被编译成指令放进这一块。很多人以为Code只是“我自己写的代码”,实际上工程里任何被链接进来的函数,不管来自标准库还是第三方库,都会计入Code。
我之前遇到过一个工程,功能很简单,Code却飙到十几KB。排查下来发现,代码里一个printf带了浮点格式化功能,整个C库的浮点打印实现被拖了进来。对,一个printf就能让Code多出好几KB。这类问题在资源紧张的MCU上非常典型。所以看到Code很大,先别急着怀疑自己的逻辑,重点排查有没有引入大体积库函数。
2.3 RO-data不只装const
RO-data主要是只读常量。最常见的来源:
- 用const修饰的全局变量、全局数组。
- 字符串字面量,比如printf里的格式化字符串、日志信息。
- 编译器生成的跳转表、查表用的常量表。
一个细节需要留意:const局部变量通常不会形成RO-data。如果编译器发现它的值在编译期就已确定,而且只在小范围使用,经常直接把它变成指令里的立即数,根本不会分配一块独立存储。这时候它被算进了Code,而不是RO-data。别拿着编译表问我“我明明写了const数组,RO-data怎么没变”,先看它是不是局部变量、是不是被优化了。
2.4 RW-data的初值为什么还要留在Flash
C语言规定,全局变量和静态变量如果没有显式初始化,初始值是0;如果显式初始化成非0,程序启动后必须恢复成那个值。
假设你在全局写了一个uint8_t g_count = 10;,RAM上电后内容是随机的,可能是0,可能是73。为了保证程序一开始就能看到10,Flash里必须保存一个“10”作为初始值。启动代码在调用main之前,把这个初值从Flash拷贝到RAM里g_count对应的地址。
这就是RW-data的本质:
- 初值实体存在Flash,占一份空间。
- 运行时变量实体在RAM,占另一份空间。
- 两份数据大小相等。
所以RW-data在两个存储器里都出现。启动代码的“搬初值”动作,就是Embedded领域常说的RW拷贝,也叫.data段初始化。
2.5 ZI-data清零的意义
ZI-data对应的是“零初始化”数据。比如全局变量uint8_t g_buffer[1024];,没赋初值,或者显式写了= 0,它的初值统一就是0。这时候完全没有必要在Flash里存1024个0,只用在RAM里划出1024字节空间,然后在启动时把这整段清零。
清零动作同样发生在main之前。这就是为什么有些启动汇编代码里能看见对BSS段/ZI段做循环清0的操作。ZI-data不占Flash,但它占RAM,而且清零动作还要花一点时间。数组越大,上电到main的启动时间越长,不能忽略。
2.6 堆和栈,编译表里的“隐形人”
这是最容易踩坑的地方:编译输出里的RW-data和ZI-data统计的,通常不包含启动文件中手动预留的栈(Stack)和堆(Heap)。
Stack和Heap是RAM的一部分,但它们在启动汇编文件里通过Stack_Size、Heap_Size两个宏单独预留,链接器不一定把它们并入RW或ZI的统计。我见过太多人拿着编译表算RAM占用,算出来明明还剩不少,程序跑几天突然随机死机,最后一查是栈溢出。
严格来说,不同工具链版本对堆栈是否算入ZI-data有细微差异。与其争论,不如养成一个习惯:计算RAM时,把RW-data、ZI-data、Stack_Size、Heap_Size四项全加进去。如果怀疑某些版本已经包含,打开.map文件对着STACK和HEAP段看一眼,谁占多少立刻清楚。
3. Flash与RAM的真实占用:从编译表到选型依据的一次换算
3.1 两个最常用公式
有了上面的基础,可以直接套两个公式:
烧录文件占用的Flash = Code + RO-data + RW-data 运行时RAM占用 = RW-data + ZI-data + 栈 + 堆为什么Flash里要放RO-data?因为ROM里保存的是只读数据,不能丢,掉电后还要用。为什么Flash还要放RW-data?因为RAM里的RW副本每次上电都要从Flash恢复初值,Flash必须留一份备份。为什么RAM不需要放RO-data?因为Cortex-M直接支持从Flash寻址读取常量,没必要给只读数据再造一份RAM副本。只有在极少数需要从RAM执行代码、从RAM读常量加速的场景,你才会在分散加载文件里把RO区域映射到RAM,这不是默认行为。
3.2 用实例算一遍
假设某工程编译输出为:
Code=12000 RO-data=800 RW-data=120 ZI-data=2400芯片选的是32KB Flash、4KB RAM,启动文件里Stack_Size=0x400(1024),Heap_Size=0x200(512)。
Flash占用:
12000 + 800 + 120 = 12920 字节 ≈ 12.6KB32KB的Flash余量很充足。
RAM占用:
120 + 2400 + 1024 + 512 = 4056 字节 ≈ 3.96KB4KB的RAM只剩下40字节左右,这基本等于满载。要是程序里再搞一点动态内存分配,或者中断嵌套稍微深一点,随时可能爆栈。
从这个例子能看出来,选芯片不能只看Flash,很多时候RAM才是真正的瓶颈。通信协议栈、大缓冲区、RTOS任务栈,全是吃RAM大户。
3.3 链接器只看分散加载,不替你算总账
有朋友会问:“RAM不够了,链接器不是会报错吗?”不一定。链接器在链接时只保证每个段被放进分散加载文件规定的区域范围内。如果你的芯片型号配置成了更大容量的RAM,比如实际是4KB,型号里却选了64KB RAM的芯片,链接器会按照64KB去分配地址,实际芯片跑起来立马访问非法地址。这也是部分“编译通过、上电没反应”问题的根源。
所以编译之前,先在Options for Target -> Device里确认芯片型号和Flash/RAM容量跟实物一致。然后再把编译输出的RW+ZI+堆栈和你手上的RAM容量对一遍。链接器不是万能管家,它只按你给的地图走。
3.4 单位、进制与“剩下多少”的换算
Keil编译输出里的数字默认单位是Byte(字节),不是Bit,也不是KB。Code=12000就是12000字节,除1024大概是11.7KB。算剩余Flash时,用芯片Flash总容量减去Code+RO+RW即可;算剩余RAM时,用芯片RAM总容量减去RW+ZI+堆栈。
我一般在项目启动阶段就会列一张小表:
| 资源 | 总量 | 已用 | 剩余 |
|---|---|---|---|
| Flash | 32KB | 12.9KB | 19.1KB |
| RAM | 4KB | 4.0KB | 不足 |
这张表比一堆口头估算直观得多。每次新增功能后重新编译,把数字填进去,资源不够之前就能提前感知。
4. 随手写几个变量,看看它们各进哪个“坑位”
4.1 一段用于观察的测试代码
光看概念容易飘,我建议你新建一个空工程,把下面这段代码放进去编译,亲眼看数字变化:
#include <stdio.h> const uint8_t g_lookup[16] = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 }; int g_rw_value = 0x5A; int g_zi_value; static int g_static_zero = 0; static int g_static_value = 100; int main(void) { static int inner_static = 3; uint8_t temp = g_lookup[0]; g_zi_value = temp; printf("%d %d %d\r\n", g_zi_value, g_static_value, inner_static); while (1) { } }在默认优化等级下编译,观察编译输出:
g_lookup是const全局数组,进RO-data。g_rw_value初值为0x5A的非零全局变量,进RW-data。g_static_value是静态全局变量,初值100,也是RW-data。g_zi_value和g_static_zero初值均为0,进ZI-data。inner_static虽然是main里的局部静态变量,但存储类别和全局变量一样,初值3也是RW-data。
注意别把print去掉。如果你把printf删了,编译器可能发现这些变量完全没被使用,直接把一段代码优化掉,编译输出会不符合预期。想避免这种干扰,可以把几个变量声明成volatile,或者确保它们真的被使用。
4.2 改一个初值,看RW和ZI怎么搬家
这是最直观的实验。
把代码里的int g_zi_value;改成int g_zi_value = 0;,再编译一次。你会发现ZI-data几乎不变,因为初始化为0的全局变量本来就属于零初始化数据,编译器不会为“0”单独在Flash里存一份初值。
接着改成int g_zi_value = 1;,再编译一次。你会看到RW-data增加4字节,ZI-data减少4字节。
原因很简单:初值变成了非0,这个变量从BSS段搬到了.data段。Flash里多保存了一个1,RAM里的变量实体依然在。这一改动对RAM总占用没影响,但Flash占用会多4字节。理解了这个,你就明白为什么有人为了省Flash会把乱七八糟非0初值改成0,但RAM一点都没省。
4.3 把const去掉,观察RO和RW的转移
把const uint8_t g_lookup[16]改成uint8_t g_lookup[16],再编译。结果通常是RO-data减少16字节,RW-data增加16字节。
这是因为变量从只读区搬到了可读写区,RAM里多了一份16字节数组。而Flash那边,由于数组内容还是要被初始化成那些固定数值,初值区还是占了16字节。也就是说,这个简单改动让RAM多占16字节,Flash占用不变。
这个实验值钱在什么地方?它解释了为什么大查找表应该用const:查表本来就是只读操作,不需要放进RAM。很多人一开始图省事不写const,等RAM告急时才发现,稍微改几个关键字,RAM就省出来了。
4.4 局部变量到底去哪儿了
在编译表里,普通局部变量基本是“隐形”的。它们在函数被调用时由栈顶分配,函数返回后释放,不改变RW/ZI的统计。代码里写uint8_t temp,如果编译器没把它优化进寄存器,它就在栈里占4字节(按对齐),这一部分由启动文件预留的Stack_Size决定。
有同学问我:“为什么我定义一个很大的局部数组,编译表没变化?”因为栈空间是预先分配好的固定池子,局部数组只会在运行期从池子里划走空间,编译表的RW/ZI自然不变。但如果数组大到超出栈的剩余容量,程序运行到那个函数时就栈溢出了。这就是为什么我强烈不建议在函数内直接定义大数组,大缓冲区要么全局,要么static,要么从堆里分配,至少你能在.map文件和编译表里看见它的存在。
5. 常见误区与高发问题:ZI-data不占Flash,为什么还会超过容量?
5.1 误区一:ZI-data不占Flash,所以可以无限定义大数组
ZI-data确实不占Flash,但它是RAM的一部分。Flash省下来了,RAM照样被消耗。很多初学者在全局放一个几十KB的数组,看到编译输出里Flash没怎么涨,以为万事大吉,实际上RAM早就爆了。
举个例子:芯片RAM只有2KB,你在全局写uint8_t dma_buffer[4096];,如果链接地址配置没拦住,这个数组就会越过RAM边界。程序启动后,清零循环一跑,可能在半路就把不存在的地址写坏了,表现千奇百怪:有时复位,有时HardFault,有时跑起来隔一段时间乱掉。
链接器真的会拦住吗?不一定。得看分散加载文件里的RAM区域设置。如果芯片型号匹配,RAM范围正确,超容量会报L6220E之类错误;如果型号不对,或者分散加载文件里RAM范围写大了,越界就发生了。所以自查RAM占用时,不要只依赖链接器。
5.2 误区二:RO-data和Code永远不会占RAM
默认工程下,这句话成立。但有两种情况会打破:
- 你用分散加载文件把只读区重新映射到了RAM。
- 你在RAM里启动并运行BootLoader或某些重定位代码到RAM。
一旦出现,RO-data和Code同样会占用RAM。普通裸机工程通常不需要这种骚操作,但如果你想手动把某个高速函数放到RAM里执行,就得清楚这部分RAM其实也是从你有限的RAM里挤出来的。
5.3 误区三:把所有非0初值改成0,就能省RAM
改非0初值为0,RW-data会变小,ZI-data会变大。Flash确实省了,但RAM那边是“换了个坑位”,总占用并没有下降。如果你的目标是省Flash,这个操作有效;如果你的目标是省RAM,得把变量去掉、合并或者改成const只读表,而不是改初值。
这个误区在消耗Flash紧张、RAM宽裕的芯片上问题不大;反过来,如果RAM紧张,瞎改初值只会让RAM更差。
5.4 高发问题:编译通过,下载后板子却不工作
我遇到最多的排查链路是:
- 先看编译输出表,把RW+ZI+栈+堆和芯片RAM容量对比。
- 打开.map文件,搜索STACK段和HEAP段的地址范围,确认它们落在RAM区域内。
- 检查启动文件里的Stack_Size是不是明显偏小。
- 把所有中断服务函数过一遍,看有没有在中断里定义大局部变量、有没有在中断里调用printf这种复杂函数。
如果这些都没问题,再看Flash侧。很多板子下载后没反应,不是代码逻辑问题,而是栈指针没有正确初始化,或者启动代码里向量表设置不对。编译表本身只能排除资源性问题,不能证明程序一定正常。
5.5 高发问题:我明明没写几个变量,RW+ZI却很大
这种时候,真正的大头往往不是你自己手写的变量。常见的“隐形大户”:
- RTOS的任务栈数组。
- 通信协议栈的收发缓冲区。
- 驱动库内部定义的全局缓冲。
- 启动文件里预留的栈和堆。
- 一些库函数维护的全局状态变量。
逐个排查的方法很简单:打开.map文件的Image Symbol Table,按Size降序找最大的符号。通常前几个就能解释RW+ZI的大头。别凭感觉猜,数据都在文件里。
6. 通过.map文件与启动文件定位内存细节
6.1 如何生成并打开.map文件
编译完成后,工程目录下的Listings文件夹里一般会生成一个.map文件。如果你找不到,去Options for Target -> Listing里勾选与Linker Map相关的选项,重新编译。文件名通常是工程名.map,和.axf文件同目录或在其Listings子目录下。
用文本编辑器打开即可,不用安装额外工具。整个文件可能很长,重点看两个区域:Image Symbol Table(镜像符号表)和Memory Map of the image(内存映射图)。
6.2 Image Symbol Table怎么读
这一部分是所有全局/静态符号的“户籍册”。以AC5或AC6的表述为例,你会看到类似这样的信息:
g_rw_value 0x20000000 Data 4 main.o(.data) g_lookup 0x08000100 Data 16 main.o(.rodata) g_zi_buffer 0x20000010 Zero 1024 main.o(.bss)看三个关键点:
- 地址:0x0800开头的是Flash地址,0x2000开头的是RAM地址。
- 段名:.data对应RW,.rodata对应RO,.bss对应ZI。
- 大小:单位是字节。
拿到这份名单,你就可以精确到“哪个变量吃了几字节、在RAM/Flash哪里”。前面说的“找大户”也是这么找:把列表按地址段分一下,看谁占用最多,再决定优化谁。
6.3 Memory Map of the image怎么读
这一部分展示最终镜像的加载区(Load Region)和执行区(Execution Region)。简化示意:
Execution Region ER_IROM1 Base 0x08000000, Size 0x00003278, Max 0x00010000 Execution Region RW_IRAM1 Base 0x20000000, Size 0x00000FC0, Max 0x00001000ER_IROM1就是Flash区域,Max 0x00010000表示64KB;RW_IRAM1就是RAM区域,Max 0x00001000表示4KB。通过Size和Max的比较,可以一眼看出还剩多少余量。如果你的RW执行区Size已经接近Max,说明RAM接近满载。
我经常直接把这两行复制到项目文档里,作为资源预算的原始依据。它比编译输出窗口那行数字更权威,因为编译输出只是汇总,这里才是真正的地址分布。
6.4 启动文件里的Stack_Size、Heap_Size怎么调整
打开startup_xxx.s,一般能看到类似语句:
Stack_Size EQU 0x400 AREA STACK, NOINIT, READWRITE Stack_Mem SPACE Stack_Size Heap_Size EQU 0x200 AREA HEAP, NOINIT, READWRITE Heap_Mem SPACE Heap_SizeStack_Size和Heap_Size改了之后重新编译,.map里对应的STACK和HEAP段大小就会跟着变。这里有个经验:栈不是越大越好。栈给得太大,RAM被白占;给得太小,程序嵌套深一点就溢出。
怎么确定栈够不够?一个土办法,把Stack_Mem区域在启动时填充成固定的标记值(比如0xCC),程序运行一段时间后停下来,用调试器看栈区域末尾那段标记值被写改到哪里,截止位置和栈起始地址的距离就是实际峰值栈深度。注意,检测时要把典型的运行路径和中断都跑到,不然测得不够准。
7. 内存优化方向:让Code、RO-data、RW-data、ZI-data每一项都瘦下来
7.1 先看是哪一项偏大
面对编译输出,我不建议上来就优化。先判断瓶颈:
| 观察项 | 可能原因 | 优先排查方向 |
|---|---|---|
| Code偏大 | 库函数太重,优化等级低 | 开启优化,使用microLIB,删除未用函数 |
| RO-data偏大 | 大查找表、字符串过多 | 改小数据类型,给字符串瘦身 |
| RW-data偏大 | 全局非0变量多,大量初值 | 改成0或const,减少Flash中的初值 |
| ZI-data偏大 | 大数组、栈堆预留过多 | 复用缓冲区,收紧Stack/Heap |
7.2 Code瘦身的具体动作
第一个动作是打开编译优化。大部分MCU工程至少能开到-O2,代码体积和性能通常都会变好。代价是可读性变差、调试时变量可能被优化掉,但很多时候利大于弊。
第二个动作是在Keil里勾选Use MicroLIB。MicroLIB是阉割版C库,最明显的特点是省:少则几百字节,多则几KB,特别适合常见的裸机工程。缺点是部分标准函数行为不完整,比如printf对浮点格式化的支持可能有限。用了它之后记得跑一遍功能验证。
第三个动作是启用“每个函数单独一个段”的链接选项,让链接器把没有被调用的函数和变量整个丢掉。AC5和AC6里对应的说法不完全一样,但目的相同。这个选项对大中型工程的效果立竿见影,能自动剔除没用到的驱动接口。
还有,检查一下自己是不是在中断里拉了printf、浮点运算这类重型函数。把它们从频繁调用或中断路径里挪走,往往比编译器优化更有效。
7.3 RO-data瘦身:查表和数据类型的斤斤计较
RO-data最常见的来源是查找表和字符串。
查找表首先要“抠数据类型”。比如一份256点正弦表,取值范围0到1023,用const uint16_t就够,可别用const uint32_t。一个plain int在很多平台是4字节,uint16_t是2字节,一张256点的表直接少一半Flash。如果精度还能接受,甚至可以用const uint8_t,体积再降一半。
字符串字面量也要去重。日志里反复出现的相同文本,编译器一般会合并,但不同格式的字符串还是会各存一份。尽量把日志信息规格化、短文化。对Flash极其紧张的工程,还可以把固定字符串用索引和前缀压缩方式存储,到运行时再拼接,不过那样会增加Code,要权衡。
7.4 RW-data和ZI-data瘦身:缓冲区复用是核心
RW-data的优化逻辑和RO-data正好相反。RAM里真正放着的,是那些会被改写的变量。能做成const的,就绝不要留在RW里。剩下的全局非0初值变量,如果只是启动时需要一个状态,可以考虑改成0初始化,把Flash里的初值省掉;代价是启动代码里多一段清零时间。
ZI-data的大头通常是缓冲区。不同功能模块如果不会同时运行,可以共用一个union或内存池。比如一个通信模块要512字节发送缓冲,一个传感器模块要512字节采样缓冲,两个模块不会同时处于活跃状态,就可以把两块内存放进同一个union,RAM占用立刻减半。
栈和堆也属于广义的ZI区域。把Stack_Size和Heap_Size调到够用而不滥用,是另一个省RAM的常用手段。单靠肉眼和感觉调栈有风险,建议配合前面说的栈标记检测法,拿到真实峰值再留20%左右余量。
7.5 优化不是万能药,选型阶段才是省钱关键
很多问题在画板阶段就该想清楚:这个项目到底有多少实时数据要缓存?协议栈要几KB RAM?显示驱动要不要做显存?如果一开始目标RAM定在2KB,最后代码写出10KB的逻辑,怎么优化都很痛苦。
我的习惯是:在第一版软硬件定型之前,先用一个小Demo把预计用到的库、RTOS、通信协议都拉起来,看编译输出里的RW+ZI+Stack/Heap总和。这个数字就是最粗略的选型依据。如果它已经占掉芯片RAM的80%,趁早换更高配的芯片,别指望后续优化能挤出水来。
最后分享一个我自己的习惯:编译完第一件事不是看Error,而是把RW+ZI+栈堆抄在便签上,和芯片RAM对比。有一回一个查表用int数组,RAM没超,Flash超了,改成const uint8_t后立刻解决。这行编译信息就是工程师的尺子,希望你也能用好它。