内存布局与链接脚本

MCU 的内存优化第一步不是改代码,而是先搞清楚内存到底被谁占用。芯片手册里的 RAM 容量只是一个数字,真正决定可用量的是链接脚本(Linker Script)如何划分区域,以及编译器把代码和数据放到了哪里。

典型的 Cortex-M 工程把 Flash 划分为只读区,放代码和只读常量;把 RAM 划分为数据区,放已初始化的全局变量、未初始化的 BSS 段,以及从 RAM 顶端向下生长的栈。栈顶符号 _estack 通常指向 RAM 的最高地址,栈向下增长,堆则从数据段末尾向上增长,两者在中间相遇。

/* 链接脚本内存区域划分示例 */
MEMORY
{
  FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 512K
  RAM   (xrw) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS
{
  .text : { *(.text*) } > FLASH
  .data : { *(.data*) } > RAM AT> FLASH
  .bss  : { *(.bss*)  } > RAM
  _estack = ORIGIN(RAM) + LENGTH(RAM);
}
本文核心知识点
  • 布局链接脚本决定 Flash 与 RAM 的分配。
  • 栈分析水位检测与金丝雀定位溢出。
  • 内存池固定块分配消灭碎片与抖动。
  • 追踪malloc 钩子与用量统计。

编译完成后,用编译器生成的映射文件(.map)查看每个段的大小最直接:.text 有多大、.data 占多少、.bss 又占多少,一目了然。优化内存通常从最大的那几块入手,而不是盯着零碎的几百字节。

嵌入式内存优化配图:深蓝渐变背景上发光芯片周围悬浮半透明内存方块与层叠堆栈柱,顶部抽象光束与标尺轮廓的扁平科技插画
把 RAM 想象成一间储物间:布局清楚,才知道哪里的空间被浪费了。

栈的边界与估算

栈溢出是嵌入式系统最隐蔽的故障之一:它不会立刻报错,而是悄悄覆盖相邻变量,让系统在某个随机时刻行为异常。栈向下生长,一旦越过预设边界,最先被破坏的就是它下面的数据区或堆。

  • 主栈:系统初始化、中断处理使用,大小通常几 KB。
  • 任务栈:RTOS 每个任务独立分配,根据函数调用深度与局部变量估算。
  • 中断嵌套:每层中断都会消耗栈空间,嵌套越深开销越大。
  • 递归:嵌入式开发应尽量避免递归,每一次调用都叠一层栈。

估算任务栈有个实用办法:用特定模式填充栈区(比如 0xA5),运行一段时间后扫描,看栈区还有多少字节保持原样,这个值就是栈的余量(Watermark)。余量建议留出 30% 以上,因为最坏路径通常不是平时能踩到的。

堆与内存池实战

裸机上的 malloc/free 看似方便,在长期运行的设备上却容易埋雷:堆碎片让分配越来越慢,反复的 malloc/free 造成性能抖动,分配失败时还没有统一处理路径。对实时性敏感的系统,内存池(Memory Pool)是更稳的选择。

// 固定块内存池:预先划分,按块分配
#define POOL_BLOCKS 16
#define POOL_BLOCK_SIZE 64
static uint8_t pool_mem[POOL_BLOCKS][POOL_BLOCK_SIZE];
static uint8_t pool_used[POOL_BLOCKS];

void *pool_alloc(void) {
  for (int i = 0; i < POOL_BLOCKS; i++) {
    if (!pool_used[i]) { pool_used[i] = 1; return pool_mem[i]; }
  }
  return NULL;  // 池满,调用方自行处理
}
void pool_free(void *p) {
  uint8_t *b = (uint8_t *)p;
  int idx = (int)((b - (uint8_t *)&pool_mem[0]) / POOL_BLOCK_SIZE);
  if (idx >= 0 && idx < POOL_BLOCKS) pool_used[idx] = 0;
}

内存池的块大小要按业务中最常见的报文尺寸设计:一个块装一条消息、一个帧或一条记录。块大小固定,就不存在碎片问题,分配释放都是 O(1)。如果需要多种尺寸,可以准备两级池,而不是退回裸 malloc。

栈溢出检测与工具

除了水位法,硬件与编译期的手段更可靠。带 MPU 的芯片可以把栈区划成受保护区域,访问越界立刻触发异常;编译器的栈使用分析(-fstack-usage)能在编译期列出每个函数的最大栈消耗,帮你算出理论最坏深度。

// 任务栈水位检查(FreeRTOS 示例)
configCHECK_FOR_STACK_OVERFLOW = 2;  // 编译期钩子

void vApplicationStackOverflowHook(TaskHandle_t xTask,
                                   char *pcTaskName) {
  // 记录出错任务名与现场,进入安全状态
  log_fault(pcTaskName);
  NVIC_SystemReset();  // 或者降级运行
}

金丝雀(Canary)是另一种轻量方案:在栈底写一个固定值,周期性检查它是否被改写。中断服务程序里用一个大数组做局部变量、在中断里调用耗栈的函数,都是栈溢出的高发地带,检查时优先排查这些路径。