4、内存管理:静态内存池、动态内存堆管理、内存碎片问题、内存泄漏检测、内存保护单元(MPU)配置

内存管理,说实在的,是嵌入式系统里最容易出幺蛾子的地方。我见过太多项目,功能跑得挺好,一跑几天就挂,最后查下来都是内存问题。RT-Thread 在这方面给了我们不少工具,但工具再好,也得会用才行。

4.1 静态内存池:稳,但不够灵活

静态内存池,说白了就是提前划好一块地,按固定大小切块。你申请,我给你一块;你释放,我还给你。没有碎片,速度极快。

什么时候用? 我个人的习惯是:中断服务程序里、实时性要求极高的任务里,必须用静态内存池。因为它的分配时间是确定的,不会因为内存碎片而变慢。

核心优势: 无碎片、分配时间恒定、适合实时场景。

RT-Thread 里创建内存池很简单:

/* 定义一个内存池控制块 */
static struct rt_mempool mp;

/* 内存池的静态内存空间 */
static rt_uint8_t mem_pool[1024];

/* 初始化内存池,每个块32字节,共32个块 */
rt_mp_init(&mp, "mp", mem_pool, sizeof(mem_pool), 32);

分配和释放:

void *ptr = rt_mp_alloc(&mp, RT_WAITING_FOREVER);
/* 使用内存... */
rt_mp_free(ptr);

嗯,这里要注意:内存池的块大小是固定的。如果你申请的大小超过块大小,那就申请失败了。所以你得提前规划好,到底需要多大的块。

我的经验: 我一般会准备2-3个不同块大小的内存池。比如一个64字节的给小数据包,一个256字节的给中等数据,一个1024字节的给大块数据。这样既灵活,又避免了浪费。

4.2 动态内存堆管理:灵活,但得小心

动态内存堆,就是 malloc/free 那一套。RT-Thread 支持多种堆管理算法,默认用的是 dlmalloc 的变种。它会把空闲块用链表串起来,分配时找合适的块,释放时合并相邻的空闲块。

为什么会造成碎片?你想想看,你申请了A、B、C三个块,然后释放了B。这时候B那块就空出来了。但如果你再申请一个比B大的块,它就用不上B那块,只能去后面找更大的空间。久而久之,内存里就全是这种小空洞——这就是碎片。

RT-Thread 的动态内存分配接口:

void *ptr = rt_malloc(128);   /* 分配128字节 */
if (ptr != RT_NULL) {
    /* 使用内存 */
    rt_free(ptr);             /* 释放 */
}

警告: 千万不要在中断里调用 rt_malloc!因为 malloc 可能触发内存分配算法,这个算法不是可重入的,而且可能耗时较长。我在项目中遇到过有人这么干,结果系统随机死机,查了三天才找到原因。

4.3 内存碎片问题:看不见的杀手

内存碎片,说白了就是内存被切得七零八落,虽然总空闲空间够,但连续的大块空间没有了。系统就会分配失败。

怎么避免?

  • 尽量用静态内存池:对于固定大小的数据结构,用内存池代替堆分配。
  • 避免频繁分配释放:如果某个任务需要反复申请释放同一大小的内存,考虑用内存池。
  • 使用 slab 分配器:RT-Thread 支持 slab 算法,它对小对象的碎片控制比 dlmalloc 好。

我曾经在一个网络协议栈项目里,就因为频繁分配释放小数据包,导致系统运行一周后内存分配失败。后来把所有数据包都改成从内存池分配,问题就解决了。

经验法则: 如果系统需要连续运行超过24小时,建议对动态内存分配做压力测试。跑个几天看看内存使用量是否持续增长。

4.4 内存泄漏检测:别让内存悄悄溜走

内存泄漏,就是申请了内存没释放。每次泄漏一点点,系统最终会耗尽内存。RT-Thread 提供了内存泄漏检测的钩子函数。

开启方法:

/* 在 rtconfig.h 中定义 */
#define RT_USING_MEMTRACE

/* 然后可以注册钩子 */
void my_malloc_hook(void *ptr, size_t size) {
    /* 记录分配信息 */
}
void my_free_hook(void *ptr) {
    /* 记录释放信息 */
}
rt_malloc_sethook(my_malloc_hook);
rt_free_sethook(my_free_hook);

更实用的方法:用 list_mem 命令查看当前内存使用情况。在 FinSH 控制台输入:

list_mem

它会显示总内存、已用内存、最大已用内存。如果已用内存持续增长,那基本就是泄漏了。

我的调试技巧: 我会在每次分配时记录调用栈,释放时清除记录。如果系统运行一段时间后,发现某些地址没有被释放,就能定位到是哪个函数泄漏的。RT-Thread 的 memtrace 功能可以做到这一点。

4.5 内存保护单元(MPU)配置:给内存上把锁

MPU,说白了就是硬件级别的内存访问控制。你可以设置哪些区域可读、可写、可执行。一旦有非法访问,CPU 会触发异常,系统就能及时处理,而不是静默地跑飞。

RT-Thread 支持 MPU 配置,通常用在 安全关键系统 里。比如:

  • 把任务栈区域设置为不可执行,防止栈溢出攻击。
  • 把关键数据区设置为只读,防止意外修改。
  • 把外设寄存器区域设置为强序访问,防止编译器优化导致的问题。

配置示例(基于 ARM Cortex-M 系列):

/* 定义 MPU 区域 */
MPU_Region_InitTypeDef MPU_InitStruct;

/* 配置区域0:代码区,可读可执行 */
MPU_InitStruct.Enable = MPU_REGION_ENABLE;
MPU_InitStruct.BaseAddress = 0x08000000;
MPU_InitStruct.Size = MPU_REGION_SIZE_512KB;
MPU_InitStruct.AccessPermission = MPU_REGION_PRIV_RO_URO;
MPU_InitStruct.TypeExtField = MPU_TEX_LEVEL0;
MPU_InitStruct.IsShareable = MPU_ACCESS_NOT_SHAREABLE;
MPU_InitStruct.IsCacheable = MPU_ACCESS_CACHEABLE;
MPU_InitStruct.IsBufferable = MPU_ACCESS_NOT_BUFFERABLE;
HAL_MPU_ConfigRegion(&MPU_InitStruct);

/* 配置区域1:SRAM数据区,可读可写,不可执行 */
MPU_InitStruct.BaseAddress = 0x20000000;
MPU_InitStruct.Size = MPU_REGION_SIZE_128KB;
MPU_InitStruct.AccessPermission = MPU_REGION_PRIV_RW_URO;
MPU_InitStruct.IsExecutable = MPU_REGION_NOT_EXECUTABLE;
HAL_MPU_ConfigRegion(&MPU_InitStruct);

/* 使能 MPU */
HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT);

注意: MPU 配置不当会导致系统无法启动。我曾经在配置时把栈区域设成了不可读写,结果系统一启动就进 HardFault。所以建议先在一个测试项目里验证 MPU 配置,再移植到正式项目。

RT-Thread 里,MPU 通常和 线程保护 结合使用。比如每个线程有自己的 MPU 区域,防止线程间互相踩内存。这个在 功能安全 项目里几乎是标配。

总结一下: 内存管理没有银弹。静态内存池保实时,动态内存堆保灵活,MPU 保安全。根据你的项目需求,选择合适的组合。我个人建议:能静态分配就别动态分配,能用内存池就别用堆,能用 MPU 就别裸奔。

好了,这一章就到这里。下一章我们聊聊任务调度与优先级反转的问题,那个也是坑不少。