4、内存优化:堆栈管理、动态内存分配陷阱、内存池设计

嵌入式系统里,内存就那么多。你想想看,动不动就几KB、几十KB的RAM,要跑协议栈、要处理数据、还要响应中断。内存用不好,系统就崩给你看。

我个人习惯,做项目第一件事就是画内存地图。哪里放堆,哪里放栈,哪里留给全局变量,心里得有数。今天咱们就聊聊内存优化的三个核心话题:堆栈管理、动态内存分配的坑、还有内存池设计。

4.1 堆栈管理:别让栈溢出成为你的噩梦

栈这东西,说白了就是函数调用的临时仓库。局部变量、函数参数、返回地址,全往里面塞。栈的大小是固定的,你塞多了就溢出。

我在项目中遇到过一件事。一个同事调试了三天,系统总是随机死机。查到最后,是中断服务函数里定义了一个512字节的局部数组。主循环的栈本来就不大,中断一嵌套,栈直接爆了。嗯,这个问题其实很常见。

4.1.1 栈大小怎么定?

没有标准答案,但有经验法则:

  • 先估算最大调用深度。比如你的函数调用链有5层,每层局部变量加起来200字节,那至少得留1KB。
  • 中断嵌套要单独算。每个中断服务函数至少预留其局部变量总和的2倍。
  • 留余量。我一般按估算值的1.5倍来设。

关键点: 栈溢出不会立刻报错,它会悄悄覆盖其他变量的数据。你看到的现象可能是全局变量莫名其妙被改了,或者函数返回地址被篡改导致跑飞。

4.1.2 栈检测技巧

我常用的方法:在栈底填充一个特殊模式,比如0xDEADBEEF。运行一段时间后检查这个模式有没有被覆盖。代码很简单:

// 栈底填充
#define STACK_FILL_PATTERN 0xDEADBEEF

void stack_init(void) {
    uint32_t *stack_bottom = (uint32_t *)STACK_START_ADDR;
    for (int i = 0; i < STACK_SIZE / 4; i++) {
        stack_bottom[i] = STACK_FILL_PATTERN;
    }
}

// 检查栈使用情况
uint32_t stack_check(void) {
    uint32_t *ptr = (uint32_t *)STACK_START_ADDR;
    uint32_t used = 0;
    while (*ptr == STACK_FILL_PATTERN) {
        ptr++;
        used++;
    }
    return (STACK_SIZE / 4 - used) * 4; // 返回已用字节数
}

小技巧: 在RTOS里,每个任务都有自己的栈。我习惯在任务创建时把栈全部填充0xAA,然后定期检查。这样能及时发现哪个任务栈快不够了。

4.2 动态内存分配陷阱:malloc/free不是万能的

很多从PC端转过来的工程师,习惯用malloc和free。但在嵌入式里,这俩函数可能是定时炸弹。

为什么会这样?有三个原因:

  1. 碎片化:频繁分配释放,堆空间会碎成一片片。明明总空闲内存还有1KB,但最大连续块只有100字节,你申请200字节就失败。
  2. 不确定性:malloc的执行时间不固定。它要遍历空闲链表,找合适大小的块。实时系统里,这可能导致任务超时。
  3. 内存泄漏:忘了free,或者异常路径没释放。嵌入式系统一跑就是几个月,泄漏一点就慢慢耗光内存。

警告: 我曾经在一个项目中,用malloc分配了一个结构体,然后在某个错误处理分支里忘了free。系统跑了72小时后,内存耗尽,所有任务都卡在malloc上。排查了整整两天才找到问题。

4.2.1 什么时候可以用动态分配?

我的原则很简单:

  • 系统初始化阶段可以用。比如启动时分配一次,后面不再释放。
  • 确定性的分配可以用。比如每次分配固定大小的块。
  • 非实时任务可以用。比如后台日志处理,偶尔分配一次。

其他情况,我建议你三思。尤其是中断服务函数里,绝对不要用malloc。

4.2.2 替代方案

如果确实需要动态分配,我推荐两个方向:

  • 静态分配:提前算好最大需求,用全局数组或静态变量。
  • 内存池:这个我们下面详细讲。

4.3 内存池设计:高效、确定、无碎片

内存池,说白了就是提前切好一堆固定大小的内存块。你要用,直接拿一块;用完,还回去。没有碎片,没有不确定性。

我最早接触内存池是在一个网络协议栈项目里。数据包大小基本固定,用内存池管理,性能提升非常明显。

4.3.1 基本设计思路

一个简单的内存池包含:

  • 一块连续的内存区域
  • 一个空闲块链表
  • 分配和释放函数

代码实现其实不复杂:

typedef struct {
    void *next;      // 指向下一个空闲块
} mem_block_t;

typedef struct {
    void *pool_start;    // 内存池起始地址
    uint32_t block_size; // 每个块的大小
    uint32_t block_num;  // 块的数量
    mem_block_t *free_list; // 空闲链表头
} mem_pool_t;

// 初始化内存池
void mem_pool_init(mem_pool_t *pool, void *mem, uint32_t blk_size, uint32_t blk_num) {
    pool->pool_start = mem;
    pool->block_size = blk_size;
    pool->block_num = blk_num;
    pool->free_list = (mem_block_t *)mem;
    
    // 构建空闲链表
    mem_block_t *current = pool->free_list;
    for (uint32_t i = 0; i < blk_num - 1; i++) {
        current->next = (void *)((uint8_t *)current + blk_size);
        current = current->next;
    }
    current->next = NULL; // 最后一个块指向NULL
}

// 分配一个块
void *mem_pool_alloc(mem_pool_t *pool) {
    if (pool->free_list == NULL) {
        return NULL; // 没有空闲块了
    }
    mem_block_t *block = pool->free_list;
    pool->free_list = block->next;
    return (void *)block;
}

// 释放一个块
void mem_pool_free(mem_pool_t *pool, void *block) {
    mem_block_t *blk = (mem_block_t *)block;
    blk->next = pool->free_list;
    pool->free_list = blk;
}

注意: 这个实现里,每个块至少得能存放一个指针。如果块大小小于指针大小,需要做特殊处理。我一般要求块大小至少8字节(32位系统)或16字节(64位系统)。

4.3.2 多尺寸内存池

实际项目中,往往需要不同大小的块。比如协议栈里,有64字节的控制包,有256字节的数据包,还有1024字节的大包。

我的做法是创建多个内存池,每个池管理一种尺寸:

池名称 块大小 块数量 用途
pool_small 64字节 32 控制消息
pool_medium 256字节 16 数据包
pool_large 1024字节 4 大块数据

分配时,根据请求大小选择对应的池。如果请求大小超过最大块,可以返回失败,或者用多个块拼接(但我不推荐,太复杂)。

4.3.3 内存池的进阶技巧

用久了,我总结出几个实用技巧:

  • 对齐处理:块起始地址最好对齐到4字节或8字节。有些MCU的DMA要求地址对齐,不对齐会出问题。
  • 统计信息:在池结构体里加个used_count字段,分配时加1,释放时减1。调试时能知道峰值使用量。
  • 溢出检测:每个块前后加个哨兵值。释放时检查哨兵有没有被改写,能发现越界访问。

我的经验: 内存池的块数量怎么定?我一般先估算峰值并发量,然后乘以1.2。比如最多同时处理10个数据包,那就准备12个块。多出来的20%是安全余量。

4.4 总结一下

内存优化这事,没有银弹。栈管理靠估算和检测,动态分配要谨慎使用,内存池是嵌入式里的好帮手。

我个人最推荐的组合:

  • 栈:用静态分析工具估算大小,运行时加填充检测
  • 动态分配:只在初始化阶段用,或者完全不用
  • 内存池:作为主要的动态内存管理手段

你想想看,一个系统里,栈是确定的,内存池是确定的,全局变量也是确定的。整个内存使用都是可预测的,系统稳定性自然就上去了。

嗯,今天就聊到这里。下一章咱们讲讲中断和任务调度的优化,那也是嵌入式性能调优的重头戏。