写到现在,Mach-O 这个系列已经走到第三十三篇了。前面聊过 Mach-O 的整体结构、Load Command、__mod_init_func 初始化节,也折腾过符号表、字符串表这些老熟人。今天要聊的 __mod_term_func,正好和 __mod_init_func 是一对孪生兄弟:一个管"进程启动时该跑哪些初始化",一个管"程序退出前该跑哪些清理"。对做逆向、做底层 SDK、做动态库注入或者写跨平台启动框架的人来说,理解这个节既能帮你判断一段清理逻辑什么时候生效,也能帮你解释不少"为什么我的析构没跑"之类的诡异问题。
这篇文章我会从节的作用、文件中的真实形态、链接器怎么生成它、dyld 什么时候调用它,到怎么用代码主动读取和调用,最后再整理几个实际的坑。内容偏向实操,但也会把链路中的关键原理讲透。C、C++、Objective-C 混在一起的项目里,这个节的行为尤其值得关注,建议先收藏再慢慢看。
1. __mod_term_func 是干什么的:从"退出清理"说起
1.1 一个再熟悉不过的触发场景
写过动态库或者插件的人,应该都遇到过这种场景:你的库被某个宿主 App 加载起来,干了一些活,然后宿主可能在某个时刻把库卸载掉,或者进程直接退出。这时候你想做一些收尾工作——关闭文件、释放全局缓存、把统计数据回传、断开网络连接。如果在宿主不主动调用你的清理接口的前提下,这些事还能被系统自动触发,那靠的就是 terminate 机制。
Mach-O 里专门为这种"镜像(image)不再使用时的清理函数指针"划了一个节,名字就叫 __mod_term_func。链接器会把终止函数(termination function)的函数指针收集到这个节里。进程退出或者动态库被卸载时,dyld 会找到这些指针,一个个调用,让镜像有机会做最后的清理。
这里说的"镜像"不只指可执行文件,也包括动态库和 bundle。只要是一个独立的 Mach-O 文件,理论上都可以有 __mod_term_func 节。所以你写一个.so给 iOS 的 dlopen 用,或者写一个 loadable bundle 给 macOS 的插件系统用,这个节就会成为你的"退出钩子"。
1.2 它不是为 C++ 静态对象析构准备的?——说清楚分工
很多资料会把 __mod_term_func 直接解释成"C++ 全局对象析构函数存放的地方",这个说法不太准确,甚至会误导排查方向。
实际上,C++ 的静态对象析构,大多数情况走的是__cxa_atexit注册机制,最终挂在 libsystem 的 atexit 链表上。而 __mod_term_func 里存的是编译器显式生成的终止函数指针,最常见的来源是__attribute__((destructor))修饰的函数,或者某些语言运行时自行放进来的回调。
你可以简单理解成分工是这样的:
__mod_init_func:存放初始化函数指针,main 之前被调用。__cxa_atexit/atexit:存放普通的退出回调,main 返回或 exit 时按注册逆序调用。__mod_term_func:存放镜像级终止函数指针,由 dyld 在镜像生命周期结束时调用。
C++ 静态局部对象和全局对象的析构,之所以普遍走 atexit,是因为析构顺序需要配合构造顺序,而且能处理同一个镜像多次加载的情况。直接塞到 __mod_term_func 里是做不到这种精细管理的。
既然这个节被很多人误解,我觉得很值得展开聊一聊,下面我先从 Mach-O 文件层面看看它到底长什么样。
2. Mach-O 文件里的真实形态:section 描述和节类型
2.1 从 Load Command 到 section_64
任何 Mach-O 里的 section,都不是凭空存在的,它属于某个 segment,由 Load Command 描述。想看 __mod_term_func,最直接的办法是解析LC_SEGMENT_64(32 位 Mach-O 则是LC_SEGMENT)中的section_64数组。
section_64结构体里,核心字段是这几个:
struct section_64 { char sectname[16]; // 节名,比如 "__mod_term_func" char segname[16]; // 所属段名,比如 "__DATA_CONST" 或 "__DATA" uint64_t addr; // 节在虚拟内存中的起始地址 uint64_t size; // 节的大小(字节) uint32_t offset; // 节在文件中的偏移 uint32_t align; // 字节对齐 uint32_t reloff; // 重定位信息偏移 uint32_t nreloc; // 重定位条目数量 uint32_t flags; // 节属性标志,包含 section type 等 uint32_t reserved1; uint32_t reserved2; uint32_t reserved3; };关键在于 flags 的低 8 位,也就是 section type。拿loader.h里的定义对照,常量名是S_MOD_TERMINATOR_FUNC_POINTERS,值为 0xA。很多老工具链也叫它S_MOD_TERMINATOR。
S_MOD_INIT_FUNC_POINTERS 0x9 S_MOD_TERMINATOR_FUNC_POINTERS 0xA所以你在解析 Mach-O 的时候,判断一个 section 是不是 __mod_term_func,不能只比对sectname字符串,还要看这个 section type。有些第三方工具会通过字符串匹配来过滤,在极少数被刻意混淆过的文件里会失效,标准做法永远是看 type。
再说段的位置。老版本 macOS/iOS 上,__mod_term_func 一般出现在__DATA段;Apple 后来推动不可写数据进入只读段,新的链接器倾向于把初始化/终止函数指针放到__DATA_CONST段。不同 SDK 版本、不同架构,段归属可能不一样。所以做通用解析时,最好不要写死 segname,把__DATA和__DATA_CONST都兼容一下,最省心。
2.2 用 otool 和 llvm-objdump 快速验证
理论讲完了,直接上手看一眼真实文件最快。拿一个 macOS 上的命令行工具或动态库,执行:
otool -l /path/to/binary | grep -A 8 __mod_term_func如果你用的是 arm64 架构的较新二进制,输出可能长这样:
sectname __mod_term_func segname __DATA_CONST addr 0x0000000100008000 size 0x0000000000000018 offset 16384 align 2^3 (8) reloff 0 nreloc 0 type S_MOD_TERMINATOR_FUNC_POINTERS attributes PURE_INSTRUCTIONS SOME_INSTRUCTIONSsize 是 0x18,也就是 24 字节,说明这个节里存了 3 个函数指针(arm64 下一个指针 8 字节)。从这里能直接看出,__mod_term_func 在文件里本质上就是一个函数指针数组。
如果你的二进制开启了 PIE,addr 是 vmaddr 层面的地址,不是运行时真实地址。实际调用前需要加上ASLR slide,这个后面写读取代码时会强调。
llvm-objdump --section-headers /path/to/binary也能看到节信息,而且输出比 otool 更规整。想直观一点,用 MachOView、010 Editor 的 Mach-O 模板也都可以,但命令行在脚本化处理时更好用。
3. 链接器怎么把函数指针放进这个节
3.1__attribute__((destructor))做了什么
在 C 或者 C++ 里,如果你写了一个函数,希望它在程序退出时被调用,最简单的写法是:
__attribute__((destructor)) static void my_cleanup(void) { printf("cleanup called\n"); }编译之后,clang 会把这个函数指针放进目标文件的__DATA_CONST,__mod_term_func(或者__DATA,__mod_term_func)里。链接器把所有目标文件里的这些指针合并到最终二进制的同一个 section,得到一个连续的指针数组。
在汇编层面,这个节的声明长这样:
.section __DATA_CONST,__mod_term_func .p2align 3 .quad _my_cleanup如果你在写汇编,想手动添加一个终止函数,这就是最底层的做法。
和__attribute__((constructor))一样,destructor 也可以接受优先级参数:
__attribute__((destructor(1))) static void cleanup_first(void) { // 优先级数值越小,越晚被调用?不,这里正好相反 }这里有个非常容易搞错的点:constructor 的优先级数字越小越先执行;destructor 虽然也支持优先级,但实际执行顺序会和直觉相反——数字越小的 destructor,执行时机越晚。清理顺序通常是构造的逆序。Apple 的文档没有写死所有平台上的细节,所以跨平台代码里最好不要依赖不同优先级之间的相对顺序,只依赖"同类钩子彼此之间完全有序"是很危险的。
3.2 为什么很多二进制里这个节是空的
你拿 otool 去看一个普通的 iOS App 可执行文件,很可能会发现__mod_term_func的 size 是 0,甚至根本没有这个节。
这不是 Bug,而是正常现象。原因是 iOS 上绝大多数清理逻辑都走 atexit 注册链,编译器默认不会把普通 C++ 静态析构硬塞进 __mod_term_func。只有代码里显式写了__attribute__((destructor)),或者运行时库有特殊需求,这个节才会有内容。
另外一个隐藏因素是链接器的 dead strip。如果目标文件里的终止函数没有被别的符号强引用,而链接时又开了-dead_strip,这个节可能会被精简掉。不过编译器生成的 constructor/destructor 函数,一般会被链接器特殊处理保留,很少真的被 strip。真正的风险在于:如果你在静态库里写了 destructor 函数,但整条链接过程认为该目标文件没有被引用,那么整个目标文件都不会进 binary,终止函数自然也就没了。这个问题常见于"我在库的 .a 里放了清理函数,但宿主集成后根本不回调"的场景。
3.3 与 __mod_init_func、atexit 的联动
要理解 __mod_term_func 的完整执行链路,就得把它放进"初始化-终止"这个大闭环里看。
- 镜像被加载时,dyld 会先找
__mod_init_func,调用里面的初始化函数。 - 初始化函数里,如果代码调用
atexit或者 C++ 运行时调用__cxa_atexit,这些回调会被注册到 libsystem 的退出回调链表。 - 镜像被卸载或进程退出时,dyld 会找
__mod_term_func,执行终止函数;同时 libsystem 会执行 atexit 注册的回调。
所以一个典型的 C++ 全局对象生命周期是这样的:__mod_init_func里执行构造(更准确地说,是执行一段注册代码,把对象的析构函数通过__cxa_atexit注册进去),等到退出时,atexit 链表回调析构,然后 dyld 再调__mod_term_func。两者顺序上,通常是先 atexit 回调,之后镜像终止函数;但如果你动态卸载镜像,__mod_term_func会先执行,因为镜像已经从进程里卸载了,之后不可能再调它的 atexit 回调。
在 dyld 源码里,旧版本有一个ImageLoader::doModTerm()函数,负责遍历 __mod_term_func 的函数指针并逐个调用;新版本 dyld 也保留了类似逻辑。实现上并不神秘,但我建议你把精力放在"什么时机触发"上,这直接影响你写的终止代码是否可靠。
4. 运行时读取和调用:一个可直接上手的 C 示例
4.1 getsectdata 与 getsectiondata 怎么选
Mach-O 提供了一组运行时接口,可以直接拿到某个 section 的指针。最常见的两个是getsectdata和getsectiondata。
头文件是:
#include <mach-o/getsect.h>函数原型:
uint8_t *getsectdata(const char *segname, const char *sectname, unsigned long *size); uint8_t *getsectiondata(const struct mach_header *mhp, const char *segname, const char *sectname, unsigned long *size);前者适合"当前主镜像"的节读取;后者需要你传一个mach_header指针,更适合在动态库内部读取自己镜像的某个节。
一个重要提醒:getsectdata返回的地址在不同版本的 dyld 里,可能已经做了 slide 调整,也可能没有。现代系统上,getsectiondata返回的地址通常是可以直接用的,但在较老的 iOS 版本或特殊链式加载场景里,都有翻车案例。最稳妥的做法依然是拿到节地址后,用dladdr或者_dyld_get_image_vmaddr_slide计算 slide,手动修正。
如果你要遍历__mod_term_func里的函数指针并调用,核心逻辑很简单:把节数据当作void (**)(void)数组,逐个取出函数指针,然后调用。但有几件事必须注意,下面代码里我会写清楚。
4.2 完整示例代码与逐步解析
先看代码,我加了充分的注释:
#include <mach-o/dyld.h> #include <mach-o/getsect.h> #include <mach-o/loader.h> #include <stdio.h> #include <string.h> #include <dlfcn.h> // 尝试从镜像头获取 __mod_term_func 的地址,并返回函数指针数量 static void **get_mod_term_funcs(const struct mach_header *mhp, size_t *count) { unsigned long size = 0; uint8_t *sect = NULL; // 优先从 __DATA_CONST 找,找不到再从 __DATA 找 sect = getsectiondata(mhp, "__DATA_CONST", "__mod_term_func", &size); if (sect == NULL) { sect = getsectiondata(mhp, "__DATA", "__mod_term_func", &size); } if (sect == NULL || size == 0) { *count = 0; return NULL; } *count = size / sizeof(void *); return (void **)sect; } // 手动调用一个镜像的所有终止函数 void call_mod_term_funcs(void) { // 获取当前主镜像的 mach_header // 注意:在动态库里调用时,应该用 dladdr 找到自己的 header Dl_info info; if (dladdr((void *)&call_mod_term_funcs, &info) == 0) { printf("dladdr failed\n"); return; } struct mach_header *mhp = (struct mach_header *)info.dli_fbase; size_t count = 0; void **funcs = get_mod_term_funcs(mhp, &count); if (funcs == NULL) { printf("no __mod_term_func\n"); return; } for (size_t i = 0; i < count; i++) { void (*func)(void) = (void (*)(void))funcs[i]; if (func == NULL) { continue; } printf("calling mod_term_func[%zu] at %p\n", i, func); func(); } }这段代码有几个值得展开的点:
第一,为什么不直接写死__DATA?因为节可能被链接器放到__DATA_CONST。现代 Clang 的默认目标文件可能还是往__DATA里放,但最终链接产物和你的 SDK 配置有关。两个段都查一遍,成本很低,兼容性却好很多。
第二,为什么要用dladdr拿 header?因为这段代码可能被编译成动态库,在别人的进程里加载。此时getsectiondata需要的是"你自己的镜像的 header",不是主程序的 header。用函数指针自己的地址去dladdr,能精确定位到当前镜像。
第三,调用前为什么要判空?虽然链接器理论上不会把空指针放进这个节,但在 dyld shared cache 或者被调试器修改过的内存里,指针值是不可信的。代码里加一个if (func == NULL)的防御,可以避免在真实环境里偶发蹦出一个空指针崩溃。
如果你在 32 位 Mach-O 上做同样的事,要把mach_header换成mach_header(非 64 位版本的结构体),getsectiondata本身是通用的,但 header 指针类型要匹配。现在 32 位已经很少见了,这一条知道就行。
4.3 调用终止函数的注意事项
手动调用终止函数是一件很有趣但也很危险的事。正常流程下,dyld 自己会调,你没必要掺和。但在做逆向分析、动态注入、或者测试一个 bundle 是否正确注册了退出回调时,手动调用能帮你快速验证。
调用时我强烈建议你注意这几点:
- 不要在终止函数里再调用
dlopen加载别的镜像,极容易触发 recursive lock 崩溃。 - 不要在终止函数里依赖已经注册过的其他终止函数,因为它们的执行顺序并不保证。
- 如果宿主可能已经进入退出流程,很多系统服务可能已经不可用,调用终止函数里包含的
printf、NSLog有时不会输出,不代表没执行。 - 如果同一个镜像被多次加载(dlopen 没有配对 dlclose),
__mod_term_func可能被执行多次,全局资源释放必须做幂等处理。
5. dyld 到底什么时候调用它
5.1 进程正常退出路径
进程正常退出,指的是 main 函数 return,或者你显式调用exit()。这时候流程大致是:
- C runtime 会调用注册在 atexit 链上的所有回调。
- libsystem 在初始化时会把自己通过
__cxa_atexit注册的终结器挂进 atexit 链。 - 这个终结器负责处理 Objective-C 运行时清理、C++ 静态对象析构、线程清理等。
- dyld 也会注册一个终结器,在进程退出的后期,统一调用所有已加载镜像的
__mod_term_func。
这里有一个细节:exit()调用和 main return 稍有不同,但最终都会走 exit 流程。如果是_exit()或者_Exit(),则不会再执行这些清理逻辑,__mod_term_func 也不会被调用。这是很多服务器端 C++ 程序"析构为什么没跑"的经典原因之一。
在 iOS 上,如果你在应用生命周期里正常按 Home 键,App 并不会立刻退出进程,大概率只是进入后台,__mod_term_func 不会被调用。真正被系统 kill 掉时,更不可能调用。所以如果你想依赖终止函数保存关键数据,是很危险的设计。
5.2 dlclose 与 bundle 卸载路径
比进程退出更典型的使用场景,是动态卸载。
你写了一个插件 bundle,宿主通过dlopen加载它,用完之后dlclose卸载。此时 dyld 会执行该镜像的终止函数,给你做清理的机会。这个机制对插件架构特别重要,因为插件可能被反复加载、卸载,资源泄漏会累积。
如果你自己通过dlopen加载一个 dylib,然后dlclose卸载,同样会触发该镜像的__mod_term_func。不过这要求在编译该 dylib 时,清理函数确实被放进了节里,并且该 dylib 没有被系统级缓存锁定。
这里有个经验:如果你的动态库同时被其他镜像通过依赖关系引用,dlclose可能不会真的卸载它,__mod_term_func也不会被调用。想确认是否真的卸载,用dlopen返回的 handle 做dlclose后,可以再用dladdr查一下镜像地址是否仍然有效。
5.3 崩溃和 kill 时为什么不会执行
这一点几乎每次讲终止函数都需要强调:崩溃、被kill -9、被系统看门狗杀掉、设备强制重启,所有这些场景都不会执行__mod_term_func。
原因很简单,这些退出要么是信号处理直接终止进程,要么是内核直接回收资源,压根没有机会走到 dyld 的清理流程。信号里像 SIGTERM 这种默认动作,也不会执行 atexit 钩子;除非你自己捕获信号后调用exit(),不然进程直接死亡。
所以正确的设计观念是:__mod_term_func只能用于"优雅退出"时的清理,不能作为资源回收的唯一保障。数据库连接、文件写入、计费统计这类关键操作,必须在业务层做持久化,不能指望终止函数。
6. 常见问题与排查技巧实录
6.1 终止函数不执行的几个原因
问得最多的问题就是:我明明写了__attribute__((destructor)),为什么跑起来就是不回调?我总结了这几个原因,按概率排序:
- 镜像没有被正常卸载。iOS 上 App 进程退出不走这一套,
dlclose时也可能因为引用计数不为 0 导致不卸载。 - 编译器把函数优化掉了。如果你在编译单元里写了 destructor 函数,但函数体是空的,或者只是简单打印,优化器可能认为这个函数无副作用,将其合并或者移除。你可以先用
nm -nm binary | grep "destructor"看看符号是否还存在。 - 函数被放进了错误的 section。有些手写汇编或者第三方编译选项,可能把函数放到了常规代码段,而不是 __mod_term_func。用
otool -l检查即可。 - 静态库目标文件没有被链接进最后产物。这是最常见也最隐蔽的,特别是你写的清理代码放在一个 .a 静态库里,宿主链接时如果没有任何符号引用到那个目标文件,整个文件都会被丢弃。
排查的时候,先确认节存在,再看函数指针是否在节里,最后确认调用时机。三步走完,基本能定位。
6.2 在终止函数里 Crash 怎么办
终止函数里崩溃,现场往往很难看。因为此时进程已经处于退出中段,很多系统状态不完整,堆栈可能不准确,甚至崩溃发生在你根本没有涉及的线程上。
我的建议分两点:
第一,尽量让终止函数简单且无依赖。不要做 Objective-C 消息发送,不要调用 dispatch 异步,不要访问已经释放的单例。如果确实需要发日志或上报,尽量走最底层的 write() 或 os_log,别走高层框架。
第二,如果必须排查,用 lldb 提前在终止函数入口下断点。
(lldb) breakpoint set --name my_cleanup或者直接通过地址下断点:
(lldb) breakpoint set -a 0x100008000然后在终止函数入口加一个__asm__("int3")或者__builtin_trap(),这样崩溃现场会停在固定位置,比随机崩溃好查很多。当然这只是调试手段,发布前记得去掉。
6.3 想调试终止函数?用 lldb 这样搞
如果你怀疑某个动态库的 __mod_term_func 没有被调用,又不想改代码,可以直接在 lldb 里观察。
先加载你的目标进程,然后执行:
(lldb) image lookup -n my_cleanup能查到函数地址;再输入:
(lldb) breakpoint set -n my_cleanup然后正常退出程序,如果断点没命中,说明 dyld 确实没有走这个函数。继续排查,可以用:
(lldb) image list确认镜像是否还在加载状态。很多时候你会发现,镜像根本没被卸载,或者终止函数所在的镜像在这个进程退出路径里压根没被框架调用。
还有一个小技巧,直接打印 Mach-O 的 section 内容:
(lldb) memory read -s 8 -c 3 0x100008000把 __mod_term_func 地址传进去,能看到指针数组里的每个值,对照image lookup -a可以反查每个函数所属符号。这个方法在分析恶意样本或者第三方闭源库时特别好用。
写到这里,关于 __mod_term_func 的机制和实操已经说得比较全了。我个人在实际排查中最大的体会是:这个节本身不难,难在"你以为它会调用,实际上根本没到那一步"。所以遇到终止回调不触发的问题,先别急着怀疑节定义有问题,用上面说的三步排查法走一遍,往往能在镜像加载状态或者链接阶段就找到答案。后面如果你在写插件、动态库,或者做越狱插件和注入工具,可以专门把 __mod_term_func 的执行时机当成一个观察点,会有很多有意思的发现。