从源码到加载器:__attribute__((constructor)) / __attribute__((destructor))的跨平台实现原理详解

文章来源声明: 原文作者:用户6958610672711; 来源站点:掘金; 原文链接:https://juejin.cn/post/7685662874368081962; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

适合需要做跨平台自初始化、插件注册或排查启动顺序的开发者,能帮助理解加载器行为并避开优先级与平台差异的坑。

摘要 --

__attribute__((constructor))__attribute__((destructor)) 是 GCC/Clang 提供的一对函数属性,允许开发者注册"在 main() 之前自动执行"和"程序退出或库卸载时自动执行"的函数,是运行时自初始化(Self-Registration)、插件系统、全局资源管理等场景中极为常用的机制。然而这对属性在不同目标文件格式(ELF / Mach-O / PE-COFF)下的底层实现方式差异很大——分别对应不同的段(section)、不同的加载器逻辑。本文从语法与优先级规则出发,深入剖析 Linux(ELF)、macOS(Mach-O)、Windows(PE,以 MinGW 工具链为例)三种平台下该属性编译后落在哪个段、加载器如何发现并调用这些函数,并给出用工具实测验证的方法。


  1. 基本用法与语义

<span>#<span>include</span> <span><stdio.h></span></span>

__attribute__((constructor))
<span>static</span> <span>void</span> <span>on_load</span><span>(<span>void</span>)</span> {
    <span>puts</span>(<span>"模块已加载,执行初始化"</span>);
}

__attribute__((destructor))
<span>static</span> <span>void</span> <span>on_unload</span><span>(<span>void</span>)</span> {
    <span>puts</span>(<span>"模块即将卸载,执行清理"</span>);
}

<span>int</span> <span>main</span><span>(<span>void</span>)</span> {
    <span>puts</span>(<span>"main 执行中"</span>);
    <span>return</span> <span>0</span>;
}

输出顺序固定为:

模块已加载,执行初始化
<span>main</span> 执行中
模块即将卸载,执行清理

即:constructor 函数总在 main() 之前执行,destructor 函数总在程序正常退出(return/exit())之后执行;若目标是动态库,则分别对应"库被加载(dlopen/隐式链接加载)时"与"库被卸载(dlclose/进程退出)时"。

1.1 优先级:constructor(N) / destructor(N)

__attribute__((constructor(<span>101</span>))) <span>void</span> <span>init_a</span><span>(<span>void</span>)</span> { <span>puts</span>(<span>"init_a"</span>); }
__attribute__((constructor(<span>102</span>))) <span>void</span> <span>init_b</span><span>(<span>void</span>)</span> { <span>puts</span>(<span>"init_b"</span>); }
__attribute__((constructor))       <span>void</span> <span>init_c</span><span>(<span>void</span>)</span> { <span>puts</span>(<span>"init_c(默认优先级)"</span>); }

  • 数字越小优先级越高,constructor 按优先级数字从小到大执行,destructor 则按相反顺序执行(后构造的先析构,类似栈的 LIFO 语义,与 C++ 局部对象析构顺序一致)。
  • 优先级 0~100 由编译器/运行时保留(GCC 用 -Wprio-ctor-dtor 检查违规使用),例如 gcov 的覆盖率收集使用 destructor(100);应用代码应使用 101~65535
  • 不带数字的 constructor/destructor 默认优先级为 65535(最低),这与 C++ 全局对象的动态初始化(_GLOBAL__sub_I_*)优先级相同——也解释了为什么 C++ 全局对象构造顺序和无优先级的 constructor 函数在跨编译单元时是"未定义顺序"的:它们本质上被放进了同一个优先级桶。
  1. Linux / ELF:.init_array 与历史遗留的 .ctors

2.1 两套实现方案

GCC 在 ELF 平台上实现 constructor/destructor 语义,历史上经历过两代方案:

方案使用的段现状
**legacy(旧方案)**`.ctors` / `.dtors`早期 GCC(4.7 之前)默认方案,依赖 `.ctors` 段中的函数指针数组**逆序**排布来实现优先级(编号越大越先执行,因此实现上做了取反映射)
**init\_array(现代方案)**`.init_array` / `.fini_array`自 GCC 4.7 起 Linux 平台默认方案,是 System V ABI 标准定义的机制,语义更直观:`.init_array` 中的指针按数组顺序**正序**执行

现代工具链(GCC ≥ 4.7、Clang 默认 -fuse-init-array)生成的目标文件里,一个 constructor(101)) 函数会被编译器放入名为 .init_array.101 的子段,链接器再依据子段名的数字后缀对其排序、合并进最终的 .init_array 段。可以用如下伪汇编直观理解(GCC 实际产出与此等价):

.section .init_array.101,"aw",%init_array
    .quad init_a          # constructor(101)

.section .init_array.102,"aw",%init_array
    .quad init_b          # constructor(102)

.section .init_array,"aw",%init_array
    .quad init_c          # 默认优先级 65535,不带数字后缀

对应地,destructor 生成的指针放入 .fini_array/.fini_array.N

2.2 用工具验证

对一个编译好的可执行文件或 .so,可以直接查看这两个段:

gcc -O2 ctor_demo.c -o ctor_demo
readelf -S ctor_demo | grep -E <span>'init_array|fini_array'</span>
<span># [ N] .init_array  INIT_ARRAY   ...</span>
<span># [ M] .fini_array  FINI_ARRAY   ...</span>

objdump -s -j .init_array ctor_demo    <span># 以十六进制查看段内容(是一串函数地址)</span>
readelf -x .init_array ctor_demo       <span># 效果类似</span>

<span># 更直观:直接反汇编并列出符号名对应地址,再和 .init_array 中的地址比对</span>
nm ctor_demo | grep -E <span>' init_| _GLOBAL__sub_I'</span>

也可以用 readelf -d(动态段)查看 INIT_ARRAY/INIT_ARRAYSZ/FINI_ARRAY/FINI_ARRAYSZ 这几个动态标签(Dynamic Tag),它们记录了 .init_array 段在运行时的起始地址与字节大小,这正是加载器定位该段的方式。

2.3 加载原理

ELF 规范中,一个可执行文件或共享对象的初始化流程大致如下(以 glibc/ld.so 为例):

  1. DT_INIT(_init 函数):System V 定义的最古老机制,由编译器在 .init 段生成一小段汇编前导代码,现代 GCC 已很少直接依赖它做用户级初始化,但符号仍然存在,充当"起点"角色。
  2. .preinit_array(仅可执行文件的主程序适用):在所有其它初始化之前运行,glibc 的 __libc_csu_init 在调用 .init_array 之前先遍历它。
  3. .init_array:按数组顺序(即按 constructor 优先级从小到大排序后的结果)依次调用每个函数指针。
    • 对于可执行文件,这一步发生在 __libc_start_main__libc_csu_init 中,即在跳转到用户 main() 之前;
    • 对于共享库,这一步发生在动态链接器(ld.so)完成符号重定位之后,无论该库是在程序启动时被隐式链接加载,还是运行期通过 dlopen() 显式加载,ld.so 都会在把控制权交还调用方之前遍历该库的 .init_array 并执行。
  4. 程序退出(exit()/return from main/共享库被 dlclose())时,.fini_array 中的函数按数组逆序执行,DT_FINI(_fini)作为终点收尾。

需要特别注意的语义细节:

  • 一个 .so 在被 dlopen 之后,即使后续又被同一进程再次 dlopen(引用计数增加),只要它此前已经加载过,其 constructor不会被重复调用。
  • 若某个 constructor 函数自身调用了 _exit()(而非 exit())或崩溃,后续尚未执行的 constructor 及 main() 都不会再运行。
  • ARM EABI 等部分平台规定必须使用 .init_array 方案,不允许退回 .ctors
  1. macOS / Mach-O:__DATA,__mod_init_funcdyld

3.1 使用的段

macOS 上没有 ELF 的 .init_array 概念,Mach-O 格式使用专门的 section type 标志而非固定段名来标识初始化/终止函数指针数组:

语义常见段(segment,section)Section Type 标志
constructor 指针数组`__DATA,__mod_init_func`(有时也写作 `__DATA_CONST,__mod_init_func`)`S_MOD_INIT_FUNC_POINTERS`
destructor 指针数组`__DATA,__mod_term_func``S_MOD_TERM_FUNC_POINTERS`

也就是说,dyld(macOS 的动态链接器/加载器)在扫描一个 Mach-O 镜像的 Load Command 时,并不靠段名字符串匹配,而是检查每个 section 头部 flags 字段中记录的类型是否等于 S_MOD_INIT_FUNC_POINTERS/S_MOD_TERM_FUNC_POINTERS——这与 ELF 依赖固定段名 + 动态段标签的方式有本质区别。另外还存在一种历史上更古老、现已废弃的机制:通过 LC_ROUTINES/LC_ROUTINES_64 Load Command 直接指定单一入口函数,早期 Mach-O 用它承载类似语义,现代工具链已不再产生这种 Load Command。

3.2 用工具验证

clang -O2 ctor_demo.c -o ctor_demo

otool -l ctor_demo | grep -A5 __mod_init_func
<span># Section</span>
<span>#   sectname __mod_init_func</span>
<span>#    segname __DATA</span>
<span>#      addr 0x...</span>
<span>#      size 0x...</span>
<span>#    ...</span>
<span>#      flags 0x00000009   <- S_MOD_INIT_FUNC_POINTERS 对应的标志位</span>

otool -v -s __DATA __mod_init_func ctor_demo   <span># 以指针列表形式查看内容(函数地址)</span>

<span># 图形化工具 MachOView 可以更直观地看到 __mod_init_func / __mod_term_func 两个 section</span>

macOS 上所有可执行文件事实上都是动态链接的(即便看起来是"独立"的可执行文件,也依赖 dyld 完成启动),因此不存在 Linux 意义上"静态可执行文件用另一套流程"的区分。

3.3 加载原理

dyld 的启动流程大致为:

  1. 映射 dyld 共享缓存(预先链接好的系统库),随后递归解析并映射目标可执行文件依赖的所有动态库(包括 DYLD_INSERT_LIBRARIES 注入的库)。
  2. 所有依赖库加载完成、符号绑定(非懒绑定部分)完成后,dyld 开始按依赖顺序(被依赖者先于依赖者)遍历每个镜像,扫描其 Load Command 中带有 S_MOD_INIT_FUNC_POINTERS 标志的 section,取出其中的函数指针数组并逐一调用——这一逻辑在开源的 dyld 源码中对应 ImageLoaderMachO::doModInitFunctions()
  3. 主执行文件自身的 __mod_init_func 也会在这一阶段被处理,发生在 main() 被调用之前。
  4. 进程退出或镜像被 dlclose() 卸载时,dyld 调用 ImageLoaderMachO::doTermination(),遍历 S_MOD_TERM_FUNC_POINTERS 标记的 __mod_term_func section 并执行,顺序与初始化相反。

由于这套机制完全基于"扫描已加载镜像的 section 标志",历史上还被用作二进制注入/红队研究的一个切入点:在 Mach-O 中伪造或追加一个带有 S_MOD_INIT_FUNC_POINTERS 标志的 section,即可让 dyld 在正常加载流程中"顺手"执行攻击者指定的函数——这也是理解该机制安全含义的一个重要旁证。

  1. Windows / PE-COFF:GCC 语义的"移植"与 MSVC 的 .CRT$XCU

这是三个平台里情况最特殊的一个,因为 PE-COFF 格式本身以及微软官方工具链(MSVC)并不原生支持 GCC 的 __attribute__((constructor)) 语义。Windows 上能用到这个属性,通常是通过 MinGW / MinGW-w64(GCC for Windows)或 Clang 的 -target *-w64-mingw32 工具链,它们在 CRT(C 运行时)启动代码层面"重新实现"了一遍这套机制。

4.1 MinGW 的实现:沿用 ELF 的旧方案 .ctors/.dtors

与 ELF 现代方案不同,MinGW-w64 默认对 constructor/destructor 采用的是 .ctors/.dtors 这套"legacy"方案(而非 .init_array),这是由 Clang 的 -fno-use-init-array(MinGW 目标下的默认值)以及 MinGW-w64 自身 CRT 设计决定的。编译产物中会看到:

x86_64-w64-mingw32-gcc -O2 ctor_demo.c -o ctor_demo.exe

objdump -h ctor_demo.exe | grep -E <span>'ctors|dtors|CRT'</span>
<span>#  .ctors        ...</span>
<span>#  .dtors        ...</span>

链接阶段(无论是 GNU ld 还是 LLVM 的 lld-link)会把各个编译单元产生的 .ctors/.dtors/.CRT$* 子段合并、排序,最终 .ctors/.dtors 这两个段在 MinGW 目标下会被并入 .rdata(只读数据段)——因为其内容本质上只是一串函数指针,不需要可执行属性,这一优化行为由 LLD 显式实现,GNU ld 则沿用内置链接脚本将其放入 .text

MinGW-w64 的 CRT 启动代码(crt0/crtexe.c)在进入用户 main() 之前,会通过遍历以特殊哨兵值(0(uintptr_t)-1)标记起止的 __CTOR_LIST__/__DTOR_LIST__ 指针数组来逐一调用这些函数,这一"链表遍历+哨兵终止"的实现细节直接继承自传统 GNU .ctors 方案。

4.2 MSVC 的等价机制:.CRT$XCU(补充对照)

虽然 MSVC 编译器本身不识别 __attribute__,但理解 MinGW 方案时经常需要与 MSVC 的官方机制做对照,便于跨工具链协作(如 pthreads-win32、许多跨平台库会同时兼容两者):

<span>#<span>if</span> defined(__MINGW32__) || defined(__MINGW64__)</span>
<span>#  <span>define</span> ATTR_SECTION(name) __attribute__((section(name)))</span>
<span>#<span>elif</span> defined(_MSC_VER)</span>
<span>#  <span>define</span> ATTR_SECTION(name) __pragma(section(name, long, read)); __declspec(allocate(name))</span>
<span>#<span>endif</span></span>

ATTR_SECTION(<span>".ctors"</span>)     <span>void</span> *gcc_ctor = on_process_init;   <span>// GCC/MinGW 路径</span>
ATTR_SECTION(<span>".CRT$XCU"</span>)   <span>void</span> *msc_ctor = on_process_init;   <span>// MSVC 路径</span>

MSVC 的 CRT 启动代码会在初始化阶段扫描 .CRT$XIx(C 初始化)、.CRT$XCx(C++ 初始化)等一系列以 .CRT$X 为前缀、按字母序排列的分段(XIAXIZXCAXCZ 等),XCU 是官方文档中开放给用户注册自定义初始化函数使用的分段位置,这与 GCC 的 .ctors/.init_array 在设计思路上高度相似——都是"把一组函数指针塞进一个专门段里,由启动代码统一遍历调用",只是分段命名规则、排序依据(字母序 vs 数值优先级)不同。

4.3 加载原理小结(Windows)

由于 Windows PE 加载器(ntdll/kernel32 的 PE 映像加载逻辑)本身不认识不处理 .ctors/.CRT$* 这些段,constructor/destructor 语义在 Windows 上完全依赖用户态 CRT 启动代码主动去扫描、调用,而不是像 ELF(ld.so)或 Mach-O(dyld)那样由系统级加载器直接处理:

  1. PE 加载器把可执行文件/DLL 的各个段映射进内存、完成导入表(Import Table)重定位。

  2. 控制权交给该模块的入口点——对可执行文件是 CRT 的 mainCRTStartup/WinMainCRTStartup(MinGW 对应 crtexe.c 中的启动逻辑),对 DLL 是 DllMain

  3. CRT 启动代码在真正调用用户 main()/DllMain 之前,主动遍历 .ctors(或 MSVC 下的 .CRT$XCU 系列)中的函数指针并逐一调用。

  4. 进程退出或 DLL 被卸载(FreeLibrary/进程终止)时,CRT 退出逻辑(atexit 处理链的一部分)遍历 .dtors 并调用。

  5. 三平台对照速查表


维度Linux (ELF)macOS (Mach-O)Windows (PE, MinGW)
constructor 落地的段`.init_array`(现代)/ `.ctors`(legacy)`__DATA,__mod_init_func``.ctors`(合并进 `.rdata`)
destructor 落地的段`.fini_array` / `.dtors``__DATA,__mod_term_func``.dtors`(合并进 `.rdata`)
段的识别方式固定段名 + 动态段标签 `DT_INIT_ARRAY`section flags 中的 `S_MOD_INIT_FUNC_POINTERS` 类型标志无系统级识别,纯靠 CRT 启动代码约定
执行者动态链接器 `ld.so`(共享库)/ glibc 启动代码 `__libc_csu_init`(主程序)动态链接器 `dyld`(`ImageLoaderMachO::doModInitFunctions`)CRT 启动代码(用户态,非系统加载器)
排序依据子段数字后缀(优先级数值)加载(依赖)顺序,同一镜像内数组顺序链接器合并顺序 / 链表遍历
查看工具`readelf -S` / `readelf -d` / `objdump -s -j .init_array``otool -l` / `otool -v -s __DATA __mod_init_func` / MachOView`objdump -h` / dumpbin(MSVC 对照)
  1. 实践建议

  1. 不要依赖跨优先级的执行顺序:即便在同一平台上,不同编译单元里默认优先级(65535)的 constructor 之间顺序也是未定义的(受链接顺序影响),涉及依赖关系时务必显式指定优先级区间。

  2. constructor/destructor 里避免调用尚未初始化完成的其它模块的接口:因为你无法保证依赖库的 constructor 一定先于自己的执行完(尤其是 Windows 下 DLL 的 DllMain/CRT 初始化阶段,微软官方文档明确警告在 DllMain 中调用 LoadLibrary、大部分 Win32 API 是不安全的,MinGW 的 .ctors 机制同样运行在这一敏感阶段)。

  3. 在 Windows 上优先考虑显式初始化函数 + 手动调用,而不是依赖 .ctors 隐式机制,因为该机制不是 PE 规范的一部分,不同链接器(GNU ld、LLD、MSVC link.exe)对其支持程度和排序细节均有差异,可移植性最差。

  4. __has_attribute(constructor) 做特性探测,并为 MSVC 分支手动实现基于 .CRT$XCU 的等价逻辑,以获得跨三大工具链一致的行为。

  5. 共享库场景下警惕重复加载语义:dlopen/LoadLibrary 对已加载模块的重复调用不会重新触发 constructor,若需要"每次获取句柄都执行一次"的语义,应改用显式初始化 API 而非依赖这套机制。

  6. 小结


__attribute__((constructor))/__attribute__((destructor)) 在语义层面是统一的"加载前初始化、卸载后清理"约定,但其底层实现随目标平台的可执行文件格式与加载器设计而截然不同:ELF 平台上是标准 ABI 定义的 .init_array/.fini_array 加动态段标签,由 ld.so/glibc 启动代码系统级处理;Mach-O 平台上是依赖 section flags 类型标识的 __mod_init_func/__mod_term_func,由 dyld 系统级处理;而 Windows/PE 平台上这套机制完全是 GCC/MinGW 工具链"借用" legacy ELF 方案在用户态 CRT 层面的重新实现,并非系统加载器原生支持,可移植性与行为一致性也因此最弱。理解这些差异,对编写跨平台的自注册组件、诊断"构造函数为什么没执行/执行顺序不对"一类问题,都有直接帮助。


参考资料:GCC/Clang 官方文档 "Common Function Attributes";maskray.me 博客 ".init, .ctors, and .init_array"(2023);Apple dyld 开源项目源码(ImageLoaderMachO.cpp);LLVM lld COFF 后端 MinGW 相关补丁与测试用例;MinGW-w64 mingw-w64-crt/crt/crtexe.c 源码;Microsoft Learn "CRT Initialization"(.CRT$X* 分段说明)。