
模块拆分之后,有一类崩溃特别难查:代码看起来完全对称,但就是会崩,而且崩得随机。
典型写法是这样的:模块 A 分配一块内存,把指针交给模块 B,由 B 在合适的时机释放。
逻辑上很整齐。但它隐含了一个前提——两边必须使用同一套运行时库、同一份堆。而这个前提在很多工程里并不成立。
第一种情况:运行时库的链接方式不同。
如果模块各自静态链接了运行时库,那么每个模块都带着自己的一份——各自维护各自的堆。
这时候,在 A 的堆上分配的内存,用 B 的释放函数去释放,行为是未定义的。不是"可能出错",而是没有任何保证。
第二种情况:运行时库版本不同。
不同版本、不同编译工具链的运行时,不保证可以互相操作内存。混用是隐患。
第三种情况:调试版本与发布版本混用。
两套运行时并存时,这个问题更容易显形。
所以“谁分配谁释放”不是代码风格偏好,而是一条正确性要求。 也正因为它是设计层面的问题,DLL修复工具 这类补齐文件的工具无从下手。
后果一:直接在释放处崩溃。
堆校验失败,程序立刻结束。这一类还算"友好",至少位置明确。
后果二:随机崩溃,位置与原因相距很远。
堆的内部结构被破坏后,崩溃往往延迟发生——破坏发生在释放那一刻,但报错出现在之后某次无关的分配上。这是最难查的一类,也解释了为什么这类崩溃用 DLL修复工具 扫描不出任何异常。
后果三:内存缓慢泄漏。
释放调用去了错误的堆,等于没释放。程序长期运行后内存持续增长。
第一条,谁分配谁释放。
这不是一句口号,要落到接口设计上:提供"返回内存"的接口,就同时提供对应的"销毁内存"接口,由同一个模块负责两端。
// allocate and free inside the same module; pass only the pointer across
extern "C" MYLIB_API char* CreateBuffer(int size);
extern "C" MYLIB_API void DestroyBuffer(char* buf);第二条,跨模块只传数据与句柄,不传"需要对方释放的内存"。
句柄是标识,谁持有都能用,但它背后的资源仍由原模块管理——这样所有权不会跨过边界。
第三条,统一运行时库。
同一进程内,各模块要么都用同一版本的动态运行时,要么都做一致的处理。避免"一个静态、一个动态"这种组合。
第四条,接口成对提供。
只提供创建、不提供销毁的接口,等于强制调用方去猜该由谁释放。这是设计缺陷,不是使用者的错。
很多人知道"不要跨模块传裸指针",于是改成传结构体。这只解决了一半。
按值传递的结构体是拷贝——拷贝出来的内存由被调方自己管理,不涉及跨模块释放。这部分是安全的。
但如果结构体里有指针成员,那个指针指向的内存的归属问题依旧存在。
判断标准要看跨边界的是什么:
最后一行是两类问题叠加:所有权不清 %2B 类型身份不等同,崩溃概率成倍上升。
模块 A 创建对象交给模块 B 使用与销毁——除了内存归属,还多了一层:这个对象的方法在哪个模块里执行、它依赖哪一份运行时状态。
所以接口文档里必须写清三件事:谁创建、谁销毁、所有权是否可以转让。含糊的接口在单模块内测不出问题,一到拆分就暴露。
三个特征,凑齐两条就该怀疑:
一个很有效的验证顺序:
如果第 3 步之后问题消失,基本可以确认是这一类。
顺带说明:这类问题在原地打转是没有出路的——它不是运行环境的问题,也不是文件问题,所以修补类的 DLL修复工具 在这儿派不上用场:模块全都在、版本也没错,出错的是内存被交给了不该释放它的那一方。
关于跨模块的内存管理,记住四条:
这类问题有个共同的背景:一旦把代码拆成多个模块,就要同时管理"内存归属"和"类型身份"两件事。 它们都不是文件层面的问题——文件齐备、版本正确,照样会崩;所以排查时要往接口约定和运行时配置上看,而不是往"缺什么补什么"上想。
https://www.ijinshan.com/functions/repairdll.html?channel=4074
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。