C 动态内存(malloc/free)
普通变量在栈上自动分配、函数返回就回收。但有时你不知道要多大内存(运行时才知道数组多大),或者要一块"超出函数生命周期"的内存——这时用堆,手动申请、手动释放。C 没有 GC(垃圾回收),内存生命周期完全靠你——这是 C 强大的根源,也是它容易出 bug 的根源。
一、内存布局:栈 vs 堆
理解动态内存前,先理解程序内存的布局:
// C 程序的内存布局(从低地址到高地址):
// ┌─────────────────┐ 高地址
// │ 栈(stack) │ ← 局部变量、函数参数、返回地址
// │ ↓ │ 向下增长,函数返回自动回收
// │ │
// │ ... │
// │ │
// │ ↑ │
// │ 堆(heap) │ ← malloc 分配的内存,手动管理
// ├─────────────────┤
// │ BSS 段 │ ← 未初始化的全局/static 变量(自动清0)
// ├─────────────────┤
// │ 数据段 │ ← 已初始化的全局/static 变量
// ├─────────────────┤
// │ 代码段 │ ← 程序指令(只读)
// └─────────────────┘ 低地址
// 栈 vs 堆 的区别:
// 栈:自动分配回收,速度快,空间小(通常 1~8 MB)
// 堆:手动分配回收,速度慢,空间大(几乎可用全部内存)栈和堆的核心区别:
- 栈:局部变量。自动分配回收,速度极快,但空间小(Linux 默认 8MB)、大小编译期确定。
- 堆:malloc 分配的内存。手动分配回收,空间大(几乎全部可用内存),运行时决定大小。
什么时候用堆?(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;
}三个要点:
- 参数是字节数:通常写
n * sizeof(T),别忘了 sizeof,否则空间不够。 - 返回
void *:可以隐式转任何类型,但显式写(int *)更清晰(C++ 强制要求转换)。 - 失败返回 NULL:内存不足时会失败,必须检查!否则解引用 NULL 会崩溃。
- malloc 不清零:返回的内存里是垃圾值,必须自己初始化。
三、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 旧的)。所以正确写法是用临时变量接收,检查成功后再赋回原指针:
int *tmp = realloc(arr, new_size);if (tmp) arr = tmp;(失败时 arr 仍指向原内存,可继续用或 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;
}- 内存泄漏(memory leak):malloc 了不 free。短期程序无所谓,长期运行的服务(服务器、浏览器)会因为内存越占越多最终崩溃。
- 重复释放(double free):同一块内存 free 两次。会破坏堆的数据结构,程序崩溃或被攻击。
- 释放后使用(use after free):free 后继续访问指针。行为未定义,可能拿到旧数据,可能崩溃,也是安全漏洞的高发地。
预防原则:谁分配谁释放;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 都这么干)。
七、内存管理工具
调试内存问题的两大杀器:
- valgrind(Linux):运行程序后报告所有内存错误(泄漏、越界、未初始化)。命令:
valgrind --leak-check=full ./程序。 - AddressSanitizer(ASan):编译时加
-fsanitize=address -g,运行时实时检查,速度比 valgrind 快 10 倍。gcc/clang 都支持。 - mtrace:glibc 自带的简单内存追踪。
写 C 程序时强烈建议开发期就开启 ASan,能在第一时间发现内存错误,避免后期 debug 到崩溃。
八、所有权与生命周期
C 不像 Rust 有明确的所有权模型,但项目里通常有约定来管理内存:
- 谁分配谁释放:函数返回 malloc 的内存,文档里要写明"调用者负责 free"。
- 成对的 alloc/free API:如
fopen/fclose、SDL_CreateWindow/SDL_DestroyWindow——名字对称,提示用户配对调用。 - 避免部分初始化的泄漏:如果在 malloc 多块内存中途失败,要记得 free 已经分配的。
大型 C 项目(Linux 内核、Redis)会发展出自己的内存管理规范,比如引用计数、对象池。C++ 的 RAII、智能指针本质上就是把这些规范语言化。
小结
栈自动管理、堆手动管理。malloc 申请(不清零)、calloc 申请并清零、realloc 调整大小、free 释放。三大错误:泄漏、重复释放、悬垂指针——用 valgrind/ASan 检测。养成"malloc 必检查 NULL、free 后置 NULL、谁分配谁释放"的习惯。下一篇看 C 的预处理器。
← 上一篇 C 结构体
下一篇 C 预处理器 →