第 9 章 · 指针与动态内存

栈上的内存是「包月制」:作用域到了自动回收,省心但死板。堆是「按天租房」:想要多久住多久,但退房(free)全靠自己记。手动管理是 C 的自由,也是 C 的原罪,本章把 malloc 家族四兄弟讲透,再把我自己踩过的、教材里写着的、以及 ASan 替我抓过的内存错误整理成一张「案发记录」。原文小节编号错成 8.x,一并修正。

9.1 malloc / calloc:申请的两副面孔

int *p = malloc(100 * sizeof(int));    // 100 个 int,内容是垃圾
int *q = calloc(100, sizeof(int));     // 同样大小,但全部清零

三件事必须刻进手指记忆。用 sizeof 算大小:malloc(100) 是 100 字节(约 25 个 int),写错不报错、越界等着你;永远 n * sizeof(type)。检查返回值:失败返回 NULL,直接解引用就是空指针解引用(第 1 章的案卷)。垃圾值是真的垃圾:malloc 不打扫,新拿的内存里是上一位房客的遗留,读它是未定义行为,要零初始化用 calloc(它还顺手防了乘法溢出)。

malloc 底下是什么?glibc 的分配器维护着空闲块链表,小块找堆顶(brk)、大块找内核要独立映射(mmap,操作系统课第 6 章讲过这条分工);free 把块挂回空闲链。理解这一层,「free 之后为什么指针还有值」「为什么 free(NULL) 安全」「为什么不能 free 栈上指针」全都自动有答案:free 操作的是分配器的记账结构,传一个不是它发的地址,就是把账记烂。

9.2 free:悬空指针与 double free

free(p);
p = NULL;      // 老兵的习惯:退房后把钥匙销毁

free 之后 p 里还存着旧地址,但那块内存已归还,这个状态叫悬空指针:再解引用是 UB(可能读到新住客的数据),再 free 一次是 double free(堆的记账被破坏,后续 malloc 可能返回重叠的块,故障点远在案发千里之外)。置 NULL 的妙处:free(NULL) 是标准保证的无操作,后续误用要么无害要么干净地崩,把「悄悄错」变成「大声错」。

9.3 realloc:四兄弟里最阴的一个

int *tmp = realloc(p, 8 * sizeof(int));   // 扩容
if (!tmp) { /* 失败:p 还有效,别覆盖! */ }
p = tmp;                                   // 成功才换手

realloc 的行为分三种:原地扩(后面正好有地)、搬家扩(新块、拷贝、释放旧块,旧指针全部失效)、失败(返回 NULL,原块原样保留)。第三种是陷阱的产地:教科书错误写法 p = realloc(p, new); 在失败时把 p 覆盖成 NULL,原来的内存从此无人认领,泄漏 + 悬空双杀。正确姿势就是上面的「先存临时、验证再赋值」,一行顺序,人命关天。

9.4 案发记录:四大案型

案型症状现场特征
内存泄漏跑得越久内存越肥长期服务 RSS 缓涨不落;忘了 free / 异常路径跳过了 free
悬空读写偶发脏数据或崩free 后还在用;返回了指向局部对象的指针(第 2 章)
double free崩在看似无关的 malloc两个拥有者都以为该自己 free;free 后没置 NULL
越界写堆崩在很久之后的 free溢出写坏了堆块的元数据(块头),案发与案发现场相隔甚远

这些案子的共同困难是案发地不在案发现场:越界写时可能毫无动静,几百万次 malloc 之后的某一次才崩。裸肉眼看代码基本没戏,上工具:编译加 -fsanitize=address(ASan,实时抓越界、悬空、double free,报错直接带调用栈),或跑 valgrind(慢十倍但不用重编译)。我个人的建议流程:开发期全程 ASan,CI 里跑 valgrind,两者抓这类案子几乎是秒破。

9.5 工程化:把纪律封装起来

与其每次默写纪律,不如写进工具函数,让错误在公共层被消化:

#include <stdlib.h>
#include <stdio.h>

void *xmalloc(size_t n) {            // 检查 + 失败即退,调用方不再重复检查
    void *p = malloc(n);
    if (!p) { fprintf(stderr, "out of memory\n"); exit(1); }
    return p;
}

(失败策略按项目定:守护进程可能选择降级重试,而不是 exit,但「绝不解引用 NULL」的底线不变。)再配两条结构级纪律:谁申请谁释放(一个模块里 malloc 和 free 最好同层出现,别让所有权跨层漂移),以及复合结构的析构函数(数据结构课第 7 章的「先逐个、后整体」)。C 没有析构函数,就把「创建/销毁成对函数」当作自己的 RAII,所有结构体类型都配一对 xxx_create/xxx_destroy,泄漏概率断崖式下降。

常见错误

  • realloc 直接赋回原指针。9.3 的主案,永远走临时变量。
  • 返回栈缓冲区,或返回了 free 过的指针。前者第 2 章讲过,后者是悬空的近亲;要带出数据,堆上分配 + 约定谁释放,或调用者传缓冲区。
  • 以为「free 了就没泄漏」。free 的指针如果还被别的结构引用着(use-after-free 的温床),或者同一块内存被两个模块各 free 一次,问题比泄漏更糟;所有权要想清楚再写代码。

练习

  1. 动手:实现动态数组(第 2 章的 SeqList 堆上版):malloc 初始、满了 realloc 翻倍(先存临时!),配 create/destroy 成对函数;用 ASan 跑一轮增删,确认零泄漏零越界。
  2. 动手:制造四种案型各一个最小程序,分别用 ASan 跑,抄录 ASan 的报错原文(它连案发现场调用栈都给你),体会「工具先于聪明」。
  3. 手算:int **m = malloc(n * sizeof(int*)); 后每行 malloc(cols * sizeof(int)),若第 k 行分配失败,写出正确的回滚代码(把前面 k 行全释放,再释放行指针数组,返回 NULL)。
  4. 思考:Java 的 GC 免除了泄漏和悬空,却带来 GC 暂停和「内存用不完也可以不还」的不确定性。从「服务器低延迟」和「嵌入式小内存」两个场景,分别评估 GC 和手动管理谁更合适,再想想 Rust 的所有权模型是在两者之间抄了哪边的作业。