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 静态内存布局优化全景图

下面这张图总结了静态内存布局优化的核心思路:

静态内存布局优化全景图 静态内存布局优化全景图 优化前(碎片严重) .text + .rodata (混合存放) Flash占用: 高, 访问效率: 低 .data (大数组占RAM+Flash) RAM浪费: 高, 启动拷贝开销大 .bss (变量顺序混乱) flag_a (1B) | 填充(3B) | counter (4B) flag_b (1B) | 填充(1B) | temp (2B) 浪费: 4字节 (33%) 优化后(零碎片) .rodata_fast (高频常量) Flash低地址, 1周期访问 .rodata_slow (低频常量) Flash高地址, 大块数据 .data (仅保留必要变量) 大数组移至.rodata, RAM节省1024B .bss (按对齐大小排序) counter (4B) | temp (2B) flag_a (1B) | flag_b (1B) 零浪费 核心原则:按访问频率分组常量,按对齐大小排序变量,大数组移至只读区 链接脚本精细调优 + 代码声明优化 = 零碎片、高性能的静态内存布局

3.6 实战检查清单

最后,我总结了一份检查清单,每次做静态内存优化时对照着看:

  1. 检查.data段:有没有大数组可以挪到.rodata?用const修饰后放到自定义段。
  2. 检查.bss段:变量声明顺序是否按对齐大小从大到小排列?用__attribute__((aligned(4)))强制对齐。
  3. 检查.rodata段:高频访问的常量是否放在Flash低地址?用链接脚本分段存放。
  4. 检查链接脚本:是否有未使用的内存区域?MEMORY命令定义的区域是否合理?
  5. 检查启动代码:.data段的拷贝和.bss段的清零是否高效?可以用DMA加速拷贝。

一个小工具:用arm-none-eabi-objdump -t firmware.elf | sort可以查看所有符号的地址和大小,快速定位哪些变量占用了大量BSS空间。

静态内存优化看起来是「小打小闹」,每个优化点可能只省几个字节。但在资源受限的嵌入式系统中,积少成多,往往能省出几KB甚至几十KB的RAM。这些空间,足够你再加一个功能模块或者提升一下缓冲区大小了。