到目前为止,我们定义的所有变量和数组,大小都是在写代码时就定好了的——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)工作流程是这样的:
- 先查空闲链表(free list):堆管理器维护着一批"已释放但未归还"的内存块。如果链表中有一块大小足够(或可以切分),直接返回它——大多数 malloc 根本不经过操作系统,因为频繁的系统调用太慢了。
- 不够就向操作系统要:如果空闲链表里没有合适的块,对小块内存(默认阈值以下,如 128KB 以内),通过
brk/sbrk系统调用把堆顶指针(program break)往上推;对大块内存,通过mmap直接映射一块匿名内存。 - 记录元数据:在返回给调用者地址的"前面"(低地址处)存放这块内存的大小等信息——这解释了为什么
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 分配时按对齐/头部元数据多出的部分)是堆管理器要应对的两大难题。缓解手段:
- 少用 malloc:能用栈数组就用栈数组,能复用缓冲区就复用。
- 大块一次性分配:像柔性数组那样,把"结构体 + 数据"合并成一次 malloc。
- 内存池:预先分配一大块,自己在上面分配小对象——这是游戏引擎和嵌入式系统的常用手段。
总结
到这里,动态内存管理的核心就讲完了。从 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 | 用完归还 |
思考题
malloc和calloc的区别是什么?什么时候该用哪一个?为什么 calloc 的参数设计成两个?- 为什么
ptr = realloc(ptr, new_size)是危险的?如果 realloc 失败,会发生什么? - 在
void f() { int *p = malloc(100); }中,为什么 p 是局部变量但 malloc 的内存不会被自动释放? - 什么情况下会出现"悬垂指针"?如何用
free(p); p = NULL;消灭它? - 动态分配一个
int a[3][4](3 行 4 列),用二级指针实现,并说明释放顺序为什么必须是"先逐行、再整体"。 - 柔性数组的
sizeof(struct)为什么不含数组部分?如果data[]换成data[0]或data[1]呢? - 为什么"翻倍扩容"的总开销是 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 把次数做成了"连续自然数和"(二次增长),翻倍把它变成"等比数列和"(线性增长)。元素越多,翻倍越省——这也是大数组扩容必须翻倍而非逐项递增的根本原因。
还没有评论 — 第一条由你来留。