2.2 Flash、RAM 与变量落位 上一节给芯片画好了地址地图,这一节回答一个更具体的问题:你写的那份 C 代码,编译完之后每一部分去了哪?全局变量初值为什么"一上电就在"?const 数组为什么号称"不占 RAM"?栈溢出时到底压坏了什么?把"落位"搞清楚,嵌入式开发里一大半的玄学问题会自动消失。 两类存储,两种性格 STM32F103C8T6 内置 64 KB Flash 和 20 KB SRAM,性格完全相反。Flash 掉电不丢但写入慢、且有擦写寿命(约一万次擦写周期),擦除必须按页(1 KB)整页进行;SRAM 读写随意、速度快,但掉电即失。所以编译器把"不变的"(代码、常量、已初始化数据的初值)放进 Flash,把"会变的"(全局变量、栈、堆)放进 SRAM。
上一节给芯片画好了地址地图,这一节回答一个更具体的问题:你写的那份 C 代码,编译完之后每一部分去了哪?全局变量初值为什么"一上电就在"?const 数组为什么号称"不占 RAM"?栈溢出时到底压坏了什么?把"落位"搞清楚,嵌入式开发里一大半的玄学问题会自动消失。
STM32F103C8T6 内置 64 KB Flash 和 20 KB SRAM,性格完全相反。Flash 掉电不丢但写入慢、且有擦写寿命(约一万次擦写周期),擦除必须按页(1 KB)整页进行;SRAM 读写随意、速度快,但掉电即失。所以编译器把"不变的"(代码、常量、已初始化数据的初值)放进 Flash,把"会变的"(全局变量、栈、堆)放进 SRAM。
| 属性 | Flash | SRAM | 典型用途 |
|---|---|---|---|
| 掉电保持 | 保持 | 丢失 | — |
| 写入粒度 | 按页擦、按半字写 | 按字节随意 | — |
| 速度 | 较慢(带预取缓冲) | 快 | — |
| 存放内容 | 代码、常量、初值 | 变量、栈、堆 | — |
| 寿命约束 | 约 1 万次擦写 | 无限 | — |
EEPROM 的生态位在这两者之间:掉电保持且可按字节改写。STM32 片上没有真正的 EEPROM,需要时用一片 Flash 页模拟(记录磨损的写法),或者外挂一颗 I2C 接口的 EEPROM——第 5 章讲 I2C 时正好拿它当实验对象。
编译链接体系把程序内容切成若干"段"(section),每段有自己的去处。嵌入式里必须认识四个:
| 段名 | 内容 | 落位 |
|---|---|---|
| .text | 机器指令 | Flash |
| .rodata | const 常量、字符串字面量 | Flash |
| .data | 已初始化的全局/静态变量 | 运行时在 SRAM,初值镜像存 Flash |
| .bss | 未初始化或零初始化的全局/静态变量 | SRAM,上电清零,Flash 不占空间 |
用一段代码把四段占全,再看对应的链接产物:
#include <stdint.h> const uint8_t sine_table[8] = {0, 90, 127, 180, 255, 180, 127, 90}; /* .rodata,只占 Flash */ uint32_t run_count = 100; /* .data:SRAM 里住着,初值 100 存在 Flash 里 */ uint32_t counter; /* .bss:上电后被清零,Flash 不为它花空间 */ static char msg[] = "hello"; /* .data:可修改,且初值要从 Flash 搬过去 */ int main(void) /* main 本身在 .text */ { counter = run_count++; /* 运行时都活在 SRAM */ while (1) { } }
用 arm-none-eabi-gcc 编译后,size 工具给出的报告正是按这个口径统计的:
$ arm-none-eabi-size led.elf text data bss dec hex filename 1348 20 12 1380 564 led.elf
读法:Flash 实际占用 = text + data = 1368 字节(text 已含 .rodata),SRAM 占用 = data + bss = 32 字节。"为什么灯还没点亮程序就 1.3 KB"——那是启动代码和向量表也在 text 里。20 KB 的 SRAM 里,data+bss 只有几十字节是常态,大头是栈和将来你亲手定义的大缓冲区。
.data 段的变量运行时在 SRAM,但 SRAM 掉电即失,初值必须有人替它"复活"。这个人是启动代码:上电后、进入 main 之前,启动代码把存在 Flash 里的 .data 初值镜像逐字搬到 SRAM 的 .data 区,再把 .bss 区整段清零。用伪代码看这个过程的骨架(真实的启动文件是汇编,逻辑一致):
extern uint32_t _sidata; /* .data 初值在 Flash 中的起始地址(链接脚本提供)*/ extern uint32_t _sdata; /* .data 在 SRAM 的起始地址 */ extern uint32_t _edata; /* .data 在 SRAM 的结束地址 */ extern uint32_t _sbss; /* .bss 起始 */ extern uint32_t _ebss; /* .bss 结束 */ void reset_handler(void) { uint32_t *src = &_sidata, *dst = &_sdata; while (dst < &_edata) *dst++ = *src++; /* 搬运 .data 初值 */ dst = &_sbss; while (dst < &_ebss) *dst++ = 0; /* 清零 .bss */ main(); /* 一切就绪,进入 main */ }
那几个下划线开头的符号由链接脚本定义。第 7 章讲启动流程与 Bootloader 时会回到这段代码,把它放进完整的复位链条里;此刻你只需记住结论:全局变量的初值是 Flash 里的一份镜像,"开箱即用"是启动代码的功劳。
SRAM 里除了 .data 和 .bss,还住着栈(stack)和堆(heap)。栈从 SRAM 顶端往下生长,存函数局部变量、返回地址、中断现场;堆从 .bss 末尾往上生长,供 malloc 使用。两头相向而行,中间的空隙就是你的余量——栈撞上堆(或撞上 .bss)就是栈溢出,症状千奇百怪:变量莫名被改、-hardfault、跑着跑着复位。
三条工程纪律能避开绝大多数坑。其一,中断里不要定义大局部数组,ISR 栈深要按最坏嵌套估计;其二,嵌入式里慎用 malloc,碎片化会让 20 KB 的堆慢慢"碎"到无块可分,静态分配或内存池是更稳的选择;其三,链接脚本里的栈大小按需给足并用水位检查:填充一遍栈区(常见魔数 0xA5),运行一段时间后从尾部找最后被改写到的位置,就知道真实栈深。
/* 栈水位检查的最小实现:初始化时铺魔数,运行后从栈底向上找最后一个被改写的字 */ #define STACK_FILL 0xA5A5A5A5UL extern uint32_t _sstack; /* 栈底(链接脚本符号)*/ extern uint32_t _estack; /* 栈顶 */ uint32_t stack_watermark(void) { const uint32_t *p = &_sstack; while (p < &_estack && *p == STACK_FILL) p++; /* 跳过从未用到的区域 */ return (uint32_t)(&_estack - p) * 4; /* 已用栈深,单位字节 */ }
技巧一:把大表丢进 Flash。查表法在嵌入式里无处不在(正弦表、字库、校准曲线),只要声明成 const,它们就住在 Flash 里一分 RAM 不占。反过来,若发现 .data 异常大,多半是哪个"已初始化的大数组"被搬进了 RAM——改成 const 往往立省几 KB。
技巧二:看 .map 文件核对落位。链接器生成的 .map 文件列出了每个符号的地址与归属段。怀疑某个数组把 RAM 吃了、某段代码超了 Flash,在 .map 里搜符号名即可实锤,比任何猜测都快。这是嵌入式调试中性价比最高的习惯之一。
下一节我们解决另一个物理前提:这些存储器和外设要转起来,节拍从哪来?72 MHz 的时钟树、分频器与低功耗模式,是寄存器编程最后一块地基。