在C和C++中,main函数是逻辑上的程序执行入口,但入口并非指其最早执行,即便是从语法意义来说也是如此,有的时候,我们需要在main之前执行一些操作,例如程序的自行初始化等(一般是各库或模块的,即main中无法感知或无需感知操作的那种)
由于这种需求还是比较多,很多语言就提供了相关的机制,例如Java的static代码块,Golang的init函数等,然而在C和C++这里,并没有一个专门的针对性设计,需要我们利用一些语法特性或扩展特性,且在实践中会碰到一些问题
最简单的做法是利用全局变量初始化:
~/test/cpp_test$ cat a.cpp
#include <stdio.h>
static void f()
{
printf("f\n");
}
static int _x = (f(), 0); //逗号表达式,当然直接让f返回int也行
int main()
{
printf("main start\n");
}
~/test/cpp_test$ g++ -o a a.cpp && ./a
f
main start
~/test/cpp_test$ C++还可以更简单一点,用lambda省去显式定义:
#include <stdio.h>
static int _x = [] () -> int {
printf("f\n");
return 0;
}();
int main()
{
printf("main start\n");
} 当然类似的搞个自定义类的全局变量,用类的构造函数做也行,但是这类做法需要注意,语言并没有规定全局变量初始化和析构的严格顺序,只是说析构顺序和构造相反。虽说同一个编译单元中的多个全局对象的构造是顺序的,但C++程序一般都是多个编译单元所构成,所以不要依赖这点
上面的做法不爽的一个地方是占用了一个变量名,对于强迫症人员来说,可以用有些编译器扩展提供的相关机制,例如GNUC中,可以指定一个函数的属性为constructor,使其在main之前执行:
~/test/cpp_test$ cat a.cpp
#include <stdio.h>
static __attribute__((constructor)) void f()
{
printf("f\n");
}
int main()
{
printf("main start\n");
}
~/test/cpp_test$ g++ -o a a.cpp && ./a
f
main start
~/test/cpp_test$ 类似的,GNUC也提供了destructor属性来实现在main后面执行某个函数的用法,而且这两个属性也都支持指定优先级,具体可参考链接:
https://gcc.gnu.org/onlinedocs/gcc-13.1.0/gcc/Common-Function-Attributes.html#index-constructor-function-attribute
但是和全局变量初始化顺序问题类似,也有执行顺序的问题,就GNUC来说,虽然constructor函数之间可以通过优先级来控制,但是同优先级的就不一定了,而且它们和全局变量的初始化顺序之间也是未指定的,例如:
~/test/cpp_test$ cat a.cpp
#include <stdio.h>
#include <map>
static std::map<int, int> m;
static __attribute__((constructor)) void f()
{
m[1] = 2;
}
int main()
{
printf("main start\n");
printf("%d\n", m[1]);
}
~/test/cpp_test$ g++ -o a a.cpp && ./a
Segmentation fault (core dumped)
~/test/cpp_test$ 在我这个环境下的这个例子中,constructor f执行的时候,全局变量m还没有初始化,即这个map对象的值是非法的(实际是全0),此时直接操作就会出问题,改成这样就可以执行:
#include <stdio.h>
#include <map>
static std::map<int, int> *m;
static __attribute__((constructor)) void f()
{
if (!m)
{
m = new std::map<int, int>;
}
(*m)[1] = 2;
}
int main()
{
printf("main start\n");
printf("%d\n", (*m)[1]);
} 但是需要注意,这种做法能成功的前提是:constructor的执行是在m的默认初始化之前,由于在这里m是一个指针,其默认初始化可以认为是程序加载时候(将全局变量赋值为代码预设值,或者对于未指定预设值的清零(bss段)),那么当f执行的时候,就可以像上面这样去做事了,而且也不用担心在f执行完成之后对m的初始化会覆盖掉f中对m的赋值
如果m有赋值初始化呢:
~/test/cpp_test$ cat a.cpp
#include <stdio.h>
#include <map>
static std::map<int, int> *g()
{
printf("g\n");
return new std::map<int, int>;
}
static std::map<int, int> *m = g();
static __attribute__((constructor)) void f()
{
printf("f\n");
if (!m)
{
m = new std::map<int, int>;
}
(*m)[1] = 2;
}
int main()
{
printf("main start\n");
printf("%d\n", (*m)[1]);
}
~/test/cpp_test$ g++ -o a a.cpp && ./a
f
g
main start
0
~/test/cpp_test$ 可以看到,在我的环境下,f的执行是给m做赋值之前的,然而,其他环境下有可能不是这个顺序,所以如果要这样写代码,需要特别注意,例如,上面调用g之前最好判断一下m是否已经被赋值,可以用一个全局变量来标记
但实际上我们可以有更好的做法:
~/test/cpp_test$ cat a.cpp
#include <stdio.h>
#include <map>
static std::map<int, int> &GetM()
{
static std::map<int, int> m;
return m;
}
static __attribute__((constructor)) void f()
{
printf("f\n");
GetM()[1] = 2;
}
int main()
{
printf("main start\n");
printf("%d\n", GetM()[1]);
}
~/test/cpp_test$ g++ -o a a.cpp && ./a
f
main start
2
~/test/cpp_test$ 这里利用了C语言的机制,同样是全局变量m,放置于函数中,就保证在函数第一次调用时初始化,当然也保证只初始化一次,这样就比较安全了
以上例子是在一个cpp文件,也就是说单个翻译单元中实现的,而一般C++或C项目都是有多个翻译单元(或者也叫源码模块之类)组成的,那么如果main和初始化代码分开放会如何呢:
~/test/cpp_test$ cat a.cpp
#include <stdio.h>
int main()
{
printf("main start\n");
}
~/test/cpp_test$ cat b.cpp
#include <stdio.h>
static __attribute__((constructor)) void AUTO_INIT()
{
printf("AUTO_INIT\n");
}
~/test/cpp_test$ g++ -o a a.cpp b.cpp && ./a
AUTO_INIT
main start
~/test/cpp_test$ 如果按一般的做法,将每个文件先编译成.o,然后连接,也是一样的:
~/test/cpp_test$ g++ -c a.cpp
~/test/cpp_test$ g++ -c b.cpp
~/test/cpp_test$ g++ -o a a.o b.o && ./a
AUTO_INIT
main start
~/test/cpp_test$ 但是,如果你认为这样就可以在工程中使用,那大概率还是会掉入一个坑中:
~/test/cpp_test$ g++ -c a.cpp b.cpp
~/test/cpp_test$ ar -crsP libb.a b.o
~/test/cpp_test$ g++ -o a a.o libb.a && ./a
main start
~/test/cpp_test$ 直接将a和b的cpp源码,或者.o的目标文件连接在一起是没有问题的,但是,将b.o打包成静态库,就不行了,b中的AUTO_INIT代码没有被执行,nm看一下会发现并没有链接进去:
~/test/cpp_test$ nm libb.a | grep AUTO_INIT
0000000000000000 t _ZL9AUTO_INITv
~/test/cpp_test$ nm b.o | grep AUTO_INIT
0000000000000000 t _ZL9AUTO_INITv
~/test/cpp_test$ nm a | grep AUTO_INIT
~/test/cpp_test$ 有人说这是不是因为GNUC的constructor机制,但实际上如果用一开始说的全局变量的初始化方式,也是一样的:
~/test/cpp_test$ cat b.cpp
#include <stdio.h>
static void AUTO_INIT()
{
printf("AUTO_INIT\n");
}
int _x = (AUTO_INIT(), 0);
~/test/cpp_test$ g++ -c a.cpp b.cpp
~/test/cpp_test$ rm libb.a && ar -crsP libb.a b.o
~/test/cpp_test$ g++ -o a a.o libb.a && ./a
main start
~/test/cpp_test$ 出现这种情况的原因,是链接器(虽然命令是g++,但大家都知道链接过程是g++调用了其他链接器,这里默认是ld)在从静态库查找.o文件时,是根据当前依赖来找的,由于a.cpp中没有用到b.cpp中任何元素,所以b.o就没有被链入了,如果在a.cpp中用到了b.cpp的元素(全局变量、函数等),即便和初始化代码无任何关系,b.o也会被链入,而AUTO_INIT也被“顺便”链入从而能起效了
其实从“代码链接”的角度看,ld链接器这种做法并不太对,因为constructor是显式指定了要在main之前运行的,换句话说,它是程序启动器的默认依赖对象,但是这个属于编译器的GNUC扩展语法,ld认为自己不用管这些,从实现角度也管不好;而如果使用下面的全局变量_x这种方式,全局变量的初始化调用了AUTO_INIT,是可能产生副作用的,但是ld也不管这种情况,一切只是以显式的依赖关系为准,对.o文件做最少量的链入
但有的时候我们还是需要将libb.a里的b.o链入,即便其他模块并不显式依赖它,这一般是出现在一些需要启动时静默初始化的代码,且代码文件单独维护的情况下(例如这段代码是编译系统自动生成),这时候我们可以通过选项修改链接器的行为,给ld传递参数,强制要求其链接静态库中所有目标:
~/test/cpp_test$ g++ -o a a.o \
> -Wl,--whole-archive libb.a -Wl,--no-whole-archive
~/test/cpp_test$ ./a
AUTO_INIT
main start
~/test/cpp_test$ 这里,我们使用-Wl将选项--whole-archive传递给ld,这个会指示ld改变工作模式,将后续.a静态库中所有.o都强行链入,就达到了执行b.o中AUTO_INIT的效果,但请注意,在libb.a之后还需要用--no-whole-archive解除这个行为模式,即只对libb.a起效,否则在后续链接libc等默认库时,会出现大量的连接错误
将静态库整个链入自然会增加可执行程序的体积,不过一般情况下这个问题并不大,像Golang等语言默认也都是这样做的