3. 常见泄漏场景分析:未释放的 malloc/calloc、事件循环中的累积泄漏、图片缓存泄漏

内存泄漏这东西,说白了就是「借了不还」。在嵌入式 GUI 里,资源本来就紧巴巴的,泄漏一点可能当时看不出来,跑个几天就崩了。我这些年排查过的泄漏,十有八九都逃不出下面这三类。咱们一个一个掰开看。

核心观点:嵌入式 GUI 的内存泄漏,90% 集中在动态分配未释放、循环累积、缓存失控这三个场景。抓住它们,就抓住了排查的主线。

3.1 未释放的 malloc / calloc

这是最基础、也最容易被忽视的一类。你想想看,调了 malloc 或者 calloc,用完之后忘了 free,这块内存就永远回不来了。在桌面程序里可能还能撑一阵,但在嵌入式环境里,几次泄漏就能把堆耗光。

我遇到过最典型的例子:一个按键弹窗的绘制函数,每次弹窗都 malloc 一块缓冲区来存位图数据,弹窗关闭时却忘了 free。用户按几次按键,内存就爆了。

// 错误示例:只分配,不释放
void show_popup(const char *text) {
    char *buf = (char *)malloc(256);
    if (buf) {
        snprintf(buf, 256, "Popup: %s", text);
        draw_text(buf);   // 绘制后忘记 free(buf)
    }
}

// 正确做法:谁分配,谁释放
void show_popup(const char *text) {
    char *buf = (char *)malloc(256);
    if (buf) {
        snprintf(buf, 256, "Popup: %s", text);
        draw_text(buf);
        free(buf);        // 释放
        buf = NULL;       // 置空,防止野指针
    }
}

我的习惯:每次写 malloc 的时候,我习惯先写 free 再写中间逻辑。这样就不会忘了。你可以试试这个「先写释放」的编码习惯。

还有一种情况更隐蔽——条件分支里的泄漏。比如函数中间提前 return 了,后面的 free 就没执行到。

// 条件分支泄漏
char *load_config(const char *path) {
    char *data = (char *)malloc(1024);
    if (!data) return NULL;

    FILE *fp = fopen(path, "r");
    if (!fp) {
        return NULL;   // 泄漏!data 没释放
    }
    // ... 读取数据
    fclose(fp);
    return data;       // 调用方负责释放
}

避坑指南:我曾经在一个项目里排查了整整两天,最后发现是一个 realloc 失败后,原指针没释放,导致内存泄漏。记住:realloc 返回 NULL 时,原内存块仍然存在,必须手动 free。

3.2 事件循环中的累积泄漏

GUI 程序的核心是事件循环。每次触屏、每次按键、每次定时器触发,都可能产生一次内存分配。如果这些分配在循环结束时没有清理干净,就会像水龙头没关紧一样——一滴一滴地漏,一天下来就是一大桶。

我见过一个血淋淋的例子:一个智能家居面板,每次触摸事件都会创建一个 EventData 结构体,用 malloc 分配,然后丢进消息队列。消息处理完后,处理函数忘了 free 这个结构体。设备运行 8 小时后,触摸完全失灵。

// 事件循环中的累积泄漏
void event_loop() {
    while (1) {
        Event *ev = get_event();        // 从队列取事件
        if (ev) {
            EventData *data = (EventData *)malloc(sizeof(EventData));
            data->type = ev->type;
            data->timestamp = get_tick();
            // 处理事件...
            process_event(data);
            // 忘记 free(data) —— 每次循环泄漏 sizeof(EventData) 字节
        }
    }
}

这种泄漏的特点是:单次泄漏量很小,但频率很高。你很难在短时间测试里发现它,必须用长时间压力测试才能暴露。

泄漏类型 单次泄漏量 泄漏频率 暴露时间
malloc 未释放 大(KB ~ MB) 低(几次操作) 几分钟
事件循环累积 小(几十 ~ 几百字节) 高(每秒多次) 几小时 ~ 几天
图片缓存泄漏 中等(几 KB ~ 几百 KB) 中(页面切换时) 几小时

排查技巧:对于事件循环泄漏,我建议在每次循环的入口和出口打印堆使用量。如果出口比入口多几个字节,那就是泄漏了。用 mallinfo() 或者自己维护一个分配计数器,很管用。

3.3 图片缓存泄漏

嵌入式 GUI 里,图片是内存消耗大户。一张 800x480 的 32 位 RGBA 位图,就要 1.5MB 左右。如果缓存管理不当,泄漏起来非常快。

常见的场景是这样的:页面切换时,新页面加载图片到缓存,旧页面释放缓存。但如果某个页面忘记释放,或者缓存替换策略有 bug,图片就会一直占用内存。

// 图片缓存泄漏示例
typedef struct {
    char name[32];
    uint8_t *data;
    uint32_t size;
} ImageCache;

ImageCache cache[10];  // 固定大小缓存

void load_image(const char *name) {
    // 查找空闲槽位
    for (int i = 0; i < 10; i++) {
        if (cache[i].data == NULL) {
            cache[i].data = (uint8_t *)malloc(image_size(name));
            // 加载图片数据...
            strncpy(cache[i].name, name, 32);
            return;
        }
    }
    // 缓存满了,但没有替换策略 —— 旧图片永远不会被释放
    // 新图片加载失败,旧图片占着内存不放
}

我遇到过更坑的:一个图片解码库,每次解码都 malloc 输出缓冲区,但解码失败时直接返回错误码,不释放已分配的缓冲区。调用方以为失败了就没数据,结果内存被悄悄吃掉了。

避坑指南:我曾经在一个项目里,图片缓存用了 LRU 算法,但实现时把「最近使用」和「最近加载」搞混了。结果最常用的图片反而被优先淘汰,每次都要重新加载和分配,内存碎片越来越严重。最后堆上全是小碎片,连一块完整的 1MB 都分配不出来。

图片缓存泄漏的另一个重灾区是缩略图生成。很多 GUI 框架会在后台生成缩略图,如果缩略图生成后没有正确关联到缓存生命周期,就会产生大量孤儿内存块。

// 缩略图泄漏
Thumbnail *create_thumbnail(const char *path) {
    Thumbnail *thumb = (Thumbnail *)malloc(sizeof(Thumbnail));
    thumb->data = decode_thumbnail(path);  // 内部 malloc
    // 如果 decode_thumbnail 失败,thumb 本身泄漏
    // 如果成功但后续插入缓存失败,thumb->data 泄漏
    return thumb;
}

3.4 三类泄漏的对比与排查思路

这三类泄漏,说白了就是三个不同的「漏点」:一次性忘记释放、高频小量累积、缓存管理失控。它们的排查方法也不一样。

  • malloc/calloc 未释放:用静态代码分析工具扫一遍,或者手动 review 每个 malloc 对应的 free 路径。重点关注条件分支和错误处理路径。
  • 事件循环累积:用压力测试 + 堆监控。让设备跑一晚上,每小时记录一次堆使用量。如果曲线一直往上走,就是累积泄漏。
  • 图片缓存泄漏:检查缓存的插入、淘汰、清空三个操作。重点看缓存满时的处理逻辑,以及页面销毁时是否释放了关联的图片内存。
三类内存泄漏排查流程图 发现内存泄漏 malloc/calloc 未释放 事件循环累积泄漏 图片缓存泄漏 排查方法 静态代码分析 review malloc/free 路径 排查方法 压力测试 + 堆监控 每小时记录堆使用量 排查方法 检查缓存插入/淘汰/清空 页面销毁时释放图片 工具:Valgrind / Cppcheck 工具:mallinfo / 自定义计数器 工具:缓存命中率统计 对症下药,分类排查