11、构造函数调用:C++全局对象构造与__init_array
说实话,C语言开发者第一次接触C++的全局对象构造时,多半会懵一下。
我记得早年有个项目,团队从C转向C++,结果发现程序启动时莫名其妙地崩溃。查了两天,最后定位到——全局对象的构造函数没按预期执行。嗯,从那以后,我就把启动代码里的构造函数调用机制摸了个透。
11.1 全局对象构造的本质问题
C++里,全局对象(或者说静态存储期的对象)有个特点:它们在main函数执行之前就得构造好。你想想看,main函数里第一行代码可能就用到了某个全局对象,如果它还没构造,那不就出事了?
但问题来了——链接器怎么知道哪些函数是构造函数?又该按什么顺序调用?
说白了,C++标准只规定了「同一个编译单元内,全局对象按定义顺序构造」,但跨编译单元的顺序是未定义的。这就是所谓的「静态初始化顺序惨案」(Static Initialization Order Fiasco)。
我在项目中遇到过好几次这种问题,尤其是当多个全局对象互相依赖时。后来我养成了一个习惯:能用局部静态变量就别用全局对象,实在要用,也得用单例模式加延迟初始化。
11.2 __init_array:启动代码的桥梁
好,回到正题。在嵌入式C语言启动代码里,我们怎么调用C++的构造函数?
答案就是 __init_array。
这是一个由链接器生成的数组,里面存放了所有需要调用的构造函数指针。启动代码只需要遍历这个数组,挨个调用就行了。
它的典型定义长这样:
typedef void (*init_func_t)(void);
extern init_func_t __init_array_start[];
extern init_func_t __init_array_end[];
void _init(void)
{
for (init_func_t *func = __init_array_start;
func < __init_array_end;
func++)
{
(*func)();
}
}
你看,代码其实很简单。但背后的机制值得深挖。
11.3 谁把构造函数放进了__init_array?
答案是——编译器+链接器配合完成的。
当你写一个全局对象时:
class MyClass {
public:
MyClass() { /* 初始化代码 */ }
};
MyClass g_obj; // 全局对象
编译器会生成一个特殊的构造函数包装函数,比如叫 __GLOBAL__sub_I_g_obj。然后,编译器把这个函数的地址放到一个特定的段里——通常是 .init_array 段。
链接器在链接时,会把所有目标文件的 .init_array 段合并成一个连续的数组。然后定义两个符号:__init_array_start 和 __init_array_end,分别指向数组的开头和结尾。
我习惯把整个过程画成一张图,这样更直观:
11.4 构造函数的优先级问题
你可能会问:如果我想控制构造函数的调用顺序怎么办?
嗯,这里有个小技巧。GCC 提供了 init_priority 属性:
class MyClass { /* ... */ };
MyClass g_obj1 __attribute__((init_priority(101)));
MyClass g_obj2 __attribute__((init_priority(200)));
优先级数值越小,越早构造。但说实话,这个特性用得不多。我一般建议用函数内的静态局部变量替代:
MyClass& getGlobalObj() {
static MyClass obj; // 第一次调用时构造
return obj;
}
这样构造时机就完全可控了。
11.5 析构函数与__fini_array
有构造就有析构。对应的,__fini_array 存放了全局对象的析构函数指针。程序退出时(或者调用 exit() 时),启动代码会遍历这个数组,调用析构函数。
但在嵌入式系统中,很多程序根本不会退出。所以 __fini_array 经常被忽略。我个人习惯在启动代码里把 _fini() 函数留空,除非你确实需要做资源清理。
11.6 避坑指南:我踩过的几个坑
讲几个我实际遇到过的坑,希望能帮你省点时间:
- 链接脚本里忘了定义 __init_array_start/End:这是最常见的错误。启动代码里引用了这两个符号,但链接脚本没提供,链接直接报错。
- 构造函数里调用了未初始化的全局对象:跨编译单元的构造顺序不确定。我曾经有个日志系统,在另一个全局对象的构造函数里被调用,结果日志系统还没构造好,直接死机。
- 构造函数里用了动态内存分配:在嵌入式环境里,堆可能还没初始化。构造函数里调用
new或malloc大概率会失败。 - 构造函数抛异常:C++ 允许构造函数抛异常,但在全局对象构造阶段,异常处理机制可能还没准备好。一旦抛出异常,程序直接 abort。
11.7 裸机环境下的特殊处理
在裸机嵌入式系统里,情况更特殊。因为很多 C++ 运行时特性(比如异常、RTTI)可能根本没实现。但全局对象构造这个机制,只要有 C++ 编译器支持,就一定会生成 .init_array 段。
我建议你在启动代码里这样处理:
void _init(void)
{
// 1. 先初始化 BSS 段和 DATA 段
// 2. 再初始化堆(如果构造函数需要)
// 3. 最后调用构造函数
for (init_func_t *func = __init_array_start;
func < __init_array_end;
func++)
{
(*func)();
}
}
顺序很重要。BSS 段清零和 DATA 段加载必须在构造函数之前完成,否则全局变量的初始值都是错的。
11.8 如何验证构造函数是否被正确调用?
我习惯在构造函数里加一个简单的标记:
class Test {
public:
Test() {
// 在某个已知内存地址写一个魔数
*((volatile uint32_t*)0x20000000) = 0xDEADBEEF;
}
};
Test g_test;
然后在调试器里观察那个地址的值。如果启动后看到 0xDEADBEEF,说明构造函数被调用了。这个方法虽然土,但很有效。
-Wl,--verbose 链接选项,查看链接器是如何处理 .init_array 段的。这能帮你确认段的位置和大小。
11.9 总结一下
全局对象构造,说白了就是编译器帮你生成了一堆构造函数指针,放在 .init_array 段里。启动代码遍历这个数组,挨个调用。就这么简单。
但简单背后藏着不少细节。我建议你每次移植 C++ 到新平台时,都先写一个只有全局对象的测试程序,确认构造和析构都能正常工作。这个习惯帮我避免了好几次线上事故。
嗯,关于构造函数调用,就聊到这里。记住一点:全局对象虽方便,但用多了容易出问题。能用局部静态变量就别用全局对象,这是我在无数个熬夜调试的夜晚总结出来的经验。
核心要点回顾:
__init_array存放所有全局对象的构造函数指针- 链接器合并各目标文件的
.init_array段,生成__init_array_start/End - 启动代码在 main 之前遍历调用这些构造函数
- 构造函数里避免依赖运行时环境
- 跨编译单元的构造顺序不确定,建议用局部静态变量替代
公众号:蓝海资料掘金营,微信deep3321