关于全局变量引发的内存问题探源

 一、引子

在 C++ 中,全局和静态变量在程序中断时会被析构,无论是从 main 函数返回还是调用 exit()。然而,静态变量的构造、析构函数调用顺序是不确定的,甚至随着构建变化而变化,这样会导致许多难以预料的 bug。

笔者就曾在工作中遇到一个因静态变量引发的 core dump 问题,大费周折才调查清楚。此类问题非常有研究价值,从中能加深对编译器和链接器的理解,窥探到那些汇编程序员才会关注的隐秘细节,也能让你更加认识你自己——离一名合格的 C++ 程序员究竟还有多少距离。

闲话少说,让我们回到事故现场,细细道来。从 dump 文件获取到的 backtrace 如下:

从 backtrace 可以看出些许端倪:首先 C 运行库(libc.so.6)里面的 exit 调用表明这是在 main 函数退出之后;接着,发现一个名为 __cxa_finalize 的函数了调用一个 map 对象的析构函数,该 map 对象位于 libcr_ui.so。

Demangle 这个析构函数,可以看到该对象的类型是 std::map<std::string, int>。经过层层排查,最终在 libcr_ui.so 里面定位到了这个全局对象:

这是一个具有文件作用域的静态 std::map 型对象。查看模块依赖关系,发现这个 .cc 文件首先被编译进某静态库 libxx.a 当中,进而又被链接进 libcr_core.so 和 libcr_ui.so 这两个动态库当中。在进程退出,两个动态库分别被卸载的过程中,这个静态 map 对象先后被析构了两次,由此产生了 crash。

这是一个经典的关于编译器的坑。此问题涉及诸多有关 gcc 编译器和 ELF 共享库动态链接的细节,笔者查阅了大量的相关资料和书籍才弄清楚里面的玄机。本文旨在对此问题作一般性的阐述和总结,并通过一些演示示例来逐步加以揭示。

二、本质分析

2.1 从 C/C++ 运行库谈起

操作系统在装载程序之后,首先运行的代码并非 main 函数的第一行,而是某些别的代码。这些代码负责准备好 main 函数执行所需要的环境,并且负责调用 main 函数。在 main 返回之后,它会记录 main 函数的返回值,调用某些清理函数,然后结束进程。

这些特殊的代码称为入口函数或入口点(Entry Point),因不同的平台上而有不同的名字。程序的入口点实际上就是一个程序的初始化和结束的部分,它往往是 C/C++ 运行库的一部分。

一个典型的 C++ 程序其运行步骤大致如下:

  • 操作系统在创建进程后,把控制权交到程序入口点,即运行库中某个入口函数;
  • 入口函数对运行库和运行环境进行初始化,包括堆、I/O、线程、全局变量构造等;
  • 入口函数在完成初始化之后,调用 main 函数,开始执行程序主体部分;
  • main 函数执行完毕以后,返回到入口函数,入口函数进行清理工作,包括全局变量的析构、堆销毁、关闭 I/O 等,然后进行系统调用结束进程。

由此我们看到,运行库才是程序世界的创世者和终结者,是这个隐秘世界的真正主宰。

Windows 和 Linux 平台下主要的 C 运行库分别为 glibc (GNU C Library) 和 MSVC (Microsoft Visual Run-time),它们都是标准 C 语言运行库的超集,各自对 C 标准库进行了一些扩展。本文主要关注 Linux 平台下的 C/C++ 运行库。

2.2 关于 glibc 和 libstdc++

glibc 的发布版本主要包含两部分:一部分是头文件,比如 stdio.h,stdlib.h 等,它们往往位于 /usr/include;另外一部分则是库的二进制文件。二进制部分主要就是 C 语言的标准库,它有静态和动态两个版本。动态的标准库位于 /usr/lib/libc.so.6,而静态标准库则位于 /usr/lib/libc.a。类似的,Linux 的 C++ 运行库为 libstdc++.so / libstdc++.a。

一般而言,C++ 运行库都是依赖于 C 运行库的,它仅仅包含对 C++ 的一些语言特性方面的支持,比如 STL、new/delete、异常处理、流(stream)等。但是并不包括诸如入口函数、堆管理、基本函数操作这些特性,而这些也是 C++ 运行库所必需的,比如 C++ 的流和文件操作依赖于 C 运行库的基本文件操作,所以它必须依赖于 C 运行库。

除非在编译时显示指定静态(-static)链接 C/C++ 运行库,默认情况下运行库都是动态链接到可执行文件的:

上面示例中的 libc.so.6 就是运行时动态加载的 C 运行库。glibc 的启动过程在不同的情况下区别很大,比如静态链接的 glibc 和动态链接的 glibc,glibc 作用于可执行文件和作用于共享库。以下仅探讨静态链接的 glibc 作用于可执行文件的情况。

2.3 glibc 的入口函数

glibc 的程序入口是一个叫 _start 的函数,由汇编实现,并且与平台密切相关。_start 函数经过层层调用,最终调到用一个名为 __libc_start_main 的函数。

注:入口函数是由 ld 链接器默认的链接脚本所指定的,我们也可以通过相关参数设定自己的入口函数。

在 glibc 的源码里面,找到 __libc_start_main 函数的声明头,它是通过宏来定义的:

这个函数一共有 8 个参数。其中第一个参数传入 main 函数指针,紧接着是我们非常熟悉的 argc 和 argv。此外,还传入了三个函数指针:init 指向 main 函数调用前的初始化例程,fini 指向 main 结束后的收尾工作,rtld_fini 代表与动态库加载有关的收尾工作,rtld 是 runtime loader 的缩写。最后的 stack_end 参数代表栈底的地址,即最高的栈地址。

上面的函数参数里,init 和 fini 这两个函数指针实际分别指向函数 __libc_csu_init 和 __libc_csu_fini,csu 为 “C start up” 缩写。

紧接着,__libc_csu_init 函数又调用了 _init 函数,里面调用的是程序的 “.init” 段,它是由各个输入目标文件中的 “.init” 段拼接而来的。

通过反汇编,发现 _init 调用了一个叫作 __do_global_ctorx_aux 的函数。这个函数并不属于 glibc,而是来自 gcc 提供的一个目标文件。该函数位于 gcc/Crtstuff.c,简化后的代码如下:

这段代码中,__CTOR_LIST__ 数组的第一个元素存放着这个数组元素的个数,然后将第一个元素之后的元素都当做是函数指针,并一一调用。事实上,__CTOR_LIST__ 里面存放的是所有全局对象的构造函数的指针,所有的全局对象会在这里被构造。

注:链接器在进行最终链接时,有一部分目标文件是来自于 gcc,它们是那些与语言密切相关的支持函数,比如 C++ 的全局对象构造是与语言密切相关的,相应负责构造的函数来自于 gcc。

2.4 全局对象的构造

由于 ELF 文件的改进,出现了必须在 main 函数之前执行全局、静态对象的构造函数,以及必须在 main 函数之后执行全局、静态对象的析构函数等需求。为满足类似的需求,运行库在每个目标文件中引入了两个特殊的段:”.init” 段和 “.finit” 段,运行库会保证所有位于这两个段中代码先/后于 main 函数执行。对于全局、静态对象,又引入了两个特殊的段 “.ctors” 和 “.dtors”,分别处理构造和析构。这两个段经过编译器的层层处理,最终会分别合入到 “.init” 段和 “.finit” 段当中。

对于每个编译单元(.cpp),gcc 编译器会遍历其中所有的全局对象,生成一个特殊的函数(_GLOBAL__I_Hw),这个特殊函数负责本编译单元内所有全局、静态对象的构造和析构。一旦一个目标文件里有这样的函数,编译器会在这个编译单元产生的目标文件(.o)的 “.ctors” 段里放置一个指针,这个指针指向的便是 GLOBAL__I_Hw。

在编译器为每个编译单元生成一份特殊函数之后,链接器在链接这些目标文件时,会将同名的段合并在一起,这样每个目标文件的 .ctors 段会被合并为一个 .ctors 段,其中的内容是各个目标文件的 .ctors 段的内容拼接而成。由于每个目标文件的 .ctors 段都只存储了一个指针(指向该类里的全局构造函数),因此拼接起来的 .ctors 段就成为了一个函数指针数组,每一个元素都指向一个目标文件的全局构造函数。

把每个目标文件的全局或静态对象的构造函数地址放在一个特殊的段里,为的是能够让链接器把这些特殊的段收集起来。收集齐所有的的全局构造函数后,便可以在初始化的时候进行构造。

2.5 全局对象的析构

对于 glibc 和 GCC,在完成对象的构造之后,程序结束前,运行库还要进行对象的析构。正常的全局对象析构与前面介绍的构造是完全一样的,并且所有的函数、符号名都一一对应。

__libc_start_main 会将 __libc_csu_fini 通过 __cxa_exit() 注册到退出列表中,这样当进程退出前 exit() 里面就会调用 __libc_csu_fini,接着会调用 _fini。_fini 的原理同 _int 的原理是一样的。

编译器对每个编译单元的全局对象,都会生成一个特殊函数来调用这个编译单元的所有全局对象的析构函数,它的调用顺序与调用构造函数的顺序恰好相反。

这样做的好处是保证了全局构造和析构的顺序(先构造,后析构),链接器必须包装所有的 “.dtors” 段,其合并顺序必须是 “.ctors” 段的严格反序。但是这样增加了链接器的工作量,后来人们放弃了这种做法,采取了一种新的做法,通过 __cxa_atexit() 在 exit() 函数中注册进程退出回调函数来实现析构。

综上所述,C++ 全局构造与析构的实现是比较特殊的,它与编译器、链接器密切相关。它们的实现依赖于编译器、链接器和运行库三者的协作。全局构造的实现主要依赖于特殊的段合并后形成构造函数数组,全局析构的实现则依赖于 atexit() 函数。

注:由于全局对象的构建和析构都是由运行库来完成的,于是在程序或者共享库中有全局对象时,不能使用 “-nostartfiles” 或 “-nostdlib” 选项,否则构建与析构函数不能正常执行。

2.6 ELF 文件

为了进一步理解全局对象的构造和析构所带来的挑战,我们还需要了解共享库以及链接装载的原理。这里从 ELF 文件谈起。

ELF 的全称是可执行与可链接格式(Executeable and Linking Formate),它有三个不同的类型:可定位的、可执行的和共享目标(shared objects)。

可定位文件由编译器和汇编器创建,但在运行前需要被链接器处理。可执行文件完成了所有的重定位工作和符号解析(除了那些可能需要在运行时被解析的共享库符号)。共享目标就是共享库,既包括链接器和共享器所需的符号信息,也包括运行时可以直接执行的代码。

2.7 链接时重定位

因为编译是以源文件为单位进行的,编译器此时并没有一个全局的视野,因此对一个编译单元内的符号它是无力确定其最终地址的。而对于可执行文件来说,在现代操作系统上,程序加载运行的地址则是固定、可以预期的。在链接时,链接器可以直接计算分配该文件内各种段的绝对或相对地址。因此,对于可执行文件来说,可在链接阶段进行模块内部的符号重定位,这就是链接时重定位。

2.8 装载时重定位

如果可执行程序用到了共享库里面的函数,那么情形就不一样了。因为动态库的加载是在运行时,且加载的地址不固定,因此没法事先确定该模块的起始地址,所以对动态库的符号重定位,只能推迟。

共享库里的符号重定位是怎么解决的呢?目前来说,Linux 下 ELF 主要支持两种方式:装载时符号重定位及地址无关代码。地址无关代码接下来会讲到。对于装载载时的重定位,其原理很简单,它与链接时重定位是一致的,只是把重定位的时机放到了动态库被加载到内存之后,由动态链接器来进行。

系统在装载程序的时候,需要对程序的指令和数据中对绝对地址的引用进行重定位。因为整个程序是按照一个整体被加载的,程序中指令和数据的相对位置是不会发生改变的。比如一个程序在编译时假设被装载的目标地址为 0x1000,但是装载时操作系统发现 0x1000 这个地址已经被别的程序使用了,而从 0x4000 开始有一块足够大的空间可以容纳该程序,那么该程序就可以被装载至 0x4000,程序指令或数据中的所有绝对引用只要都加上 0x3000 的偏移量就可以了。这就是装载时重定位(Load Time Relocation)。

2.9 地址无关代码

装载时重定位是解决动态模块中有绝对地址引用的办法之一,但它有一个很大的缺点是指令部分无法在多个进程之间共享,这样就失去了动态链接节省内存的一大优势。还需要一种更好的方法解决共享对象指令中绝对地址的重定位问题。

我们希望程序模块中共享的指令部分在不要因为状态地址的改变而改变。其实现的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分则可以在每个进程中拥有一个副本。这种方案就是目前被称为地址无关代码(PIC,Position-Independent-Code)的技术。

2.10 PIC 与 GOT

为了理解地址无关代码的原理,我们先来分析共享模块各种类型的地址引用方式。我们把共享对象模块中的地址引用按照是否为跨模块分为两类:模块内部引用和模块外部引用,按照不同的引用方式又分为指令引用和数据访问,这样就组合得到四种情况:

  • 1) 模块内部的函数调用、跳转;
  • 2) 模块内部的数据访问,比如模块中定义的全局变量、静态变量;
  • 3) 模块外部的函数调用、跳转等;
  • 4) 模块外部的数据访问,比如其他模块中定义的全局变量。

这四类情况示例如下:

对于每种类型,分别来探讨其关于地址无关代码的解决方案。

类型一:模块内部函数调用

被调用的函数与调用者同处于一个模块,它们之间的相对位置是固定的,所以这种情况是比较简单的。对于现代的体系结构来讲,模块内部的跳转、函数调用都可以是相对地址调用,或者是基于寄存器的相对调用,所以对于这种指令是不需要重定位的,也就达到了地址无关代码的目标。

类型二:模块内部数据访问

由于指令中不能包含数据的绝对地址,那么唯一的办法就是相对寻址。我们知道,一个模块前面一般是若干页的代码,后面紧跟着若干页的数据,这些页之间的相对位置是固定的。任何一条指令与它需要访问的模块内部数据之间的相对位置是固定的,那么只需要相对于当前指令加上固定的偏移量就可以访问模块内部数据了。

现代体系结构中,数据的相对寻址往往没有相对于当前指令 PC 的寻址方式。但是,ELF 用了一个巧妙的办法来得到当前的 PC 值,然后再加上一个偏移量就可以达到访问相对变量的目的。因此,这类指令也不需要重定位,可以做到地址无关代码。

类型三 模块间的数据访问

模块间的数据访问比模块内部稍微麻烦一点,因为模块间的数据访问目标地址要等到装载前才决定。比如上面例子中的变量 b,它被定义在其他模块中,并且该地址要在装载时才能确定。

要使得地址无关代码,基本的思想就是把跟地址相关的的部分放在数据段里面。ELF 的做法是在数据段里面建立一个指向这些变量的指针数组,也称为全局偏移表(Global Offset Table,GOT),当代码需要引用该全局变量时,可以通过 GOT 中相对应的表项间接引用。

链接器在装载模块的时候,会查找每个变量所在的地址,然后填充 GOT 中的各个项,以确保每个指针所指向的地址正确。由于 GOT 本身是放在数据段的,所以它可以在模块装载时被修改,并且每个进程都可以有独立的副本,相互不受影响。

当指令要访问变量 b 时,程序会先找到 GOT,然后根据 GOT 中变量所对应的表项找到变量的目标地址。

那么问题来了,那 GOT 自身又是如何做到与指令的地址无关呢?

从第二种类型的数据访问我们了解到,模块在编译时可以确定模块内部变量相对于当前指令的偏移,那么我们也可以在编译时确定 GOT 相对于当前指令的偏移。确定 GOT 的位置跟上面的访问变量 a 的方法基本一样,通过得到 PC 值然后加上一个偏移量,就可以得到 GOT 的位置。然后根据变量在 GOT 中的偏移量就可以得到变量的地址。

当然, GOT 表项中每个地址对应于哪个变量是由编译器决定的,比如第一个地址对应变量 b,第二个对应变量 c 等。

类型四 模块间的调用、跳转

对于模块间调用和跳转,我们也可以采用上面类型三的方法来解决。所不同的是,GOT 中相应的表项保存的是目标函数的地址,当函数要调用目标函数时,可以通过 GOT 中的表项进行间接跳转。

这样,我们针对每种不同的指令类型,似乎都找到了能够地址无关代码的解决方案。

2.11 全局变量的地址无关代码

定义在其他模块的全局变量的地址是跟模块装载地址有关的。当一个模块引用了一个定义在共享对象的全局变量 global,而模块中是这么引用的:

当编译器编译 module.c 时,它无法根据这个上下文判断 global 这个变量是定义在同一个模块的其他目标文件还是定义在另外一个共享对象之中,即无法判断是否为跨模块间的调用。

假设 module.c 是可执行文件的一部分,那么在这种情况下,由于程序主模块的代码并不是地址无关代码,也就是说代码不会使用这种类似于 PIC 的机制,它引用全局变量的方式跟普通数据的访问方式一样,编译器会产生这样的代码:mov1 $0x1,xxxxxxxxx;其中,XXXXXXXX 就是全局变量 global 的地址。

由于可执行文件在运行时并不进行代码重定位,所以变量的地址必须在链接过程中确定下来。为了能够使得链接过程正常进行,链接器会在创建可执行文件时,在它的 “.bss” 段创建一个 global 变量的副本。那么问题就来了,现在 global 变量定义在原先的共享对象中,而在可执行文件的 “.bss” 段还存在一个副本。如果同一个变量同时存在于多个位置,这在程序实际运行过程中肯定是不可行的。

解决的办法只有一个,那就是所有使用这个变量的指令都指向位于可执行文件中的那个副本。ELF 共享库在编译时,默认都把定义在模块内部的全局变量当作定义在其他模块的全局变量,也就是说当作前面的类型四,通过 GOT 来实现变量的访问。

当共享模块被装载时,如果某个全局变量在可执行文件中拥有副本,那么动态链接器就会把 GOT 中相应地址指向该副本,这样该变量在运行时实际上最终就只有一个实例。如果变量在共享模块中被初始化,那么动态链接器还需要将该初始化值赋值给程序主模块中的全局副本;如果该全局变量在程序主模块中没有副本,那么 GOT 中的相应地址就指向模块内部的该变量副本。

如果 module.c 是一个共享对象的一部分,那么 GCC 编译器在 -fPIC 的情况下,就会把对 global 变量的调用按照跨模块模式产生代码。原因很简单:编译器无法确定对 global 的引用是跨模块的还是模块内部的。即使是模块内部,对全局变量的引用,按照上面的结论,还是会产生跨模块代码,因为 global 可能被可执行文件引用,从而使得共享模块对 global 的引用要执行可执行文件中的 global 副本。

2.12 地址无关可执行文件

通过上面的方法,能够保证共享对象中的代码部分地址无关,但是数据部分是不是也有绝对地址引用的问题呢?让我们来看看这样一段代码:

如果某个共享对象有这样一段代码的话,那么指针 p 的地址就是一个绝对地址,它指向变量 a,而变量 a 的地址会随着共享对象的装载地址改变而改变。那么有什么方法解决这个问题呢?

当编译器在编译 pic.c 时,它实际并不能确定变量 b 和函数 ext() 是模块外部的还是模块内部的,因为他们都有可能被定义在同一个共享对象的其他目标文件中。由于没法确定,编译器只能把它们都当作模块外部的函数和变量来处理。

对于数据段来说,它在每个进程中都有一份独立的副本,所以并不担心被进程改变。从这点来看,我们可以选择装载时重定位的方法来解决数据段中绝对地址引用问题。对于共享对象来说,如果数据段中有绝对地址引用,那么编译器和链接器就会产生一个重定位。例如,Linux x86 平台,重定位表里面会包含 “R_386_RELATIVE” 类型的重定位入口,用于解决上述问题。当动态链接器装载共享对象时,如果发现该共享对象有这样的重定位入口,那么动态链接器就会对该共享对象进行重定位。

实际上,我们甚至可以让代码段也使用这种装载时重定位的方法,而不是用地址无关代码。通常,使用 gcc 编译共享对象时我们需要使用了 “-fPIC” 参数,这个参数表示产生地址无关的代码段。如果我们不使用这个参数来产生共享对象又会怎么样呢?

这样就会产生一个不使用地址无关代码而使用装载时重定位的共享对象。但如前面分析过的一样,如果代码不是地址无关的,它就不能被多个进程之间共享,于是也就失去了节省内存的优点。但是装载时重定位的共享对象的运行速度要比使用地址无关代码的共享对象快,因为它省去了地址无关代码中每次访问全局数据和函数时需要做一次计算当前地址以及间接地址寻址的过程。

对于可执行文件来说,默认情况下,如果可执行文件是动态链接的,那么 GCC 会使用 PIC 的方法来产生可执行文件的代码段部分,以便于不同的进程能够共享代码段,节省内存。所以我们可以看到,动态链接的可执行文件中存在 “.got” 这样的段。

注:在 64 位操作系统上,gcc 默认已强制使用地址无关代码来编译共享对象,即便你没有指定 -fPIC/-fPic 参数,相关讨论在这里

三、演示示例

通过以上的梳理,我们大致了解了 C 运行库和链接器器所做的工作,以及 ELF 文件实现地址无关代码的原理。下面再从演示示例来进一步理解本文所提到的问题的根源:

3.1 演示示例源码及结构

为演示需要,这里提供一个微型的项目,其结构描述如下:

test.c 是主程序,包含有两个头文件:api1.h 与 api2.h;头文件 api1.h 包含头文件 lib1/lib.h 和一功能函数 func_api1(),api2.h 包含头文件 lib2/lib.h 和一功能函数 func_api2();目录 lib1 和 lib2 下的源文件分别编译生成共享库 lib1.so 和 lib2.so。同时,头文件 lib1/lib.h 与 lib2/lib.h 链接到同一共享文件 lib.h。在文件 lib.h 中定义有一静态成员变量 static std::vector<int> vec_int。

源码目录层级结构如下:

源码文件内容如下:

通过 Makefile 可以快速理清这个示例项目的依赖关系:lib.cc 经编译生成静态库 libcommon.a;共享库 lib1.so 和 lib2.so 分别由 api1.cc 和 api2.cc 生成,均链接静态库 libcommon.a;可执行文件 a.out 由 test.cc 生成,链接共享库 lib1.so 和 lib2.so。

lib1.so 和 lib2.so 分别提供了功能函数 func_api1 与 func_api2,其实现类似,都是通过调用类 A 的静态成员函数来访问和操作其静态成员变量 vec_int。

3.2 重复析构背后的真相

与全局变量类似,静态成员变量也采用了静态存储方式。对于加了选项 -fpic 或 -fPIC的共享库,这些变量的地址都存放在该共享库的全局偏移表(GOT)中。

在上述示例中,由于链接同一个静态库 libcommon.a,导致两个共享库中都有 class A 的实现,存在着同名的符号。又由于在生成共享库时使用了 -fPIC 选项,最终同名的静态成员变量 vec_int 分别位于两个共享库的 GOT 区。

编译并运行上述程序,可以观察到有 crash,提示发生 double free,涉及到的地址正是我们在 Class A 里面定义的静态成员变量 vec_int 第一个元素的地址。

通过获取 backtrace,并 demangle 相关的符号,可以看到 core dump 正好发生在这个 std::vector 对象的析构函数里。

运行时,系统从符号表中查找并装载构造一份 vec_int。当程序结束时,对该变量进行了两次析构操作。

3.3 动态库的装载与卸载

观察上述演示示例的执行结果,main 函数开始之前,有 lib2_init 和 lib1_init 两个函数先后被执行,这是在这两个共享库分别被装载时发生的;同样,在 main 函数结束之后,lib1_fini 和 lib2_fini 先后被执行,这发生在两个共享库分别被卸载的时候。

这是因为我们使用了 __attribute__ 关键字分别把这些函数声明为 contructor 属性 和 destructor 属性,这样它们就会被编译器分别放入 ELF 文件的 “.init” 段和 “.fini” 段,从而先/后于 main 函数被执行。

此外,另一个值得注意的地方是,共享库的装载是明显有先后顺序的,并且卸载的顺序跟装载严格反序。这里,之所以共享库 lib2.so 会先于 lib1.so 被装载,是因为我们在可执行文件 a.out 的链接阶段指定了先后次序:

编译器传递给链接器的参数,其解析是按照从右往左的顺序来处理的。链接阶段,右侧的库会先于左侧的库被链接;同理,运行时右侧的库也就会先于左侧的库被装载。这也就这解释了上述示例中,lib2.so 为何最先被链接,而又最后被卸载。

由于链接器对共享库的处理是有顺序的,我们在开发过层中应该尽量按垂直关系来链接共享库,越是底层的库越是放到后面,这样也可以避免循环依赖。

四、解决方案

4.1 按 PIE 的标准生成共享库

在 2.9~2.12 节我们探讨了地址无关代码和地址无关可执行文件的机理。如果我们按地址无关可执行文件的方式来编译共享库,生成的共享库就不再会为静态成员变量或全局变量在 GOT 中创建对应的条目,从而避免了由于静态对象“构造一次,析构两次”而对同一内存区域释放两次引起的程序 core dump。

如上所示,使用 gcc 的 -fpie/-fPIE 选项来编译静态库和两个共享库,最终可以避免重复析构。选项 -fpie/-fPIE 与 -fpic/-fPIC 的用法很相似,区别在于前者总是将生成的位置无关代码看作是属于程序本身,并直接链接进该可执行程序,而非存入全局偏移表中。这样,对于同名的静态或全局对象的访问,其构造与析构将保持一一对应。

这里的关键在于静态库 libccomon.a 的编译选项必须是 -fpie/-fPIE,它直接决定了类成员变量 A::vec_int 是否存在于 GOT 中。如果只修改两个共享库的编译选项,是没有效果的。另外,-pie 参数是链接器 ld 的参数,用于生成地址无关可执行文件,它必须配合编译器参数 -fpie/-fPIE 才能发挥作用,单独使用任何一个都无效达到效果。

对于 gcc 编译器,小写的 -fpic 大写的 -fPIC 的区别在于:全局偏移表大小的上限是平台相关的,如果使用 -fpic,超出限制后链接器将会报错,这时应该使用 -fPIC。而小写的 -fpie 与大写的 -fPIE 基本没有区别。

注:经笔者测试,此方法只适用于 X86 平台,不适用于 X64 平台。大致原因是 gcc 默认强制使用地址无关代码来编译共享对象,即便使用 -fpie/-fPIE 选项,最终也还是按照 -fpic/-fPIC 的方式来编译。具体原因有待深入研究。

4.2 杜绝多个动态库链接同一静态库

正如上述示例,在工程中,如果同一静态库如果被多个动态库或者可执行文件所链接的话,说明项目的模块组织结构存在冗余,存在优化的空间。这样不仅会增加二进制文件的体积,还会引入全局变量的重复析构问题。因此在实践中,我们要尽量避免这种情况的发生。这也能从根源上解决本文提到全局变量被重复析构的问题。

举例来说,两个模块(A、B)依赖于同一个模块(C),如果模块 A 和 B 都是共享库的话,那么 C 也应该是共享库而不应该是静态库,这样就可以避免静态库被重复链接。按照这个思路,我们改造上面实例程序的模块结构,将 lib.cc 编译成一个共享库:

这样,class A 仅在 libcommon.so 里面被定义,不会发生符号的重复,其静态成员变量也只存在一份,也就不会再被重复析构。

4.3 控制共享库的符号可见性

对于 C++ 编程,性能是重要的关注点。然而,由于对其他库的依赖性以及使用特定的 C++ 特性(比如模板),编译器/链接器趋向于会使用和生成大量的符号。因此,导出所有符号会减慢程序速度,并耗用大量内存。而导出有限数量的符号可以缩短动态共享库的加载和链接时间,这也意味着会生成更有效率的代码,同时也对库的安全有益。

如何控制动态共享对象(DSO)中的符号呢,主要有如下几种方式:

4.3.1 使用 static 关键字

这种方式的局限性在于它只对本文作用域的普通变量或者函数有效。它为文件中的符号禁用了外部链接。这是一种语言级别的控制,也是最简单的一种隐藏符号的方式。这意味着带有关键字 static 的符号永远不会是可链接的,因为编译器不为链接器留下关于此符号的任何信息。

然而,对于 C++ 的类成员变量/函数,static 关键字已然是另一层含义,并不能达到隐藏符号的效果。

4.3.2 使用 -fvisibility 编译选项

GCC 提供了-fvisibility 编译选项,其可选取值为 default 或者 hidden。前者表示编译对象中对于没有显式地设置为隐藏的符号,其属性均对外可见;后者将隐藏没有进行显式设置的符号。

对于编译没有显式指定的情况,则 GCC 编译器默认使用 -fvisibility=default,表示库的符号均对外可见。

4.3.3 定义 visibility 属性

GCC 同时支持使用 visibility 属性定义符号的可见性。它为此定义 4 个类型,但是大多数情况下,最常用的只有前两个:

  • STV_DEFAULT: 用它定义的符号将被导出。换句话说,它声明符号是到处可见的。
  • STV_HIDDEN: 用它定义的符号将不被导出,并且不能从其他对象进行使用。
  • STV_PROTECTED: 符号在当前可执行文件或共享对象之外可见,但是不会被覆盖。换句话说,如果共享库中的一个受保护符号被该共享库中的另一个代码引用,那么此代码将总是引用共享库中的此符号,即便可执行文件定义了相同名称的符号。
  • STV_INTERNAL:符号在当前可执行文件或共享库之外不可访问。

要定义 visibiliy 属性,需要包含 __attribute__ 和用括号括住的内容。当符号的可见性指定为 visibility(“hidden”),这将不允许它们在库中被导出,但是可以在源文件之间共享。实际上,隐藏的符号将不会出现在动态符号表中,但是还被留在符号表中用于静态链接。这是一种良好定义的行为,完全可以达到我们的目的。它显然优于 static 关键字方法。

注:对于用 visibility 属性指定的变量,将它声明为 static 可能会让编译器感到混淆,因此编译器会显示一条警告消息。同时 attribute 机制的优先级要高于 -fvisibility 编译选项。

4.3.4 使用导出列表

符号可见性主要动态链接中涉及到,这正是链接器的需要处理的。然而上面解决方案似乎都是针对于编译器的,有没有直接针对链接器的呢?解决方案是导出列表。

导出列表常常由编译器或相关工具在创建共享库的时候自动生成,也可以由开发者手工编写。导出列表由链接器选项传入并当作链接器的输入。

导出列表的原理是,显式地告诉链接器可以通过外部文件从对象文件导出哪些符号。此类外部文件被作为“导出映射”。我们可以为本文的示例编写一个导出映射:

上面的描述告诉链接器,只有 func1 符号将被导出,其他符号(由 * 匹配)是局部的。你也可以显式地列出其他的符号为局部符号。但是很明显,使用匹配符(*)更为方便。一般来说,高度推荐使用匹配符(*)来将所有符号都标记为 local,挑出需要导出的符号指定为 global,这样更简单也更安全,可以避免一些非预期的行为。

对于上面的演示程序,我们只需要控制这个静态的 std::vector 对象的符号可见性就能达到目的。对于上面的四种方式,第一种显然不可能了。为简单起见,我们采取第二种方式,即通过 visibility 属性来控制这个成员变量的可见性。

这样一来,这个类成员变量在模块(共享库)外就不再可见了,不会因为 PIC 被复制到可执行程序中去,也就避免了重复析构的发生。

4.4 全局/静态对象的局部化

当我们需要一个全局或者静态变量时,可以考虑使用局部静态变量来代替:

这段代码构造了一个局部的静态对象,避免了指针的直接传递,保证了全局对象的单实例。由于将拷贝构造函数声明为 private,使得所有需要使用该对象的地方,都需要声明为引用。这样,即便是进程结束,其构造函数也不会被调用。

针对上面的演示示例,我们将原本定义子类中的静态成员变量,改为局部的静态变量:

这样,这个静态对象的构造函数在进程结束时便不会被调到,从根本上避免了被重复析构的可能。

4.5 阻止进程退出时的析构

某些情况下,我们希望一些重要的全局对象在程序运行的整个过程中都存在,生命周期跟进程相同。

例如 Chromium 里面的 V8 对象,就是一个跟进程生命周期相同的全局变量。任何时候都不允许它被析构掉,如果被析构掉,HTML 就无法执行 JavaScript 了。

这种情况下,其实是要防止全局对象被析构,即便是进程结束也不要调用其析构函数。除了上面提到的局部静态变量的方法可以禁止全局对象被析构以外,还有一个方法就是 quick_exit。程序结束的时候调用 quick_exit() 来替代 exit(),这样全局对象的析构函数就不会被调用了。

我们改造上面的 test.cc,使用 quick_exit() 来退出:

重新编译后运行,发现进程结束时,我们观察到共享库里显式注册的 .fini 段函数都没有被执行,也包含了那个静态成员的析构函数。因此,double free 也不再会发生了。

注:quick_exit() 与 exit() 的不同之处是前者不会执行任何析构,也不会执行 atexit() 所绑定的任何 handlers。如果想在执行 quick_exit() 中断时执行某 handler(比如刷 log),可以把它绑定到 _at_quick_exit()。如果想在 exit() 和 quick_exit() 都用上该 handler,应该都绑定上去。

五、编程建议

Google 的 C++ 编码规范中有专门指出关于全局和静态变量的注意事项,如果我们能遵循相关建议,基本上能从根源上避免此类问题。

  • 禁止使用 class 类型的静态或全局变量:它们会导致难以发现的 bug 和不确定的构造和析构函数调用顺序。constexpr 变量除外,毕竟它们不涉及动态初始化或析构。
  • 静态生存周期的对象,即包括了全局变量,静态变量,静态类成员变量和函数静态变量,都必须是原生数据类型(POD : Plain Old Data);即 int, char 和 float, 以及 POD 类型的指针、数组和结构体。
  • 不允许用函数返回值来初始化 POD 变量,除非该函数(比如 getenv() 或 getpid())不涉及任何全局变量。
  • 尽量不用全局函数和全局变量,考虑作用域和命名空间限制,尽量单独形成编译单元。
  • 多线程中的全局变量(含静态成员变量),不要使用 class 类型(含 STL 容器),避免不明确行为导致的 bug。
  • 函数作用域里的静态变量除外,毕竟它的初始化顺序是有明确定义的,而且只会在指令执行到它的声明那里才会发生。

综上,我们只允许 POD 类型的静态变量,禁用 std::vector(使用 C 数组替代)和 std::string(使用 const char []),以及其他所有的容器类型。如果确实需要一个 class 类型的静态或全局变量,可以考虑使用函数返回一个局部静态变量来代替,或者是在 main() 函数或 pthread_once() 内初始化一个指针且永不回收。注意只能用 raw 指针,别用智能指针,毕竟后者的析构函数涉及到上文指出的不定顺序问题。

六、结束语

曾有先贤踩进这个坑里,并且提交了一份 patch,用于对此类全局同名对象的问题加上编译时的警告。但是 gcc 官方好像并没有接受这个 featrue,相关讨论在这里。比较一致的看法是,这个问题的这并不是 gcc 的 bug,而因该归咎于程序员对全局变量不合理的重定义或者对编译模块不合理的组织。

因笔者水平有限,本文相当一部分是引用相关书籍及其他相关主题文章的内容,后面会附上参考文献及引用的地址链接。原作者如觉不当,请联系笔者删除。本文的不当或者错误之处,也恳请批评校正。

参考文献:

发表评论

电子邮件地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据