深入分析C++全局变量初始化

1,729 阅读15分钟

1. 背景

在C++开发中, 我们经常会面临一些有关全局对象的一些问题, 比如两个动态库之间全局对象初始化相互引用的问题, 即用外部的全局对象来初始化当前全局对象, 由于引用的外部全局对象未预先初始化,从而触发了coredump。那么如何在开发中避免由于全局对象初始化顺序而引发的各种程序崩溃呢?我们需要了解清楚全局变量是何时完成初始化, 初始化的顺序是什么, 以及如何控制全局对象的初始化顺序。本篇文章将重点介绍全局对象的初始化相关的内容,并进行深入分析,帮助大家更好地理解其原理。

测试环境:

  1. CPU 架构:x86_64

  2. 操作系统及基础库版本:Debian GLIBC 2.28-10

  3. Compiled by GNU CC version 8.3.0.

2. 程序启动入口是 main 吗

  • 给一段简单的用例
struct C {
  C() {} // <== break
};

C c;

int main() {
  return 0;
}

编译成可执行文件, 使用 gdb 在第 2 行打一个断点,运行:

可以看到, 在调用栈中并没有看到 main 的身影, 这是因为在 main 之前, 先进入到了全局对象 c 的构造函数中, 来完成初始化相关的工作。 并且栈底指向了 _start, 这可能就是我们要找的入口函数。 在确定 _start 是不是我们要找的入口函数之前, 需要先了解一下 Entry point 的概念:

  • Entry point

docs.oracle.com/cd/E19120-0…

e_entry: The virtual address to which the system first transfers control, thus starting the process.

在 gdb 中继续执行:

info files

来观察可执行程序的各个 section 在内存中的分布:

这里的 Entry point, 就是程序运行起来的入口地址, 我们可以进一步调试它:

b *0x555555555040
r

可以看到, 该入口地址指向了 _start。

3. main 之前

下图给出了 main 之前与初始化相关的关键流程:

  • 给一段简单的示例

test.cpp:

#include <stdio.h>

int main() {
  printf("Hello ByteDance!\n");
  return 0;
}

当我们将这段代码编译成可以执行文件时, 站在开发者的角度上来说, 只有 test.cpp 一个文件参与了这个过程。 但实际上并非如此, 追加 -v 选项后可以看到详细的编译连接过程, 实际上还有一些 .o 文件参与了链接的过程, 这些 .o 文件除了标准库相关的文件, 还包含了程序启动相关的文件, 我们接下来要做的就是找到这些与程序启动相关的文件, 这有助于我们理解 main 之前发生的一些事情。

  • -nostdlib

gcc.gnu.org/onlinedocs/…

Do not use the standard system startup files or libraries when linking.

在找到程序启动相关的文件之前, 我们准备通过 -nostdlib 来忽略这些系统相关的文件和库, 当然除了 C library, 因为我们用了 printf, 然后来尝试编译我们的程序:

g++ -g -nostdlib -lc test.cpp

可以看到, 找不到 _start 符号, 在运行时发生了 coredump。

_start 是程序启动的入口, 这是一个很好的起点。 后续会详细介绍 _start 到 main 之间发生了什么, 同时找到程序启动相关的文件, 来让这段简单的程序编译通过并成功运行起来。

3.1 _start

elixir.bootlin.com/glibc/glibc…

This is the canonical entry point, usually the first thing in the text segment.

Extract the arguments as encoded on the stack and set up the arguments for `main': argc, argv. envp will be determined later in __libc_start_main.

简化一下 _start 的指令代码:

_start:
    xorl %ebp, %ebp

    mov %RDX_LP, %R9_LP /* Address of the shared library termination
                   function.  */

    popq %rsi       /* Pop the argument count.  */

    /* argv starts just at the current stack top.  */
    mov %RSP_LP, %RDX_LP

    /* Provide the highest stack address to the user code (for stacks
       which grow downwards).  */
    pushq %rsp

    /* Pass address of our own entry points to .fini and .init.  */
    mov $__libc_csu_fini, %R8_LP
    mov $__libc_csu_init, %RCX_LP

    mov $main, %RDI_LP

    call *__libc_start_main@GOTPCREL(%rip)

    hlt         /* Crash if somehow `exit' does return.  */

更多指令细节可以参考: 6.s081.scripts.mit.edu/sp18/x86-64…

上述指令代码的解释:

  • xorl %ebp, %ebp: 寄存器清零。
  • mov %RDX_LP, %R9_LPrdx 存储了共享库终止函数的地址, 将该地址存入第 6 参数寄存器 r9
  • popq %rsi: 将 argc 存入第 2 参数寄存器 rsi
  • mov %RSP_LP, %RDX_LP: 将 argv 地址存入第 3 参数寄存器 rdx
  • pushq %rsp: 压入堆栈地址。
  • mov $__libc_csu_fini, %R8_LP: 将 __libc_csu_fini 存入第 5 参数寄存器 r8
  • mov $__libc_csu_init, %RCX_LP: 将 __libc_csu_init 存入第 4 参数寄存器 rcx
  • mov $main, %RDI_LP: 将 main 存入第 1 参数寄存器 rdi
  • call *__libc_start_main@GOTPCREL(%rip): 调用 __libc_start_main
  • hlt:保证退出。

也就是说, _start 所做的事情就是调用 __libc_start_main。

在程序启动相关的文件中, _start 是定义在 crt1.o 中的, 我们尝试链接它:

g++ -g -nostdlib /usr/lib/x86_64-linux-gnu/crt1.o -lc test.cpp

可以看到, 找不到 _init 而发生错误。

那 _init 是在哪里调用的, 看到上述告警信息, 在 __libc_csu_init 中引用了 _init, 而在前面介绍的 _start 内容中, __libc_csu_init 是作为参数传入 __libc_start_main 里的, 我们顺着这个调用链来看看里面发生了什么事。

3.2 __libc_start_main

elixir.bootlin.com/glibc/glibc…

简化一下 __libc_start_main 的代码:

STATIC int
__libc_start_main (int (*main) (int, char **, char ** MAIN_AUXVEC_DECL),
         int argc, char **argv,
         __typeof (main) init,
         void (*fini) (void),
         void (*rtld_fini) (void), void *stack_end)
{
  /* Result of the 'main' function.  */
  int result;

  char **ev = &argv[argc + 1];

  __environ = ev;

  /* Register the destructor of the program, if any.  */
  if (fini)
    __cxa_atexit ((void (*) (void *)) fini, NULL, NULL);

  if (init)
    (*init) (argc, argv, __environ MAIN_AUXVEC_PARAM);

  result = main (argc, argv, __environ MAIN_AUXVEC_PARAM);

  exit (result);
}

在这段代码里主要关注这几件事:

  • __environ = ev: 存储环境变量信息。
  • __cxa_atexit(...): 注册全局析构。
  • (*init)(...): 调用全局构造。
  • result = main(...): 调用 main。
  • exit(result): 调用exit。

可以看到, 在调用 main 之前, 会调用全局构造 init, 而这里的 init 就是 __libc_csu_init。

3.3 __libc_csu_init

elixir.bootlin.com/glibc/glibc…

简化一下 __libc_csu_init 的代码:

void
__libc_csu_init (int argc, char **argv, char **envp)
{
#ifndef NO_INITFINI
  _init ();
#endif

  const size_t size = __init_array_end - __init_array_start;
  for (size_t i = 0; i < size; i++)
      (*__init_array_start [i]) (argc, argv, envp);
}

在这个函数中主要关注这 2 件事:

  1. 调用 _init(), 它被定义在 .init 段。
  2. 循环调用 __init_array_start 数组里面的内容, 它被定义在 .init_array 段。

3.3.1 .init

_init 对应 ELF sections 中的 .init section。

elixir.bootlin.com/glibc/glibc…

_init:
    _CET_ENDBR
    /* Maintain 16-byte stack alignment for called functions.  */
    subq $8, %rsp
#if PREINIT_FUNCTION_WEAK
    movq PREINIT_FUNCTION@GOTPCREL(%rip), %rax
    testq %rax, %rax
    je .Lno_weak_fn
    call *%rax
.Lno_weak_fn:
#else
    call PREINIT_FUNCTION
#endif
  • 这里的 PREINIT_FUNCTION 实际上是 gmon_start, 与 gprof 有关, 只有开启 -pg 选项才会生效, 否则为0x0:

在程序启动相关的文件中,_init 是定义在 crti.o 中的, 我们尝试链接它:

g++ -g -nostdlib /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o -lc test.cpp

可以看到, 测试文件可以正常编译链接, 但是在运行时发生了 coredump。

我们需要看下可执行文件的反汇编:

test %rax,%rax, 即如果 rax 为 0 则 je 1012 <_init+0x12> 跳转到 1012, 这是一个一块非法内存地址。结合 _init 的定义和可执行程序的反汇编代码, 可以看出在结尾是没有 ret 返回指令的, 这部分实际上是被定义在了 crtn.S 中:

elixir.bootlin.com/glibc/glibc…

简化下代码:

_init:
    addq $8, %rsp
    ret

使用汇编代码在某个section中插入代码: .section .init,"ax",@progbits

在程序启动相关的文件中,_init 结尾部分是定义在 crtn.o 中的, 我们尝试链接它:

g++ -g -nostdlib /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/x86_64-linux-gnu/crtn.o -lc test.cpp

如上图结果显示:运行成功! 也就是说, 要成功编译运行这段简单的程序, 至少需要 crt1.o、crti.o 和 crtn.o 这三个启动相关的文件。

3.3.2 .init_array

DT_INIT_ARRAY: This element holds the address of the array of pointers to initialization functions.

__init_array_start 对应 ELF sections 中的 .init_array section。

在 __libc_csu_init 中我们可以看到, _init 并没有做有关全局对象初始化相关的工作, 实际在程序走到 main 之前, 是通过 __init_array 来实现程序本身的一些初始化的工作的:

  const size_t size = __init_array_end - __init_array_start;
  for (size_t i = 0; i < size; i++)
      (*__init_array_start [i]) (argc, argv, envp);
  • __init_array_start 是一个函数指针数组, 里面存储了全局初始化相关的函数:
#include <stdio.h>

void  __attribute__ ((constructor)) constructor() {
  printf("%s\n", __FUNCTION__);
}

class C {
public:
  C() { printf("hello C!\n"); }
};

C c;

int main() {
  printf("hello ByteDance!\n");
  return 0;
}
g++ -g -nostdlib /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/x86_64-linux-gnu/crtn.o -lc test.cpp
objdump -s -j .init_array a.out

可以看到 .init_array 里面存储了2个地址: 0x10e1 和 0x1137, 反汇编查看下这个地址:

可以看到 0x10e1 就对应了我们自定义编写的 attribute constructor,0x1137 对应了全局对象 c 的初始化相关的函数。

3.4 .preinit_array

DT_PREINIT_ARRAY: This element holds the address of the array of pointers to pre-initialization functions. The DT_PREINIT_ARRAY table is processed only in an executable file; it is ignored if contained in a shared object.

.preinit_array 是在进程建立映射并重定位之后, 但在所有初始化函数之前执行的初始化函数列表。

也就是说, .preinit_array 中的初始化函数是更早被执行的:

elixir.bootlin.com/glibc/glibc…

可以通过源码发现, .preinit_array 的预初始化函数是在 _dl_init 中调用的, 而 _dl_init 是在 _start 之前被调用。

我们可以在 .preinit_array 中注册一个初始化函数:

#include <stdio.h>

void  __attribute__ ((constructor)) constructor() {
  printf("%s\n", __FUNCTION__);
}

class C {
public:
  C() { printf("hello C!\n"); }
};

C c;

void preinit(int argc, char **argv, char **envp) {
 printf("%s\n", __FUNCTION__);
}
__attribute__((section(".preinit_array"))) typeof(preinit) *__preinit = preinit;

int main() {
  printf("hello ByteDance!\n");
  return 0;
}
g++ -g -nostdlib /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/x86_64-linux-gnu/crtn.o -lc test.cpp
./a.out

可以看到 preinit 函数是最早被执行的, 我们可以观察下这个函数的存放位置:

objdump -s -j .preinit_array a.out

可以看到多了一个 .preinit_array section, 里面存放了一个地址为 0x10f4, 反汇编查看下地址信息:

可以看到 0x10f4 对应了我们自定义编写的 preinit 函数。

4. 全局对象构造

4.1 构造过程

给一段简单的示例代码:

#include <stdio.h>

struct C {
  C() {
    printf("%s\n", __FUNCTION__);
  }
};

C c;  // <== break

int main() {
  printf("%s\n", __FUNCTION__);
  return 0;
}
g++ -g -nostdlib /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/x86_64-linux-gnu/crtn.o -lc test.cpp
gdb ./a.out
b 9
r
bt

观察调用栈, 发现在调用 C::C() 之前还调用了另外两个函数, 分别是 _GLOBAL__sub_I_c 和 __static_initialization_and_destruction_0。

在之前介绍 .init_array 的时候, 我们其实就发现 .init_array 里存的函数地址并不是 C::C(), 而是 _GLOBAL__sub_I_c, 这是编译器以 GLOBAL__sub_I 为前缀给每个全局对象生成的symbol, 专门用于全局对象初始化的事宜, 我们看下它的内容:

f 1
disassemble

_GLOBAL__sub_I_c 中调用了 __static_initialization_and_destruction_0(0x1,0x1, 0xffff), 我们再进一步观察下 __static_initialization_and_destruction_0 的指令信息:

f 0
disassemble

__static_initialization_and_destruction_0 的定义位于 github.com/gcc-mirror/… 有些信息解释了该函数参数列表的含义:

__initialize_p: If __INITIALIZE_P is nonzero, it performs initializations. Otherwise, it performs destructions.

__priority: It only performs those initializations or destructions with the indicated __PRIORITY.

在 __static_initialization_and_destruction_0 中来调用 c 的构造函数 C::C(), 完成初始化。

为什么不在 .init_array 里直接放 构造函数 的地址:

构造函数是与对象相关联的, 在调用构造函数之前需要为当前对象开辟内存, 需要将内存地址传入构造函数。在 .init_array 中只放构造函数的地址很明显是无法确定构造的是哪个对象的, 因此编译器在外部进行了封装。

在上述的示例中, 全局对象初始化的过程用链:__libc_csu_init -> _GLOBAL__sub_I_c -> __static_initialization_and_destruction_0 -> C::C()。

4.2 init_priority

gcc.gnu.org/onlinedocs/…

In Standard C++, objects defined at namespace scope are guaranteed to be initialized in an order in strict accordance with that of their definitions in a given translation unit. No guarantee is made for initializations across translation units. However, GNU C++ allows users to control the order of initialization of objects defined at namespace scope with the init_priority attribute by specifying a relative priority, a constant integral expression currently bounded between 101 and 65535 inclusive. Lower numbers indicate a higher priority.

在 C++ 标准中, 编译单元内的对象是按照定义的顺序进行初始化的,通过 init_priority 可以来控制构造函数被调用的相对顺序。

看一段简单的示例:

godbolt.org/z/6ozEGWKPq

通过 init_priority 来使后定义的对象先初始化:

godbolt.org/z/s5G1G3eEd

查看下该示例的 section 与 反汇编代码:

init_priority 决定了 初始化函数在 .init_array 里面的相对顺序, 200 的排在 201 的前面, 看下 __static_initialization_and_destruction_0 的汇编:

__priority 在 __static_initialization_and_destruction_0 仅表示一个数字, 这个数字代表了要调用的初始化函数, 它并不会实际控制初始化函数的调用顺序, 实际控制调用顺序的是 init_priority。

4.3 思考

  1. Does attribute((constructor)) execute before C++ static initialization?

stackoverflow.com/questions/8…

5. 全局对象初始化顺序

downloads.ti.com/docs/esd/SP…

Constructors for global objects from a single module are invoked in the order declared in the source code, but the relative order of objects from different object files is unspecified.

在同一编译单元内, 可以通过 init_priority 来控制全局对象的相对初始化顺序; 跨编译单元之间全局对象初始化的顺序是未定义的。

编译单元内

在同一编译单元内, 我们可以使用 init_priority 很好控制全局初始化函数的执行顺序。

godbolt.org/z/7ox99qv1n

跨编译单元

看一段示例:

test.cpp:

#include "third.h"

class C {
public:
  C() {
    printf("%s\n", __FUNCTION__);
    G_tc.dosomething();
  }
};

C G_c;

int main() {
  printf("%s\n", __FUNCTION__);
  return 0;
}

thrid.h:

#include <stdio.h>

class ThirdC {
public:
  ThirdC() {
    printf("%s\n", __FUNCTION__);
    p = new int(10);
  }
  void dosomething() {
    *p = 20;
  }
  int *p;
};

extern ThirdC G_tc;

third.cpp:

#include "third.h"

ThirdC G_tc;

静态链接

将 third.cpp 编译成静态库并链接到主程序执行:

g++ -g -c third.cpp
ar -r libthird.a third.o
g++ -g test.cpp -L . -lthird

可以看到程序发生了core, 使用 gdb 进行调试:

发现在在走到 G_c 构造函数的时候, 依赖的 G_tc 对象还没初始化, 在 G_tc.dosomething(); 的时候由于访问到非法内存而引发程序崩溃。

我们知道全局对象的构造函数是放在 .init_array 里面的, 静态链接的时候, 已经将两个编译单元内全局对象的构造函数相对地址按照某种顺序放在了 .init_array 里面, 查看可执行程序的 section 信息:

那么他们的执行顺序为: 先 0x1188, 后 0x1209, 查看可执行程序的反汇编代码, 查看这两个地址对应的内容:

也就是说, 先构造 G_c, 再构造 G_tc。

思考:

downloads.ti.com/docs/esd/SP…

The linker combines the .init_array section form each input file to form a single table in the .init_array section.

静态链接, 在链接阶段会把所有编译单元的.init_array按照某种顺序进行合并, 那么这个顺序是随机的吗?

codebrowser.dev/llvm/lld/EL…

Sorts input sections by section name suffixes, so that .foo.N comes before .foo.M if N < M.

答案是否定的, 链接器会根据 .init_array 中存放的初始化函数的后缀来进行排序, 注释中的 N 和 M 是 init_priority 指定的数字。

动态链接

将 third.cpp 编译成动态库并链接到主程序执行:

g++ -g -shared -fPIC third.cpp -o libthird.so
g++ -g test.cpp ./libthird.so
LD_LIBRARY_PATH=. ./a.out

程序正确运行, 在 G_c 初始化之前, G_tc 已经完成了初始化。

那么在动态链接的场景里, 这段程序一定是 100% 运行的吗?

答案是一定的, 这是因为对于动态链接的程序来说, loader 会预先加载动态库, 完成动态库中全局对象的初始化, 然后再执行主程序中全局对象的初始化: www.gnu.org/software/hu…

可以看到在进入 _start 之前, 会先调用 _dl_init, 完成动态库的初始化工作:

elixir.bootlin.com/glibc/glibc…

当然, 这只是动态库相对于主程序而言。 在复杂的项目中, 很可能会出现两个动态库的全局对象之间有引用的情况, 在这种情况下, 我们依然是无法保证全局对象的初始化的顺序, 这依赖于 linker 和 loader 来决定依赖的多个动态库谁的初始化工作先进行。

如何避免

可以看出, 控制跨编译单元的全局对象的初始化顺序是一个棘手的问题,在 C++ 中这是一个未定义的行为, 这受链接器和加载器的影响。 最安全的方案就是尽量避免使用全局变量或跨编译单元之间全局对象的相互引用。

en.cppreference.com/w/cpp/langu…

The Construct on First Use Idiom can be used to avoid the static initialization order fiasco and ensure that all objects are initialized in the correct order.

在初始化全局对象时, 使用 Construct on First Use 来初始化, 即在首次使用时进行初始化, 可以认为是延迟初始化的一种设计方式:

test.cpp:

#include "third.h"

class C {
public:
  C() {
    printf("%s\n", __FUNCTION__);
    LazyThirdC::GetTC().dosomething();
  }
};

C G_c;

int main() {
  printf("%s\n", __FUNCTION__);
  return 0;
}

thrid.h:

#include <stdio.h>

class ThirdC {
public:
  ThirdC() {
    printf("%s\n", __FUNCTION__);
    p = new int(10);
  }
  void dosomething() {
    *p = 20;
  }
  int *p;
};

class LazyThirdC {
public:
  static ThirdC& GetTC() {
    static ThirdC tc;
    return tc;
  }
};

third.cpp:

#include "third.h"

6. std::cout 是如何工作的?

en.cppreference.com/w/cpp/io/co…

These objects are guaranteed to be initialized during or before the first time an object of type std::ios_base::Init is constructed and are available for use in the constructors and destructors of static objects with ordered initialization (as long as is included before the object is defined).

std::cout 是一个全局对象, 并且在 C++11 中, 它可以保证在其他全局对象构造之前被正确初始化, 它是通过

Schwarz Counter 这项设计来实现的, 并且纳入了 C++标准。

在 iostream 头文件中, 定义了 static ios_base::Init __ioinit; 私有的全局对象, 当包含 iostream 头文件时, 根据定义的顺序, 会预先执行 __ioinit 的初始化, 在 Init::Init() 里面会完成 cout 的初始化工作:

  • 在此之前会定义一块全局的char类型的缓冲区, 该缓冲区的大小要能装得下流对象的大小, 并定义全局流对象指向这块缓冲区。

  • 在 Init::Init() 中使用 placement new 在缓冲区中构造流对象

Schwarz Counter 来改写之前的示例:

test.cpp:

#include "third.h"

class C {
public:
  C() {
    printf("%s\n", __FUNCTION__);
    G_tc.dosomething();
  }
};

C G_c;

int main() {
  printf("%s\n", __FUNCTION__);
  return 0;
}

thrid.h:

#include <stdio.h>

class ThirdC {
public:
  ThirdC() {
    printf("%s\n", __FUNCTION__);
    p = new int(10);
  }
  void dosomething() {
    *p = 20;
  }
  int *p;
};

extern ThirdC &G_tc;

class ThirdCInitializer {
public:
  ThirdCInitializer();
};

static ThirdCInitializer Init;

third.cpp:

#include "third.h"

#include <new>         // placement new
#include <type_traits> // aligned_storage

static int nifty_counter;
static typename std::aligned_storage<sizeof(ThirdC), alignof(ThirdC)>::type Buffer;
ThirdC &G_tc = reinterpret_cast<ThirdC&>(Buffer);

ThirdCInitializer::ThirdCInitializer()
{
  if (nifty_counter++ == 0) new (&G_tc) ThirdC(); // placement new
}

注:

stackoverflow.com/questions/9…

在工程代码中并不建议使用 Schwarz Counter 来设计你的代码:

  • 确保在当前编译单元中引入了初始化相关的头文件。

  • 保证全局变量在其他各个编译单元的全局变量前完成初始化而带来的启动延迟。

参考文献

www.gnu.org/software/hu…

refspecs.linuxbase.org/elf/gabi4+/…

refspecs.linuxfoundation.org/elf/gabi4+/…

stackoverflow.com/questions/4…

gcc.gnu.org/onlinedocs/…