15、指针与动态内存分配:malloc/calloc/realloc/free、动态数组的实现、内存泄漏的避免

动态内存分配,说白了就是让程序在运行的时候,跟操作系统“要”一块内存来用。这跟咱们平时定义变量不一样——变量的大小是编译时就定死的,而动态分配的内存,大小可以在运行时才决定。

我刚开始学C语言那会儿,总觉得静态数组够用了。直到有一次做嵌入式项目,需要处理不同长度的数据包,才真正体会到动态分配的威力。嗯,咱们今天就把这块彻底讲透。

15.1 为什么需要动态内存分配?

你想想看,写程序的时候,很多时候你根本不知道用户会输入多少数据。比如一个文本编辑器,用户可能打开一个1KB的文件,也可能打开一个1GB的文件。如果用静态数组,你只能按最大可能去申请——这太浪费了。

动态内存分配解决了三个核心问题:

  • 大小不确定:运行时才知道需要多少内存
  • 生命周期灵活:可以手动控制内存的创建和销毁
  • 内存复用:用完就释放,给别的模块用

在嵌入式系统里,内存是稀缺资源。我见过不少同事因为滥用动态分配,导致系统跑着跑着就崩了。所以,掌握好这块,是每个C程序员的必修课。

15.2 四大内存管理函数

C标准库提供了四个核心函数,都在 <stdlib.h> 里。咱们一个一个说。

15.2.1 malloc —— 最常用的分配方式

malloc 的全称是 memory allocation。它的原型很简单:

void* malloc(size_t size);

它分配 size 个字节的连续内存,返回一个指向这块内存的指针。注意,它不会初始化这块内存——里面的数据是随机的。

我个人习惯,拿到 malloc 返回的指针后,第一时间用 memset 清零,或者直接用 calloc。为什么?因为我在项目中遇到过,读取未初始化的内存,导致程序行为随机——这种bug最难查。

核心用法示例:

int *p = (int*)malloc(10 * sizeof(int));
if (p == NULL) {
    // 处理内存分配失败
    fprintf(stderr, "内存分配失败\n");
    exit(1);
}
// 使用内存...
free(p);  // 别忘了释放

15.2.2 calloc —— 带清零的分配

calloc 和 malloc 的区别在于两点:

  • 它会自动把分配的内存全部清零
  • 它接受两个参数:元素个数和每个元素的大小
void* calloc(size_t num, size_t size);

比如分配10个int:

int *p = (int*)calloc(10, sizeof(int));
// 此时 p[0] 到 p[9] 全部是 0

我建议:如果你需要初始化为0的场景,直接用 calloc。它比 malloc + memset 更安全,而且有些实现上 calloc 还能利用操作系统的零页优化,性能反而更好。

15.2.3 realloc —— 调整已分配内存的大小

这个函数很实用。比如你一开始分配了100个字节,后来发现不够用,想扩展到200个字节。这时候就用 realloc

void* realloc(void* ptr, size_t new_size);

它的行为有点复杂,我总结一下:

  • 如果 ptr 是 NULL,等价于 malloc
  • 如果 new_size 是 0,等价于 free
  • 如果扩展内存,原有数据保持不变,新增部分不初始化
  • 如果缩小内存,尾部数据被丢弃
  • 如果无法在原地扩展,会分配新内存,拷贝旧数据,然后释放旧内存

重要陷阱:千万不要这样写:

p = (int*)realloc(p, new_size * sizeof(int));  // 危险!

如果 realloc 失败,返回 NULL,原来的 p 就丢了——内存泄漏!

正确做法:

int *new_p = (int*)realloc(p, new_size * sizeof(int));
if (new_p == NULL) {
    // 处理失败,但 p 仍然有效
    free(p);
    exit(1);
}
p = new_p;

15.2.4 free —— 释放内存

有借有还,再借不难。动态分配的内存,用完之后必须用 free 归还给系统。

void free(void* ptr);

关于 free,有几点必须记住:

  • 只能 free 动态分配的内存(malloc/calloc/realloc 返回的)
  • 不能 free 同一块内存两次(double free 是未定义行为)
  • free 之后,指针变成野指针,建议立即置为 NULL

我的习惯:

free(p);
p = NULL;  // 避免野指针

这样即使不小心再次 free,free(NULL) 是安全的。

15.3 动态数组的实现

动态数组,说白了就是可以在运行时改变大小的数组。咱们用 malloc + realloc 就能实现一个简单的版本。

我曾经在一个数据采集项目中,需要实时存储传感器数据。数据量不确定,用静态数组要么浪费要么不够。动态数组完美解决了这个问题。

15.3.1 基本结构

typedef struct {
    int *data;      // 数据指针
    size_t size;    // 当前元素个数
    size_t capacity; // 当前容量
} DynamicArray;

15.3.2 初始化与销毁

// 初始化
void da_init(DynamicArray *da, size_t initial_capacity) {
    da->data = (int*)malloc(initial_capacity * sizeof(int));
    da->size = 0;
    da->capacity = initial_capacity;
}

// 销毁
void da_destroy(DynamicArray *da) {
    free(da->data);
    da->data = NULL;
    da->size = 0;
    da->capacity = 0;
}

15.3.3 添加元素(自动扩容)

void da_append(DynamicArray *da, int value) {
    if (da->size >= da->capacity) {
        // 容量翻倍,避免频繁 realloc
        size_t new_capacity = da->capacity * 2;
        int *new_data = (int*)realloc(da->data, new_capacity * sizeof(int));
        if (new_data == NULL) {
            fprintf(stderr, "扩容失败\n");
            exit(1);
        }
        da->data = new_data;
        da->capacity = new_capacity;
    }
    da->data[da->size++] = value;
}

扩容策略:每次容量不够时,翻倍扩容。这样平均每次插入的时间复杂度是 O(1)。如果每次只加1个元素,那 realloc 会被频繁调用,性能极差。

15.4 内存泄漏的避免

内存泄漏,就是申请了内存但没释放,导致系统可用内存越来越少。在嵌入式系统里,跑个几天几夜,内存泄漏会导致系统崩溃——我吃过这个亏。

我曾经维护过一个老项目,设备运行72小时后必死。查了三天,发现是一个循环里每次 malloc 但只在特定条件下 free。条件不满足时,内存就漏了。修复后,设备稳定运行了半年。

15.4.1 常见的内存泄漏场景

场景 说明 解决方案
忘记 free malloc 后没有对应的 free 写代码时成对出现,立即补上 free
丢失指针 指针被重新赋值,原内存无法访问 使用临时变量保存原指针
异常路径未释放 函数提前 return 或抛出异常 使用 goto cleanup 模式
容器未释放元素 释放了容器本身,但没释放里面的元素 遍历释放每个元素

15.4.2 最佳实践

我总结了几条铁律,照着做基本不会漏:

  1. 谁分配,谁释放:模块A malloc 的内存,由模块A free,不要跨模块
  2. 成对出现:写 malloc 的同时,立刻写对应的 free 注释
  3. 初始化指针为 NULL:避免野指针
  4. free 后置 NULL:防止 double free
  5. 使用工具检测:Valgrind、AddressSanitizer 都是好帮手

嵌入式环境特别提醒:

在资源受限的MCU上,我建议尽量少用动态分配。如果非用不可,考虑使用静态内存池——预先分配一大块内存,然后自己管理。这样既避免了碎片,又不会因为 malloc 失败而崩溃。

15.5 知识体系总览

下面这张图,把本章的核心知识点串起来了。你可以把它当作一个快速索引。

指针与动态内存分配 · 知识体系 动态内存管理 malloc calloc realloc free 动态数组实现 链表节点分配 缓冲区管理 ⚠ 常见陷阱 内存泄漏 · 野指针 · Double Free · 越界访问 · 忘记检查NULL 最佳实践:谁分配谁释放 · free后置NULL · 使用Valgrind检测 · 嵌入式优先用内存池

15.6 总结

动态内存分配是C语言的精髓之一,也是最容易出bug的地方。说白了,就是四个函数加一个原则:malloc/calloc/realloc/free,加上“有借有还”。

我最后再啰嗦一句:在嵌入式系统里,能不动态分配就别动态分配。如果非用不可,一定要做好错误处理,并且用工具反复检查。我见过太多因为内存泄漏导致的产品事故了——别让自己成为下一个。

嗯,这一章就到这儿。代码写多了,自然就熟练了。


公众号:蓝海资料掘金营,微信 deep3321