不同编译单元内定义之 non-local static 对象的初始化次序

这个概念我最早在 《Effective C++》碰到,想要用具体代码来做个实验,记录下实验过程。

概念

  • 不同编译单元:指的是不同的 ELF 文件。包括可连接的目标文件,库文件(静态库,共享库),可执行文件。其实就是用编译器经过预处理,编译,汇编得到的二进制文件。
  • static 对象:就是数据内容存储在 data段或 bss段的对象。也就是说在生成二进制文件后已经有空间留出来给 static 对象了!如果被初始化的则存入 data段,未被初始化的则存入 bss段,证明可以看下面的实验。
  • non-local:static 又分 local static 和 non-local static,只要记住除了函数内申明的 static 是 local static,其他地方的都是 non-local static。

POD 类型

所谓的 POD 类型就是 Plain Of Data,即朴素的数据,也就是 C 语言中的原始类型。

首先证明一下 static 对象(不管是 local 还是 non-local)在源文件编译成 ELF 文件后已经在二进制文件中占有一席之地了(被分配空间)!并且被初始化的对象存放在 data段,而未初始化的对象存放在 bss段。

hello.cc:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>

int a; // 未初始化
int b = 1; // 已初始化
static int c; // 未初始化
static int d = 2; // 已初始化
extern char __bss_start;
extern char _end;
extern char __data_start;
extern char _edata;

int main(){
printf("bss start: %p\n", &__bss_start);
printf("bss end: %p\n", &_end);
printf("data start: %p\n", &__data_start);
printf("data end: %p\n", &_edata);
printf("a address: %p\n", &a);
printf("b address: %p\n", &b);
printf("c address: %p\n", &c);
printf("d address: %p\n", &d);
return 0;
}

解释一下:

__data_start,_edata, __bss_start, _end 符号是链接器脚本定义的符号。分别表示 data段开始,data段结束,bss段开始,bss段结束。

那么我是怎么知道这些符号的名字的呢?

刚开始我也不知道所以废了好长的时间,哎~~~后面才发现可以用 $ ld -verbose 命令可以把 gcc 的默认链接脚本打印出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
···
}
.jcr : { KEEP (*(.jcr)) }
.data.rel.ro : { *(.data.rel.ro.local* .gnu.linkonce.d.rel.ro.local.*) *(.data.rel.ro .data.rel.ro.* .gnu.linkonce.d.rel.ro.*) }
.dynamic : { *(.dynamic) }
.got : { *(.got) *(.igot) }
. = DATA_SEGMENT_RELRO_END (SIZEOF (.got.plt) >= 24 ? 24 : 0, .);
.got.plt : { *(.got.plt) *(.igot.plt) }
.data :
{
*(.data .data.* .gnu.linkonce.d.*)
SORT(CONSTRUCTORS)
}
.data1 : { *(.data1) }
_edata = .; PROVIDE (edata = .);
. = .;
__bss_start = .;
.bss :
{
*(.dynbss)
*(.bss .bss.* .gnu.linkonce.b.*)
*(COMMON)
/* Align here to ensure that the .bss section occupies space up to
_end. Align after .bss to ensure correct alignment even if the
.bss section disappears because there are no input sections.
FIXME: Why do we need it? When there is no .bss section, we don't
pad the .data section. */
. = ALIGN(. != 0 ? 64 / 8 : 1);
}
.lbss :
{
*(.dynlbss)
*(.lbss .lbss.* .gnu.linkonce.lb.*)
*(LARGE_COMMON)
}
. = ALIGN(64 / 8);
. = SEGMENT_START("ldata-segment", .);
.lrodata ALIGN(CONSTANT (MAXPAGESIZE)) + (. & (CONSTANT (MAXPAGESIZE) - 1)) :
{
*(.lrodata .lrodata.* .gnu.linkonce.lr.*)
}
.ldata ALIGN(CONSTANT (MAXPAGESIZE)) + (. & (CONSTANT (MAXPAGESIZE) - 1)) :
{
*(.ldata .ldata.* .gnu.linkonce.l.*)
. = ALIGN(. != 0 ? 64 / 8 : 1);
}
. = ALIGN(64 / 8);
_end = .; PROVIDE (end = .);
. = DATA_SEGMENT_END (.);
···

现在执行 hello 文件来看看:

1
2
3
4
5
6
7
8
bss start:  0x558cdfa3c018
bss end: 0x558cdfa3c028
data start: 0x558cdfa3c000
data end: 0x558cdfa3c018
a address: 0x558cdfa3c01c
b address: 0x558cdfa3c010
c address: 0x558cdfa3c020
d address: 0x558cdfa3c014

其中 a,c 在 bss 段内(0x558cdfa3c018~0x558cdfa3c028),b,d 在 data 段内(0x558cdfa3c000~0x558cdfa3c018),而我们在源文件中可以看大到 a 和 c 都是未经过初始化而 b 和 d 都是经过初始化的。由此可以证明以上的结论。

再证明 static 对象在 ELF 文件中的段(data段或 bss段)中占有一席之地!

执行如下指令:

1
$ objdump -s -j .data hello

objdump 的 -s 选项用于查看某个 section 的全部内容,而 -j 选项用来指定某个具体的 section,该命令的使用可以看我这篇文章 objdump

得到以下输出:

1
2
3
4
5
6
hello:     文件格式 elf64-x86-64

Contents of section .data:
201000 00000000 00000000 08102000 00000000 .......... .....
201010 01000000 02000000 ........

我们可以看到第一列是相对地址,每一行有 16 个字节。在之前的符号输出中已经看到了 data段起始于 0000000000201000 在这里也可得到验证。再看 201010 处后面四个字节表示的就是数字 1(即 b 对象的值),再后面四个字节表示的数字 2(即 d 对象的值)。

用户自定义类

我们已经对 POD 类型进行了实验,接下来就是对用户自定义的类进行实验了!

有如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <stdio.h>

extern char __data_start;
extern char _edata;

extern char __bss_start;
extern char _end;
class A{
public:
A()
:a(10)
{}
private:
int a = 5;
};

A a;
int main(){
printf("bss start: %p\n", &__bss_start);
printf("bss end: %p\n", &_end);
printf("data start: %p\n", &__data_start);
printf("data end: %p\n", &_edata);

printf("a address: %p\n", &a);
return 0;
}

编译并执行:

1
2
3
4
5
bss start:  0x56124497d010
bss end: 0x56124497d020
data start: 0x56124497d000
data end: 0x56124497d010
a address: 0x56124497d014

由此可见 a 在 bss段。这证明了很多东西!

  1. 在类内对 ints 类型的赋初值并不是定义而仅仅是申明,所以在类内进行赋初值有什么用?我也不知道现在。
  2. 虽然在程序被加载进入内存之前,在 bss 段中就已经给未初始化的 static 对象记录了应该预留的空间,注意不是真正的预留了,而是写了一个数,因为 bss 段空间的值都是 0,没有必要让费磁盘空间来存一些没有意义的 0.

链接的顺序不同导致初始化次序不同

看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# ji.h
#include "stdio.h"
#include <iostream>
class ji{
public:
ji()
:weight(1000)
{ std::cout <<"wo you yi zhi ji." << std::endl;}
void xiadan(){std::cout << "ji kai shi xiadan" << std::endl;}
int w(){ return weight; }
private:
int weight = 10;
};


# ji.cc
#include "ji.h"
#include "stdio.h"

ji j; // nonlocal-static 对象

# dan.h
#include <iostream>
#include "ji.h"
class dan{
public:
dan(ji j)
:weight(50)
{
std::cout << "ji zhong " << j.w() << std::endl;
j.xiadan();
}
private:
int weight;
};

# main.cc

#include "dan.h"

extern ji j;

dan d(j); // nonlocal-static 对象
int main(){
return 0;
}

首先将 j.cc 和 main.cc 都编译成 .o 文件:

1
2
$ g++  -c -o main.o main.cc
$ g++ -c -o ji.o ji.cc

然后再链接 2 个 .o 文件,首先我们先把 main.o 放在前面,ji.o 放在后面,并执行:

1
2
3
4
5
$ g++ main.o ji.o
$ ./a.out
ji zhong 0
ji kai shi xiadan
wo you yi zhi ji.

我们再把 ji.o 放在前面,main.o 放在后面,并执行:

1
2
3
4
5
$ g++ ji.o main.o
$ ./a.out
wo you yi zhi ji.
ji zhong 1000
ji kai shi xiadan

这说明了在不同编译单元的 nonlocal-static 对象的初始化次序是根据链接的时候的次序来排的,如果链接时排序出现问题就会导致初始化顺序出现问题,那么怎么做到我链接时目标文件即使是无序的也能保证初始化顺序是有序的呢?那就是用 local-static !

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# ji.h
#include "stdio.h"
#include <iostream>
class ji{
public:
ji()
:weight(1000)
{ std::cout <<"wo you yi zhi ji." << std::endl;}
void xiadan(){std::cout << "ji kai shi xiadan" << std::endl;}
int w(){ return weight; }
private:
int weight = 10;
};

# ji.cc
#include "ji.h"
#include "stdio.h"

ji getji(){
static ji j; // 函数内的 static 对象都是 local static 对象!
return j;
}

# dan.h
#include <iostream>
#include "ji.h"
class dan{
public:
dan(ji j)
:weight(50)
{
std::cout << "ji zhong " << j.w() << std::endl;
j.xiadan();
}
private:
int weight;
};

# main.cc

#include "dan.h"
ji getji();
ji j = getji();

dan d(j);
int main(){
return 0;
}

像上面那样进行连接测试,经测试发现,不管 main.o 在前还是在后都不会影响对象 j 和 d 的初始化次序。为什么可以这样呢?这个手法的基础在于:

C++ 保证,函数内的 local static 对象会在“该函数被调用期间”“首次遇上该对象之定义式”时被初始化。所以如果你以“函数调用”(返回一个 reference 指向 local static 对象)替换“直接访问 non-local static 对象”,你就获得了保证,保证你所获得的那个 reference 将指向一个历经初始化的对象。

参考

  1. 《Effective C++》
  2. 浅谈程序中的text段、data段和bss段