首页 / 资讯中心 / 文章详情

C++ main函数前运行代码详解:全局对象、constructor与.init_array

C++ main函数前运行代码详解:全局对象、constructor与.init_array ★ FEATURED ARTICLE
做 C 的迟早会在一个需求上栽跟头业务入口main还没执行但代码希望抢先跑起来。我第一次遇到这事是在某跨平台采集组件里写模型注册表。算法模块一旦被链接进程序就得自动登记到能力列表里不能等用户在main里手动调注册函数。后来做日志倒灌、性能采样这一类基础设施组件需求又变成“在业务代码开工之前把探针和环境铺好”。于是绕不开同一个话题怎么在main函数执行前运行自己的代码。主流的宽路子有四条——静态存储期对象构造、__attribute__((constructor))、直接操作.init_array段、以及 Windows 下借助init_seg和 CRT 段。四条路各有脾气适用场景差别不小。这篇就把原理、完整代码、实测踩坑一次讲透适合正在写框架、SDK、插件系统的 C 开发者也适合想把启动过程彻底搞清楚的同行。1. 为什么代码要抢在 main 之前执行1.1 哪些项目真的需要这种能力先说最典型的四类场景你看看是不是你的现状。插件系统或者模块注册表。程序内部有一个全局工厂表各种算法模块、设备插件、编解码器都要在启动时自动把自己注册进去。如果这块逻辑放在main里手工调结果就是每加一个模块就要改一次启动代码漏改一次运行期才暴雷。更气人的是别人写的模块还得你催着他去改注册入口。反过来如果注册动作发生在main之前模块只要被链接器拉进来自动就把自己的工厂函数塞进总表完全无侵入。SDK 和公共组件。你写了一个日志 SDK 或监控采集库交付给业务方时只希望对方链接一下就完事。日志文件在哪、性能采样的频率多少、后台上报线程是否启动这些都应该由 SDK 自己在main之前初始化完毕。业务侧不需要知道你内部有什么前置条件甚至不该有资格调用你的初始化接口。性能分析与诊断工具。想要测某个服务启动那一刻的 CPU、内存、IO 行为采样探针必须比业务代码先跑。我在某中间件项目里做启动阶段性能剖析时就是把探针挂在main之前的初始化函数里这样连全局对象构造函数的时间都能被统计到。嵌入式网关和带外设的现场设备。外部设备初始化依赖一张参数表而参数表必须在任何硬件寄存器被触碰之前校验完。这种场景对执行顺序极其敏感main里再校验已经晚了。共性就一句话这些初始化行为与业务代码解耦同时不能依赖业务方的主动调用。放在main之前是让系统“先把自己长出来再开始干活”的最自然位置。1.2 四条路线全景对比老规矩先给一张对比表心里有数再往下看。方案目标编译器核心技术原理可控性上手难度适用场景全局/静态对象构造全平台通用CRT 启动代码遍历初始化数组调用对象构造函数中低注册表、单例、早期状态准备__attribute__((constructor))GCC / Clang编译器把函数指针放进.init_array段中低早期日志、环境探针、C 工程自定义.init_array段GCC / 链接器手工把函数指针放入初始化数组由启动代码遍历调用高高嵌入式、严格排序、定制启动链路MSVCinit_seg/.CRT$XCU段MSVC运行库按段名字母顺序遍历初始化函数中中Windows 服务组件、SDK很多人看到第四行的原理会问为什么 Windows 上不像 Linux 那样直接叫.init_array因为 MSVC 有自己的 CRT 段体系初始化函数被放在.CRT$XCU这类段里段名后缀的字母决定遍历顺序。理解这一点Windows 侧的很多魔法就不神秘了。1.3 不管走哪条路最终都绕不开启动代码所有方案最终殊途同归程序真正的入口不是main而是运行时/CRT 的启动代码。在 ELF 文件里链接器会安排_start作为程序入口然后依次调用__libc_start_main、__libc_csu_init。__libc_csu_init的本质工作就是遍历.init_array段里的函数指针数组挨个调用。等这一圈跑完main才登场。Windows 上同理。CRT 初始化代码会用_initterm遍历.CRT$XC*段里的函数指针。你写的全局对象构造函数也好编译属性标记的函数也好最终都是把地址塞进这些初始化列表中。记住这个结论后面所有方案的原理都是一件事往“启动代码需要遍历的那个表”里塞一个你自己的函数地址。2. 基础路线全局对象构造与静态初始化2.1 启动链路里到底发生了什么C 标准白纸黑字承诺过一件事非局部变量的静态存储期对象其动态初始化必须在main函数体执行之前完成不考虑跨线程调度。这是 C 标准层面就承认的机制也是最少魔法、最少编译平台差异的做法。细节层面编译器会把每个需要动态初始化的全局对象包装成一个“无参构造函数调用”。这些调用被收集成一个初始化函数序列最终放进可执行文件的.init_array段。程序加载后启动代码遍历这个段每个构造函数就被调用一次。有一个极其容易搞错的点初始化顺序的保证范围。同一个翻译单元内全局对象的构造严格按照定义顺序执行但跨翻译单元标准明确不保证顺序。很多项目一提到“静态初始化顺序问题”就头大根因就在这。注意你在a.cpp里定义的全局对象在b.cpp里定义的另外一个全局对象谁先构造完全没约定。这取决于链接器的具体布局和源文件名字母顺序、编译顺序没有必然关系千万别赌。我在某跨平台采集组件里就吃过这个亏。设备枚举模块里有个全局单例后续所有的日志初始化都要读它另一个文件里有个早于它构造的全局对象在构造函数里访问这个单例结果得到空数据查了大半天才发现是跨翻译单元的初始化顺序踩雷了。2.2 自动注册工厂的一个真实写法这是我目前最常用也最稳妥的注册器写法。用一个函数内的局部 static 对象作为注册表这样一个全局注册对象在main前被构造时就会去操作这张表。#include map #include string #include cstdio typedef void* (*FactoryFn)(); std::mapstd::string, FactoryFn factoryTable() { static std::mapstd::string, FactoryFn table; return table; } struct FactoryRegistrar { FactoryRegistrar(const char* name, FactoryFn fn) { factoryTable()[name] fn; } }; #define REGISTER_FACTORY(Tag, Func) \ static FactoryRegistrar registrar_##Tag(#Tag, Func) void* createAudioDevice() { return nullptr; } REGISTER_FACTORY(device_audio, createAudioDevice); int main() { printf(registered%zu\n, factoryTable().size()); return 0; }这个代码跑起来printf输出registered1也就是说factoryTable()里的条目在main执行前就已经被插入。逻辑很简单全局对象registrar_device_audio的构造函数在启动阶段被调用执行了一次factoryTable()[name] fn的赋值。如果我想验证它不是靠运气而是真的在main之前跑的会在 GDB 里对factoryTable()下断然后看函数调用栈。栈底能看到__libc_csu_init遍历初始化数组的过程非常直观。2.3 静态初始化顺序问题的规避手段前面提到了跨翻译单元顺序未定义这一节专门说怎么躲坑。最有效的解法是把“全局对象”改成“函数内局部 static 对象”也就是 Meyers Singleton。局部 static 对象的初始化发生在第一次执行到该声明时而不是进程启动阶段天然绕开了初始化顺序问题。DeviceRegistry registry() { static DeviceRegistry instance; return instance; }instance在第一次调用registry()时才构造不管哪段代码第一个碰它构造顺序都可控。代价是没法保证它在main之前一定初始化完毕不过对大多数注册表场景这根本不是问题因为注册动作本身延迟到了构造时。另一个军规全局对象构造函数里不要碰其他模块的全局对象。构造函数里不要打开依赖运行时环境的资源不要抛异常。初始化阶段所有代码都应该保持“轻量、同步、无外部依赖”的状态。如果确实要读配置文件也要做好文件不存在时的兜底否则启动阶段直接崩连main都进不了。3. GNU 系大杀器attribute((constructor))3.1 它和全局对象构造的关系全局对象构造虽然通用但写起来还是有点“重”。你得定义一个类、实例化一个对象为了一个简单的初始化调用折腾出完整类型体系确实繁琐。GCC 和 Clang 提供了一个扩展属性__attribute__((constructor))。它能把一个普通函数标记为“启动阶段需要被调用的函数”不要求你定义对象、不用管生命周期、纯函数回调甚至 C 代码里也能用。编译器背地里做的是把函数地址塞进.init_array段和全局对象构造走同一条启动链路。__attribute__((constructor)) static void early_init() { printf(early_init called before main\n); } int main() { printf(main called\n); return 0; }运行后输出顺序必然是early_init called before main main called这个属性在纯 C 工程里特别香。很多老的 C 项目需要做钩子注入但因为不想引入 C 运行时全局对象方案直接被否掉constructor就是那根救命稻草。3.2 priority 怎么控制多个初始化函数的先后一个场景里往往不只有一个初始化函数。模块 A 要先初始化内存池模块 B 要注册插件模块 C 要统计启动信息。顺序乱了就容易出 bug。constructor支持设定优先级__attribute__((constructor(101))) static void first_stage() { printf(first_stage\n); } __attribute__((constructor(202))) static void second_stage() { printf(second_stage\n); }优先级数值越小越先执行所以输出一定是first_stage在前。注意 GCC 文档里有明确说法0~100 这段区间保留给编译器内部使用包括 C 全局对象构造相关机制。用户自定义代码建议从 101 开始避免和编译器内部使用的级别打架。踩坑提醒constructor优先级只控制“同为该属性的函数”之间的先后关系它和全局对象构造之间的相对顺序并没有严格的契约。如果同一个工程里既有全局对象构造又有constructor(101)函数不要假设谁一定在前除非你实际验证过链接器的排序结果。我在一次把模块注册从全局对象迁移到constructor函数时发现一个模块的工厂表数据在另一个constructor里还没出现最后查链接 map 才知道是优先级和链接顺序叠加导致。遇到跨机制混合绝对不能想当然。3.3 副作用、保活方式与跨平台适配constructor最隐蔽的坑是“死代码消除”。如果标记函数在整个程序里没有被显式引用链接器开启--gc-sections时可能把这个函数所在的目标文件段整体丢弃。解决办法有几种一是给函数加__attribute__((used))二是链接时对目标文件使用KEEP指令三是在依赖这个函数的模块里显式引一次。__attribute__((used, constructor(101))) static void early_init() { // ... }另外要注意这个属性是 GNU 扩展MSVC 完全不认识。如果代码要跨 Windows 和 Linux就得包一层条件宏要么在 Windows 下用下一章要讲的方案否则编译直接报错。4. 再往下挖一层直接操作 .init_array 段4.1 .init_array 是怎么被调起来的讲原理.init_array是一个函数指针数组。链接脚本里定义了__init_array_start和__init_array_end两个符号call_init会从 start 遍历到 end把每个元素当作函数指针调用。全局对象构造和constructor本质上也都是往这个数组塞地址只是由编译器替你完成了。理解了数组的本质就能理解为什么可以手动介入。你完全可以自己定义一个段把函数指针放到这个数组里不用依赖编译器的属性机制。static void manual_init() { printf(manual_init called\n); } __attribute__((used, section(.init_array))) static void (*init_entries[])(void) { manual_init };这段代码会在全局对象构造和constructor函数都注册完成后作为数组里的一份子被调用。顺序并不严谨但如果只是为了“确保这段逻辑在 main 之前执行”它是可行的。4.2 手写初始化数组的正确姿势如果初始化函数比较多可以把它们按顺序写出数组static void init_network(); static void init_logging(); static void init_plugin_loader(); __attribute__((used, section(.init_array))) static void (*init_entries[])(void) { init_logging, init_network, init_plugin_loader };这时候顺序由数组元素书写顺序决定比一堆constructor(101)、constructor(102)更直观。想在两个模块之间精确排序直接调换数组顺序就行。验证方法也很简单编译后跑objdump -s -j .init_array a.out能看到数组里存放的函数地址。配合nm找函数地址就能确认你的函数确实进了这个段。4.3 高自由度带来的高风险动手前先想清楚直接操作.init_array是自由度最高、风险也最高的一条路。段名在不同架构和链接器下可能有差异。ELF 上是.init_arrayMach-O 是__mod_init_func某些嵌入式平台的链接脚本甚至根本没有这个段。如果你不是为了嵌入式或特殊启动链路直接塞数组很容易把启动代码搞挂轻则初始化缺失重则段错误。我的一贯建议是常规项目的初始化顺序constructor的优先级足够用了。到了非要动.init_array的程度通常是你在做操作系统移植、定制 C 运行库、或者必须精确控制每一个初始化函数在启动链路中的位置。这时候与其用数组硬排不如定义自己的.early_init、.late_init段再通过链接脚本把段插入到启动代码遍历序列的指定位置让链接器帮你完成排序。5. Windows 侧写MSVC 的 init_seg 与 CRT 段5.1 init_seg 的优先级语义Windows 侧的 C 开发者一般不太关心启动链路因为在 MSVC 环境下全局对象构造在大多数场景下天然可用。但如果要做偏底层的基础设施或者想模拟constructor的行为就要知道init_seg。#pragma init_seg可以指定当前单元里全局对象的初始化优先级完整的声明格式是#pragma init_seg(compiler | librarian | user | custom_segment)默认情况下用户代码里的全局对象在user段初始化。系统运行库自身在compiler和librarian阶段初始化。如果希望某个全局对象比普通用户对象更早构造可以写成librarian#pragma init_seg(librarian) static EarlyLogger g_earlyLogger;这个对象会在用户代码的全局对象之前完成构造。但有一条我反复强调的警告不要用compiler段除非你正在写编译器配套的运行时库。compiler阶段太靠前CRT 自身的一些基础设施可能还没就绪那时构造对象属于高危操作。5.2 用 pragma section 和 .CRT$XCU 实现类似 constructorMSVC 里更接近constructor的做法是利用.CRT$XC*段。MSVC 的初始化函数被放在这些段里链接器按段名字母顺序遍历。$XCU是用户区里比较靠前的位置。可以这样把一个函数指针塞进$XCU#pragma section(.CRT$XCU, read) typedef void (__cdecl* _PVFV)(void); static void startup_callback() { // 在 main 之前执行 } __declspec(allocate(.CRT$XCU)) _PVFV p_startup startup_callback;原理和 ELF 下的.init_array如出一辙只是段的名字换成了 MSVC 风格。__declspec(allocate)把变量分配到指定段_PVFV是 MSVC 运行库里常用的函数指针类型。注意在静态库场景下如果这个变量没有被外部引用链接器可能会把它丢弃。稳妥的做法是用#pragma comment(linker, /INCLUDE:p_startup)或者确保有某个被引用的对象指向这个变量。5.3 别忽略 DLL 入口机会如果项目以动态库的形式交付有一个更常规的切入时机DllMain的DLL_PROCESS_ATTACH。DLL 被加载时加载器会调用 Dll 入口函数这个时机直观上比进程main更早因为 DLL 的加载先于进程主函数的执行确切地说是在加载时。BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) { if (ul_reason_for_call DLL_PROCESS_ATTACH) { // 初始化逻辑 } return TRUE; }这里有个需要注意的死锁问题DllMain期间系统持有加载器锁不要在这个函数里做复杂的同步操作、不要加载其他 DLL、不要长时间等待线程。老老实实做轻量初始化复杂逻辑延后到main或者首次调用时再执行。6. 整合一套跨平台的初始化宏6.1 为什么要封装宏看到这里你应该明白了想在 Linux 和 Windows 上写一份统一的“main 之前执行”代码不能直接贴编译器属性。于是封装一层宏就是必然选择。6.2 宏的封装思路与变体一个最简单的跨平台宏可以长这样#if defined(_MSC_VER) #pragma section(.CRT$XCU, read) #define EARLY_INIT_FUNC(fn) \ static void fn(); \ __declspec(allocate(.CRT$XCU)) \ void (*fn##_entry)() fn; \ static void fn() #else #define EARLY_INIT_FUNC(fn) \ __attribute__((used, constructor(101))) \ static void fn() #endif EARLY_INIT_FUNC(init_config) { // 初始化逻辑 } int main() { return 0; }这段代码在 GCC/Clang 下走constructor(101)在 MSVC 下走.CRT$XCU段。实际项目里可能要针对编译器版本再微调但骨架思路就是靠预处理分支切换底层机制。宏写好后业务侧使用成本极低一行EARLY_INIT_FUNC(init_module_a)就能挂一个初始化函数不需要关心它背后是怎么被调用的。这也是我推荐多数项目采用的工程化路线。6.3 项目里的应用效果侧记我在模拟项目 X 的插件注册表模块里试过把全局对象方案替换成这套宏。项目同时要出 Linux 和 Windows 两个版本Linux 用 Clang 编译Windows 用 MSVC 编译。实测结果稳定两个平台下注册表的填充都发生在业务代码之前断点验证过调用栈Linux 侧从__libc_csu_init进入Windows 侧从initterm进入。全局对象构造最稳但需要定义对象稍显笨重constructor写起来最顺手但只有 GNU 系$XCU段适合 MSVC但也要花心思处理链接器保活。封装成宏之后这三种机制对上层完全透明项目代码基本不用再改。7. 避坑指南与调试经验7.1 我踩过的坑清单这些坑我基本都踩过列出来等于给你省一个月的调试时间。构造函数依赖别的模块的全局对象。跨翻译单元初始化顺序未定义轻则拿到脏数据重则直接崩溃。解法是转向 Meyers Singleton。构造函数里抛异常。初始化阶段抛异常不会被main里的 try-catch 捕获因为此刻main还没进。结果基本就是全局终止很多新人对这个问题毫无防备。用了--gc-sections结果初始化函数被剪掉。尤其是手写.init_array时特别容易碰到。解决办法就是__attribute__((used))或者链接脚本里KEEP。手写.init_array时把数组顺序写反。这会导致某个模块早于它的依赖被初始化恰好在启动阶段的脆弱期触发一系列诡异问题。那时你连常规的调试手段都不好用程序可能在输出任何日志之前就挂了。DllMain里做了重量级初始化导致死锁。系统持有加载器锁你在这个锁里等待其他线程的资源线程又加载了另一个 DLL典型的死锁场景。轻量初始化留给DllMain复杂逻辑迁移到延迟初始化。7.2 如何验证初始化函数真的先于 main 执行验证比你想的简单。Linux 上最直接的方式是用 GDB(gdb) break main (gdb) break early_init (gdb) run如果early_init先命中看调用栈应该是__libc_csu_init调用了call_init然后调你的函数。紧接着继续运行才会进入main。这个调用栈本身就是最好的证据。不用调试器时objdump -s -j .init_array a.out可以查看初始化数组里的函数地址readelf -S a.out | grep init可以确认段存在配合链接时生成的 map 文件能核对每个函数的最终摆放位置。Windows 上则用dumpbin /headers或dumpbin /symbols检查 CRT 段。7.3 如果让我再来一次我会怎么设计根据我个人的实际经验main 之前的代码并不是越早越好而是越可控越好。能走 C 静态对象机制的就尽量用标准能力解决最少魔法。需要精确控制多个初始化函数顺序时先考虑constructor优先级不要一上来就动链接脚本。Windows 平台单独适配init_seg或.CRT$XCU段但留意链接器的符号裁剪问题。所有初始化函数都必须遵守“轻量、无依赖、不抛异常”三条军规。这套方案组合起来就是我可以放心推荐给同行的一套完整答案。如果你正在设计框架或者 SDK把启动阶段的控制权握在自己手里从 main 之前的第一行代码开始。
阅读完成 · 觉得有帮助?
咨询建站