到目前为止,我们定义的所有变量和数组,大小都是在写代码时就定好了的——int arr[100] 就是 100 个元素,不多不少。编译器看到这行代码,直接在栈上划出 400 字节(假设 int 是 4 字节),谁来都一样。

但真实世界的程序充满了不确定性。你不知道用户要输入多少个数字,不知道文件有多大,不知道字符串有多长。这时候怎么办?给数组分配一个"足够大"的大小?5000?10000?这样做要么浪费内存(大多数时候用不满),要么迟早溢出(万一用户真的输入了 10001 个数据呢)。

C 语言给了你另一种选择:动态内存分配——在程序运行期间,根据实际需要向操作系统"要"内存,用完了再"还"回去。你之前学的指针,就是操作这些动态内存的核心工具——malloc 返回给你的就是一个指针,指向刚申请的那块内存的起始地址。

int val = 20;           // 栈上开辟4字节,编译期定死
char arr[10] = {0};     // 栈上开辟10字节连续空间,编译期定死

这两种方式有两个核心限制:(1)空间大小在编译期就确定了,不能变;(2)数组声明时必须指定长度,一旦定下来就不可调整。对于"用户输入 N 个整数然后排序"这种需求,你怎么办?这就是动态内存要解决的问题。

为什么要有动态内存分配

想象一下,你写了一个成绩管理系统。第一版只有 30 个学生,你用 int scores[30] 刚刚好。第二学期学校扩招了,来了 50 个学生——你的程序就崩了。你不得不改代码、重新编译。更糟糕的是,如果学生人数是运行时从配置文件读出来的,你根本没法在编译时知道数组应该开多大。

动态内存分配让这件事变得简单:程序跑起来之后,读到"50 个学生",就向操作系统申请 50 个 int 的空间;读到"200 个学生",就申请 200 个。一切都在运行时决定,代码不用改,编译不用重来。

C 语言提供了四个核心函数来做这件事,都声明在 <stdlib.h> 头文件中:malloc、free、calloc、realloc。

先看全景:堆与栈的对比

动态内存分配的核心战场是堆区(heap),为了说清楚"为什么需要堆",先把它和栈做个对比:

维度栈(stack)堆(heap)
分配方式编译器自动分配/释放程序员手动分配/释放
分配时机编译期确定,函数调用时分配运行期,随时可以
大小有限(几 MB 级),太大栈溢出大(接近物理内存),但受系统限制
分配速度极快(只动栈指针)较慢(要查找空闲块)
生命周期随函数调用结束而消亡直到手动 free 或程序结束
典型错误栈溢出(递归太深)内存泄漏、野指针

栈适合"生命周期和函数调用一致"的临时数据;堆适合"生命周期比函数更长、大小运行期才确定"的数据。动态内存管理,管理的正是堆。

malloc 和 free

malloc 是 dynamic memory allocation 的缩写。它向内存的堆区(heap)申请一块指定大小的连续空间,返回指向这块空间起始地址的指针:

void* malloc(size_t size);

几个关键特征你必须记住:

  • 返回值是 void*。malloc 不知道你打算用这块空间存什么类型的数据——可能是 int,可能是 double,可能是自定义结构体——所以返回无类型指针。C 语言中 void* 可以隐式转换为任何类型的指针,你既可以直接赋值,也可以显式转型来增强可读性。
  • 失败返回 NULL。如果系统内存不足,malloc 不会报错、不会崩溃,只是安静地返回 NULL。这是最容易出问题的地方——很多新手拿到指针直接用,压根不检查。
  • size 为 0 是未定义行为。C 标准说,传入 size = 0 的行为取决于编译器——有的返回 NULL,有的返回一个不能解引用的"幽灵指针"。别去试探这个边界。

malloc 底层到底做了什么?

很多人以为 malloc(100) 就是"向操作系统要 100 字节"。实际上远没那么简单。一个典型的 malloc 实现(如 glibc 的 ptmalloc)工作流程是这样的:

  1. 先查空闲链表(free list):堆管理器维护着一批"已释放但未归还"的内存块。如果链表中有一块大小足够(或可以切分),直接返回它——大多数 malloc 根本不经过操作系统,因为频繁的系统调用太慢了。
  2. 不够就向操作系统要:如果空闲链表里没有合适的块,对小块内存(默认阈值以下,如 128KB 以内),通过 brk/sbrk 系统调用把堆顶指针(program break)往上推;对大块内存,通过 mmap 直接映射一块匿名内存。
  3. 记录元数据:在返回给调用者地址的"前面"(低地址处)存放这块内存的大小等信息——这解释了为什么 free 时不能把指针偏移。
/* 示意图:malloc 返回的指针不是"块的开头",而是"块开头 + 头部元数据" */
/* +--------+----------------+      |
   | header |    usable      |      |
   +--------+----------------+      |
    ^        ^                       |
    |        |__ 返回给调用者的指针   |
    |__ 存储 size 等元数据            |
*/
 
int main()
{
    /* 演示:free 需要的是"原始指针" */
    int *p = (int *)malloc(100);
    free(p);      /* 正确:p 就是 malloc 返回的原始指针 */
    /* free(p + 1);  错误:偏移后的指针找不到头部元数据 */
    return 0;
}

malloc 的内存对齐保证

malloc 返回的指针满足最严格的对齐要求(至少在 8 字节对齐,x86-64 上是 16 字节)——这意味着你可以放心地把返回值强转为任何类型的指针(包括 double *、long long *、结构体指针),不需要担心对齐问题。这也是 malloc 和"自定义内存池"的差异之一:自定义池子往往要手动保证对齐。

最基本的用法:动态创建数组

有借就要有还。free 就是用来归还 malloc(或 calloc、realloc)分配的内存:

void free(void* ptr);

几个要点:

  • 只能释放动态分配的内存。如果你拿一个栈变量(int a)的地址去 free,行为未定义——操作系统维护着一个内部数据结构来管理堆空间,free 试图对一个不在堆上的地址操作时,内部的链表或树结构会被破坏,后果完全不可预测。
  • free(NULL) 是安全的,什么都不发生。所以有些人习惯在释放后立即把指针置为 NULL,双重保险。
  • 释放后指针本身不会自动变成 NULL。这非常重要——free 只是告诉操作系统"这块内存我不要了",指针变量里存的地址值并不会自动清空。如果不手动置 NULL,你就拿到了一个野指针(悬垂指针),继续通过它访问那块已释放的内存,运气好程序崩溃,运气不好数据被悄悄写到了其他代码正在使用的内存区域。

下面是最基本的用法——动态创建一个数组,大小由用户在运行时决定:

#include <stdio.h>
#include <stdlib.h>
 
int main()
{
    int n;
    printf("请输入数组大小: ");
    scanf("%d", &n);
 
    // 向堆空间申请 n 个 int 的内存
    // sizeof(int) 保证跨平台兼容性
    int *arr = (int*)malloc(n * sizeof(int));
 
    // 永远检查 malloc 的返回值!
    if (arr == NULL) {
        printf("内存分配失败!\n");
        return 1;
    }
 
    // 和普通数组用法完全一致——arr[i] 等价于 *(arr+i)
    for (int i = 0; i < n; i++) {
        arr[i] = i * i;
        printf("arr[%d] = %d\n", i, arr[i]);
    }
 
    // 用完后归还内存
    free(arr);
    // 把指针置为 NULL,防止后续误用
    arr = NULL;
 
    return 0;
}

这里有一个容易忽略的点:arr[i] 和普通数组的用法完全一样,但 arr 本身是在栈上的一个指针变量,它指向的内存才在堆上。free(arr) 释放的是堆上的内存,arr 这个指针变量本身仍然存在(它在栈上),只是它指向的地址已经无效了。所以 arr = NULL 这一步很重要——它把指针"清空"了,防止你后续不小心通过这个指针访问已释放的内存。

动态分配二维数组:三种写法

动态数组不仅有一维,二维数组也经常需要动态分配(比如动态大小的矩阵)。这里介绍三种写法,面试常考:

#include <stdio.h>
#include <stdlib.h>
 
int main()
{
    int rows = 3, cols = 4;
    int i, j;
 
    /* 写法一:用"指向数组的指针"一次性分配(内存连续) */
    /* 注意:cols 必须是编译期常量才能用这种方式 */
    /* int (*mat1)[4] = (int (*)[4])malloc(rows * sizeof(int[4])); */
 
    /* 写法二:先分配行指针数组,再逐行分配(经典两级指针) */
    int **mat2 = (int **)malloc(rows * sizeof(int *));
    if (mat2 == NULL) return 1;
    for (i = 0; i < rows; i++)
    {
        mat2[i] = (int *)malloc(cols * sizeof(int));
        if (mat2[i] == NULL) return 1;
    }
 
    /* 使用:和普通二维数组一样的下标访问 */
    for (i = 0; i < rows; i++)
        for (j = 0; j < cols; j++)
            mat2[i][j] = i * cols + j;
 
    printf("矩阵内容:\n");
    for (i = 0; i < rows; i++)
    {
        for (j = 0; j < cols; j++)
            printf("%3d ", mat2[i][j]);
        printf("\n");
    }
 
    /* 释放:先释放每行,再释放行指针数组(顺序不能反!) */
    for (i = 0; i < rows; i++)
        free(mat2[i]);
    free(mat2);
 
    return 0;
}

注意写法二的释放顺序:先逐行释放,再释放行指针数组——顺序反了(先 free(mat2) 再 free(mat2[i]))就会在释放时访问已释放的内存,属于未定义行为。

calloc 和 realloc

malloc 只负责分配空间,不负责清理——分配出来的内存里是"垃圾值",上一段程序留下的随机数据。如果你需要一个干净的、全部初始化为 0 的空间,就要用到 calloc:

void* calloc(size_t num, size_t size);

它分配 num * size 字节的空间,并且把每个字节初始化为 0。换句话说,calloc(10, sizeof(int)) 相当于 malloc(10 * sizeof(int)) 加上 memset(p, 0, 10 * sizeof(int))。

参数设计上,calloc 有两个参数——数量和单个大小——这隐含了一个好处:如果 num * size 溢出 size_t,好的 calloc 实现会检测到溢出并返回 NULL,而 malloc(num * size) 可能悄悄溢出后只分配一小块空间,埋下一个很难排查的 bug。

#include <stdio.h>
#include <stdlib.h>
 
int main()
{
    // calloc(元素个数, 每个元素大小),自动初始化为全0
    int *p = (int*)calloc(10, sizeof(int));
    if (p == NULL) {
        printf("分配失败\n");
        return 1;
    }
 
    // 验证是否全部为 0
    printf("calloc 分配后的初始值:\n");
    for (int i = 0; i < 10; i++) {
        printf("%d ", p[i]);  // 输出: 0 0 0 0 0 0 0 0 0 0
    }
    printf("\n");
 
    free(p);
    p = NULL;
    return 0;
}

如果你需要的是一个计数器数组、标志位数组等需要"从零开始"的场景,calloc 一步到位,不需要再多写一个 memset。

calloc 的性能迷思

有人说 calloc 比 malloc 慢(因为要清零),这是对的;也有人说 calloc 更快(因为可以复用系统的零页,用 COW 机制延迟清零),这在 Linux 大块分配时也是对的。结论:小块分配差距很小,不要在这个层面优化。选择标准只有一个——你需要清零吗?需要就 calloc,不需要就 malloc。

接下来是 realloc。程序运行到一半,你发现之前分配的空间不够用了——realloc 就是为这种场景设计的:

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

它尝试将 ptr 指向的内存块调整为 size 字节。关键来了——这个调整有两种互斥的情况:

情况一:原地扩展。 如果 ptr 后面的堆空间足够大,realloc 直接在原地追加空间,原有数据不变,返回值就是原来的 ptr。

情况二:搬家。 如果后面没有足够空间,realloc 会在堆上找一个更大的连续区域,把原数据拷贝过去,释放旧空间,然后返回新地址。

这意味着什么?意味着你不能直接把 realloc 的返回值赋给原来的指针变量!如果 realloc 失败了(返回 NULL),而你又写了 ptr = realloc(ptr, newsize),那结果就是:ptr 变成 NULL,原来那块内存的指针也丢了——你在堆上丢了一块内存,永远找不回来了。这就是内存泄漏。

正确的做法永远是用一个临时变量先接住返回值,确认非 NULL 后再赋给原指针:

#include <stdio.h>
#include <stdlib.h>
 
int main()
{
    int capacity = 5;
    int *data = (int*)malloc(capacity * sizeof(int));
    if (data == NULL) return 1;
 
    for (int i = 0; i < capacity; i++) {
        data[i] = i + 1;
    }
 
    printf("扩容前 (容量=%d): ", capacity);
    for (int i = 0; i < capacity; i++) printf("%d ", data[i]);
 
    // 扩容到 10 个
    int new_capacity = 10;
    // 关键:用临时指针接住 realloc 的返回值!
    int *tmp = (int*)realloc(data, new_capacity * sizeof(int));
    if (tmp == NULL) {
        printf("\n扩容失败!旧数据仍然可用。\n");
        free(data);  // 即使扩容失败,原内存也需要手动释放
        return 1;
    }
    // 确认成功后,再将临时指针赋给原指针
    data = tmp;
    capacity = new_capacity;
 
    // 填充新增部分的数据
    for (int i = 5; i < capacity; i++) {
        data[i] = (i + 1) * 10;
    }
 
    printf("\n扩容后 (容量=%d): ", capacity);
    for (int i = 0; i < capacity; i++) printf("%d ", data[i]);
    printf("\n");
 
    free(data);
    data = NULL;
    return 0;
}

运行结果是:扩容前 1 2 3 4 5,扩容后 1 2 3 4 5 60 70 80 90 100。原有数据完整保留,新增部分被填入新值。

顺便提一下,如果 realloc 的新大小比原来的小,它只会截断数据——多余部分的内容就丢失了,但剩余部分不变。另外,如果 realloc 的第一个参数是 NULL,它的行为等价于 malloc(size),直接分配新空间。

realloc 的典型应用:动态扩容的完整套路

realloc 最常见的使用模式是"容量不够就翻倍扩容"——这也是 std::vector 的核心机制:

#include <stdio.h>
#include <stdlib.h>
 
/* 简易动态数组:容量不够时翻倍 */
typedef struct
{
    int *data;
    int size;      /* 当前元素个数 */
    int capacity;  /* 已分配容量 */
} DynArr;
 
void da_push(DynArr *arr, int value)
{
    if (arr->size >= arr->capacity)
    {
        int new_cap = arr->capacity == 0 ? 4 : arr->capacity * 2;
        int *tmp = (int *)realloc(arr->data, new_cap * sizeof(int));
        if (tmp == NULL)
        {
            printf("扩容失败!\n");
            return;
        }
        arr->data = tmp;
        arr->capacity = new_cap;
    }
    arr->data[arr->size++] = value;
}
 
int main()
{
    DynArr arr = { NULL, 0, 0 };   /* 全零初始化 */
 
    for (int i = 0; i < 10; i++)
        da_push(&arr, i * 10);
 
    printf("元素: ");
    for (int i = 0; i < arr.size; i++)
        printf("%d ", arr.data[i]);
    printf("\n容量: %d, 大小: %d\n", arr.capacity, arr.size);
 
    free(arr.data);   /* 只要一次 free(data 是唯一动态内存) */
    return 0;
}

为什么翻倍而不是"每次 +1"?因为翻倍扩容的总代价是 O(n)(摊销分析),而每次 +1 是 O(n²)——你可以用等比数列求和证明:翻倍意味着每个元素平均只被拷贝 O(1) 次。这是数据结构和算法课上的经典结论。

常见的动态内存的错误

上面这些函数看起来并不复杂,但实际用起来,坑一个接一个。我们来把这些常见错误排一排,你写代码的时候心里有数。

错误一:不对 malloc 返回值做空检查。

int *p = (int*)malloc(10000000000000);
*p = 42;  // 如果 p 是 NULL,解引用 NULL 直接崩溃

这是最常见的错误,没有之一。malloc 可能失败——尤其是在申请大块内存或系统内存紧张的时候。三个字符的 NULL 检查可能省去你三个小时的调试。

错误二:忘记释放——内存泄漏。

void leak() {
    int *p = (int*)malloc(100);
    *p = 20;
    // 函数结束,p 本身是局部变量被销毁
    // 但 malloc 分配的那 100 字节堆内存永远回不去了
}

内存泄漏的危险在于它不会立刻让程序崩溃,而是缓慢地吃掉系统内存。在长时间运行的服务器程序或嵌入式设备中,内存泄漏是致命的。原则很简单:谁分配,谁释放;一对一,不遗漏。

错误三:释放非动态分配的内存。

int a = 10;
int *p = &a;
free(p);  // 未定义行为!a 在栈上,不是 malloc 分配的

操作系统维护着一个内部数据结构来管理堆空间。当 free 试图对一个不在堆上的地址操作时,内部的链表或树结构会被破坏,后果完全不可预测——可能在 free 的时候直接崩溃,也可能在很久以后某个不相关的 malloc 调用中崩溃,让你完全找不到原因。

错误四:释放动态内存的一部分。

int *p = (int*)malloc(10 * sizeof(int));
p++;        // 指针移到了中间某个位置
free(p);    // 未定义行为!p 不再指向分配的起始地址

free 的参数必须是指向动态分配内存起始地址的指针。malloc 分配时,堆管理器会在内存块的起始位置附近记录这块内存的大小等信息;free 需要靠这些信息才能正确回收。你把指针移到中间(p++),free 就找不到这块内存的"档案"了——行为未定义,可能直接崩溃,也可能悄悄搞乱堆管理器的内部结构,让后续某个看似无关的 malloc 或 free 行为异常。记住:free 的参数必须是你当初从 malloc/calloc/realloc 拿到的那个原始地址,不能"位移"。

错误五:释放后指针未置 NULL——野指针。

int *p = (int*)malloc(sizeof(int));
*p = 42;
free(p);         // 内存被回收
*p = 100;        // 未定义行为!p 成了野指针

free 之后指针变量里存的地址值不会自动消失——它仍然存着之前分配的那个地址。如果你继续通过它访问那块内存,运气好的话程序崩溃;运气不好,数据被悄悄写到了其他代码正在使用的内存区域,引发难以追踪的 bug。

错误六:多次释放同一块内存。

int *p = (int*)malloc(100);
free(p);
free(p);  // 第二次 free 同一个地址,未定义行为

如果你 free 后不置 NULL,然后在代码的其他分支不小心又 free 了一次——程序会在某个可能完全不相关的地方崩溃,让你怀疑人生。但如果 free 后立即 p = NULL,第二次 free(NULL) 是安全的(什么都不做),问题自然消失。

错误七:对动态开辟空间的越界访问。

int *p = (int *)malloc(10 * sizeof(int));
for (int i = 0; i <= 10; i++) {
    p[i] = i;  // i=10 时越界!p 只有 10 个元素的容量
}

这和你用普通数组越界是一样的——但堆上的越界往往更难排查,因为越界写入可能破坏的是堆管理器的内部数据结构(比如相邻块的头部元数据),导致后续的 malloc 或 free 行为异常。

三种"坏指针"辨析:野指针、悬垂指针、空指针

初学者经常把这三个概念搞混,这里一次性说清楚:

概念含义典型来源后果
空指针(NULL)值为 0 的指针,不指向任何对象malloc 失败、初始化解引用立即崩溃(相对"好")
野指针(wild)未初始化、值随机的指针int *p; 没赋值就用指向哪不知道,崩溃或静默破坏
悬垂指针(dangling)曾有效但指向的内存已被释放free 后没置 NULL访问已回收内存,未定义行为

口诀:空指针是"合法但为空",野指针是"没初始化",悬垂指针是"释放后仍持有"。三者的共同点:解引用都是错误,但错误的表现方式不同。

int *p1 = NULL;                  /* 空指针 */
int *p2;                         /* 野指针:未初始化 */
int *p3 = (int *)malloc(4);
free(p3);                        /* 之后 p3 变成悬垂指针 */
 
/* 防御习惯:释放后立即置 NULL */
p3 = NULL;                       /* 悬垂指针变成空指针 */
free(p3);                        /* free(NULL) 安全,什么都不做 */

动态内存经典笔试题分析

动态内存相关的笔试题,考察的就是你是否真正理解了"指针的值传递"和"栈/堆的生命周期"。下面四道题是经典中的经典。

题目一:值传递陷阱。

void GetMemory(char *p) {
    p = (char *)malloc(100);  // 修改的是 p 的副本
}
void Test(void) {
    char *str = NULL;
    GetMemory(str);            // str 仍然是 NULL
    strcpy(str, "hello world"); // 解引用 NULL,崩溃!
    printf(str);
}

C 语言的参数传递是值传递。GetMemory 函数中的 p 是 str 的副本——给它赋一个新值,就好比你在复印件上写字,原件纹丝不动。所以 str 还是 NULL,对 NULL 做 strcpy 直接崩溃。修复方法:传二级指针 char **p,或者让函数返回指针。

题目二:返回栈空间地址。

char *GetMemory(void) {
    char p[] = "hello world";  // p 是局部数组,位于栈上
    return p;                   // 返回栈地址——函数返回后栈空间被回收
}
void Test(void) {
    char *str = NULL;
    str = GetMemory();
    printf(str);  // 打印的是被回收的栈空间内容,未定义行为
}

函数返回后,其栈帧被销毁,局部变量 p 所在的内存可能已经被其他函数调用覆盖。打印出来的可能是乱码,也可能碰巧还是正确内容——这种不确定性正是 bug 难排查的原因。记住:永远不要返回局部变量的地址。

题目三:正确传递二级指针。

void GetMemory(char **p, int num) {
    *p = (char *)malloc(num);  // 通过二级指针修改外部的 str
}
void Test(void) {
    char *str = NULL;
    GetMemory(&str, 100);      // 传 str 的地址
    strcpy(str, "hello");
    printf(str);               // 正确输出 hello
    // 但这里缺少 free(str),存在内存泄漏!
}

这是题目一的正确写法——把 str 的地址传给函数,函数通过二级指针 *p 修改外部的 str。功能上是对的,但课件里故意没有写 free(str)——每个 malloc 都要有对应的 free,这也是考题希望提醒你的。

题目四:释放后继续使用。

void Test(void) {
    char *str = (char *) malloc(100);
    strcpy(str, "hello");
    free(str);                 // 内存已归还
    if(str != NULL) {          // str 中仍有地址值,不是 NULL
        strcpy(str, "world");  // 访问已释放的内存,未定义行为
        printf(str);
    }
}

free(str) 后 str 不是 NULL——它仍然存着之前分配的那个地址。所以 if(str != NULL) 会通过,然后程序尝试向已释放的内存写入数据。这就是为什么 free 后必须 str = NULL。

附加题:二级指针的修改为什么能生效?

很多同学对题目三还是不理解——为什么传 &str 就能改到外面的 str?核心逻辑:

void GetMemory(char **p, int num) {
    *p = (char *)malloc(num);
    /* p 是 &str(str 的地址)
       *p 就是 str 本身
       *p = malloc(...) 等价于 str = malloc(...) —— 改的是原件! */
}

传 char **p 时,p 是"指向 str 的指针"。*p 解引用得到 str 本身(一个 char* 变量),对它赋值就是在修改外部的 str。这就是"通过二级指针修改一级指针"的标准套路。

柔性数组

也许你从来没有听说过"柔性数组"(flexible array)这个概念,但它确实存在,而且是 C99 标准引入的一个很有用的特性。

C99 规定,结构体的最后一个成员可以是未知大小的数组——这就是柔性数组成员:

struct st_type {
    int i;
    int a[];  // 柔性数组成员——C99 标准写法
};

注意几个硬性条件:(1)必须是结构体的最后一个成员;(2)结构体中柔性数组成员前面至少有一个其他成员;(3)sizeof 返回的结构体大小不包括柔性数组的内存——sizeof(struct st_type) 在这个例子中是 4(只有 int i);(4)必须用 malloc 一次性分配整个结构体。

来看实际用法:

#include <stdio.h>
#include <stdlib.h>
 
typedef struct {
    int length;
    int data[];  // 柔性数组
} FlexArray;
 
int main()
{
    int n = 10;
 
    // 一次性分配:结构体部分 + 柔性数组部分
    // sizeof(FlexArray) 只包含 length,不包含 data[]
    FlexArray *fa = (FlexArray*)malloc(sizeof(FlexArray) + n * sizeof(int));
    if (fa == NULL) return 1;
 
    fa->length = n;
 
    for (int i = 0; i < fa->length; i++) {
        fa->data[i] = i * i;
    }
 
    printf("柔性数组内容: ");
    for (int i = 0; i < fa->length; i++) {
        printf("%d ", fa->data[i]);
    }
    printf("\n");
    printf("sizeof(FlexArray) = %zu (只包含 length,不含柔性数组)\n",
           sizeof(FlexArray));
 
    // 只需要一次 free——这是柔性数组最大的优势
    free(fa);
    fa = NULL;
    return 0;
}

输出:

柔性数组内容: 0 1 4 9 16 25 36 49 64 81
sizeof(FlexArray) = 4

和传统的"结构体 + 指针成员"方案相比,柔性数组的优势一目了然。下面把两种方案放在一起对比:

#include <stdio.h>
#include <stdlib.h>
 
// 方案A:使用指针成员(需要两次 malloc,两次 free)
typedef struct {
    int length;
    int *data;
} PtrStruct;
 
// 方案B:使用柔性数组(只需要一次 malloc,一次 free)
typedef struct {
    int length;
    int data[];
} FlexStruct;
 
int main()
{
    printf("=== 方案A:指针成员 ===\n");
    PtrStruct *ps = (PtrStruct*)malloc(sizeof(PtrStruct));
    if (ps == NULL) return 1;
    ps->length = 5;
    ps->data = (int*)malloc(ps->length * sizeof(int));  // 二次分配
    if (ps->data == NULL) { free(ps); return 1; }
    for (int i = 0; i < 5; i++) ps->data[i] = i;
    for (int i = 0; i < 5; i++) printf("%d ", ps->data[i]);
    printf("\n");
    free(ps->data);  // 先释放内部指针
    free(ps);        // 再释放结构体本身
 
    printf("=== 方案B:柔性数组 ===\n");
    FlexStruct *fs =
        (FlexStruct*)malloc(sizeof(FlexStruct) + 5 * sizeof(int));
    if (fs == NULL) return 1;
    fs->length = 5;
    for (int i = 0; i < 5; i++) fs->data[i] = i;
    for (int i = 0; i < 5; i++) printf("%d ", fs->data[i]);
    printf("\n");
    free(fs);  // 只需要一次 free!
 
    return 0;
}

柔性数组的两个核心优势:(1)内存释放简单——只需一次 free,不会因为你忘了释放内部指针成员而导致泄漏;(2)内存连续性更好——结构体和数组数据存储在相邻的内存区域,对 CPU 缓存更友好,访问效率更高,同时还能减少内存碎片。

如果你用的是老编译器(C99 之前),可能要用 int data[0] 或 int data[1] 来代替(俗称"struct hack")。但任何现代编译器都应该支持 [] 写法。

C/C++ 中程序内存区域划分

谈了这么多堆上的操作,最后我们把视角拉高,看看一个 C 程序的整个内存布局。你的程序在运行时,操作系统给它分配了一块虚拟地址空间,这块空间大致分为四个区域(从低地址到高地址):

区域存放内容生命周期管理方式
代码段函数体的二进制机器指令整个程序运行期操作系统加载
数据段(静态区)全局变量、静态变量整个程序运行期操作系统加载/卸载
堆区malloc/calloc/realloc 分配的空间程序员控制(手动 free)程序员手动管理
栈区局部变量、函数参数、返回地址函数调用期间编译器自动管理

严格来说,"数据段"还可以细分:已初始化的全局/静态变量在 .data 段,未初始化的在 .bss 段(不占磁盘空间,加载时清零)。理解到"四大区域"这个粒度对写 C 程序已经足够。

来看实际代码中这些区域的位置:

#include <stdio.h>
#include <stdlib.h>
 
int global_var = 100;          // 全局变量 → 数据段(静态区)
static int static_global = 200; // 静态全局变量 → 数据段
 
void show_addresses()
{
    int local_var = 300;       // 局部变量 → 栈区
    static int static_local = 400; // 静态局部变量 → 数据段
    int *heap_var = (int*)malloc(sizeof(int)); // 动态分配 → 堆区
    *heap_var = 500;
 
    printf("=== C 程序四大内存区域 ===\n");
    printf("代码段 (函数地址):      %p\n", (void*)show_addresses);
    printf("数据段 (全局变量):      %p\n", (void*)&global_var);
    printf("数据段 (静态全局):      %p\n", (void*)&static_global);
    printf("数据段 (静态局部):      %p\n", (void*)&static_local);
    printf("栈区   (局部变量):      %p\n", (void*)&local_var);
    printf("堆区   (动态分配):      %p\n", (void*)heap_var);
 
    free(heap_var);
    heap_var = NULL;
}
 
int main()
{
    show_addresses();
    return 0;
}

不同平台上地址的相对位置会不同,但你通常能看到:代码段和数据段在低地址区域,堆区在中间并向高地址增长,栈区在高地址并向低地址增长。堆和栈相向而行,中间的空白区域就是两者可以动态使用的空间——如果它们碰头了,要么栈溢出,要么 malloc 失败。

静态局部变量 vs 普通局部变量:static int static_local 虽然是函数内声明的,但它不在栈上,而在数据段——它的生命周期是整个程序运行期,只是"可见范围"被限制在函数内。这是"生命周期"和"作用域"两个概念的典型区分。

内存碎片:malloc/free 的隐形杀手

频繁地 malloc/free 会产生内存碎片——空闲的小块分散在堆的各个角落,导致"总空闲空间足够,但找不到一块连续的大空间"。

分配前:  [已用][空闲][已用][空闲][已用][空闲]
  ↑ 总量空闲可能很多,但没有一块能装下大请求

外部碎片(块之间的小空隙)和内部碎片(malloc 分配时按对齐/头部元数据多出的部分)是堆管理器要应对的两大难题。缓解手段:

  1. 少用 malloc:能用栈数组就用栈数组,能复用缓冲区就复用。
  2. 大块一次性分配:像柔性数组那样,把"结构体 + 数据"合并成一次 malloc。
  3. 内存池:预先分配一大块,自己在上面分配小对象——这是游戏引擎和嵌入式系统的常用手段。

总结

到这里,动态内存管理的核心就讲完了。从 malloc 和 free 的基本用法,到 calloc 的零初始化、realloc 的动态扩容,再到柔性数组和整个程序的内存布局,你手里的工具已经从"编译器替你管理一切"变成了"你自己决定每一块内存的命运"。自由是有了,责任也大了——记住那七条铁律:检查 NULL、别忘了 free、别 free 非堆空间、free 后置 NULL、realloc 用临时变量接、别重复 free、别越界访问。掌握了这些,你才算真正理解了 C 语言对内存的控制力。

最后用一张表做收尾,把四个函数的"分工"看清晰:

函数作用返回值典型场景
malloc(n)分配 n 字节,内容不确定指针/NULL动态数组
calloc(m, n)分配 m×n 字节并清零指针/NULL计数器、标志位数组
realloc(p, n)调整 p 指向的块到 n 字节新指针/NULL动态扩容(翻倍)
free(p)释放 p 指向的动态内存void用完归还

思考题

  1. malloc 和 calloc 的区别是什么?什么时候该用哪一个?为什么 calloc 的参数设计成两个?
  2. 为什么 ptr = realloc(ptr, new_size) 是危险的?如果 realloc 失败,会发生什么?
  3. 在 void f() { int *p = malloc(100); } 中,为什么 p 是局部变量但 malloc 的内存不会被自动释放?
  4. 什么情况下会出现"悬垂指针"?如何用 free(p); p = NULL; 消灭它?
  5. 动态分配一个 int a[3][4](3 行 4 列),用二级指针实现,并说明释放顺序为什么必须是"先逐行、再整体"。
  6. 柔性数组的 sizeof(struct) 为什么不含数组部分?如果 data[] 换成 data[0] 或 data[1] 呢?
  7. 为什么"翻倍扩容"的总开销是 O(n) 而"每次 +1"是 O(n²)?(提示:等比数列求和)

参考答案与详解

1. malloc 和 calloc 的区别是什么?什么时候该用哪一个?为什么 calloc 的参数设计成两个?

  • 区别只有一点:calloc 会把分配到的每个字节清零,而 malloc 返回的内存内容是"垃圾值"(不确定)。calloc(num, size) 等价于 malloc(num * size) 后再 memset(清零)。
  • 什么时候用哪个:需要"从零开始"的数据(计数器、标志位、哈希表桶、要当空白建的缓冲区)就用 calloc;其他情况优先 malloc——省掉一次清零,性能更好。不要为了"干净"去猜",用不用 calloc 只取决于你需不需要初始化为 0。
  • 为什么是两个参数:calloc(num, size) 拆成"个数×单个大小",语意更清晰;更关键的是给实现留出了溢出检测的空间——当 num * size 超过 size_t 上限时,好的实现能检测到溢出并返回 NULL,而 malloc(num * size) 里的乘法可能悄悄溢出,只分配出一小块错误大小的内存,埋下一个极难排查的 bug。
// calloc 用法:分配并清零 10 个 int
int *p = (int*)calloc(10, sizeof(int));
if (p == NULL) { /* 处理失败 */ }
for (int i = 0; i < 10; i++) printf("%d ", p[i]); // 全是 0
free(p);

2. 为什么 ptr = realloc(ptr, new_size) 是危险的?如果 realloc 失败,会发生什么?

realloc 失败时返回 NULL。如果你直接写成 ptr = realloc(ptr, new_size),那一行执行完,ptr 就变成了 NULL——原来那块内存的首地址被覆盖丢了,你再也拿不到它,也没法 free,这就是一次永久的内存泄漏。更糟的是,原本的数据本可以在"保留旧块"的前提下安全退出,现在全部丢了。

// 危险写法:失败即泄漏旧块
int *tmp = (int*)realloc(ptr, new_size);  // 用临时变量先接住
if (tmp != NULL) {
    ptr = tmp;          // 成功才更新
} else {
    // 失败:ptr 仍有效,旧数据还在,可以继续用或 free(ptr)
}

3. 在 void f() { int *p = malloc(100); } 中,为什么 p 是局部变量但 malloc 的内存不会被自动释放?

关键在于区分两样东西:局部变量 p(在栈上)和 malloc 那块 100 字节的内存(在堆上)。函数结束时,栈上的 p 变量被自动回收——但这只是"指向它的指针没了",堆上的那块内存本身并不会因为指针消失而被归还。C 语言里,堆内存的生命周期完全由程序员控制:除非你显式 free,否则它一直存在,直到程序结束由操作系统回收。于是函数结束后,p 消失了、堆内存还挂着而你再也找不到它的地址——这就是内存泄漏。

void f() {
    int *p = (int*)malloc(100);  // 堆上挂了 100 字节
    // ... 没有 free(p)!函数结束,p 变量销毁,但 100 字节泄漏了
    free(p);  // 补上这一句才是对的(能释放时就释放)
}

4. 什么情况下会出现"悬垂指针"?如何用 free(p); p = NULL; 消灭它?

当指针指向的内存已被释放、但指针变量里存的地址没有清空时,这个指针就是悬垂指针(dangling pointer)。典型场景:free(p) 之后没有置 NULL,p 仍保存着那块已回收的首地址。

free(p); p = NULL; 做两件事:先归还内存,再把指针变量清成空指针。清成 NULL 后有两个好处:一是再通过它访问会"立即崩溃"(解引用 NULL)而不是"静默破坏数据"——崩溃好排查多了;二是后续想再 free 一次也安全(free(NULL) 什么都不做),避免双重释放。

int *p = (int*)malloc(100);
free(p);     // 释放内存
// p 此刻仍是悬垂指针,危险!
p = NULL;    // 置空:悬垂指针变成安全空指针
// 之后误用 p 会崩,但不会造成难以追踪的内存破坏

5. 动态分配一个 int a[3][4](3 行 4 列),用二级指针实现,并说明释放顺序为什么必须是"先逐行、再整体"。

用"行指针数组 + 每行单独分配"的两级指针方式:

int rows = 3, cols = 4;
int **mat = (int**)malloc(rows * sizeof(int*));   // 行指针数组
if (mat == NULL) return 1;
for (int i = 0; i < rows; i++) {
    mat[i] = (int*)malloc(cols * sizeof(int));     // 每行
    if (mat[i] == NULL) return 1;
}
mat[1][2] = 42;   // 与普通二维数组下标用法一致
 
// 释放顺序:必须先逐行释放每一行,再释放行指针数组
for (int i = 0; i < rows; i++) free(mat[i]);
free(mat);

为什么顺序不能反? 因为 mat(行指针数组)里存的正是每一行的首地址。如果先 free(mat),这块行指针数组就被归还了,里面的地址信息全没了,接下来你根本不知道每一行在哪,无法再逐行 free——而且那个循环已经去读一块被释放的内存(未定义行为)。所以必须先释放行,最后的"总表"才释放。一句话:先还小的、后还大的;先还"指向的数据",再还"装着指针的那张表"。

6. 柔性数组的 sizeof(struct) 为什么不含数组部分?如果 data[] 换成 data[0] 或 data[1] 呢?

因为柔性数组成员未知大小——它是"留给别人用 malloc 决定多大"的占位,编译器不知道也不负责分配这块空间,所以 sizeof 只算前面确定大小成员的累加(含对齐)。struct { int i; int data[]; } 的 sizeof 就是 4(只算 int i)。

  • 换成 data[0]:这是零长度数组,是 GCC/Clang 的扩展(不是 C 标准),在支持它的编译器上 sizeof 同样是 4,行为与 [] 一致;
  • 换成 data[1]:这是老的"struct hack"(C99 之前的手法)。int data[1] 是确定大小的数组,sizeof 会包含这个元素,即 sizeof 变成 8(int i 的 4 + int data[1] 的 4,本例无额外对齐)。此时 sizeof(struct) 不是柔性数组意义上的"结构体本身大小",用它做分配基准会多算 4 字节。所以旧式写法要配合"尺寸减一"之类的技巧,正是这个别扭让 C99 引入了真正的 [] 柔性数组。
typedef struct { int n; int a[]; } F;        // C99 标准柔性数组
printf("%zu\n", sizeof(F));                   // 4(不含 a[])
F *p = (F*)malloc(sizeof(F) + 10*sizeof(int)); // 一次性分配

7. 为什么"翻倍扩容"的总开销是 O(n) 而"每次 +1"是 O(n²)?(提示:等比数列求和)

设已装了 n 个元素,扩容次数为 k。

  • 每次 +1:每次插入新元素都要申请一组全新内存,并把旧数据全拷一遍。插入第 i 个元素前,要把已有的 i 个元素拷走。总拷贝次数 = 1 + 2 + 3 + ... + n = n(n+1)/2,即 O(n²)。
  • 翻倍:容量按 1、2、4、8、…、2^k 增长,只有在这 2^k 的"翻倍点上"才发生拷贝,每次拷走当前已有的 2^(k-1) 个元素。总拷贝次数 = 1 + 2 + 4 + ... + 2^(k-1) = 2^k - 1 ≤ 2n,即 O(n)(这也是 std::vector、可变数组的教科书结论)。

关键差异在于:每次 +1 把次数做成了"连续自然数和"(二次增长),翻倍把它变成"等比数列和"(线性增长)。元素越多,翻倍越省——这也是大数组扩容必须翻倍而非逐项递增的根本原因。