详解C语言中动态内存管理
前言
C 语言没有new、没有容器、没有析构函数,动态内存全靠四个函数:malloc、calloc、realloc、free。它们只负责分配和释放原始字节,不调用构造,也不记录长度。因此「分配了多少、谁负责释放、释放后还有没有指针指向它」这三个问题,必须由程序员自己保证。
最常见的错误是释放后继续使用(use-after-free)和重复释放(double free)。这两种错误在很多情况下不会立刻崩溃,而是悄悄破坏分配器的内部结构,等到程序运行到别处才出问题。另一个高频错误是realloc的返回值直接赋回原指针:扩容失败时返回NULL,原内存仍然有效,但指针已经丢失,造成泄漏。
本文按「分配—使用—扩容—释放」的顺序讲清四个函数的语义,给出一个完整的动态数组示例,并列出空指针、长度计算、所有权这几个容易忽略的细节。示例以 C11 为标准。
一、四个函数的语义
| 函数 | 作用 | 初始化 | 失败返回 |
|---|
malloc(size) | 分配size字节 | 不初始化 | NULL |
calloc(count, size) | 分配count * size字节 | 全部置 0 | NULL |
realloc(ptr, size) | 调整已分配块的大小 | 新增部分不初始化 | NULL,原块不变 |
free(ptr) | 释放malloc/calloc/realloc返回的块 | 不适用 | 无返回值 |
malloc返回的指针对齐到适合任何对象类型的边界,因此可以安全地转换成int*、double*或结构体指针。calloc会把所有字节置零;对整数这是「值为 0」,对指针这是「空指针」,但对浮点数是否等同于0.0、对某些平台是否等同于空指针,标准只保证「所有位为零」,不要过度依赖。
realloc的行为要分三种情况:
- 成功且能原地扩展:返回原指针,内容不变。
- 成功但需要搬家:分配新块、复制旧内容、释放旧块,返回新指针。
- 失败:返回
NULL,原块保持不变,仍然需要由调用方释放。
因此不能写p = realloc(p, n),否则失败时p被覆盖成NULL,旧块泄漏。
free(NULL)是合法的,什么也不做;但free一个不是由上述函数返回的指针(例如栈数组地址、已经释放过的指针、malloc返回指针加偏移后的地址)是未定义行为(undefined behavior, UB)。
二、完整的动态数组示例
下面的程序分配一个int数组,填值后扩容,再释放。每一步都检查了失败路径。
/* 适用于 C11 */
#include <stdio.h>
#include <stdlib.h>
int main(void) {
size_t n = 10;
int* values = malloc(n * sizeof *values);
if (values == NULL) {
return 1;
}
for (size_t i = 0; i < n; ++i) {
values[i] = (int)(i * i);
}
size_t newSize = n * 2;
int* grown = realloc(values, newSize * sizeof *values);
if (grown == NULL) {
free(values); /* 扩容失败,原数组仍然有效,必须释放 */
return 1;
}
values = grown;
for (size_t i = n; i < newSize; ++i) {
values[i] = 0; /* realloc 新增的部分不保证初始化 */
}
for (size_t i = 0; i < newSize; ++i) {
printf("%d ", values[i]);
}
printf("\n");
free(values);
values = NULL; /* 降低悬垂指针被误用的概率 */
return 0;
}sizeof *values是一个值得坚持的写法:它不依赖元素类型名,即使以后把int改成long long,分配大小也会自动跟着变。写成sizeof(int)在重构时容易漏改。
指针置NULL只能降低误用概率,不是万能药。如果还有其他指针指向同一块内存,置空一个并不能让它们失效;根本办法是明确所有权,让每块内存在任何时刻只有一个「负责人」。
三、长度计算与溢出
malloc(n * sizeof *p)中的乘法可能溢出。若n是外部输入,攻击者可以传入一个让n * size回绕成小值的数,分配出远小于预期的缓冲区,后续写入就越界。防御性写法是先检查:
/* 适用于 C11 */
#include <stdint.h>
#include <stdlib.h>
void* allocateArray(size_t count, size_t elementSize) {
if (elementSize != 0 && count > SIZE_MAX / elementSize) {
return NULL; /* 乘法会溢出 */
}
return malloc(count * elementSize);
}SIZE_MAX定义在<stdint.h>中。calloc(count, size)的语义是分配能容纳count个元素的空间;如果乘积超出size_t能表示的范围,实现无法满足这个请求,通常会返回NULL。在需要零初始化时,它比malloc加memset更省心,但仍不要把它当成唯一的溢出防线,外部输入的长度还是要先检查。
malloc(0)的返回值由实现定义:可能返回空指针,也可能返回一个可以传给free的非空指针。无论哪种,都不能解引用。若逻辑上需要零长度,最好在接口层直接拒绝或返回空指针,避免依赖实现细节。
四、所有权与生命周期
动态内存的问题几乎都能归结为所有权不清。建议在项目里约定:
| 场景 | 所有权约定 |
|---|
| 函数返回指针 | 调用方负责free,在文档中写明 |
| 函数接收指针 | 默认只借用,不释放;若要接管,函数名或注释必须说明 |
| 结构体成员指针 | 由结构体的销毁函数统一释放 |
| 扩容 | 用临时指针接realloc,成功后再覆盖原指针 |
| 释放后 | 不再访问;可置NULL,但不要依赖它解决别名问题 |
对于结构体,通常写一对create/destroy函数,把分配和释放配对封装起来。这样调用方不需要知道内部有几个malloc,也不容易漏掉某个成员。
常见坑点
- ❌
realloc的返回值直接赋回原指针。
/* 适用于 C11 */
p = realloc(p, newSize); /* 失败时 p 变成 NULL,旧块泄漏 */
if (p == NULL) { return -1; }✅ 用临时指针接收,成功后再赋值。
/* 适用于 C11 */
void* tmp = realloc(p, newSize);
if (tmp == NULL) { free(p); return -1; }
p = tmp;- ❌ 释放后继续访问。
/* 适用于 C11 */
free(p);
printf("%d\n", p[0]); /* use-after-free,UB */✅ 释放后不再使用,必要时把指针置空。
/* 适用于 C11 */
free(p);
p = NULL;- ❌ 重复释放。
/* 适用于 C11 */
free(p);
free(p); /* double free,UB */✅ 每块内存只释放一次;置空后再free(NULL)虽然安全,但不能替代所有权设计。
/* 适用于 C11 */
free(p);
p = NULL;- ❌ 用
sizeof(指针)计算元素大小。
/* 适用于 C11 */
int* p = malloc(n * sizeof(p)); /* 只分配了 n 个指针大小,通常偏小 */✅ 用sizeof *p。
/* 适用于 C11 */
int* p = malloc(n * sizeof *p);- ❌ 在 C 中强制转换
malloc的返回值。
/* 适用于 C11 */
int* p = (int*)malloc(n * sizeof *p); /* 可能掩盖忘记包含 stdlib.h 的错误 */✅ C 中直接赋值,让void*隐式转换。
/* 适用于 C11 */
int* p = malloc(n * sizeof *p);- ❌ 对栈上数组或偏移后的指针调用
free。
/* 适用于 C11 */
int arr[10];
free(arr); /* 不是 malloc 返回的块,UB */
free(p + 1); /* 不是块起始地址,UB */✅ 只释放分配函数返回的原始指针。
/* 适用于 C11 */
free(p);- ❌ 认为
realloc新增的内存已经清零。
/* 适用于 C11 */
int* p = realloc(old, 2 * n * sizeof *p);
/* 直接读取 p[n..2n-1],值是未指定的 */✅ 手动初始化新增部分。
/* 适用于 C11 */
for (size_t i = n; i < 2 * n; ++i) { p[i] = 0; }- ❌ 不检查
malloc是否返回NULL。
/* 适用于 C11 */
int* p = malloc(n * sizeof *p);
p[0] = 1; /* 分配失败时解引用空指针,UB */✅ 每次分配后检查,并设计好失败路径。
/* 适用于 C11 */
if (p == NULL) { /* 处理错误 */ }总结
| 操作 | 关键点 |
|---|
malloc | 不初始化,检查NULL,用sizeof *p |
calloc | 零初始化,乘法溢出时通常失败返回NULL |
realloc | 用临时指针,失败时原块仍有效 |
free | 只释放原始指针,每块一次,free(NULL)安全 |
| 长度 | 防乘法溢出,malloc(0)不解引用 |
| 所有权 | 每个块有唯一负责人,接口写清约定 |
动态内存管理没有捷径,核心是两条纪律:每次分配都想好谁释放,每次释放后都不再碰。把这两条落实到接口设计和代码审查里,比事后用工具排查更有效。