C 动态内存(malloc/free)

普通变量在上自动分配、函数返回就回收。但有时你不知道要多大内存(运行时才知道数组多大),或者要一块"超出函数生命周期"的内存——这时用,手动申请、手动释放。C 没有 GC(垃圾回收),内存生命周期完全靠你——这是 C 强大的根源,也是它容易出 bug 的根源。

一、内存布局:栈 vs 堆

理解动态内存前,先理解程序内存的布局:

// C 程序的内存布局(从低地址到高地址):

// ┌─────────────────┐ 高地址
// │     栈(stack)   │  ← 局部变量、函数参数、返回地址
// │       ↓          │     向下增长,函数返回自动回收
// │                  │
// │      ...         │
// │                  │
// │       ↑          │
// │     堆(heap)   │  ← malloc 分配的内存,手动管理
// ├─────────────────┤
// │   BSS 段         │  ← 未初始化的全局/static 变量(自动清0)
// ├─────────────────┤
// │   数据段         │  ← 已初始化的全局/static 变量
// ├─────────────────┤
// │   代码段         │  ← 程序指令(只读)
// └─────────────────┘ 低地址

// 栈 vs 堆 的区别:
// 栈:自动分配回收,速度快,空间小(通常 1~8 MB)
// 堆:手动分配回收,速度慢,空间大(几乎可用全部内存)

的核心区别:

什么时候用堆?(1) 数组大小运行时才知道;(2) 对象生命周期超出函数;(3) 数据量大(几 MB 以上,栈装不下);(4) 灵活的数据结构(链表、树)。

二、malloc 分配内存

malloc(memory allocate)是最常用的动态分配函数,在 <stdlib.h> 里:

#include <stdio.h>
#include <stdlib.h>    // malloc / free / calloc / realloc

int main(void) {
    // malloc:申请 n 字节内存,内容是随机垃圾值
    // 返回 void*,需要强制转换成需要的类型
    int *arr = (int *)malloc(5 * sizeof(int));
    if (arr == NULL) {            // 必须检查!分配可能失败
        printf("内存不足\n");
        return 1;
    }

    // 使用
    for (int i = 0; i < 5; i++) {
        arr[i] = i * 10;
    }
    for (int i = 0; i < 5; i++) {
        printf("%d ", arr[i]);    // 0 10 20 30 40
    }
    printf("\n");

    // 用完一定要 free!
    free(arr);
    arr = NULL;                   // 好习惯:防止悬垂指针

    // 分配单个对象
    double *p = (double *)malloc(sizeof(double));
    if (p == NULL) return 1;
    *p = 3.14;
    printf("%.2f\n", *p);
    free(p);
    return 0;
}

三个要点:

三、calloc:分配并清零

calloc(contiguous allocate)和 malloc 类似,但会把内存清零。参数也不同——接收元素个数和元素大小:

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    // malloc vs calloc
    // malloc(n):申请 n 字节,内容是垃圾值
    // calloc(count, size):申请 count*size 字节,并清零

    int *a = (int *)malloc(5 * sizeof(int));
    int *b = (int *)calloc(5, sizeof(int));

    // malloc 的内容是随机的
    printf("malloc: ");
    for (int i = 0; i < 5; i++) printf("%d ", a[i]);
    printf("\n");            // 可能是任意值

    // calloc 的内容确定是 0
    printf("calloc: ");
    for (int i = 0; i < 5; i++) printf("%d ", b[i]);
    printf("\n");            // 0 0 0 0 0

    free(a);
    free(b);

    // 实践建议:需要清零的场景(数组初始化)用 calloc 更安全
    // 不需要初始化(马上要覆盖)用 malloc 省一次 memset
    return 0;
}

实践建议:需要初始化为 0 的场景(数组、缓冲区)用 calloc 更安全、更省心;马上要全部覆盖的场景用 malloc 省一次 memset 调用。

四、realloc:调整大小

realloc(re-allocate)用于调整已分配内存的大小——实现"动态数组"的关键:

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    // realloc:调整已分配内存的大小
    // 场景:动态数组的扩容/缩容

    int n = 3;
    int *arr = (int *)malloc(n * sizeof(int));
    arr[0] = 10; arr[1] = 20; arr[2] = 30;

    // 现在想扩到 5 个元素
    int *new_arr = (int *)realloc(arr, 5 * sizeof(int));
    if (new_arr == NULL) {
        // 注意:realloc 失败时原内存 arr 仍然有效,要记得 free
        free(arr);
        return 1;
    }
    arr = new_arr;          // 让 arr 指向新地址
    arr[3] = 40;
    arr[4] = 50;

    for (int i = 0; i < 5; i++) {
        printf("%d ", arr[i]);   // 10 20 30 40 50
    }
    printf("\n");

    // realloc 的几种行为:
    // 1. 当前块够大:原地扩容,指针不变
    // 2. 当前块不够:分配新块,拷贝旧数据,free 旧块
    // 3. 传 NULL:等同于 malloc
    // 4. 传大小 0:等同于 free(返回值可能是 NULL 或特殊指针)

    // 缩容也行
    arr = (int *)realloc(arr, 2 * sizeof(int));   // 保留前 2 个
    free(arr);
    return 0;
}

重要细节:realloc 可能返回新的地址(原空间不够时,会另找一块大的,把旧数据拷贝过去,自动 free 旧的)。所以正确写法是用临时变量接收,检查成功后再赋回原指针:

五、内存泄漏与三大错误

C 没有自动内存管理,最常见的三类内存错误:

#include <stdio.h>
#include <stdlib.h>

// 错误1:内存泄漏(分配了不释放)
void leak(void) {
    int *p = malloc(100);
    // 函数返回,p 丢失,但 100 字节的堆内存没 free
    // → 内存泄漏!长期运行的服务会越占越多
}

// 错误2:重复释放(double free)
void double_free(void) {
    int *p = malloc(100);
    free(p);
    free(p);          // 崩溃!或破坏堆结构
}

// 错误3:释放后继续使用(use after free)
void use_after_free(void) {
    int *p = malloc(sizeof(int));
    *p = 42;
    free(p);
    printf("%d\n", *p);    // 未定义行为!可能 42、可能崩溃
}

// 正确做法:谁分配谁释放,成对出现
int *create_array(int n) {
    int *arr = malloc(n * sizeof(int));
    return arr;            // 把所有权交给调用者
}
// 调用者负责 free

int main(void) {
    int *a = create_array(10);
    // 用完...
    free(a);               // 调用者负责释放
    a = NULL;
    return 0;
}

预防原则:谁分配谁释放;malloc 和 free 成对出现;free 后立即把指针置 NULL(防止悬垂指针和重复 free);用工具检测(valgrind、AddressSanitizer)。

六、实战模式:动态数组

把前面的知识组合起来,实现一个最简单的"动态数组"(类似 C++ 的 vector):

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// 经典模式:动态字符串拼接
char *str_join(const char *a, const char *b) {
    size_t len = strlen(a) + strlen(b) + 1;
    char *result = malloc(len);
    if (result == NULL) return NULL;

    strcpy(result, a);
    strcat(result, b);
    return result;
}

// 经典模式:动态增长的数组
typedef struct {
    int *data;
    size_t size;       // 当前元素数
    size_t capacity;   // 总容量
} Vector;

void vector_push(Vector *v, int value) {
    if (v->size >= v->capacity) {
        // 容量翻倍策略(均摊 O(1))
        v->capacity = v->capacity == 0 ? 4 : v->capacity * 2;
        v->data = realloc(v->data, v->capacity * sizeof(int));
    }
    v->data[v->size++] = value;
}

int main(void) {
    // 动态字符串
    char *s = str_join("Hello, ", "World!");
    printf("%s\n", s);   // Hello, World!
    free(s);

    // 动态数组(C++ vector 的 C 版)
    Vector v = {0};
    for (int i = 0; i < 10; i++) {
        vector_push(&v, i * i);
    }
    for (size_t i = 0; i < v.size; i++) {
        printf("%d ", v.data[i]);   // 0 1 4 9 16 25 36 49 64 81
    }
    printf("\n");
    free(v.data);
    return 0;
}

关键点:容量翻倍策略——当数组满了,realloc 扩大到 2 倍。这样均摊时间复杂度是 O(1),是所有动态数组实现的标配(C++ vector、Go slice、Python list 都这么干)。

七、内存管理工具

调试内存问题的两大杀器:

写 C 程序时强烈建议开发期就开启 ASan,能在第一时间发现内存错误,避免后期 debug 到崩溃。

八、所有权与生命周期

C 不像 Rust 有明确的所有权模型,但项目里通常有约定来管理内存:

大型 C 项目(Linux 内核、Redis)会发展出自己的内存管理规范,比如引用计数、对象池。C++ 的 RAII、智能指针本质上就是把这些规范语言化。

小结

栈自动管理、堆手动管理。malloc 申请(不清零)、calloc 申请并清零、realloc 调整大小、free 释放。三大错误:泄漏、重复释放、悬垂指针——用 valgrind/ASan 检测。养成"malloc 必检查 NULL、free 后置 NULL、谁分配谁释放"的习惯。下一篇看 C 的预处理器

← 上一篇 C 结构体

下一篇 C 预处理器

✈️💬