FreeRTOS 入门(四):堆的核心原理

目录

一、前言

大家好,我是 Hello_Embed 。上一篇我们吃透了 ARM 架构的底层逻辑,搞懂了寄存器和栈如何支撑函数调用 ------ 而这一篇,我们将聚焦 FreeRTOS 内存管理的 "半壁江山"------堆(Heap)

在 FreeRTOS 开发中,堆的作用至关重要:动态创建任务、队列、信号量时,都需要通过堆分配内存;甚至很多外设驱动的缓冲区,也依赖堆来灵活管理空间。这篇笔记会从最朴素的堆概念讲起,先实现一个极简的堆分配函数,再剖析它的缺陷,最终引出工业级堆管理的核心思路(链表式管理),帮大家彻底搞懂 "堆如何实现内存的分配与释放"。

二、堆的核心概念:可分配 / 释放的内存空间

提到 "堆",很多人会和上一篇讲的 "栈" 混淆 ------ 其实两者的核心区别很明确:

  • 栈:自动分配 / 释放,用于函数局部变量、参数传递,由 CPU 寄存器 SP 自动管理;
  • 堆:手动分配 / 释放,用于程序运行中需要灵活控制生命周期的内存(比如动态创建的任务结构体),需通过代码主动管理。

堆的本质,就是一块预留的空闲内存空间,我们可以通过代码向它 "申请" 一块内存(分配),不用时再 "归还" 给它(释放),实现内存的重复利用。

最朴素的堆,就是一个普通数组 ------ 比如 char heap_buf[1024],这 1024 字节的连续空间,就是我们的 "堆空间",后续的内存分配都围绕它展开。

三、简单堆分配实现:从数组到内存管理

我们先从最基础的代码入手,实现一个极简的堆分配函数my_malloc,通过它理解堆分配的核心逻辑。

3.1 基础堆分配函数(my_malloc)

c 复制代码
// 定义堆空间:1024字节的连续数组
char heap_buf[1024];
// 堆分配指针:记录当前已分配到的位置(初始为0,指向堆空间首地址)
int pos = 0;

/**
 * @brief  简单堆分配函数
 * @param  size:需要分配的内存字节数
 * @retval 分配成功:返回内存首地址;分配失败(超出堆空间):返回NULL(本实现暂未处理)
 */
void *my_malloc(int size)
{
    int old_pos = pos;  // 保存当前分配位置(后续作为内存首地址返回)
    pos += size;        // 分配size字节后,更新pos到新的空闲位置
    // 注意:实际应用需添加判断(pos <= 1024),避免堆溢出
    return &heap_buf[old_pos];  // 返回分配的内存首地址
}

这个函数的逻辑非常直白,相当于 "切蛋糕":

  1. 堆空间heap_buf是一整块 "蛋糕",pos是 "刀的位置",初始在蛋糕最开头(0 字节处);
  2. 申请size字节时,先记下当前刀的位置(old_pos),再把刀向后移size字节(pos += size);
  3. 返回old_pos对应的地址,就是分配给用户的 "蛋糕块"。

3.2 实际调试效果验证(结合内存截图)

我们在main函数中调用my_malloc分配 100 字节,并写入 'A'~'Z' 的字符,通过调试器观察内存变化:

c 复制代码
int main(void)
{
    int i;
    // 分配100字节内存,buf指向分配的内存首地址
    char *buf = my_malloc(100);
    // 向分配的内存中写入数据:buf[0]='A', buf[1]='B'...buf[25]='Z'
    for(i = 0; i < 26; i++)
        buf[i] = 'A' + i;  // 原笔记此处格式有误,修正为'A' + i
}

调试结果与内存分析:

  1. 堆空间分配大小:从调试器可见,分配的内存大小为 0x64(即 100 字节),与my_malloc(100)一致;

  2. 堆空间首地址与内容:heap_buf的首地址为 0x20000004,分配的 100 字节从该地址开始,前 26 字节依次存入 'A'~'Z',与代码逻辑完全匹配;

这说明我们的极简堆分配函数是有效的 ------ 它能成功分配内存并供用户使用。

3.3 简单实现的致命缺陷:无法释放内存

虽然my_malloc能分配内存,但它没有对应的 "释放函数"(比如my_free),核心原因有两个:

  1. 没有记录 "分配大小" :用户调用my_free(buf)时,只传入了内存首地址buf,但函数不知道这块内存占多大 ------ 无法确定要把pos回退多少字节;
  2. 内存碎片问题 :即使知道大小,直接回退pos也会导致 "内存空洞"。比如先分配 100 字节,再分配 100 字节,若释放第一个 100 字节,pos回退到 100 字节处,但第二个 100 字节仍占用 200~300 字节,此时申请 150 字节会因空间不连续而失败,造成内存浪费。

要解决这些问题,就需要更智能的堆管理方式 ------链表式堆管理

四、堆的释放难题:链表式管理方案

工业级堆管理的核心思路是:给每块分配的内存加一个 "头部",用链表串联所有空闲内存块------ 通过头部记录内存大小,通过链表管理空闲块的位置,实现精准分配与释放。

4.1 核心思路:用 "头部 + 链表" 记录内存信息

  • 头部(Head):在每块内存的开头,额外分配几个字节存储 "管理信息",核心是内存大小下一个空闲块的地址
  • 链表:把所有空闲的内存块用链表串联起来,分配时从链表中找合适的块,释放时把块重新加入链表。

这样一来,释放内存时,只需通过buf地址找到头部(buf减去头部大小),就能获取内存大小,再通过链表把空闲块连接起来,实现重复利用。

4.2 链表结构设计与初始状态

我们用结构体定义内存块的 "头部",并通过全局变量管理链表头:

c 复制代码
// 内存块头部结构体:存储管理信息
struct head
{
    int size;               // 该内存块的总大小(头部+用户数据)
    struct head next_free;  // 下一个空闲内存块的地址(注意:实际需定义为指针struct head*)
};

struct head g_heap;  // 链表头:管理所有空闲内存块

初始状态说明(堆空间为 1024 字节):

  1. 堆空间的第一个字节就是头部,头部的size设为 1024(整个堆的大小);
  2. 头部的next_free设为NULL(表示当前只有一块空闲内存);
  3. 全局链表头g_heap.next_free指向堆空间的头部 ------ 相当于链表的 "入口",所有分配操作都从这里开始。

4.3 堆分配与释放的链表变化(结合示意图)

场景 1:分配 100 字节内存
  1. 从链表头g_heap.next_free找到空闲块(大小 1024 字节);
  2. 从该块中拆分出 "头部(8 字节,假设 int 和指针各 4 字节)+100 字节用户数据",共 108 字节;
  3. 剩余内存大小为 1024-108=916 字节,作为新的空闲块,更新链表:g_heap.next_free指向新空闲块的头部;
  4. 返回用户数据的首地址(头部地址 + 8 字节)给用户。

分配后的内存布局与链表结构:

场景 2:再分配 50 字节内存

重复上述流程:从 916 字节的空闲块中拆分出 "头部 + 50 字节"(共 58 字节),剩余 916-58=858 字节,g_heap.next_free指向 858 字节的空闲块头部。

场景 3:释放之前分配的 100 字节内存
  1. 用户调用my_free(buf)buf是 100 字节用户数据的首地址;
  2. 计算头部地址:buf - 8(减去头部大小 8 字节),从头部中读取size=108(总大小);
  3. 把这块 108 字节的内存块重新加入空闲链表:让原空闲块的next_free指向该块,该块的next_free设为NULL
  4. 此时空闲链表中有两块空闲块:108 字节和 858 字节,后续分配时会优先从链表头开始查找合适的块。

释放后的链表结构:

核心优势

  • 分配时:通过链表遍历空闲块,找到能容纳size的块(按需拆分);
  • 释放时:通过头部获取大小,将块重新接入链表,实现内存重复利用;
  • 解决碎片:后续可通过 "内存合并" 优化(把相邻的空闲块合并成大块),减少碎片问题。

五、下一篇预告

本次我们彻底搞懂了堆的核心逻辑 ------ 从极简分配到链表式管理,而堆的 "孪生兄弟" 栈,在 FreeRTOS 中同样关键(每个任务都有独立的任务栈)。下一篇我们将聚焦:

  1. 堆与栈的核心区别(生长方向、管理方式、使用场景);
  2. FreeRTOS 任务栈的本质(为什么任务必须有独立栈);
  3. 任务栈的配置与溢出风险(栈大小如何确定)。

六、结尾

本篇笔记的核心,是从 "能用" 到 "好用" 拆解堆的实现 ------ 极简的my_malloc让我们理解了堆的本质是 "空闲空间的分配",而链表式管理则解决了 "释放与复用" 的工业级需求。

在 FreeRTOS 中,堆的应用非常直接:xTaskCreateDynamic(动态创建任务)、xQueueCreate(创建队列)等函数,本质都是调用 FreeRTOS 提供的堆管理接口(如pvPortMalloc)分配内存。理解堆的管理逻辑,不仅能帮我们合理配置堆大小,还能在出现 "内存分配失败" 时快速定位问题(比如堆空间不足、碎片过多)。

堆和栈是嵌入式内存管理的基石,也是 FreeRTOS 任务调度的核心依赖 ------ 搞懂它们,就等于掌握了 RTOS 内存管理的 "钥匙"。我是 Hello_Embed,下一篇我们将深入栈的世界,揭开 FreeRTOS 任务栈的神秘面纱,继续扎实推进 RTOS 的学习之旅!

相关推荐
先生沉默先2 小时前
NodeJs 学习日志(8):雪花算法生成唯一 ID
javascript·学习·node.js
烧冻鸡翅QAQ2 小时前
考研408笔记——数据结构
数据结构·笔记·考研
异步的告白3 小时前
C语言-数据结构-2-单链表程序-增删改查
c语言·开发语言·数据结构
lkbhua莱克瓦243 小时前
集合进阶6——TreeMap底层原理
java·开发语言·笔记·学习方法·hashmap
T***u3333 小时前
后端缓存技术学习,Redis实战案例
redis·学习·缓存
Gorgous—l4 小时前
数据结构算法学习:LeetCode热题100-图论篇(岛屿数量、腐烂的橘子、课程表、实现 Trie (前缀树))
数据结构·学习·算法
im_AMBER4 小时前
算法笔记 13 BFS | 图
笔记·学习·算法·广度优先
烤麻辣烫4 小时前
黑马程序员苍穹外卖(新手) DAY3
java·开发语言·spring boot·学习·intellij-idea
驯狼小羊羔4 小时前
学习随笔-hooks和mixins
前端·javascript·vue.js·学习·hooks·mixins