【问题标题】:Why do program-level constructors get called by `__libc_csu_init` but destructors don't get called by `__libc_csu_fini`?为什么程序级构造函数会被 __libc_csu_init 调用,而析构函数不会被 __libc_csu_fini 调用?
【发布时间】:2020-08-22 06:53:24
【问题描述】:

这是一个简单的程序:

void __attribute__ ((constructor)) dumb_constructor(){}

void __attribute__ ((destructor)) dumb_destructor(){}

int main() {}

我用以下标志编译它:

g++ -O0 -fverbose-asm -no-pie -g -o main main.cpp 

我向gdb 确认__libc_csu_init 正在调用我用构造函数标记的函数:

Breakpoint 1, dumb_constructor () at main.cpp:1
1   void __attribute__ ((constructor)) dumb_constructor(){}
(gdb) bt
#0  dumb_constructor () at main.cpp:1
#1  0x000000000040116d in __libc_csu_init ()
#2  0x00007ffff7abcfb0 in __libc_start_main () from /usr/lib/libc.so.6
#3  0x000000000040104e in _start ()

我假设destructor 属性意味着dumb_destructor() 将在__libc_csu_fini 期间被调用,但这不会发生:

Breakpoint 1, dumb_destructor () at main.cpp:3
3   void __attribute__ ((destructor)) dumb_destructor(){}
(gdb) bt
#0  dumb_destructor () at main.cpp:3
#1  0x00007ffff7fe242b in _dl_fini () from /lib64/ld-linux-x86-64.so.2
#2  0x00007ffff7ad4537 in __run_exit_handlers () from /usr/lib/libc.so.6
#3  0x00007ffff7ad46ee in exit () from /usr/lib/libc.so.6
#4  0x00007ffff7abd02a in __libc_start_main () from /usr/lib/libc.so.6
#5  0x000000000040104e in _start ()

我检查了__libc_csu_fini 确实没有对 objdump 做任何事情,它确实是一个存根:

0000000000401190 <__libc_csu_fini>:
  401190:   f3 0f 1e fa             endbr64 
  401194:   c3                      ret    

为什么我们称它为_dl_fini什么是_dl_fini?为什么不一致,不调用__libc_csu_fini

【问题讨论】:

标签: c++ assembly gdb glibc libc


【解决方案1】:

我指的是撰写本文时最新的 glibc 版本标签,即 glibc 2.34(于 2021 年 8 月发布),它改变了相当多的启动过程(我强调了主要差异)。大多数发现也应该适用于其他版本和架构。此答案中的 ELF 转储来自 x86-64 系统。


在我们研究析构函数之前,我们必须了解启动时发生了什么。

当我们运行一个程序时实际发生了什么?

内核:加载程序二进制文件和动态链接器

为简洁起见,我在这里跳过了一些内核模式部分。我们从程序的 ELF 文件已经根据其segment ("program header") table 映射到内存的点开始:

$ readelf -l a.out

Elf file type is DYN (Shared object file)
Entry point 0x10a0
There are 13 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  PHDR           0x0000000000000040 0x0000000000000040 0x0000000000000040
                 0x00000000000002d8 0x00000000000002d8  R      0x8
  INTERP         0x0000000000000318 0x0000000000000318 0x0000000000000318
                 0x000000000000001c 0x000000000000001c  R      0x1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000628 0x0000000000000628  R      0x1000
  LOAD           0x0000000000001000 0x0000000000001000 0x0000000000001000
                 0x0000000000000215 0x0000000000000215  R E    0x1000
  LOAD           0x0000000000002000 0x0000000000002000 0x0000000000002000
                 0x00000000000001a0 0x00000000000001a0  R      0x1000
  LOAD           0x0000000000002da8 0x0000000000003da8 0x0000000000003da8
                 0x0000000000000268 0x0000000000000270  RW     0x1000
...(and a few more)

我们的应用程序是动态链接的(即 ELF 文件不包含它调用的所有函数),因此我们还必须将所有依赖项加载到进程的虚拟地址空间中。不过内核本身对ELF格式的理解有限,无论如何也不应该对用户空间环境做太多的假设。因此,ELF 指定了一个特殊的interpreter 程序,其路径可以在INTERP 段中找到。

在 Linux 上,这通常恰好是 动态链接器 lib64/ld-linux-x86-64.so.2。内核随后将该动态链接器 ELF 加载到与我们的应用程序相同的虚拟地址空间中,然后调用动态链接器的入口点(不是我们应用程序的入口点)。

动态链接器:加载和初始化依赖项

动态链接器现在读取我们程序的DYNAMIC 段(动态表),其中包含有关所需依赖项、符号表、重定位等的信息:

$ readelf -d a.out

Dynamic section at offset 0x2dc8 contains 27 entries:
  Tag        Type                         Name/Value
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
 0x000000000000000c (INIT)               0x1000
 0x000000000000000d (FINI)               0x1208
 0x0000000000000019 (INIT_ARRAY)         0x3da8
 0x000000000000001b (INIT_ARRAYSZ)       16 (bytes)
 0x000000000000001a (FINI_ARRAY)         0x3db8
 0x000000000000001c (FINI_ARRAYSZ)       16 (bytes)
 0x000000006ffffef5 (GNU_HASH)           0x3a0
 0x0000000000000005 (STRTAB)             0x470
 0x0000000000000006 (SYMTAB)             0x3c8
 0x000000000000000a (STRSZ)              130 (bytes)
...(and a few more)

有了这些信息,它开始递归地访问我们程序的所有NEEDED 依赖项。对于每个依赖项,执行以下步骤:

  • 将对应的 ELF 文件映射到虚拟内存中。
  • 解析其动态表并加载依赖项。
  • 运行dl_init,它会调用INIT/INIT_ARRAY动态表条目(即库的构造函数)中的所有函数。

一旦动态链接器完成并加载和初始化所有依赖项,它会将控制权交给我们应用程序的入口点 (_start)。

我们的程序:初始化 libc 并运行构造函数

_start 有几个参数,最值得注意的是 rdx 中指向 _dl_fini 的函数指针。 _start 然后准备堆栈,将一些参数放入寄存器,最后调用 __libc_start_main

__libc_start_main 接收以下参数:

  • 指向main 的函数指针(这是我们编写的main 方法)
  • argc, argv
  • 一个函数指针init(在glibc 2.34之前指向__libc_csu_init
  • 一个函数指针fini(在glibc 2.34之前指向__libc_csu_fini
  • 一个函数指针rtld_fini(等于_startrdx参数,因此指向_dl_fini

该函数对 libc 进行一些初始化,设置线程本地存储和堆栈金丝雀,等等。这里我们只关心两个调用:

__libc_csu_initcall_init 做的事情基本相同:它们运行在动态表条目 INITINIT_ARRAY 中注册的所有构造函数。然而,虽然__libc_csu_init 被静态编译到我们的程序中,但call_init 存在于libc 中,因此位于不同的内存区域中。这是changed__libc_csu_init 的汇编代码中的安全研究人员found a ROP gadget 之后。

因此,我们观察到每个构造函数的以下回溯:

  • my_constructor()
  • __libc_csu_init() (或 call_init() (>= glibc 2.34)
  • __libc_start_main()
  • _start()

__libc_start_main完成后,它transfers control到我们的main方法:

_Noreturn static __always_inline void
__libc_start_call_main (int (*main) (int, char **, char ** MAIN_AUXVEC_DECL),
                        int argc, char **argv MAIN_AUXVEC_DECL)
{
  exit (main (argc, argv, __environ MAIN_AUXVEC_PARAM));
}

我们现在已经看到了初始化可执行文件时会发生什么。但是结局呢?

运行终结器

正如我们在上面的代码 sn-p 中看到的,exitmain 返回时立即运行。那么exit 是做什么的呢?

原来,only transfers control__run_exit_handlers

void
exit (int status)
{
  __run_exit_handlers (status, &__exit_funcs, true, true);
}

__run_exit_handlers 然后通过__cxa_atexit 之类的调用调用已在__exit_funcs 列表中注册的各种函数。如果我们现在回顾一下启动过程,我们会发现这个列表也应该包含我们的 _dl_fini 函数,因为它作为 rtld_fini 参数传递给 _start/__libc_start_main!

_dl_fini 是动态链接器的终结器,它遍历所有依赖项我们的可执行文件,并为每个依赖项运行来自FINIFINI_ARRAY 的析构函数。

因此我们得到每个析构函数的以下回溯:

  • my_destructor()
  • _dl_fini()
  • __run_exit_handlers()
  • exit()
  • __libc_start_main()
  • _start()

这回答了“什么”,而不是“为什么”。


为什么不一致,不调用__libc_csu_fini

(请对以下内容持保留态度 - 我找不到原始推理的来源,但从源代码、提交消息和一些 cmets 中推断出来)

我相信实际上恰恰相反:为了更加一致。动态链接器负责运行所有依赖项的构造函数,因此它也应该运行它们的析构函数。由于我们的程序与这些依赖项没有太大区别,为什么不也运行它的析构函数呢?可能这就是__libc_csu_finidisabled around 17 years ago 的原因。我不确定为什么没有完全删除它 - 可能是为了保持与现有编译器的兼容性。

随着最近发布的 glibc 2.34,__libc_csu_init__libc_csu_fini 函数都被完全删除,因为它们的任务现在由运行时的其他部分完成。

那么为什么动态链接器不在dl_init中运行我们程序的构造函数呢?

好吧,dl_init 在我们应用程序的入口点 _start 之前运行 - 其中运行时的几个重要部分尚不可用(初始化在 __libc_start_main 中完成)。所以我们的构造函数需要是自包含的并且避免调用外部函数。由于这会给可靠性和安全性带来相当大的风险,因此构造函数会在所有其他初始化完成后执行。

实际上,is support 用于由dl_init 执行的初始化函数 - 这些可以通过PREINITPREINIT_ARRAY 动态表条目指定,并在我们的_start 函数之前运行。但是,there does not appear to be a straightforward way to register these with the compiler,无论如何也不推荐使用。


注意: 回答这个问题需要深入研究 glibc 的内部工作原理,结果证明它比我最初预期的还要复杂。为了使这个答案连贯一致,我不得不简化一些事情并跳过其他事情。如果您发现任何不准确的地方,请随时编辑或在 cmets 中提出。

【讨论】:

  • 相关:Linux x86 Program Start Up or - How the heck do we get to main()? 是 glibc 2.34 之前很久(且更长)的一篇文章。它对某些事情有更多细节,但可能不太关注这个答案涵盖的其他一些事情。当然也没有提到 glibc 2.34 的变化。
  • 感谢您的解释!但恐怕我找到了“为什么动态链接器不在 dl_init 中运行我们程序的构造函数?”的答案?不令人满意,因为它没有提供为什么“dl_init 在我们的应用程序的入口点 _start 之前运行”的理由。我完全同意构造函数应该在 libc 初始化后运行,但为什么解决方案不是“将 dl_init 传递给 __libc_start_main”呢?它现在的工作方式只解决了主二进制文件中构造函数的问题。
猜你喜欢
  • 2013-12-11
  • 2015-04-28
  • 1970-01-01
  • 2022-07-05
  • 1970-01-01
  • 1970-01-01
  • 2016-07-12
  • 1970-01-01
相关资源
最近更新 更多