第三章 内存池与静态内存分配:避免动态内存碎片

说到HUD的内存管理,我第一个想聊的就是动态内存碎片问题。你想想看,HUD系统里图像数据、帧缓冲、字符纹理,哪个不是吃内存的大户?如果放任动态分配满天飞,系统跑个几小时就开始卡顿,甚至直接崩掉——我在项目里真遇到过这种事。

说白了,嵌入式系统最怕的就是内存碎片。动态分配就像在沙滩上挖坑,挖了填、填了挖,最后沙滩上全是小坑,大块数据就放不下了。HUD这种实时性要求极高的系统,绝对不能容忍这种情况。

3.1 固定大小内存池实现

我个人习惯的做法是:提前规划好所有内存需求。HUD里哪些数据是固定大小的?帧缓冲、字符位图、告警图标,这些都可以用固定大小内存池来管理。

来看一个我常用的实现:

/* 固定大小内存池结构 */
typedef struct {
    void *pool_start;      /* 内存池起始地址 */
    size_t block_size;     /* 每个块的大小 */
    uint32_t total_blocks; /* 总块数 */
    uint32_t free_list;    /* 空闲链表头索引 */
    uint8_t *bitmap;       /* 分配位图 */
} fix_pool_t;

/* 初始化内存池 */
int fix_pool_init(fix_pool_t *pool, void *mem, 
                  size_t blk_size, uint32_t blk_num) {
    pool->pool_start = mem;
    pool->block_size = blk_size;
    pool->total_blocks = blk_num;
    pool->free_list = 0;
    pool->bitmap = (uint8_t *)mem;
    
    /* 构建空闲链表 */
    uint8_t *ptr = (uint8_t *)mem + blk_num; /* 位图之后 */
    for (uint32_t i = 0; i < blk_num - 1; i++) {
        *(uint32_t *)(ptr + i * blk_size) = i + 1;
    }
    *(uint32_t *)(ptr + (blk_num - 1) * blk_size) = 0xFFFFFFFF;
    
    return 0;
}

/* 分配一个块 */
void *fix_pool_alloc(fix_pool_t *pool) {
    if (pool->free_list == 0xFFFFFFFF) return NULL;
    
    uint32_t idx = pool->free_list;
    uint8_t *base = (uint8_t *)pool->pool_start + pool->total_blocks;
    void *block = base + idx * pool->block_size;
    
    pool->free_list = *(uint32_t *)block;
    pool->bitmap[idx / 8] |= (1 << (idx % 8));
    
    return block;
}

/* 释放一个块 */
void fix_pool_free(fix_pool_t *pool, void *block) {
    uint8_t *base = (uint8_t *)pool->pool_start + pool->total_blocks;
    uint32_t idx = ((uint8_t *)block - base) / pool->block_size;
    
    *(uint32_t *)block = pool->free_list;
    pool->free_list = idx;
    pool->bitmap[idx / 8] &= ~(1 << (idx % 8));
}
我的经验:固定大小内存池的块大小怎么定?我一般取HUD中最常用的数据块大小,比如64字节、128字节、256字节。如果数据大小超过块大小,就拆分成多个块。这样分配和释放都是O(1)复杂度,实时性有保障。

3.2 伙伴算法在HUD中的应用

固定大小池有个局限——它只能处理单一尺寸。HUD里有些数据大小是变化的,比如动态生成的导航箭头、变长的文本字符串。这时候伙伴算法就派上用场了。

伙伴算法的核心思想:把内存按2的幂次分割。分配时找最小能满足需求的块,释放时检查相邻块是否空闲,是的话就合并。这样既能灵活分配,又能控制碎片。

我在HUD项目中是这样用的:

/* 伙伴算法内存管理器 */
#define MIN_BLOCK_SIZE  32    /* 最小块32字节 */
#define MAX_ORDER       10    /* 最大阶数:32 * 2^10 = 32KB */

typedef struct {
    void *base_addr;
    uint32_t total_size;
    list_t free_lists[MAX_ORDER + 1]; /* 每个阶的空闲链表 */
    uint32_t alloc_count;
} buddy_allocator_t;

/* 分配内存 */
void *buddy_alloc(buddy_allocator_t *alloc, size_t size) {
    /* 计算需要的阶数 */
    uint32_t order = 0;
    size_t req_size = MIN_BLOCK_SIZE;
    while (req_size < size && order < MAX_ORDER) {
        req_size <<= 1;
        order++;
    }
    
    /* 查找可用块 */
    uint32_t current = order;
    while (current <= MAX_ORDER) {
        if (!list_empty(&alloc->free_lists[current])) {
            break;
        }
        current++;
    }
    
    if (current > MAX_ORDER) return NULL;
    
    /* 从current阶取出块,如果大于需求则分割 */
    block_t *block = list_pop(&alloc->free_lists[current]);
    while (current > order) {
        current--;
        /* 分割:将块分成两个伙伴 */
        block_t *buddy = (block_t *)((uint8_t *)block + 
                                     (MIN_BLOCK_SIZE << current));
        buddy->order = current;
        list_add(&alloc->free_lists[current], buddy);
    }
    
    block->order = order;
    block->used = 1;
    alloc->alloc_count++;
    
    return (void *)((uint8_t *)block + sizeof(block_t));
}

关键点:伙伴算法在HUD里特别适合处理图像层数据。比如一个告警图标需要2KB,一个导航箭头需要512B,一个文本层需要4KB。这些大小都是2的幂次,伙伴算法分配起来几乎零碎片。

注意:伙伴算法有个坑——内部碎片。比如你只需要100字节,但最小块是32字节,实际分配128字节,浪费了28字节。我在项目中会统计每种数据的大小分布,合理设置最小块大小,把内部碎片控制在5%以内。

3.3 栈空间预分配策略

嗯,这里要重点说说栈。HUD系统里任务多、中断多,栈溢出是常见问题。我见过一个案例:某个HUD在高温环境下运行,栈空间因为函数调用深度变化而溢出,导致图像撕裂——这问题排查了整整两周。

我的策略很简单:静态分析 + 动态监测 + 冗余预留

先看静态分析:

/* 栈使用分析工具(伪代码) */
typedef struct {
    const char *task_name;
    uint32_t stack_size;      /* 配置的栈大小 */
    uint32_t peak_usage;      /* 峰值使用量 */
    uint32_t reserved;        /* 预留空间 */
    uint8_t overflow_flag;    /* 溢出标志 */
} stack_info_t;

/* 栈空间预分配表 */
static stack_info_t hud_stack_table[] = {
    {"display_task",   4096, 0, 1024, 0},  /* 显示任务 */
    {"graphics_task",  8192, 0, 2048, 0},  /* 图形渲染 */
    {"input_task",     2048, 0, 512,  0},  /* 输入处理 */
    {"sensor_task",    1024, 0, 256,  0},  /* 传感器采集 */
    {"comm_task",      2048, 0, 512,  0},  /* 通信任务 */
};

动态监测怎么做?我习惯在栈底填充一个特殊模式(比如0xDEADBEEF),然后定时检查这个模式是否被覆盖:

/* 栈溢出检测 */
void stack_watermark_check(stack_info_t *info) {
    uint32_t *stack_bottom = (uint32_t *)get_stack_base(info->task_name);
    uint32_t pattern = 0xDEADBEEF;
    uint32_t count = 0;
    
    /* 从栈底向上扫描,统计未被覆盖的字节数 */
    while (*stack_bottom == pattern) {
        count += 4;
        stack_bottom++;
        if (count > info->reserved) break;
    }
    
    uint32_t used = info->stack_size - count;
    if (used > info->peak_usage) {
        info->peak_usage = used;
    }
    
    /* 如果预留空间被侵蚀,触发告警 */
    if (count < info->reserved) {
        info->overflow_flag = 1;
        /* 触发系统告警或记录日志 */
    }
}
我的习惯:栈空间预留至少25%的余量。比如分析出峰值使用3KB,我就分配4KB。别觉得浪费,HUD系统里一个栈溢出导致的故障,代价远大于那1KB内存。我曾经因为省了512字节的栈空间,结果在路测时频繁死机,教训深刻。

3.4 三种策略的对比与选择

这三种方法各有适用场景。我整理了一个对比表,方便你选型:

策略 适用场景 碎片控制 分配速度 灵活性
固定大小内存池 帧缓冲、字符位图、图标 零碎片 O(1)
伙伴算法 图像层、动态文本 低碎片 O(log N)
栈预分配 任务栈、中断栈 无碎片 编译时确定

实际项目中,我通常三种混用:

  • 固定大小池:管理所有固定尺寸的数据,比如16x16图标、32x32字符
  • 伙伴算法:处理变长数据,比如动态生成的导航路径
  • 栈预分配:每个任务独立栈空间,加上水印检测

这样组合下来,HUD系统的内存碎片率基本能控制在1%以下。我上一个项目跑了连续72小时压力测试,内存分配/释放循环了上百万次,没有出现一次分配失败。

总结一句话:HUD的内存管理,核心就是「提前规划、静态为主、动态为辅」。别等到运行时再去malloc/free,那是在给自己挖坑。我见过太多项目因为动态内存问题返工,真的不值得。

下一章我会讲HUD的实时调度与任务优先级设计,到时候聊聊怎么让图像渲染和传感器采集和谐共处。嗯,那个话题更有意思。