3. 动态内存分配:malloc/free的引入,堆空间的概念,RTOS中的动态分配需求

好,咱们继续往下走。前面两章我们把静态内存分配讲透了,说白了就是编译时就把一切安排得明明白白。但现实世界哪有这么理想?你写一个嵌入式应用,永远不知道用户会创建多少个任务、多少个队列。这时候,静态分配就有点力不从心了。

我记得刚入行那会儿,做一个物联网网关项目,任务数量完全取决于外接传感器的个数。用静态数组?要么浪费内存,要么不够用。那叫一个难受。所以,动态内存分配就登场了。

3.1 为什么需要动态分配?

先问个问题:你写一个函数,需要一块缓冲区,但大小要到运行时才知道。怎么办?

静态分配做不到。你只能在栈上声明一个最大尺寸的数组,比如 char buf[1024]。但大多数时候,实际只需要 100 字节。剩下的 924 字节就这么白白浪费了。在 64KB RAM 的 MCU 上,这种浪费是致命的。

动态分配的核心思想就是:按需分配,用完归还。你需要多大,我就给你多大。你不需要了,我就收回来给别人用。

核心概念:动态内存分配允许程序在运行时向操作系统申请任意大小的内存块,使用完毕后主动释放。这是现代软件灵活性的基石。

3.2 malloc/free 的引入

C 标准库提供了两个最经典的函数:mallocfree

  • malloc(size_t size):从堆中分配一块大小为 size 字节的连续内存,返回指向该内存的指针。如果分配失败,返回 NULL。
  • free(void *ptr):释放之前由 malloc 分配的内存块,归还给堆。

用法很简单,但坑很多。我给大家看个典型例子:

// 动态分配一个结构体
typedef struct {
    uint8_t id;
    uint32_t data;
    char name[32];
} sensor_t;

sensor_t *p_sensor = (sensor_t *)malloc(sizeof(sensor_t));
if (p_sensor == NULL) {
    // 处理分配失败
    error_handler();
    return;
}

// 使用...
p_sensor->id = 1;
p_sensor->data = 0x12345678;
strcpy(p_sensor->name, "temperature");

// 使用完毕,释放
free(p_sensor);
p_sensor = NULL;  // 避免野指针

嗯,这里要注意:malloc 返回的指针一定要检查是否为 NULL。我在项目中见过太多人直接拿来就用,结果系统跑着跑着就挂了。嵌入式环境内存本来就紧张,分配失败是常态,不是异常。

警告:malloc/free 不是线程安全的!在多任务 RTOS 环境中,如果多个任务同时调用 malloc/free,会导致堆管理数据结构损坏。这是初学者最容易踩的坑。

3.3 堆空间的概念

malloc 分配的内存从哪里来?从堆(Heap)来。

堆是 RAM 中的一块连续区域,专门用于动态内存分配。它位于全局变量区(BSS/Data)和栈之间。在典型的 MCU 内存布局中,大概是这样的:

+------------------+  <-- 高地址
|      栈          |
|     (向下增长)    |
+------------------+
|      空闲区域     |
+------------------+
|      堆          |
|     (向上增长)    |
+------------------+
|  全局变量(BSS)   |
+------------------+
|  全局变量(Data)  |
+------------------+
|      代码        |
+------------------+  <-- 低地址

堆的大小通常在链接脚本中定义。比如在 STM32 的 GCC 链接脚本中,你会看到类似这样的定义:

_Min_Heap_Size = 0x200;  /* 最小堆大小:512字节 */

我个人习惯把堆大小设置为总 RAM 的 1/4 到 1/3。太小了不够用,太大了会挤压栈空间。你想想看,栈溢出比堆溢出更难排查,因为栈溢出往往不会立即崩溃,而是莫名其妙地改写了某个全局变量。

3.4 RTOS 中的动态分配需求

在 RTOS 内核里,动态分配的需求无处不在。我列几个典型场景:

场景 说明 分配时机
任务创建 每个任务需要独立的栈空间和 TCB(任务控制块) 运行时按需创建
消息队列 队列的缓冲区大小由用户指定 创建队列时分配
信号量/互斥量 内核对象需要分配内存 创建时分配
内存池 固定大小的内存块,用于减少碎片 系统初始化时分配
动态字符串 任务名称、设备路径等变长数据 运行时分配

但是,RTOS 内核里直接用标准库的 malloc/free 是不行的。原因有三:

  1. 不可重入:标准 malloc 不是线程安全的。两个任务同时调用,堆管理链表就乱了。
  2. 时间不确定:标准 malloc 的时间复杂度是 O(n),最坏情况下要遍历整个空闲链表。这在实时系统中是灾难。
  3. 内存碎片:频繁分配释放小内存块,会导致堆碎片化。最终明明有足够的总空闲内存,却分配不出一块连续的大内存。

经验之谈:我曾经在一个项目中,因为频繁创建和销毁任务,导致堆碎片化严重。系统跑了 72 小时后,创建新任务总是失败。排查了两天才找到原因。从那以后,我坚持在 RTOS 中使用固定大小的内存池来管理动态对象。

3.5 RTOS 内核的解决方案

既然标准 malloc 不能用,那 RTOS 内核怎么办?

主流 RTOS 都提供了自己的动态内存管理方案。比如:

  • FreeRTOS:提供了 pvPortMallocvPortFree,支持多种堆管理算法(heap_1 到 heap_5)。
  • uC/OS-III:提供了 OSMemGetOSMemPut,基于固定大小的内存分区。
  • RT-Thread:提供了 rt_mallocrt_free,实现了 slab 分配器。

这些方案的核心思想是:在分配时加锁,保证线程安全;使用更高效的算法,保证实时性;通过内存池或伙伴系统,减少碎片

说白了,就是要在「灵活」和「可靠」之间找到平衡点。我们后面几章会手写一个轻量级的堆管理器,到时候你就知道这些算法是怎么工作的了。

好,这一章我们理清了动态分配的必要性、堆的概念,以及 RTOS 中的特殊需求。下一章,我们就开始动手实现一个最简单的堆管理器——从固定大小内存池开始。