ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

悬挂指针与野指针全解析:从use-after-free排查到防御性编码

悬挂指针与野指针全解析:从use-after-free排查到防御性编码

1. 悬挂指针不是"野指针":先把概念拆开揉碎

我见过不少初学者把这两个词混着用,结果在排查问题时方向就偏了。悬挂指针(dangling pointer)指的是那块内存曾经属于你、你确实拥有过它、后来你把它还回去了,但指针变量本身还留着旧地址。野指针则是从头到尾就没指向过任何合法对象,可能是未初始化的栈变量,也可能是越界算出来的地址。两者的共同点是解引用都算未定义行为,但成因、排查手法、修复策略完全不同。搞混了,你就会在一个已经被释放的堆块上反复找"是不是越界写坏了",白白浪费半天。

这篇内容面向已经能写 malloc/free、能跑通链表和动态数组的同学,也适用于那些"代码在我机器上跑得好好的,换台机器就在客户现场崩"的场景。我不会只丢一句"free 之后置 NULL"就完事,而是要把悬挂指针是怎么产生的、为什么它总在关键时刻躲起来、以及一套从编译期到运行期层层设防的完整做法讲透。读完你至少应该能做到两件事:一是看到一段涉及动态内存的代码,能肉眼扫出潜在的悬挂风险点;二是项目里一旦出现那种玄学的间歇性崩溃,你知道该按什么顺序上工具,而不是靠打印大法一次次试。

1.1 一个看起来"完全正常"的崩溃现场

某开发者写了个小型的配置解析模块,跑单元测试全绿,本地调试也没问题。上线之后开始出现偶发的段错误,频率大概一天一两次,日志里完全看不出规律,有时候连续跑几千次都没事,有时候启动三分钟就崩。他把崩溃位置打印出来,发现是读取配置项名称时挂掉的,那个字符串指针指向的内容每次打印出来还不一样,有时候是乱码,有时候居然还是原来的配置名。这就是悬挂指针最经典的伪装:函数内部用 malloc 分配了字符串、塞进一个全局的表里、然后某个清理逻辑把临时副本 free 掉了,而表里存的正是同一个地址。崩溃点离真正的错误点隔了几百行代码、跨了好几个函数调用。

我想强调一个很多人没意识到的点:悬挂指针不一定会立刻崩溃。堆管理器回收内存时,通常只是把这块区域标记为可用,并不会把它清零。所以你 free 之后继续读,有很大概率读到的还是旧数据,程序照常往下跑,一切看起来很正常。等到后续某次 malloc 恰好复用了这块内存,内容被覆盖成别的东西,你再去读就出问题了。这就解释了为什么它总是"间歇性"发作——它依赖分配器的具体行为、依赖程序运行时的内存申请节奏。能稳定复现的悬挂指针反而是幸运的,最难搞的就是这种靠运气触发的。

还有一个细节值得知道:在常见的 glibc 环境下,free 掉的小块内存会被放进线程缓存,而这个缓存结构本身要占用这块内存的头部十几个字节来存链表指针。也就是说,你刚 free 完,原来那块内存的前 16 个字节(64 位平台)可能已经被写成了分配器的内部数据。如果那块内存里存的是字符串,你会看到前面几个字符凭空变成了乱码,后面的字符却还在。这个现象非常有辨识度,看到这种"前半截乱、后半截对"的字符串,基本可以断定是 use-after-free。

1.2 悬挂指针与野指针的边界在哪

我用一张表把两者的差异列清楚,后面排查时对着这张表判断,能省很多弯路。

对比项悬挂指针野指针
地址来源曾经合法,指向已释放或已失效的对象从未指向合法对象,或地址完全无效
典型成因free 后未置空、返回栈地址、realloc 搬移、别名释放指针未初始化、越界计算、强制类型转换错误
内存状态地址在堆区或已退栈的栈区,可能仍可读地址可能落在任意区域,甚至不可访问
崩溃特征间歇性,可能读到旧数据或乱码更容易稳定崩溃,也可能是静默错误
检测难度需要跟踪对象生命周期,工具依赖较重静态检查更容易发现,习惯养成后基本可避免
主要防线所有权约定、释放即置空、动态检测工具声明即初始化、编译告警清零

这张表里最关键的一行是"崩溃特征"。野指针因为地址本身就离谱,往往一解引用就触发段错误,反而好定位;悬挂指针因为地址曾经是对的,访问权限那一刻甚至还是合法的(那块内存还在进程的堆里),所以它更倾向于"悄悄地把错误数据带进业务逻辑",而不是干脆利落地崩掉。从工程危害上讲,悬挂指针比野指针更麻烦,因为它污染的是数据,而数据错误会顺着调用链往下游扩散。

还有一个中间地带要提一句:指向已经退栈的局部变量的指针。严格说它也是悬挂指针的一种,但它的地址落在栈区,那块栈空间随时会被下一个函数调用覆盖。这类问题的表现是"函数刚返回时读还是对的,多调用几层之后就变了",排查时如果你发现某个指针指向的内容"随着调用深度变化",那基本上就是它了。

1.3 为什么它偏偏在你演示的时候不崩

我自己踩过不止一次:写了个 demo 想复现线上问题,结果跑一百遍都不崩。原因很简单,demo 里的内存申请模式太规律了,free 完立刻就退出进程,分配器根本没机会把那块内存重新分配出去,所以读到的一直是旧值。要复现悬挂指针,关键在于制造"释放之后紧接着有新的分配"这个时序,让新数据覆盖旧数据。

具体的做法有几种,都很实用。第一种是在 free 之后立刻再 malloc 一块同样大小的内存并写入特征值,看原来的指针是不是被"串改"了。第二种是用环境变量让分配器主动填充释放的内存,把脏数据变成显眼的填充字节,这样只要读到不该读的东西,一眼就能看出来。第三种是直接上动态检测工具,让它在每次访存时做影子内存比对,这是最省力的方案,代价是程序会慢几倍到十几倍,但排查阶段完全值得。

我在实际项目里的习惯是:单元测试一律开着内存检测工具跑,只在性能测试时关掉。这样一来,绝大多数悬挂访问在提交代码之前就被拦住了,根本轮不到线上暴露。这个习惯听起来很基础,但我见过的团队里真正坚持下来的不多,而恰恰是这一类问题,靠代码评审是很难看出来的,必须让机器去跑。

2. 五种真实项目里最常见的悬挂来源

讲完概念,进入实战。我把这些年见过的悬挂指针问题归了归类,绝大多数都逃不出下面几种模式。你可以把这几节当成一份检查清单,写新模块时对着过一遍,能提前排掉大部分雷。每一条我都会给一段能跑的代码,你可以直接复制下来编译,配合后面的检测工具验证,效果比光看文字强得多。

2.1 free 之后指针还留着——最常见也最容易改

这是占比最高的一类,代码长这样:

#include <stdio.h> #include <stdlib.h> #include <string.h> int main(void) { char *buf = malloc(32); if (!buf) return 1; strcpy(buf, "config-path=/etc/demo"); free(buf); /* 中间隔了几十行业务逻辑 */ printf("%s\n", buf); /* 悬挂访问 */ free(buf); /* 双重释放,更糟 */ return 0; }

问题很明显,但现实中它往往不是这么短的距离。常见的情况是:指针存在一个结构体里,某个清理函数把它 free 了,但结构体本身没被销毁,后续还有人从结构体里取这个指针用。或者指针通过参数传进了三层函数,最里面那层觉得"我用完了就顺手释放掉",外层完全不知情。这类问题的根因不是"忘了置空",而是"没说清楚谁负责释放"。置空只是补救措施,真正该做的是把所有权约定明确下来,这部分我在第 4 节展开讲。

短期内的止血方案很直接:封装一个释放宏,强制把 free 和置空绑在一起。

#define SAFE_FREE(p) do { free(p); (p) = NULL; } while (0)

用 do-while 包起来是为了让它在 if 语句后面也能安全使用,宏里带分号是很多老项目的坑。这个宏对 NULL 也是安全的,因为free(NULL)在标准里定义为无操作。但要注意它的局限:它只能处理直接传进来的那个指针变量,管不了所有指向同一块内存的别名。如果你的代码里有两个指针指向同一块内存,SAFE_FREE 了其中一个,另一个照样悬着。别名问题才是这类 bug 的真正硬骨头。

2.2 把栈上生命周期带出了函数

第二种是返回局部变量地址,编译器一般会给出警告,但只要你用了指针转接或者塞进结构体,警告就未必出得来。

struct item { char name[64]; }; /* 错误示范:返回指向已退栈内存的指针 */ struct item *make_item_bad(void) { struct item local; strcpy(local.name, "sample"); return &local; /* 函数一返回,local 就失效 */ } /* 方案一:调用者提供缓冲区,所有权最清晰 */ void make_item_ok(struct item *out, const char *name) { if (!out || !name) return; snprintf(out->name, sizeof(out->name), "%s", name); } /* 方案二:堆分配,调用者负责释放 */ struct item *make_item_heap(const char *name) { struct item *p = malloc(sizeof *p); if (!p) return NULL; snprintf(p->name, sizeof(p->name), "%s", name ? name : ""); return p; }

我更推荐方案一,也就是"调用者提供缓冲区"。理由很实在:堆分配的方案把释放责任推给了调用方,而调用方很容易忘;一旦忘掉就是内存泄漏,一旦提前释放就是悬挂指针。方案一把生命周期放在调用者的栈帧上,天然不会悬空,也不需要谁记得去 free。代价是缓冲区大小要提前定,遇到变长数据就得换别的招,这时候堆分配方案才登场,同时必须在文档或者函数命名里体现出"返回的指针归调用者所有"。

这里补一个小经验:返回指向静态缓冲区的指针也是一种常见做法,它能避免悬空,但引入了线程安全和重入问题。如果你的函数会被多线程调用,或者会被嵌套调用两次之后再用第一次的结果,静态缓冲区会静默地互相覆盖,症状看起来和悬挂指针很像——数据莫名其妙变了。判断方法是看这个函数有没有被调用两次以上,如果第二次调用的返回值把第一次的内容冲掉了,那就是静态缓冲区的问题,不是悬挂指针。

2.3 realloc、指针别名与"释放了一半"

realloc是悬挂指针的另一个高发区,因为它可能把整块内存搬到新地址,旧地址当场失效。

/* 错误示范一:realloc 失败时原指针丢失,造成泄漏 */ void bad_grow(int **arr, size_t new_n) { *arr = realloc(*arr, new_n * sizeof(int)); /* 如果失败,*arr 变成 NULL,原来的内存再也 free 不掉 */ } /* 正确写法:先用临时变量接住 */ int grow(int **arr, size_t new_n) { int *tmp = realloc(*arr, new_n * sizeof(int)); if (!tmp) return -1; /* 失败时 *arr 仍然有效 */ *arr = tmp; return 0; }

注意这里只是解决了泄漏和丢失,悬挂问题还在:如果别处还存着*arr的旧值,realloc 一旦搬移,那个旧值就成了悬垂的。所以凡是用了 realloc 的容器,就不能让外部长期持有指向内部元素的裸指针,需要的话用下标或者句柄来间接访问,让容器自己负责在每次扩容后重新解析。

还有一种隐蔽的"释放了一半":指针指向的是数组中间某个元素,你却跑去 free 掉了整个数组的起始地址。

int *base = malloc(10 * sizeof(int)); int *mid = base + 4; /* 指向中间元素 */ free(base); /* mid 当场悬空,虽然它从未被"分配"过 */ /* 正确做法:mid 只能通过 base 计算,base 释放后 mid 一并失效 */

这类问题在字符串处理里特别多,比如strchr返回的指针指向原字符串内部,原字符串被释放后,那个返回的指针就废了。处理原则只有一条:派生指针的生命周期不能超过它的宿主。这条原则说起来简单,做起来要求你在设计函数接口时就想清楚,谁是宿主,谁只是视图。

2.4 结构体内嵌指针与容器清空

第四类是结构体内部持有堆指针,销毁的时候漏掉某个字段,或者销毁顺序搞错。

typedef struct { char *name; int *scores; size_t count; } Student; void student_destroy(Student *s) { if (!s) return; free(s->name); s->name = NULL; free(s->scores); s->scores = NULL; s->count = 0; /* 注意:这里没有 free(s) 本身,因为 s 可能是栈上的 */ }

这段代码看着没问题,坑在于调用者的用法。如果Student是栈上的,destroy 只清理内部字段是对的;如果它是堆上的,调用者用完 destroy 之后还得再free(s),顺序反了或者忘了都会出问题。同一个结构体存在两种销毁语义,是 bug 的温床。我的做法是统一成一种:要么所有 Student 都在堆上,配套student_new和student_free;要么全部在栈上,配套student_reset只清理内部。混着用迟早出事。

容器清空也是同理。一个动态数组里存了几十个堆指针,清空的时候你必须逐个 free 再 free 数组本体,少一环就是泄漏,顺序错了就是双重释放或者悬挂。这类"释放整个对象图"的逻辑,建议抽成单独的函数,并且要求它做到幂等——也就是调用两次和调用一次效果一样,第二次调用时所有指针都已经是 NULL,所有 free 都变成无操作。幂等性是防御悬挂和双重释放最有效的性质之一,写清理函数时值得专门为它设计。

3. 从崩溃现象倒推根因的排查链路

工具这块我不想罗列一堆命令就完事,而是给一条有先后顺序的链路。顺序很重要,因为越靠前的层次越便宜、越快、越容易自动化。我的习惯是先用最便宜的手段把能筛掉的筛掉,实在筛不掉才上重的工具。这条链路不是理论上的,是我日常调试时真正会走的路子。

3.1 第一层筛子:编译期把能抓的先抓掉

第一件事永远是打开警告。很多悬挂指针在编译期就有信号,只是默认关着。

gcc -std=c11 -Wall -Wextra -Wpedantic -Wshadow -Wreturn-local-addr \ -fanalyzer -g -O1 -o demo demo.c

-Wreturn-local-addr专门抓返回局部变量地址,-Wshadow抓变量遮蔽——遮蔽虽然不直接导致悬挂,但会让人看错指针变量,间接制造 bug。-fanalyzer是较新版本 GCC 自带的静态分析器,它能在编译期沿着路径走,报出"这里释放了,那条路径上还在用"这类问题。我用下来的感受是:它的误报不算多,但只要它报了,十有八九是真问题,值得逐条看清楚。

Clang 那边有--analyze,思路类似,扫描更细一些。另外还有独立的静态分析工具,配置成本高一点,适合接进持续集成里跑。这一层的价值在于它是零运行时开销、零复现条件的——不需要程序崩,不需要特定输入,编译一遍就知道。把它放进构建脚本,比事后排查划算太多。

不过要清醒地认识到静态分析的边界:它做的是路径枚举,遇到函数指针、复杂的状态机、跨编译单元的所有权传递,就力不从心了。所以它只能算第一层,不能当作全部。

3.2 第二层:ASan 让悬垂访问当场现形

AddressSanitizer 是我排查这类问题的首选。它的原理是在编译时给每次内存访问插桩,同时在被管理内存周围维护一份影子状态,记录哪些字节还活着、哪些已经释放。一旦访问到已释放区域,立刻打印完整报告并终止,不用等到崩溃发生。

使用方法就是在编译命令里加一个开关:

gcc -fsanitize=address -fno-omit-frame-pointer -g -O1 -o demo demo.c ./demo

一旦触发,报告长这样(关键字段我标了出来):

==31415==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010 READ of size 1 at 0x602000000010 thread T0 #0 0x4f2a1c in main demo.c:12 #1 0x7f... in __libc_start_main 0x602000000010 is located 0 bytes inside of 32-byte region freed by thread T0 here: #0 0x4f0d38 in free #1 0x4f2b90 in main demo.c:10 previously allocated by thread T0 here: #0 0x4f0e20 in malloc #1 0x4f2b60 in main demo.c:7

读这份报告的顺序很关键,先看最上面的错误类型和访问位置,再看 freed by 那一组的调用栈,因为那里才是真正的错误源头所在。报告会把"分配时""释放时""使用时的三组调用栈全列出来,等于免费给你做了一次完整的时序还原。我经常说,ASan 的报告比大多数人的调试记录都清楚,前提是你愿意花两分钟去读。

有几个实际使用中的注意点:编译优化建议用-O1而不是-O0,官方推荐这个级别,既能保持良好的行号准确性,又不至于让性能太难接受;如果程序里用了自定义的内存池,ASan 会认为池子里的内存一直有效,检测能力下降,这时候要把池子的分配逻辑临时关掉,改用标准 malloc 做验证;另外它跟某些检测工具不能同时开,别一股脑全加上。

3.3 第三层:Valgrind 与分配器填充对付"不崩的鬼"

有些情况 ASan 不方便用,比如你只有一个编译好的二进制、或者想在不重新编译的前提下检查第三方库。这时候 Valgrind 顶上,它不需要改编译参数,直接把程序跑在它模拟的虚拟 CPU 上:

valgrind --tool=memcheck --leak-check=full --track-origins=yes ./demo

它会把每一次非法的读写都列出来,典型输出是Invalid read of size 4后面跟着读的位置,然后是Address 0x... is 0 bytes inside a block of size 4 free'd,再下面是释放发生的位置。--track-origins=yes会让它顺带追踪未初始化值的来源,对分析"数据莫名其妙变了"特别有用。代价是慢,通常几十倍,所以只适合小规模的复现用例和单元测试。

还有一种更轻量的野路子,专门对付"我怀疑它读到了已释放内存,但它就是不崩"的情况:让分配器主动填充释放的内存。相关环境变量在常见 C 库里有支持,设置之后,释放的内存会被填成特定的字节模式,你只要看到指针指向的内容变成一片异常的重复字节,就坐实了是悬挂访问。

MALLOC_PERTURB_=165 ./demo

数值本身不重要,取个容易辨认的值就行。这个手段的好处是不需要重编译、不需要额外工具、不改一行代码,在客户现场这种"只能跑生产二进制"的场合特别实用。缺点是它只能提高暴露概率,不能精确指出哪一行读的,还是得回到 ASan 或者 gdb。

3.4 第四层:gdb 与 core dump 看现场

线上崩溃没法直接跑检测工具时,就靠 core 文件。前提是系统允许产生 core,并且编译时带了-g。

ulimit -c unlimited # 程序崩溃后 gdb ./demo core (gdb) bt full (gdb) frame 3 (gdb) info locals (gdb) p *ptr

在 gdb 里判断是不是悬挂指针,有个很实用的技巧:把可疑指针的地址和分配器状态对一下。如果这个地址落在堆范围内,但已经不在任何活跃的分配块里,那就是释放过了。有些发行版带了辅助工具能直接查,退一步的办法是干脆在可疑位置下硬件观察点:

(gdb) watch *(char *)0x5555555592a0

只要这块内存被写,gdb 就停下来。如果它在你预期之外的时刻被改写,那个时刻通常就是"分配器把这块内存重新分配给别人"的瞬间,顺着这条线往下查,很容易找到当初是谁提前释放的。

我在实际排查里还有一个笨但有效的招:在可疑结构的头部塞一个魔数字段,分配时写入固定值,释放时改成另一个固定值,然后在每次使用前断言它是"存活"值。这种做法成本极低,可以在生产环境保留,而且它能把"释放后使用"从玄学崩溃变成一条明确的断言失败,附带的日志里能直接看到是哪个对象被提前释放了。这不是什么高级技术,但在大型项目里救过我好几次。

4. 让悬挂指针在代码里"生不出来"的写法

工具是被动的,出了事才用。真正能降低事故率的,是把这类问题在代码结构上就排掉。下面这几条我不按重要性排,按落地难度从低到高排,你可以从第一条开始逐步加进团队规范里。

4.1 free 与置空必须绑成一个原子动作

这条最基础,但我想说清楚"为什么置空有用"这个常被忽略的问题。free 之后置 NULL,真正的价值不是"防止你再访问"——你照样可以访问 NULL,只是会立刻崩溃,而这个立刻崩溃恰恰是好事:它把间歇性发作的问题变成了稳定复现的问题。崩溃点就是错误点,排查成本从几小时降到几分钟。这是用确定性的失败替换不确定性的静默错误,是工程上非常划算的一笔交易。

至于怎么落实,光靠自觉不够,得靠机制。我推荐的做法是:项目里禁止直接调用 free,统一走封装函数或者宏。

static inline void mem_release(void **pp) { if (pp && *pp) { free(*pp); *pp = NULL; } } /* 使用 */ char *buf = malloc(32); /* ... */ mem_release((void **)&buf);

注意这里的强制类型转换在严格别名规则下是有讲究的,实践中绝大多数编译器都能正常处理,但如果你的项目开了很严格的告警,用宏会更省事。封装函数的好处是可以打断点、可以加统计(比如统计释放次数,帮助发现双重释放)、可以在调试构建里额外做点校验。

配套还要解决容器里的指针。一个结构体数组,逐个释放的时候用宏很容易漏,更好的办法是写一个专用的销毁函数,并且在函数结尾把整个结构体清零(memset或者逐字段赋值),让"已销毁"这个状态在内存里可辨认。我在项目里甚至会约定:销毁函数的第一个动作是把关键字段的地址记录下来打印到调试日志,这样出现双重销毁时,日志里能看到同一地址被销毁了两次,一眼定位。

4.2 用所有权约定替代口头约定

第二件事是把"谁负责释放"写进接口设计里,而不是写在注释里。注释会被忽略,接口设计不会。我见过最有效的一套约定是这样三条:

  • 函数名以_new或_create结尾的,返回的指针归调用者所有,调用者必须用对应的_free释放;
  • 函数名以_get或_peek结尾的,返回的是借用指针,调用者不得释放,且不得跨过宿主对象的生命周期持有;
  • 参数中出现const T *的,函数不会持有这个指针,返回后即可释放。

这三条一旦定下来,配合代码评审,能把绝大部分悬垂问题拦在设计阶段。关键不在于这三条本身多精妙,而在于它们让"所有权"变成了一件必须在命名时就想清楚的事。你写_get的时候会本能地想一下:我返回的到底是不是借用?如果答案是"其实我想把所有权给你",那函数就该改名,改名的过程就是发现问题的时候。

再往深一层,对于确实需要共享的对象,用引用计数把所有权显式化。C 里没有现成的,但几十行就能写一个够用的版本:创建时计数为一,每次_retain加一,每次_release减一,减到零才真正 free。这套东西的坑在于忘记 release 就泄漏,多用一次就提前释放,所以配套要求所有获取指针的路径都必须经过 retain,所有字段赋值都要先 release 旧的再赋新的。写起来啰嗦,但它把"生命周期"从隐式约定变成了显式代码,在复杂数据结构里是值得的。

4.3 用结构设计减少裸指针

第三条更偏架构。能不用裸指针持有堆对象的地方,就别用。举个具体例子:一个解析出来的配置项列表,如果每个项都是char *指向独立分配的内存,那销毁、复制、序列化全都要处理指针,处处都可能是悬挂点。改成"一坨连续内存 + 偏移量表"的结构,整个配置块只有一次分配、一次释放,内部全是偏移量而不是指针,根本不可能出现悬挂。

typedef struct { char *blob; /* 连续存储区,一次分配 */ size_t blob_size; struct { uint32_t name_off; uint32_t value_off; } *items; /* 偏移量表 */ size_t item_count; } Config;

代价是访问时要算偏移,稍微麻烦一点,收益是整个对象变成"要么整体有效、要么整体无效",不存在部分悬空的状态。这种设计在需要序列化、需要跨进程传递、需要频繁整体销毁重建的场景里优势非常明显。它不是银弹,但在数据结构相对固定的模块里,我基本都会优先考虑。

同样的思路还可以用在句柄上:外部拿到的不是指针,而是一个整数 ID,内部用 ID 去表里查真实指针。查不到就返回错误,优雅降级而不是崩溃。这在游戏引擎和资源管理系统里是常规做法,好处是悬垂的句柄最多导致一次查表失败,不会导致内存错误。

5. 一个完整案例:从泄漏到修复的完整推演

前面讲的都比较碎,这里用一个完整的例子把流程串一遍。这个例子是我从实际代码里抽象出来的,简化了但保留了所有关键特征。你可以把它敲进编辑器跑一遍,跟着报告走一遍,比看十篇文章都管用。

5.1 出问题的链表实现

#include <stdio.h> #include <stdlib.h> #include <string.h> typedef struct Node { char *key; struct Node *next; } Node; static Node *g_head = NULL; int list_add(const char *key) { Node *n = malloc(sizeof *n); if (!n) return -1; n->key = strdup(key); if (!n->key) { free(n); return -1; } n->next = g_head; g_head = n; return 0; } Node *list_find(const char *key) { for (Node *p = g_head; p; p = p->next) { if (strcmp(p->key, key) == 0) return p; } return NULL; } void list_remove(const char *key) { Node **pp = &g_head; while (*pp) { if (strcmp((*pp)->key, key) == 0) { Node *victim = *pp; *pp = victim->next; free(victim->key); free(victim); return; } pp = &(*pp)->next; } } int main(void) { list_add("alpha"); list_add("beta"); Node *hit = list_find("alpha"); printf("before: %s\n", hit->key); list_remove("alpha"); /* 错误:hit 现在指向已释放节点 */ printf("after: %s\n", hit->key); return 0; }

这段代码的每一步单独看都没毛病:list_remove正确地摘链、正确地释放了 key 和节点本身;list_find返回的是节点指针,也很合理。问题出在两者的组合——调用者拿到了一个指向内部节点的指针,然后执行了一次可能删除该节点的操作,之后继续用那个旧指针。这类错误在真实代码里极其常见,因为删除和访问往往隔着好几层函数调用,中间还夹着业务逻辑,肉眼很难联系在一起。

5.2 ASan 报告怎么读

用-fsanitize=address -g -O1编译跑一遍,立刻就能看到报告。核心信息是这三行:

ERROR: AddressSanitizer: heap-use-after-free on address 0x... READ of size 1 at 0x... thread T0 0x... is located 0 bytes inside of 12-byte region

然后是freed by下面的调用栈,指向list_remove里的free(victim->key),最后是previously allocated by指向strdup。三组调用栈一摆,因果关系完全清楚:这块内存在哪分配、在哪释放、在哪被误用,一条不缺。

如果是在 Valgrind 下跑,输出会碎一些,但信息量一样:Invalid read of size 1,接着是读的位置,然后是Address 0x... is 0 bytes inside a block of size 12 free'd,再下面是Block was alloc'd at。这时候要特别注意一点:Valgrind 默认只报第一次访问,后续的可能不再重复报,所以如果程序里有多个悬挂点,它可能一次只告诉你一个,得多跑几轮,逐个修。

修复方案有两种思路,我都推荐组合使用。

/* 方案一:让 find 返回借用指针的同时,约定它在删除后失效 */ /* 调用者必须先拷贝需要的数据,再执行删除 */ Node *hit = list_find("alpha"); char *key_copy = hit ? strdup(hit->key) : NULL; list_remove("alpha"); printf("after: %s\n", key_copy ? key_copy : "(removed)"); free(key_copy); /* 方案二:find 时把序号也带出来,删除后用序号重新定位 */

第一种方案简单直接。第二种适合节点会被频繁增删的场景。还有一种方案是让list_remove接受节点指针并返回删除后的下一个节点,调用者用返回值继续遍历,避免遍历过程中删节点导致后续访问悬空——这其实是链表遍历删除的标准写法,值得记住:

/* 遍历中删除的安全写法 */ Node **pp = &g_head; while (*pp) { Node *cur = *pp; if (should_remove(cur)) { *pp = cur->next; free(cur->key); free(cur); } else { pp = &cur->next; } }

用二级指针操作"指向当前节点的那个指针",删除时把前驱的 next 直接改成后继,全程不需要记住前驱节点,也不会在删除后继续访问已释放的 cur。这个技巧在 C 里非常经典,写链表时用熟它,能少掉一大半悬挂 bug。

5.3 修复后重新验证

改完之后不能只跑一次眼看没崩就完事,得像前面说的那样,主动制造"释放后立刻分配"的时序。具体做法是在list_remove之后紧接着分配一块同样大小的内存并写入特征字符串,看原来那个指针指向的内容有没有被覆盖:

list_remove("alpha"); char *poison = malloc(12); /* 大小尽量和刚才释放的一致 */ if (poison) strcpy(poison, "POISONED"); /* 此时再用 hit 就会看到 POISONED,证明它确实悬垂 */

这种"投毒"手法在小规模验证里非常好用,因为它能把不确定的攻击面变成确定的现象。当然,修复后的版本里你根本不该有机会再访问 hit,所以这一步主要是用来验证你修得对不对——如果修复后连投毒都读不到旧数据,说明代码路径已经改对了。

最后补一句经验:这类修改完成后,建议把整个模块的内存检测跑一遍,因为悬垂问题往往成群出现,你在这一处发现了一个,同一模块里很可能还有两三个同样的模式。一起修掉,比下次再来一次要省事得多。

6. 几个新手最容易忽略的边角情况

主流情况讲完了,这一节挑几个不那么常见但一旦踩上就很费时间的场景。它们之所以值得单独拿出来说,是因为在这些场景里,你前面学到的所有直觉都会失效。

6.1 指针算术与数组中间元素

前面提过一句,这里展开。指向数组中间元素的指针,看起来和数组本身没有关系,但它和宿主是同生共死的。

char *line = malloc(128); strcpy(line, "key=value"); char *eq = strchr(line, '='); /* eq 挂在 line 内部 */ free(line); /* eq 悬垂,虽然它从未被单独分配过 */

判断这类问题的原则很朴素:指针能不能用,不看它自己,看它依赖的那个对象还活着没有。任何通过指针算术、strchr、strstr、数组下标得到的指针,都是宿主对象的一个视图,宿主的生命周期就是它的生命周期上限。这条规则同样适用于结构体内部的数组字段、联合体里的指针、以及通过offsetof算出来的成员地址。

实际编码时有个简单办法降低风险:视图指针不和宿主指针分开保存,需要的时候从宿主现算。比如不要缓存eq,而是缓存line和一个下标eq_index,用的时候line + eq_index。多算一次的开销可以忽略,但它彻底消除了视图悬垂的可能。

6.2 双重释放与"释放后置空"的关系

有人觉得置 NULL 只是防悬挂访问,其实它顺带把双重释放也一并解决了。第二层 free 作用在 NULL 上是无操作的,所以SAFE_FREE天然幂等。这是一个非常划算的副作用,代价只有一次赋值。

但要注意它解决不了别名导致的双重释放:

char *a = malloc(16); char *b = a; /* 别名 */ free(a); a = NULL; free(b); /* b 不是 NULL,双重释放 */

要治这个问题,得靠前面说的所有权约定,或者引用计数,或者在调试构建里加一层"已释放地址表"的检查——每次 free 之前查一下这个地址是不是已经在表里,是的话就断言失败。这个检查在生产环境关掉,在测试环境全开,成本可控而收益很高,我在几个项目里都用过,确实抓出过几个隐蔽的双重释放。

还有一个容易忽略的场景是在不同模块边界释放同一块内存。A 模块分配的指针传给了 B 模块,B 模块觉得自己用完了就 free 掉了,A 模块后面还要用。这种根因不在语言,在接口约定不清楚。解决办法是在接口文档里明确写清"所有权转移"还是"借用",并且最好在函数名上体现出来,让误用者一眼就能看出不对。

6.3 跨模块边界传递指针的约定

接着上一条,跨模块传指针这件事,我总结了一套自己一直在用的三条规矩,落地成本很低但效果很好。

第一条,凡是把指针存起来的地方,都必须有一个对应的清理函数,而且这个清理函数要能被单独找到、单独测试。不能出现"我在这里存了,但忘了在哪清"的情况,这是记忆负担,一定会出错。

第二条,回调函数里传的指针一律视为借用,回调返回后立刻失效。如果回调需要长期持有,必须在回调内部主动深拷贝。这条约定能挡住大量"事件分发器把对象指针传给监听者,监听者存起来,对象销毁后监听者还在用"的问题。这条在事件驱动架构里几乎必踩,提前约定能省很多事。

第三条,跨模块的函数尽量不返回内部指针,改为通过输出参数或者 ID 间接访问。这一条代价最大,因为它会改变接口形态,但收益也最大——接口一旦不暴露裸指针,调用方就算想制造悬挂也没机会。这是从"靠纪律"升级到"靠结构"的一步,值得在核心模块里推行。

最后分享一个我自己的习惯。每次写完涉及动态内存的模块,我会专门花十分钟做一次"生命周期走查":在纸上画出每个堆对象从分配到释放的时间线,再把所有持有它的指针标上去,看有没有哪条指针的存活区间超出了对象本身。这个动作很笨,但它能发现工具发现不了的问题——比如两个指针的释放顺序在特定输入下才会出问题。走查过几遍之后,你会对哪些代码结构容易出问题形成直觉,到后来写代码的时候手会自己避开那些坑。

返回列表