Appearance
动态内存管理
⏱️ 本讲 L1 正课约 70 分钟。核心是一套固定纪律:申请→判空→使用→释放→置空。
🎯 学习目标
学完本讲,你应当能够:
- 说出栈上开辟空间的两个局限,解释为什么需要动态内存。
- 正确调用
malloc、calloc、realloc、free,每次都检查返回值。 - 识别六类常见动态内存错误,并在自己的代码中避免。
- 分析四道 GetMemory 经典笔试题,指出错因并写出正确版本。
- 按标准形式使用柔性数组,说出它相对指针成员的两个优势。
- 画出栈区、堆区、数据段、代码段的分工。
🔗 先修知识
自检三问:
- 什么是野指针?它有哪三种常见成因?
- 二级指针
char **p里,每一层分别指向什么? - 结构体的
.和->分别在什么情况下用?
一、为什么需要动态内存分配
此前我们掌握的开辟方式:
🧩 示意片段 · 两种栈上开辟
c
int val = 20; // 在栈上开辟 4 字节
char arr[10] = {0}; // 在栈上开辟 10 字节连续空间它们有两个共同特点:
- 空间大小在编译时就固定了;
- 数组声明时必须写长度,运行中无法调整。
但很多场景里,"需要多少空间"只有运行时才知道——学生人数要等用户输入、文件行数要读了才知道。这时就要用动态内存分配:程序自己在堆区申请空间,用完再归还。
二、malloc 和 free
2.1 malloc
🧩 函数原型(示意片段) · 声明在 <stdlib.h>
c
void *malloc(size_t size);功能:在堆区申请一块连续可用的空间,返回起始地址。
| 情况 | 说明 |
|---|---|
| 成功 | 返回起始地址,类型是 void *,需使用者按用途转换 |
| 失败(内存不足) | 返回 NULL,所以返回值必须检查 |
| 内容 | 不初始化,字节内容是不确定的 |
| 参数为 0 | 🟡 实现定义:可能返回空指针,也可能返回一个不能解引用的唯一指针(C11 §7.22.3)——不要依赖,也不要对它解引用 |
2.2 free
🧩 函数原型(示意片段)
c
void free(void *ptr);功能:释放 malloc、calloc、realloc 申请的堆空间。
ptr必须是当初动态分配函数返回的原始地址。- 🔴 对非动态开辟的地址(如局部变量地址)调用 free,行为未定义。
free(NULL)什么都不做,是安全的。🟢- free 之后
ptr的值本身不会变,但那块空间已经不属于你——立刻把它置 NULL。
2.3 malloc + free 的标准用法
📄 01_malloc_basic.c · ✅ 完整程序(可直接复制编译)
c
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int num = 0;
printf("请输入元素个数:");
scanf("%d", &num);
int *ptr = (int *)malloc((size_t)num * sizeof(int));
if (ptr == NULL) /* 第 1 步:判空 */
{
perror("malloc");
return 1;
}
for (int i = 0; i < num; i++) /* 第 2 步:使用 */
ptr[i] = i + 1;
for (int i = 0; i < num; i++)
printf("%d ", ptr[i]);
printf("\n");
free(ptr); /* 第 3 步:释放 */
ptr = NULL; /* 第 4 步:置空 */
return 0;
}输入 5 时输出 1 2 3 4 5。注意 (size_t)num 的写法:若 num 为负数,直接乘会得到难以预料的巨大无符号数,工程中还应先校验 num 的范围。
三、calloc 和 realloc
3.1 calloc
🧩 函数原型(示意片段)
c
void *calloc(size_t num, size_t size);为 num 个、每个 size 字节的元素申请空间,并把每个字节都初始化为 0。
📄 02_calloc_demo.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int *p = (int *)calloc(10, sizeof(int));
if (p == NULL)
{
perror("calloc");
return 1;
}
for (int i = 0; i < 10; i++)
printf("%d ", p[i]);
printf("\n");
free(p);
p = NULL;
return 0;
}输出 0 0 0 0 0 0 0 0 0 0。需要零初始化数组时,calloc 比 malloc 后再 memset 更直接。
3.2 realloc
🧩 函数原型(示意片段)
c
void *realloc(void *ptr, size_t size);功能:把已有的动态内存块调整为 size 字节,尽量保留原有数据。
ptr为 NULL 时,等价于malloc(size)。- 成功:返回调整后的地址,地址可能与原来不同。
- 失败:返回 NULL,原来的内存块仍然有效、内容不变——这点非常重要。
扩容时有两种情况:
- 原块后面正好有足够空闲:就地追加,返回原地址。
- 后面空间不够:在堆上另找一块足够大的区域,把旧数据拷过去,释放旧块,返回新地址。
因此不能直接用返回值覆盖原指针,否则一旦失败,旧地址永久丢失(内存泄漏)。安全写法:
📄 03_realloc_safe.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int *ptr = (int *)malloc(100);
if (ptr == NULL)
{
perror("malloc");
return 1;
}
int *tmp = (int *)realloc(ptr, 1000); /* 先用临时指针接住 */
if (tmp != NULL)
{
ptr = tmp; /* 确认成功后再替换 */
}
else
{
perror("realloc");
free(ptr); /* 扩容失败,旧块仍要正常释放 */
ptr = NULL;
return 1;
}
free(ptr);
ptr = NULL;
return 0;
}四、常见动态内存错误
下面六个片段都是反面教材,请勿模仿。
4.1 对 NULL 解引用
🧩 待改错片段
c
#include <stdlib.h>
#include <limits.h>
void test(void)
{
int *p = (int *)malloc(INT_MAX / 4); /* 几乎必然失败 → p = NULL */
*p = 20; /* 对 NULL 写入,程序崩溃 */
free(p);
}对策:malloc 之后第一件事就是判空。
4.2 动态空间越界访问
🧩 待改错片段
c
#include <stdlib.h>
void test(void)
{
int *p = (int *)malloc(10 * sizeof(int));
if (p == NULL) return;
for (int i = 0; i <= 10; i++) /* 有效下标只有 0~9 */
p[i] = i; /* i=10 越界,破坏堆管理信息 */
free(p);
}越界写比越界读更危险,可能在稍后 free 时才爆发,极难排查。
4.3 free 非动态内存
🧩 待改错片段
c
#include <stdlib.h>
void test(void)
{
int a = 10;
int *p = &a;
free(p); /* 栈空间不归堆管:未定义行为,通常直接中止程序 */
}4.4 只释放一部分
🧩 待改错片段
c
#include <stdlib.h>
void test(void)
{
int *p = (int *)malloc(100);
p++; /* p 已经偏离起始地址 */
free(p); /* free 拿到的不是分配时的地址:未定义行为 */
}需要移动指针时,另存一个指向起点的指针,free 时用它。
4.5 重复释放
🧩 待改错片段
c
#include <stdlib.h>
void test(void)
{
int *p = (int *)malloc(100);
free(p);
free(p); /* 同一块释放两次:未定义行为,通常触发中止 */
}释放后立即置 NULL,free(NULL) 安全,重复释放就被化解。
4.6 忘记释放(内存泄漏)
🧩 待改错片段
c
#include <stdlib.h>
void test(void)
{
int *p = (int *)malloc(100);
if (p != NULL)
*p = 20;
/* 函数结束,p 被销毁,但堆上那 100 字节无人能找到 → 泄漏 */
}程序退出后操作系统会回收全部内存,所以小程序短期看不出危害;但服务器程序常年运行,泄漏会一点点吃光内存。纪律:谁申请,谁释放。
五、动态内存经典笔试题
题目 1:值传递 + 泄漏
🧩 待改错片段
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void GetMemory(char *p)
{
p = (char *)malloc(100); /* 只改了形参 p */
}
void Test(void)
{
char *str = NULL;
GetMemory(str); /* 传值:str 本身不变 */
strcpy(str, "hello world"); /* str 仍是 NULL → 崩溃 */
printf("%s", str);
}
int main(void)
{
Test();
return 0;
}两处毛病:① 函数内 malloc 的地址随形参一起销毁——内存泄漏;② str 始终是 NULL,strcpy 对 NULL 写入。想让函数改外面的指针,要传它的地址(见题目 3)。
题目 2:返回栈空间地址
🧩 待改错片段
c
#include <stdio.h>
char *GetMemory(void)
{
char p[] = "hello world"; /* 局部数组 */
return p; /* 返回的地址在函数返回后即失效 */
}
void Test(void)
{
char *str = NULL;
str = GetMemory(); /* str 指向已回收的栈空间 */
printf("%s", str); /* 读到什么没有保证 */
}
int main(void)
{
Test();
return 0;
}gcc 直接给 -Wreturn-local-addr 警告。栈空间还给系统后会被后续调用覆盖,打印结果可能是乱码、空串,也可能"看着正常"——无论哪种都是未定义行为。本机(MSVCRT)实测打印 (null):这是本平台 printf 对 NULL 的 %s 的处理方式,换成别的环境/编译选项结果可能不同。
题目 3:二级指针的正确版本
📄 04_quiz3_correct.c · ✅ 完整程序(可直接复制编译)
c
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void GetMemory(char **p, int num)
{
*p = (char *)malloc(num); /* *p 就是外面的 str */
}
void Test(void)
{
char *str = NULL;
GetMemory(&str, 100); /* 传 str 的地址 */
if (str == NULL)
{
perror("malloc");
return;
}
strcpy(str, "hello");
printf("%s\n", str);
free(str);
str = NULL;
}
int main(void)
{
Test();
return 0;
}输出 hello,无泄漏。注意即使函数负责分配,释放仍在使用者手里;也可以约定"谁分配谁释放",但接口文档必须写清楚。
题目 4:free 之后继续使用
🧩 待改错片段
c
#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) /* str 不会自动变 NULL,条件恒成立 */
{
strcpy(str, "world"); /* 对已释放内存写入:未定义行为 */
printf("%s", str);
}
}
int main(void)
{
Test();
return 0;
}free 不会替你把指针改写成 NULL。修正就是老纪律:free(str); str = NULL;,此后判空才真正有意义。本机实测这段代码仍打印 world——这正是 UB 最迷惑人的地方:旧字节碰巧还在,程序"看起来能跑",但换编译器、加优化或数据稍复杂就可能崩溃或输出错乱,绝不能因为"能打印"就认为写法正确。
六、柔性数组
6.1 标准形式
C99 起,结构体最后一个成员允许是未知大小的数组,称为柔性数组成员:
🧩 类型定义片段
c
struct st_type
{
int i;
int a[]; /* 标准形式:方括号留空(C11 §6.7.2.1) */
};你也可能在旧代码里看到 int a[0];——那是 GNU 扩展的零长数组,不是标准写法,追求可移植应写 a[]。
6.2 柔性数组的特点与用法
- 前面至少有一个其他成员。
sizeof计算该结构体时不含柔性数组(但仍可能含为对齐补的尾填充)。- 必须用 malloc 一次性分配"结构体大小 + 数组预期大小"。
📄 05_flex_array.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
#include <stdlib.h>
typedef struct st_type
{
int i;
int a[];
} type_a;
int main(void)
{
printf("sizeof(type_a) = %zu\n", sizeof(type_a));
type_a *p = (type_a *)malloc(sizeof(type_a) + 100 * sizeof(int));
if (p == NULL)
{
perror("malloc");
return 1;
}
p->i = 100;
for (int i = 0; i < 100; i++)
p->a[i] = i;
printf("p->i=%d, a 的前 5 项:", p->i);
for (int i = 0; i < 5; i++)
printf("%d ", p->a[i]);
printf("\n");
free(p);
p = NULL;
return 0;
}输出:
text
sizeof(type_a) = 4
p->i=100, a 的前 5 项:0 1 2 3 46.3 与"指针成员"方案对比
同样的需求也可以用结构体里放指针实现:
📄 06_pointer_member.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
#include <stdlib.h>
typedef struct st_type2
{
int i;
int *p_a;
} type_b;
int main(void)
{
type_b *p = (type_b *)malloc(sizeof(type_b));
if (p == NULL) { perror("malloc 1"); return 1; }
p->i = 100;
p->p_a = (int *)malloc((size_t)p->i * sizeof(int));
if (p->p_a == NULL) { perror("malloc 2"); free(p); 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;
}两种写法功能相同,柔性数组有两个优势:
- 方便释放:柔性数组一次 malloc、一次 free;指针成员要分配/释放两次。把结构交给别人用时,使用者很难知道"内部成员还得再 free 一次"。
- 利于局部性:柔性数组与结构体头部连续存放,缓存命中更友好,也少一块堆碎片。
七、C/C++ 程序内存区域划分
text
┌───────────────────────────────────────────────┐
│ 内核空间(用户代码不可访问) │
├───────────────────────────────────────────────┤
│ 栈区 stack │
│ · 局部变量、函数参数、返回地址 │
│ · 由系统自动分配释放,效率高 │
│ · 容量有限(常见 1~8 MB) │
├───────────────────────────────────────────────┤
│ 堆区 heap │
│ · malloc / calloc / realloc 分配 │
│ · 由程序员释放;不释放则程序结束时由 OS 回收 │
│ · 容量大,但可能产生碎片 │
├───────────────────────────────────────────────┤
│ 数据段(静态区) │
│ · 全局变量、static 变量 │
│ · 程序结束时由系统释放 │
├───────────────────────────────────────────────┤
│ 代码段(只读区) │
│ · 函数的二进制指令、字符串常量 │
└───────────────────────────────────────────────┘有了这张图,就能解释很多规则:栈小且自动回收(所以大数组、返回栈地址都危险);堆大但要手动管理(所以有泄漏);静态区活到程序结束(所以 static 变量能"记住"上一次的值)。
⚠️ 常见坑与报错表
| 坑 | 现象 | 对策 |
|---|---|---|
| malloc 后不判空 | 内存紧张时空指针崩溃 | 固定套路:判空 + perror |
| 用返回值直接覆盖原指针 | realloc 失败 → 泄漏 | 临时指针接住,成功再替换 |
| 以为 malloc 内容是 0 | 读到随机值 | 需要清零用 calloc |
| free 后不置空 | 判空形同虚设、误碰野指针 | free 后立刻 NULL |
| 指针移动后再 free | 未定义,多为中止 | 保存原始地址 |
| 函数内 malloc 靠形参带出 | 地址丢失、泄漏 | 传二级指针或返回指针 |
| 柔性数组写 a[0] | 依赖 GNU 扩展 | 标准写 a[] |
| 用大 VLA/大栈数组 | 栈溢出(Windows 0xC00000FD) | 大数据改用堆分配 |
🛠️ 动手练习
📊 本讲网页练习进度0 / 8(0%)
进度自动保存在本浏览器;编程题不计数,请在编辑器中完成。
第 1 题 · 知识点:malloc 基础 · 难度:⭐
第 2 题 · 知识点:free 规则 · 难度:⭐
第 3 题 · 知识点:calloc · 难度:⭐
calloc(10, sizeof(int)) 申请到的每个 int 初值是多少?(填数字)
空①
第 4 题 · 知识点:realloc 失败语义 · 难度:⭐⭐
第 5 题 · 知识点:错误识别 · 难度:⭐⭐
下列哪些属于动态内存错误?
第 6 题 · 知识点:笔试题 1 分析 · 难度:⭐⭐
void GetMemory(char *p) { p = malloc(100); } 调用后,外部 char *str 的状态是?
第 7 题 · 知识点:柔性数组 · 难度:⭐⭐
c
struct F { int i; int a[]; };sizeof(struct F) 是多少?(填数字)
空①
第 8 题 · 知识点:综合实践 · 难度:⭐⭐⭐
写两个函数:按行列数动态创建二维数组(返回二级指针),以及配套的释放函数。
🛠 动手编程题 · 请在 VS2026(或你的编辑器)中完成
独立完成后再对照参考答案。
✅ 过关标准:3 行 4 列创建后能正常访问 arr[2][3];中途某行分配失败时,已分配的全部空间都要释放;释放函数对每一行和行指针数组各 free 一次。
💡 思路
先 malloc 一个"行指针数组",再为每一行 malloc;失败时按相反顺序回滚。
🧭 步骤
malloc(rows * sizeof(int *))。- 循环为每行
malloc(cols * sizeof(int)),失败则释放已申请的行和行数组。 - 释放时先逐行 free,再 free 行数组。
🔑 参考答案与解释
c
#include <stdio.h>
#include <stdlib.h>
int **create_2d_array(int rows, int cols)
{
int **arr = (int **)malloc((size_t)rows * sizeof(int *));
if (arr == NULL)
{
perror("malloc rows");
return NULL;
}
for (int i = 0; i < rows; i++)
{
arr[i] = (int *)malloc((size_t)cols * sizeof(int));
if (arr[i] == NULL)
{
for (int j = 0; j < i; j++)
{
free(arr[j]);
arr[j] = NULL;
}
free(arr);
return NULL;
}
}
return arr;
}
void free_2d_array(int **arr, int rows)
{
if (arr == NULL) return;
for (int i = 0; i < rows; i++)
{
free(arr[i]);
arr[i] = NULL;
}
free(arr);
}这种"指针数组"方式各行不必连续;需要真正连续的二维存储时,可一次 malloc 出 rows×cols,再用数组指针访问。
网页内无法练习写代码,亲手敲、亲手编译才能真正学会。下方按顺序展开 思路 → 步骤 → 答案。
📝 小结与自测
本讲的主线是堆内存的完整生命周期:
- 四个函数:malloc(不初始化)、calloc(清零)、realloc(调整,可能搬家)、free(归还)。
- 一套纪律:申请必判空、用完必释放、释放必置空;realloc 用临时指针;分配与释放成对出现。
- 四类考点:NULL 解引用与越界、错误 free、泄漏与返回栈地址、值/址传递。
- 柔性数组:标准写法
int a[];,一次分配一次释放,连续且好管理。
自测三问:
- 为什么在函数里
void f(int *p) { p = malloc(64); }带不出内存?怎样改才行? - realloc 返回的地址为什么可能变?直接覆盖原指针有什么后果?
- 用柔性数组相比"结构体 + 内部指针两次分配",好在哪里?
📖 点击查看答案
- p 是 str 的副本,改副本不影响 str,且 malloc 的地址泄漏;改为
void f(int **p) { *p = malloc(64); },调用f(&str),或者让函数返回新指针。 - 原块后面没有足够连续空间时,分配器会另找一块、拷数据、释放旧块;若直接覆盖,realloc 一旦返回 NULL,旧地址无人保存,数据和空间一起失控。
- 只需一次 malloc/一次 free,使用者不会漏掉内部释放;头部与数据连续,缓存局部性好、堆碎片少。
🔬 选学拓展(L3)
WARNING
以下内容不影响本讲目标。本机 MinGW 未附带 AddressSanitizer 运行库(-fsanitize=address 链接失败),因此下面的实验改用可直接运行的方式完成。
拓展 1:亲眼看 realloc "搬家"
📄 L3_realloc_move.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
char *p = (char *)malloc(32);
if (p == NULL) return 1;
printf("旧地址:%p\n", (void *)p);
char *q = (char *)realloc(p, 1024 * 1024); /* 要一块 1 MB */
if (q == NULL)
{
perror("realloc");
free(p);
return 1;
}
printf("新地址:%p\n", (void *)q);
printf(q == p ? "就地扩容\n" : "整体搬迁\n");
free(q);
return 0;
}本机实测:
text
旧地址:0000015D6731A330
新地址:0000015D67476040
整体搬迁每次运行的地址会变化,"搬不搬"也取决于堆的空闲布局(🟠不可事先假定),但"保留旧数据"由标准保证。
拓展 2:有哪些工具帮你抓内存错误
- Visual Studio 调试诊断工具:能显示堆内存增长曲线,辅助发现泄漏。
- Linux valgrind:
valgrind --leak-check=full ./程序可精确报出泄漏位置、越界访问。 - AddressSanitizer:Clang/ gcc(Linux)加
-fsanitize=address编译,运行时直接打印越界和释放后使用的调用栈;Windows 上可用 Visual Studio 或带 ASan 的 Clang。 - 最小土办法:封装
xmalloc,在分配和释放处打印日志,长期运行观察是否成对。
拓展 3:malloc 真的"用多少要多少"吗
malloc(1) 内部往往要按 16 字节对齐、还要记账(块大小、空闲链表指针),实际占用大于 1 字节;频繁申请大量小块会产生可观开销和碎片。工程上处理海量小对象时,常用"内存池":一次申请一大块,自己切分复用——这正是柔性数组和池化思想的用武之地。