C语言学习:动态内存管理(数据结构关键)

一、动态内存分配

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

之前学的栈上开辟空间(比如int val = 20;char arr[10]; )有两个硬伤

  • 空间大小固定,编译时就必须确定。
  • 数组长度一旦声明就不能修改 ,无法应对 "运行时才知道需要多大空间" 的场景。

动态内存分配(在堆区操作)的核心优势运行时灵活申请 / 释放空间,大小可调整,解决了固定大小数组的局限性。


2. mallocfree 详解

2.1 malloc(相当于在图书馆借书)

  • 函数原型void* malloc(size_t size); 需要头文件#include<stdlib.h>
  • 核心功能 :在堆区申请一块连续 的内存空间,返回其起始地址
  • 关键细节
    1. size 参数:要分配的字节数 ,不是元素个数,所以常配合 sizeof(类型) 使用,比如 malloc(num * sizeof(int))
    2. 返回值:
      • 申请成功 :返回 void* 类型的起始地址使用时需要强转成目标类型 (比如 (int*)malloc(...))。
      • 申请失败 :返回 NULL,所以必须做判空处理,否则会触发空指针访问错误。
    3. 特殊情况:size 为 0 时,行为未定义,不同编译器实现不同,不建议这么写。

2.2 free(相当于还之前借的书)

  • 函数原型void free(void* ptr); 需要头文件#include<stdlib.h>
  • 核心功能释放 之前通过**malloc/calloc/realloc 申请的堆内存。**
  • 关键细节
    1. ptr 必须是动态分配的地址,否则行为未定义。
    2. ptrNULL 时,free 什么都不做,不会报错。
    3. 释放后,建议手动将 ptr = NULL;,避免出现 "野指针"(指向已释放内存的指针)。

3. 代码示例逐行解析

复制代码
#include <stdio.h>
#include <stdlib.h>

int main()
{
    int num = 0;
    scanf("%d", &num);       // 运行时输入需要的元素个数
    // int arr[num];          // 部分编译器支持变长数组,但不是C标准特性,兼容性差
    int* ptr = NULL;
    ptr = (int*)malloc(num * sizeof(int)); // 申请num个int大小的堆空间
    
    if (NULL != ptr)         // 必须判空,防止malloc失败返回NULL
    {
        int i = 0;
        for(i=0; i<num; i++)
        {
            *(ptr+i) = 0;    // 给每个元素赋值为0
        }
    }
    else
    {
        perror("malloc");   // 打印malloc失败的错误信息
        return 1;           // 异常退出程序
    }
    
    free(ptr);              // 释放申请的堆内存,避免内存泄漏
    ptr = NULL;             // 关键:置空指针,防止野指针
    return 0;
}
  • 这里 ptr = NULL;必要的free 只是释放了内存ptr还存着原来的地址 ,不置空的话,后续如果不小心使用 ptr ,就会访问已经释放的内存导致程序崩溃或未定义行为。

二、calloc和realloc

2.1 calloc 详解

函数原型

复制代码
void* calloc(size_t num, size_t size);
  • 参数
    • num:元素个数
    • size:单个元素的字节大小
  • 功能 :在堆区申请一块连续内存,空间大小为 num * size 字节 ,并且会自动把每个字节初始化为 0
  • malloc 的核心区别calloc初始化内存为 0, 而**malloc** 申请的内存里是随机值。

示例解析

复制代码
int *p = (int*)calloc(10, sizeof(int));
  • 这段代码等价于:

    复制代码
    int *p = (int*)malloc(10 * sizeof(int));
    memset(p, 0, 10 * sizeof(int)); // 手动初始化为0
  • 输出结果是 10 个 0 ,就是因为**calloc 自动完成了初始化。**

💡 适用场景:如果你需要申请内存后立刻用 0 初始化,calloc 会比 malloc + memset 更方便、代码更简洁。


2.2 realloc 详解

函数原型

复制代码
void* realloc(void* ptr, size_t size);
  • 功能调整之前通过 malloc/calloc/realloc请的堆内存大小,可扩容也可缩容并且会保留原数据。
  • 参数
    • ptr要调整的内存起始地址 ,如果是 NULLrealloc****行为和 malloc 完全一样
    • size调整后的新大小字节数)。
  • 返回值
    • 成功:返回调整后内存的起始地址(可能和原地址相同,也可能不同)。
    • 失败:返回 NULL原内存保持不变,不会被释放。

扩容的两种核心情况

情况 条件 结果
情况 1:原地扩容 原内存后面有足够的空闲空间 直接在原地址后面追加空间原地址不变数据保留
情况 2:异地扩容 原内存后面没有足够空间 在堆区找一块新的足够大的空间把原数据拷贝过去释放旧空间,返回新地址

关键易错点:realloc 的正确用法

❌ 错误写法(代码 1)
复制代码
ptr = (int*)realloc(ptr, 1000);
  • 问题:如果 realloc 申请失败 ,会返回**NULL** ,直接赋值给 ptr 会导致:
    1. ptr 变成空指针,丢失了原来内存的地址
    2. 原内存无法再被 free ,造成内存泄漏
✅ 正确写法(代码 2)
复制代码
int* tmp = realloc(ptr, 1000);
if (tmp != NULL)
{
    ptr = tmp;
}
else
{
    // 处理扩容失败的情况,比如打印错误、释放原内存
    perror("realloc");
    free(ptr);
    ptr = NULL;
    return 1;
}
  • 要点:先用临时变量接收 realloc 的返回值判空成功后再赋值给原指针避免丢失原内存地址

补充注意事项

  1. 缩容也会有两种情况
    • 原地缩容:直接截断后面的空间,原地址不变
    • 部分实现也可能会异地缩容(少见),所以同样建议用临时变量接收返回值
  2. realloc(ptr, 0) 的行为:标准未定义 ,不同编译器实现不同,不建议使用
  3. 调整后的内存如果是异地扩容原内存会被自动释放不需要你手动 free 原地址

动态内存函数对比表

表格

函数 初始化 调整大小 适用场景
malloc 不初始化(随机值) 不能 不需要初始化、固定大小的动态内存
calloc 初始化为 0 不能 需要申请后立刻用 0 初始化的场景
realloc 不初始化(新空间部分是随机值) 可以扩容 / 缩容 运行时需要调整内存大小的场景

💡 小提示:动态内存的核心是谁申请谁释放不管用哪个函数申请的内存都要记得 free释放后要把指针置为 NULL避免野指针问题。

三、常见的动态内存的错误

1. 对 NULL 指针的解引用操作

错误代码:

复制代码
void test()
{
    int *p = (int *)malloc(INT_MAX/4);
    *p = 20; // 如果p的值是NULL,就会有问题
    free(p);
    p = NULL;
}

问题分析: malloc 申请内存失败 时,会返回 NULL 。如果不检查 p 是否为 NULL直接解引用 *p ,会导致程序崩溃(空指针访问错误)。

修正方法:

复制代码
void test()
{
    int *p = (int *)malloc(INT_MAX/4);
    if (p == NULL) // 必须先检查申请是否成功
    {
        perror("malloc failed"); // 打印错误信息
        return;
    }
    *p = 20;
    free(p);
    p = NULL;
}

2. 对动态开辟空间的越界访问

错误代码:

复制代码
void test()
{
    int i = 0;
    int *p = (int *)malloc(10*sizeof(int));
    if(NULL == p)
    {
        return 1;
    }
    for(i = 0; i <= 10; i++)
    {
        *(p+i) = i; // 当i是10的时候越界访问
    }
    free(p);
    p = NULL;
}

问题分析: 申请了 10个int 大小的空间,合法下标是 0~9,但循环条件 i <= 10 会访问到 p[10]超出了分配的内存范围属于越界访问,会导致未定义行为(崩溃、数据错乱等)。

修正方法:

复制代码
void test()
{
    int i = 0;
    int *p = (int *)malloc(10*sizeof(int));
    if(NULL == p)
    {
        return 1;
    }
    for(i = 0; i < 10; i++) // 改为i < 10,避免越界
    {
        *(p+i) = i;
    }
    free(p);
    p = NULL;
}

3. 对非动态开辟内存使用 free 释放

错误代码:

复制代码
void test()
{
    int a = 10;
    int *p = &a;
    free(p); // 错误!
}

问题分析:free 只能释放malloc/calloc/realloc 动态申请的堆内存a 是栈上的局部变量 ,用 free 释放栈地址会导致程序崩溃。

修正方法: 不要对栈变量的指针使用 freefree 只用于堆内存释放


4. 使用 free 释放一块动态开辟内存的一部分

错误代码:

复制代码
void test()
{
    int *p = (int *)malloc(100);
    p++;
    free(p); // p不再指向动态内存的起始位置
}

问题分析: malloc 返回的是内存块的起始地址free 必须接收这个起始地址 才能正确释放整个内存块。p++ 后指针偏移了指向内存块中间 ,此时 free(p) 会因为找不到内存块的元数据而崩溃

修正方法:

复制代码
void test()
{
    int *p = (int *)malloc(100);
    int *q = p; // 保存起始地址
    p++;        // 操作指针偏移
    free(q);    // 用起始地址释放
    q = NULL;
}

5. 对同一块动态内存多次释放

错误代码:

复制代码
void test()
{
    int *p = (int *)malloc(100);
    free(p);
    free(p); // 重复释放
}

问题分析:同一块堆内存只能被 free 一次重复释放破坏内存管理的元数据,导致程序崩溃或内存损坏。

修正方法:释放后立即将指针置为 NULL,避免误操作重复释放:

复制代码
void test()
{
    int *p = (int *)malloc(100);
    free(p);
    p = NULL;  // 释放后置空
    // free(p); // 此时free(NULL)是安全的(标准规定free(NULL)不做任何操作)
}

6. 动态开辟内存忘记释放(内存泄漏)

错误代码:

复制代码
void test()
{
    int *p = (int *)malloc(100);
    if(NULL != p)
    {
        *p = 20;
    }
}

int main()
{
    test();
    while(1);
    return 0;
}

问题分析:test 函数中申请了内存 ,但没有调用 free 释放 ,且程序一直运行 ,导致这部分内存永远无法被回收,造成内存泄漏长时间运行会耗尽系统内存

修正方法: 申请的内存,使用完毕后必须在所有分支路径都调用 free 释放:

复制代码
void test()
{
    int *p = (int *)malloc(100);
    if(NULL != p)
    {
        *p = 20;
        free(p);  // 用完就释放
        p = NULL;
    }
}

int main()
{
    test();
    return 0;
}

💡 总结一下动态内存的核心原则

  1. 申请必检查malloc 后必须判断返回值是否为 NULL
  2. 访问不越界:严格按照申请的大小访问内存。
  3. 释放要正确:只能释放堆内存,且必须用起始地址释放。
  4. 释放仅一次:释放后立即置空,避免重复释放。
  5. 用完必释放:所有分支路径都要确保内存被释放,防止泄漏。

四、四道经典动态内存笔试题

题目 1:值传递导致的内存泄漏与空指针访问

代码

复制代码
#include <stdio.h>
#include <stdlib.h>

void GetMemory(char *p)
{
    p = (char *)malloc(100);
}

void Test(void)
{
    char *str = NULL;
    GetMemory(str);
    strcpy(str, "hello world");
    printf(str);
}

int main()
{
    Test();
    return 0;
}

运行结果

程序崩溃,大概率触发段错误(Segmentation Fault)。

错误分析

  1. 值传递的问题GetMemory 函数的参数 pstr值拷贝 ,函数内修改的只是形参 p 的副本,无法改变实参 str 的值
  2. 空指针访问 :调用 GetMemory 后,str 仍然是 NULL ,后续 strcpy(str, "hello world")对空指针的解引用,直接触发崩溃
  3. 内存泄漏malloc 申请的 100 字节内存,函数结束后没有任何指针指向它也没有被释放造成内存泄漏

修正方案(传指针的指针)

复制代码
void GetMemory(char **p)
{
    *p = (char *)malloc(100);
}

void Test(void)
{
    char *str = NULL;
    GetMemory(&str);
    if (str != NULL)
    {
        strcpy(str, "hello world");
        printf("%s\n", str);
        free(str);
        str = NULL;
    }
}

题目 2:返回栈地址导致的野指针问题

代码

复制代码
#include <stdio.h>
#include <stdlib.h>

char *GetMemory(void)
{
    char p[] = "hello world";
    return p;
}

void Test(void)
{
    char *str = NULL;
    str = GetMemory();
    printf(str);
}

int main()
{
    Test();
    return 0;
}

运行结果

程序输出乱码或直接崩溃,属于未定义行为

错误分析

  1. char p[] = "hello world";GetMemory 函数内的局部栈数组 ,函数执行结束后,栈帧被销毁,p 指向的内存空间也被回收
  2. 函数返回的 p 是一个野指针指向已经失效的栈内存,后续访问这块内存属于未定义行为,内容不可控。

修正方案(使用静态变量 / 动态内存 / 字符串常量)

复制代码
// 方案1:用static静态变量(内存位于静态区,函数结束后不销毁)
char *GetMemory(void)
{
    static char p[] = "hello world";
    return p;
}

// 方案2:用动态内存(malloc在堆上申请)
char *GetMemory(void)
{
    char *p = (char *)malloc(12);
    strcpy(p, "hello world");
    return p;
}

// 方案3:直接返回字符串常量(常量区,函数结束后不销毁)
char *GetMemory(void)
{
    return "hello world";
}

题目 3:二级指针传递,内存申请成功但未释放

代码

复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

void GetMemory(char **p, int num)
{
    *p = (char *)malloc(num);
}

void Test(void)
{
    char *str = NULL;
    GetMemory(&str, 100);
    strcpy(str, "hello");
    printf(str);
}

int main()
{
    Test();
    return 0;
}

运行结果

程序正常输出 hello ,但存在内存泄漏

分析

  1. 正确点 :通过二级指针 char **p 传递,*p = malloc(num) 能正确修改 str 的值 ,使其指向堆上的 100 字节内存,strcpyprintf 都能正常执行
  2. 问题点malloc 申请的 100 字节内存,在**Test 函数结束后没有调用 free 释放** ,造成内存泄漏。

修正方案(添加 free 释放)

复制代码
void Test(void)
{
    char *str = NULL;
    GetMemory(&str, 100);
    if (str != NULL)
    {
        strcpy(str, "hello");
        printf("%s\n", str);
        free(str); // 释放动态内存
        str = NULL;
    }
}

题目 4:释放后未置空,导致野指针访问

代码

复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

void Test(void)
{
    char *str = (char *)malloc(100);
    strcpy(str, "hello");
    free(str);
    if(str != NULL)
    {
        strcpy(str, "world");
        printf(str);
    }
}

int main()
{
    Test();
    return 0;
}

运行结果

**程序大概率崩溃,或输出不可控的乱码,**属于未定义行为。

错误分析

  1. free(str) 执行后str 指向的堆内存已经被释放,这块内存不再属于程序,可能被系统回收或分配给其他变量。
  2. str 本身的值没有被修改,仍然指向原来的地址 ,此时 str 成为野指针
  3. if(str != NULL) 判断为真 ,执行 strcpy(str, "world")对已释放内存的非法访问,触发未定义行为。

修正方案(释放后置空)

复制代码
void Test(void)
{
    char *str = (char *)malloc(100);
    if (str != NULL)
    {
        strcpy(str, "hello");
        free(str);
        str = NULL; // 释放后立即置空,避免野指针
    }
    if(str != NULL)
    {
        strcpy(str, "world");
        printf(str);
    }
}

💡 这 4 道题的核心考点总结:

题目 核心问题 关键知识点
1 函数传参时指针的值传递 指针的传递方式(值传递 vs 地址传递)
2 返回栈上的局部变量地址 栈内存的生命周期(函数结束即销毁)
3 动态内存未释放 mallocfree 必须成对使用
4 释放后未置空导致野指针 释放后立即置空的重要性

五、柔性数组(结构体+动态内存管理)

1. 什么是柔性数组?

在 C99 标准中,允许结构体的最后一个成员是未知大小的数组 ,这就叫柔性数组成员。它有两种常见写法:

复制代码
// 写法1:部分编译器支持
struct st_type
{
    int i;
    int a[0]; // 柔性数组,大小为0
};

// 写法2:标准写法(兼容性更好)
struct st_type
{
    int i;
    int a[];  // 柔性数组,不指定大小
};

注意:柔性数组必须是结构体的最后一个成员且前面至少有一个其他成员。


2. 柔性数组的核心特点

  1. sizeof 不包含柔性数组的大小 比如上面的 struct st_typesizeof(struct st_type) 的结果是 4仅包含 int i 的大小),柔性数组本身不占结构体的内存空间

  2. 必须用 malloc 动态分配内存 柔性数组的大小是在运行时确定的 ,因此结构体必须用 malloc 一次性分配足够的内存内存大小 = 结构体本身大小 + 柔性数组需要的额外空间

cpp 复制代码
#define _CRT_SECURE_NO_WARNINGS 1​
struct S
{
	int  n;
	int arr[];//柔性数组成员
};
int main()
{    //希望有柔性数组存放五个整型
	struct S* ps = (struct S*)malloc(sizeof(struct S) + 5 * sizeof(int));
	ps->n = 100;
	int i = 0;
	for (i = 0; i < 5; i++)
	{
		ps->arr[i] = 1 + i;
	}
	return 0;
	
}

3. 柔性数组的使用示例(本质是让数组可变)

1. 基础用法(代码 1)

复制代码
#include <stdio.h>
#include <stdlib.h>

typedef struct st_type
{
    int i;
    int a[0]; // 柔性数组
} type_a;

int main()
{
    // 一次性分配:结构体大小 + 100个int的空间
    type_a *p = (type_a*)malloc(sizeof(type_a) + 100 * sizeof(int));
    if (p == NULL)
    {
        perror("malloc failed");
        return 1;
    }

    p->i = 100;
    for(int i = 0; i < 100; i++)
    {
        p->a[i] = i; // 直接像普通数组一样访问
    }

    free(p); // 一次free就释放所有内存
    p = NULL;
    return 0;
}
  • 这里**p->a 就相当于一个大小为 100 的数组** ,和结构体的内存是连续的
  • 释放时只需要**free(p) 一次**,就能释放结构体和柔性数组的所有内存。

2. 对比传统指针写法(代码 2)

不用柔性数组 ,也可以用结构体里嵌套指针的方式实现同样的功能:

复制代码
#include <stdio.h>
#include <stdlib.h>

typedef struct st_type
{
    int i;
    int *p_a; // 用指针代替柔性数组
} type_a;

int main()
{
    type_a *p = (type_a *)malloc(sizeof(type_a));
    if (p == NULL)
    {
        perror("malloc failed");
        return 1;
    }

    p->i = 100;
    // 额外给指针分配100个int的空间
    p->p_a = (int *)malloc(p->i * sizeof(int));
    if (p->p_a == NULL)
    {
        perror("malloc failed");
        free(p);
        p = NULL;
        return 1;
    }

    for(int i = 0; i < 100; i++)
    {
        p->p_a[i] = i;
    }

    // 释放时必须先释放指针指向的内存,再释放结构体本身
    free(p->p_a);
    p->p_a = NULL;
    free(p);
    p = NULL;
    return 0;
}

4. 柔性数组的两大优势

和上面的指针写法相比柔性数组两个明显的好处

1. ✅ 内存释放更简单

  • 柔性数组:一次 free 就能释放所有内存 (结构体和数组的内存是连续的,都在同一块 malloc 出来的空间里)。
  • 指针写法:需要两次 free ,如果用户只释放了结构体指针,忘记释放 p->p_a,就会造成内存泄漏。

2. ✅ 访问速度更快,内存碎片更少

  • 柔性数组的内存是连续的访问数组元素时,只需要在结构体指针的基础上做偏移即可,缓存命中率更高。
  • 指针写法需要两次内存分配,可能导致内存碎片化,而且访问数组时需要先解引用指针 ,再访问元素,多了一层寻址操作

5. 注意事项

  1. 柔性数组不能定义在栈上只能用 malloc 动态分配
  2. 分配的内存大小 必须大于结构体本身的大小否则柔性数组没有可用空间
  3. 柔性数组不能单独使用 ,必须作为结构体的最后一个成员存在

💡 一句话总结:柔性数组就是一种在结构体末尾动态扩展连续内存的技巧,用它能让内存分配和释放更简单、高效,避免内存泄漏和碎片化问题。

六、总结C/C++中程序内存区域划分

1. 四大内存区域详解(对应图里的文字说明)

1.1. 栈区(Stack)

  • 特点:由编译器自动分配释放,函数执行结束时自动回收,向下增长(从高地址往低地址扩展),空间有限。
  • 存放内容
    • 函数内的局部变量 (非 static
    • 函数参数返回值返回地址
    • 函数调用时的栈帧信息
  • 对应图里的变量
    • int localVar = 1;
    • int num1[10] = {1,2,3,4};
    • char char2[] = "abcd";(注意:这里是数组,字符串内容会拷贝到栈上

1.2 堆区(Heap)

  • 特点 :由程序员手动分配释放malloc/calloc/realloc/free),如果不释放,程序结束时可能由操作系统回收,向上增长(从低地址往高地址扩展),空间大且灵活。
  • 存放内容动态分配的内存块
  • 对应图里的变量
    • (int*)malloc(sizeof(int*4));
    • (int*)calloc(4, sizeof(int));
    • (int*)realloc(ptr2, sizeof(int)*4);

1.3 数据段(静态区 / 全局区)

  • 特点 :存放全局变量和静态变量 ,程序结束后由系统释放
  • 细分
    • 已初始化数据段:存放初始化过的全局 / 静态变量
    • 未初始化数据段:存放未初始化的全局 / 静态变量
  • 对应图里的变量
    • int globalVar = 1;(全局变量)
    • static int staticGlobalVar = 1;(静态全局变量)
    • static int staticVar = 1;(静态局部变量)

1.4 代码段(只读段)

  • 特点:存放可执行的机器指令,还有只读常量(比如字符串常量),权限是只读,防止程序意外修改指令。
  • 对应图里的变量
    • char *pChar3 = "abcd";(这里的 "abcd" 是字符串常量,存在代码段;pChar3 本身是局部指针变量,存在栈上)

2. 图里的变量分区对照表

变量 所在内存区域 关键说明
int globalVar = 1; 数据段 全局变量
static int staticGlobalVar = 1; 数据段 静态全局变量
static int staticVar = 1; 数据段 静态局部变量
int localVar = 1; 栈区 普通局部变量
int num1[10] = {1,2,3,4}; 栈区 局部数组
char char2[] = "abcd"; 栈区 数组内容拷贝到栈上
char *pChar3 = "abcd"; pChar3 在栈区"abcd" 在代码段 指针本身在栈,指向的字符串常量在代码段
int *ptr1 = malloc(...); ptr1 在栈区,指向的内存块在堆区 动态分配的内存都在堆上
int *ptr2 = calloc(...); ptr2 在栈区,指向的内存块在堆区 同上
int *ptr3 = realloc(...); ptr3 在栈区,指向的内存块在堆区 同上
相关推荐
咸甜适中4 小时前
rust语言学习笔记Trait之 AsRef 和 AsMut(引用转换)
笔记·学习·rust
leon_teacher4 小时前
HarmonyOS 6 古诗学习宝实战:基于 Preferences 实现错题本自动派生与题级去重系统
学习·华为·harmonyos
Shadow(⊙o⊙)4 小时前
进程分析2.0——进程退出、进程等待-Linux重要经典模块
linux·运维·服务器·开发语言·c++·学习
JackSparrow4144 小时前
彻底理解Java NIO(二)C语言实现 I/O多路复用+Reactor模式 服务器详解
java·linux·c语言·后端·nio·reactor模式
三品吉他手会点灯4 小时前
C语言学习笔记 - 37.数据类型 - scanf函数的基本用法
c语言·开发语言·笔记·学习
草莓熊Lotso4 小时前
【Linux系统加餐】从原理到实战:System V消息队列全解析 + 基于责任链模式的工业级封装
linux·运维·服务器·c语言·c++·人工智能·责任链模式
邪修king4 小时前
C++ 二叉搜索树 (BST) 超全详解:核心原理、完整实现、性能分析与使用场景
数据结构·c++·bst·二叉树搜索树
诙_4 小时前
C++数据结构学习总结
数据结构·c++·学习
浅念-4 小时前
LeetCode回溯算法从入门到精通完整解析
开发语言·数据结构·c++·算法·leetcode·dfs·深度优先遍历