写到现在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_6432 位 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 在文件里本质上就是一个函数指针数组。如果你的二进制开启了 PIEaddr 是 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 的执行时机当成一个观察点会有很多有意思的发现。
企业数字化 ERP 产品动态
相关推荐
AI Agent企业级落地实战:从核心原理到Spring AI实现 去年年底到今年,圈子里聊得最多的就是 AI Agent,尤其是“企业级落地”这四个字。市面上讲 agent 概念的文章多如牛毛,但真正能把 agent 从 demo 推到生产环境、能对接企业内部系统、能扛住业务压力的实战经验,其实非常稀缺。这套《… · 2026/9/24 22:52:32
GitHub日榜深度解读:7个值得上手的开源项目与筛选方法 如果你平时只在别人的二手转发里看 GitHub,那真的会错过很多东西。我几乎每天都会固定打开一次 GitHub 热榜,把当天的日榜项目扫一遍,这个习惯保持了好几年,已经成了我判断技术风向的重要信息来源。所谓热榜项目,就是 … · 2026/9/24 22:52:32
C#图书管理系统实战:Dapper+WinForms分层架构与核心流程详解 走出学校上机课,很多人做的第一个像样的C#项目就是图书管理系统。但说实话,我在看简历和帮人改代码的时候,见过太多“能跑但不敢看源码”的图书管理系统:所有数据库操作堆在窗体按钮点击事件里、SQL语句靠字符串拼接、每次查询都n… · 2026/9/24 22:52:26
用Dify+LangBot搭建多平台群聊AI写作助手实战 半个多月前,团队里提了一个"听起来很简单"的需求:把带写作能力的 AI 助手直接拉进日常工作的 QQ 群、微信群和飞书群,让它帮我们写公众号初稿、周报、文案,还要能结合团队自己的知识库回答写作相关的问题。真正上手之后… · 2026/9/24 23:22:39
Dify+LangBot实战:用GPT-6 Astra打造多IM群聊写作助手 开头最近在群里被问爆的一件事:能不能把 GPT-6 Astra 接到 QQ、微信和飞书里,让它在群聊里直接帮写文案、回消息、整理周报。老实说,这个需求一点都不新鲜,但难点在于怎么把"能用的模型"和"能聊天的群"之间那… · 2026/9/24 23:22:39
监控器芯片:嵌入式系统硬件级复位与电源监控核心指南 1. 项目概述:当系统“猝死”成为常态,监控器芯片就是那根救命的保险丝你有没有遇到过这样的场景:设备在现场运行得好好的,突然黑屏、重启、卡死,或者更糟——上电瞬间就冒烟、烧MOS、MCU锁死?我做过三年工业… · 2026/9/24 23:22:39
MCP服务端生产级落地:用Grix构建高可靠工具与资源中枢 如果你已经动手写过一两个 MCP 服务器,大概率会有同感:注册一个 Tool 出来实在太简单了,真正难的,是让这个 Tool 在模型手里不超时、不瞎传参、不报一堆让人看不懂的错,同时把资源、提示词、工具之间那层关系理顺。Mod… · 2026/9/24 23:22:32
STM32点灯之后:如何证明板子真的活了?GPIO与时钟调试实战 市面上讲STM32点灯的文章一抓一大把,但大部分都在教你怎么“把代码烧进去让灯亮”,很少有人在灯真的闪起来之后,追着问你一句:你怎么知道是板子在闪,而不是幻觉?这篇是“基于STM32的嵌入式C编程之旅”系列第… · 2026/9/24 23:22:32
基于滑模制导律的落角约束制导仿真与Matlab实现 做导弹制导控制方向的人,大概率都经历过这个场景:比例导引在仿真里打靶怎么打怎么中,但任务书里突然多了一句话——"以指定落角命中目标"。这时候十有八九要重新折腾制导律。我自己最开始试过偏置比例导引,调了几轮参数… · 2026/9/24 23:22:32
基于YOLOv8的渔船作业监控系统:从环境搭建到边缘部署全流程 简介:这是一套面向计算机、人工智能、自动化等专业学生与教师的毕业设计级项目资源,围绕YOLOv8实现渔船作业监控系统,可用于毕设、课程设计、大作业或项目立项演示。压缩包共97个文件,约24.21MB,以70个Python源码文件为… · 2026/9/24 0:00:13
1D-CNN时间序列建模实战:从Conv1d原理到工业落地 简介:面向时间序列数据建模的一维卷积神经网络完整实现,适合深度学习入门者及需要快速验证时序模型的研究者,能够从音频、文本、传感器或股价等序列中挖掘局部特征与时间依赖。压缩包体积很小,只有3KB,内含3个Python脚… · 2026/9/24 0:00:26
柔软的L:汉语语流中被忽视的舌肌张力控制 1. 这个“L”不是字母表里的L,而是舌尖上的L最近在几个方言群和语音教学社群里,反复看到有人发一句:“也说字母L:柔软的长舌”。初看以为是英语发音课笔记,点开才发现全是方言爱好者、播音系学生、语言康复师甚至戏曲演… · 2026/9/24 0:00:44