一、问题的现象
实际的项目代码中遇到一个很疑惑的问题,问题可以描述为:一个静态成员初始化的时候直接core掉,该静态成员初始化时通过另外一个文件中静态成员来完成。该问题同样发生在全局对象上。
具体可以用代码简述如下:
//test1.cpp
#include <string>
std::string a = "test";
//test2.cpp
#include <iostream>
extern std::string a;
std::string b = a;
int main()
{
std::cout<<b<<std::endl;
}
当执行如下编译命令:
g++ -g test1.cpp test2.cpp
执行结果正确,输出”test”文本,但当执行如下编译指令:
g++ -g test2.cpp test1.cpp
执行结果如下:
Segmentation fault (core dumped)
二、分析
分析一下core文件:
调试core文件,函数帧栈如下:
(gdb) bt
#0 0x00007ff5f0932f2b in std::basic_string<char, std::char_traits<char>, std::allocator<char> >::basic_string
(std::string const&)() from /usr/lib/x86_64-linux-gnu/libstdc++.so.6
#1 0x0000000000400af8 in __static_initialization_and_destruction_0 (__initialize_p=1,
__priority=65535) at test4.cpp:7
#2 0x0000000000400b24 in _GLOBAL__sub_I_b () at test4.cpp:12
#3 0x0000000000400c2d in __libc_csu_init ()
#4 0x00007ff5f02de700 in __libc_start_main () from /lib/x86_64-linux-gnu/libc.so.6
#5 0x00000000004009c9 in _start ()
可以看到程序在静态初始化全局对象时,调用string的copy constructor导致内存访问异常。该问题的原因就是依赖的静态成员还没有进行初始化导致的。
我们知道对于程序的所有全局和静态数据成员,都是放在全局数据区。对于已经初始化的全局和静态变量时存放在可执行文件的数据段(.data),而对于未初始化的全局和静态变量,则在BSS段中(BSS段在生成的可执行文件中并不存在,直到程序被加载到内存中),程序被加载到内存后,BSS段的内存被清零。
这里首先强调一个概念:静态初始化( static initialization):静态对象(包括全局和静态变量)的初始化(其实就是动态初始化,需要调用相关的构造函数dynamic initialization)。
在程序加载到内存后,针对存储在数据段中的全局和静态变量,动态链接器加载程序(dynamic linker loader),会在程序指定的动态初始化发生前,保证每一个静态对象初始化为零(当然,这里只针对内置数据类型或者叫原生类型或者叫基础类型)。而对于自定义数据类型,例如程序中以程序代码中的string对象,如果a未先被动态初始化,那么a的内存空间的数据就是未定义,就会出现调用string的copy constructor导致内存访问异常。这种情况很容易在跨文件引用时出现。
这种现象一般来说相对还是容易发现的,而如果是在程序的动态库中有类似的用法,即跨库的调用,出现的现象就是时好时坏,而且往往是正常的情况居多,这个同事以前就遇到过,有小半年才定位到是这个原因。同事在后来修改的代码中对动态库的全局变量互相依赖引起的异常问题,写了大段的描述。
按照c++的标准,静态变量的初始化顺序就是源码中定义的顺序,当然,局部静态变量的初始化是第一次调用时进行的。可是,不同的文件,或者说跨文件中的初始化顺序,标准并没给出具体的初始化的顺序,或者说出现上述的错误的原因,也恰好是顺序的问题。
这有点类似于古代皇帝继位,按中国传统的立嫡长,再接着排下去,一般不会出事,可是出现类似大清谁都想干这份工作,没顺序了,各种意外情况就出现了。
三、处理的方法
在《C++编程思想》p245中针对静态初始化依赖问题给出了一下几点建议:
1、避免静态(全局)对象初始化的依赖;
2、把静态(全局)对象放到同一个编译单元中,即同一文件。
3、如果一定要把静态(全局)对象放到不同的编译单元中,可使用两种程序设计技术进行解决
这里只说一下里面提到的技术二:通过函数获取静态对象。(另外一种其实就是抽象一下,再增加一个初始化的类,用来专门做类似工作,其实回到了类似2的解决方式)
其实我们始终关心的对象的初始化顺序,而不是初始化时间。为了解决这个问题,这种技术采用:把一个静态对象放到一个返回该对象引用的的函数中,访问该静态对象的唯一途径就是通过该函数,而在函数第一次被调用时,就会强迫该静态对象进行初始化。该技术依赖的特性是:函数内部的静态对象在函数第一次被调用时进行初始化,且在程序生命周期只被初始化一次。这样静态对象的初始化顺序就是由设计的代码而不是链接器的链接顺序来决定。上述的代码通过该技术,可以更改为为下面:
//test1.cpp
#include <string>
const std::string & GetA()
{
static std::string a = "test";
return a;
}
//test2.cpp
#include <string>
#include <iostream>
const std::string &GetA();
std::string b = GetA();
int main()
{
std::cout<<b<<std::endl;
}
上面的代码就不会存在一开始出现的那种问题。
同样,局部静态变量的初始化,在以前的标准中,是否线程安全是不确定的。而线程访问变量崩溃问题,大多数也和上述原因类似,线程调度对上层应用的不确定性和程序调用中的确定性产生冲突,导致程序的异常。但在c++11以后,标准上是要求线程安全的,而大多数的新版本编译器,都支持了标准,所以尽量使用高版本的编译器。
四、总结
通过上述的分析,可以明白在c++中的一些细节是需要引起高度重视的,一如前面所提到的头文件包含的顺序问题,这些问题产生导致的现象非常怪异,甚至会误导程序员向错误的方向深入排查。所以说,防御性编程之所以越来越流行,SNAFE进一步融入c++的新标准中,不是没有原因的。
重视细节,但不要沉迷于细节,这是一个对c++学习者的忠告。