第2章 单片机内存模型剖析:堆区与栈区的区别

做嵌入式开发,内存管理是绕不开的坎。我见过太多项目,功能调通了,跑几天就死机。一查,内存泄漏了。所以这一章,咱们把内存模型彻底讲透。

2.1 SRAM分区详解

单片机的SRAM,不是一整块随便用的。它被划分成几个区域,每个区域有各自的使命。我习惯把SRAM想象成一个仓库,不同货架放不同东西。

区域 存放内容 生命周期
.bss 未初始化的全局变量、静态变量 程序启动到结束
.data 已初始化的全局变量、静态变量 程序启动到结束
堆区 (Heap) 动态分配的内存 (malloc/new) 手动分配到手动释放
栈区 (Stack) 局部变量、函数参数、返回地址 函数调用到函数返回

嗯,这里要注意。.bss和.data在编译时就确定了大小。堆和栈是动态的,它们从两端向中间生长。一旦碰上了,就是堆栈溢出。我在项目中遇到过,一个传感器驱动里用了大数组做局部变量,结果栈把堆给吃了,系统随机崩溃。

避坑指南: 我曾经在STM32F103上调试一个Modbus协议栈,发现malloc返回NULL。查了半天,原来是链接脚本里堆大小只配了512字节。改到2KB后问题解决。记住,堆大小要在启动文件或链接脚本里显式配置。

2.2 堆区与栈区的核心区别

说白了,栈是系统自动管理的,堆是程序员手动管理的。你想想看,栈上的变量,函数一返回就自动销毁了。堆上的内存,你不free,它永远在那里。

  • 分配效率: 栈分配就是移动一下栈顶指针,一条指令的事。堆分配需要查找空闲链表、分割内存块,慢得多。
  • 碎片问题: 栈是连续分配连续释放,没有碎片。堆频繁分配释放会产生外部碎片,就像硬盘碎片一样。
  • 大小限制: 栈通常只有几KB(Cortex-M默认1-2KB)。堆可以大一些,但受总SRAM限制。
  • 线程安全: 每个任务有自己的栈,天然安全。堆是所有任务共享的,需要加锁保护。
个人经验: 我建议在单片机里尽量少用动态内存分配。能静态分配就静态分配。实在要用,用内存池代替malloc,能有效避免碎片。

2.3 FreeRTOS的内存管理机制

FreeRTOS提供了5种内存管理方案,分别叫heap_1到heap_5。我刚开始用的时候也懵,到底选哪个?

方案 特点 适用场景
heap_1 只能分配,不能释放。无碎片 任务、队列等只创建不删除
heap_2 可以释放,但合并相邻空闲块 分配释放大小固定的场景
heap_3 包装了标准malloc/free,依赖编译器 需要线程安全包装
heap_4 合并相邻空闲块,减少碎片 通用场景,推荐使用
heap_5 支持多个不连续内存区域 外部RAM+内部RAM混合使用

我个人习惯用heap_4。它在heap_2的基础上加了相邻空闲块合并,碎片控制得不错。有一次我在项目里用了heap_2,频繁创建删除小任务,结果内存越用越少。换成heap_4后,问题消失了。

// FreeRTOS heap_4 配置示例
// FreeRTOSConfig.h
#define configTOTAL_HEAP_SIZE    ((size_t)(20 * 1024))  // 20KB堆空间
#define configAPPLICATION_ALLOCATED_HEAP    0           // 使用内部数组

// 使用pvPortMalloc代替malloc
void *buffer = pvPortMalloc(256);
if (buffer != NULL) {
    // 使用内存
    vPortFree(buffer);  // 必须配对使用
}
关键点: FreeRTOS的任务栈是从堆里分配的。你配置的configTOTAL_HEAP_SIZE要同时满足任务栈和动态内存的需求。我一般预留30%的余量。

2.4 RT-Thread的内存管理机制

RT-Thread的内存管理比FreeRTOS更丰富。它提供了静态内存池和动态内存堆两种方式。静态内存池适合实时性要求高的场景,动态内存堆适合灵活性要求高的场景。

  • 静态内存池 (mempool): 预先划分固定大小的内存块,分配释放都是O(1)复杂度,无碎片。适合DMA缓冲区、网络数据包等。
  • 动态内存堆 (heap): 支持小内存分配,使用dlmalloc算法,内存利用率高。适合大小不固定的内存需求。
  • SLAB算法: 针对小对象分配优化,减少内部碎片。RT-Thread在动态堆里默认使用SLAB。

我记得在做一个物联网网关项目时,需要频繁分配不同大小的JSON数据包。用RT-Thread的动态堆,配合SLAB算法,内存碎片控制得很好。跑了一个月没出问题。

// RT-Thread 内存池使用示例
static rt_uint8_t mem_pool_buffer[1024];
static struct rt_mempool mp;

// 创建内存池,每个块64字节,共16块
rt_mp_init(&mp, "mp", &mem_pool_buffer, sizeof(mem_pool_buffer), 64);

// 分配内存块
void *block = rt_mp_alloc(&mp, RT_WAITING_FOREVER);
if (block != RT_NULL) {
    // 使用内存块
    rt_mp_free(block);  // 释放回内存池
}
注意: RT-Thread的动态内存堆默认使用rt_malloc/rt_free。在多线程环境下,这些函数内部已经加了锁保护。但内存池操作需要用户自己保证线程安全。

2.5 内存模型全景图

为了让你更直观地理解整个内存布局,我画了一张图。这张图展示了SRAM的完整分区和RTOS的内存管理层次。

单片机SRAM内存模型全景图 SRAM (静态随机存取存储器) .bss 段 未初始化全局变量 / 静态变量 (启动时清零) .data 段 已初始化全局变量 / 静态变量 (从Flash复制) 堆区 (Heap) 动态分配 (malloc/pvPortMalloc/rt_malloc) 向高地址增长 需手动释放,易产生碎片 栈区 (Stack) 局部变量 / 函数参数 / 返回地址 向低地址增长 自动分配释放,无碎片 ↑ 堆向上增长 ↓ 栈向下增长 ⚠ 堆栈碰撞 → 系统崩溃!

从这张图可以清楚看到,堆和栈从两端向中间生长。一旦它们相遇,系统就会崩溃。这就是为什么我强调要监控堆栈使用情况。

2.6 内存泄漏的根源

内存泄漏说白了就是:你借了东西不还。在单片机里,每次malloc都要对应一个free。少了一个,那块内存就永远找不回来了。

常见泄漏场景:
  • 函数内malloc,函数退出前忘记free
  • 中断服务程序里分配了内存,但没释放
  • 错误处理路径上漏了free
  • RTOS任务删除前没释放任务内部分配的内存

我有个习惯:每次写malloc,立刻写对应的free。就像括号配对一样,先写闭合再写内容。这样能减少90%的泄漏问题。

调试技巧: 在FreeRTOS里可以用uxTaskGetStackHighWaterMark()查看任务栈使用峰值。如果栈高水位接近0,说明栈快溢出了。RT-Thread里可以用rt_thread_control()获取线程栈信息。