3. 静态内存分配策略:全局变量、静态变量、常量区的布局优化
静态内存分配,说白了就是编译器在编译阶段就把地址定死了。程序跑起来之后,这些变量的地址不会变,也不会有动态申请释放的开销。很多刚入行的朋友觉得这没什么好调的,不就是定义几个全局变量吗?
嗯,我当年也这么想。直到有一次做一款工业控制器的项目,Flash和RAM都卡得死死的,就差那么几KB。那时候我才开始认真研究链接脚本,发现BSS段的碎片问题比我想象的严重得多。
3.1 静态存储区的三兄弟
嵌入式C程序的内存布局,静态区主要分三块:
| 区域 | 存放内容 | 生命周期 | 初始化行为 |
|---|---|---|---|
| .text (代码区) | 函数指令、字符串字面量 | 整个程序运行期 | 由加载器/启动代码拷贝到Flash或RAM |
| .rodata (只读数据区) | const常量、字符串常量 | 整个程序运行期 | 编译时确定,不可修改 |
| .data (已初始化数据区) | 已初始化的全局/静态变量 | 整个程序运行期 | 启动时从Flash拷贝到RAM |
| .bss (未初始化数据区) | 未初始化或初始化为0的全局/静态变量 | 整个程序运行期 | 启动时清零 |
关键认知:.data段占用两份空间——Flash里的初始值 + RAM里的运行副本。而.bss段只占RAM,启动时由C运行时库清零。很多优化空间就藏在这两个段的布局里。
3.2 链接脚本(.ld)的精细调优
链接脚本是嵌入式开发的「藏宝图」。我见过太多人直接用IDE生成的默认链接脚本,从来不看。其实这里面的门道很深。
先看一个典型的STM32链接脚本片段:
/* 默认的链接脚本布局 */
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS
{
.text : {
*(.text*)
*(.rodata*)
} > FLASH
.data : {
_sdata = .;
*(.data*)
_edata = .;
} > RAM AT> FLASH
.bss : {
_sbss = .;
*(.bss*)
_ebss = .;
} > RAM
}
这个布局有什么问题?我举个例子。假设你的.rodata段有大量查表数据(比如正弦表、CRC表),它们被放在了Flash里,没问题。但.data段里如果有一个很大的数组,比如1024字节的缓冲区,它既占Flash又占RAM,启动时还要拷贝一次。
我的优化习惯:把那些「只初始化一次、之后不再修改」的大数组,从.data挪到.rodata。怎么做?加一个自定义段:
// 在代码中声明
const uint8_t large_table[1024] __attribute__((section(".my_rodata"))) = { ... };
// 在链接脚本中
.my_rodata : {
*(.my_rodata*)
} > FLASH
这样省下了1024字节的RAM,而且启动时不需要拷贝。
3.3 减少BSS段碎片
BSS段的碎片问题,说白了就是变量对齐导致的空洞。Cortex-M系列要求4字节对齐访问,编译器会在变量之间插入填充字节。
举个例子:
// 糟糕的声明顺序
uint8_t flag_a; // 1字节
uint32_t counter; // 4字节,需要对齐到4字节边界
uint8_t flag_b; // 1字节
uint16_t temp; // 2字节,需要对齐到2字节边界
在BSS段中,实际布局是这样的:
地址 0x20000000: flag_a (1字节)
地址 0x20000001: 填充 (3字节) ← 浪费!
地址 0x20000004: counter (4字节)
地址 0x20000008: flag_b (1字节)
地址 0x20000009: 填充 (1字节) ← 浪费!
地址 0x2000000A: temp (2字节)
总共用了12字节,实际数据只有8字节,浪费了33%。
我曾经踩过的坑:在一个RTOS的任务控制块中,我把优先级(uint8_t)、栈指针(uint32_t)、消息队列指针(uint32_t)、状态(uint8_t)随意排列,结果每个TCB浪费了6字节。系统有32个任务,总共浪费192字节。在只有64KB RAM的芯片上,这可不是小数目。
优化方法很简单:按对齐要求从大到小排列变量。
// 优化后的声明顺序
uint32_t counter; // 4字节,放在最前面
uint16_t temp; // 2字节
uint8_t flag_a; // 1字节
uint8_t flag_b; // 1字节
现在布局变成:
地址 0x20000000: counter (4字节)
地址 0x20000004: temp (2字节)
地址 0x20000006: flag_a (1字节)
地址 0x20000007: flag_b (1字节)
只用了8字节,零浪费。
3.4 常量区的布局策略
常量区(.rodata)的优化,核心是「就近存放」和「按访问频率分组」。
我做过一个项目,Flash只有256KB,但需要存放中文字库、图标位图和配置参数。一开始我把所有const变量堆在一起,结果发现启动时访问频繁的配置参数被放在了Flash的高地址,每次读取都要多等几个周期(因为Flash的预取缓冲命中率下降)。
优化后的链接脚本:
SECTIONS
{
/* 高频访问的常量,放在Flash低地址 */
.rodata_fast : {
*(.rodata.config*) /* 配置参数 */
*(.rodata.lookup*) /* 查找表 */
} > FLASH
/* 低频访问的常量,放在Flash高地址 */
.rodata_slow : {
*(.rodata.font*) /* 字库 */
*(.rodata.bitmap*) /* 位图 */
} > FLASH
}
性能收益:高频常量放在Flash低地址后,CPU读取这些数据的平均等待周期从3个周期降到了1个周期。对于每秒执行数百万次查表的算法来说,这个优化效果很明显。
3.5 静态内存布局优化全景图
下面这张图总结了静态内存布局优化的核心思路:
3.6 实战检查清单
最后,我总结了一份检查清单,每次做静态内存优化时对照着看:
- 检查.data段:有没有大数组可以挪到.rodata?用const修饰后放到自定义段。
- 检查.bss段:变量声明顺序是否按对齐大小从大到小排列?用
__attribute__((aligned(4)))强制对齐。 - 检查.rodata段:高频访问的常量是否放在Flash低地址?用链接脚本分段存放。
- 检查链接脚本:是否有未使用的内存区域?MEMORY命令定义的区域是否合理?
- 检查启动代码:.data段的拷贝和.bss段的清零是否高效?可以用DMA加速拷贝。
一个小工具:用arm-none-eabi-objdump -t firmware.elf | sort可以查看所有符号的地址和大小,快速定位哪些变量占用了大量BSS空间。
静态内存优化看起来是「小打小闹」,每个优化点可能只省几个字节。但在资源受限的嵌入式系统中,积少成多,往往能省出几KB甚至几十KB的RAM。这些空间,足够你再加一个功能模块或者提升一下缓冲区大小了。