为什么程序级构造函数会被 `__libc_csu_init` 调用,而析构函数不会被 `__libc_csu_fini` 调用?
Why do program-level constructors get called by `__libc_csu_init` but destructors don't get called by `__libc_csu_fini`?
这是一个简单的程序:
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
?
我指的是撰写本文时最新的 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 文件不包含它调用的所有函数),因此我们还必须将所有依赖项加载到进程的虚拟地址 space 中。不过内核本身对ELF格式的理解也很有限,无论如何都不应该对用户space环境做太多的假设。于是,ELF指定了一个特殊的interpreter程序,其路径在INTERP
段
在 Linux 上,这通常恰好是 动态链接器 lib64/ld-linux-x86-64.so.2
。内核随后将该动态链接器 ELF 加载到与我们的应用程序相同的虚拟地址 space,然后调用动态链接器的入口点( 而不是 我们应用程序的入口点)。
动态链接器:加载和初始化依赖项
动态链接器现在读取我们程序的DYNAMIC
段(动态table),其中包含有关所需依赖项的信息,符号tables,搬迁等:
$ 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文件映射到虚拟内存中
- 解析其动态 table 并加载依赖项。
- 运行
dl_init
,它调用 INIT
/INIT_ARRAY
动态 table 条目(即库的构造函数)中的所有函数。
动态链接器完成并加载和初始化所有依赖项后,它将控制权移交给我们应用程序的入口点 (_start
)。
我们的程序:初始化 libc 和 运行 构造函数
_start
得到一些参数,最值得注意的是指向 _dl_fini
in rdx
. _start
then prepares the stack, places some arguments in registers and finally calls __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
(等于_start
的rdx
参数和因此指向 _dl_fini
)
该函数对 libc 进行一些初始化,设置线程本地存储和堆栈金丝雀等等。这里我们只关心两个调用:
__cxa_atexit ((void (*) (void *)) rtld_fini, NULL, NULL);
,在程序退出 后将_dl_fini
注册为运行的析构函数
- A call either to
init
= __libc_csu_init
(< glibc 2.34) or to call_init
(>= glibc 2.34)
__libc_csu_init
和 call_init
做的事情基本相同:它们 运行 所有在动态 table 条目中注册的构造函数 INIT
和 INIT_ARRAY
.然而,虽然 __libc_csu_init
被静态编译到我们的程序中,但 call_init
存在于 libc 中,因此位于不同的内存区域中。这是 __libc_csu_init
的汇编代码中的 changed after security researchers found a ROP gadget。
因此,我们观察到每个构造函数的以下回溯:
my_constructor()
__libc_csu_init()
(< glibc 2.34) 或 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));
}
我们现在已经看到了当一个 executable 被初始化时会发生什么。但是结局呢?
运行宁终结器
正如我们在上面的代码片段中看到的,exit
运行 与 main
returns 一样快。那么exit
是做什么的呢?
结果是 only transfers control to __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
是动态链接器的finalizer,它会遍历所有的依赖and我们executable和运行s的析构函数来自 FINI
和 FINI_ARRAY
他们每个人。
因此我们得到每个析构函数的以下回溯:
my_destructor()
_dl_fini()
__run_exit_handlers()
exit()
__libc_start_main()
_start()
这回答了“是什么”,但没有回答“为什么”。
为什么不一致不调用__libc_csu_fini
?
(请对以下内容持保留态度 - 我找不到原始推理的来源,但从源代码、提交消息和一些评论中推断)
我认为实际上恰恰相反:为了更加一致。动态链接器负责 运行 所有依赖项的构造函数,因此它也应该 运行 它们的析构函数。由于我们的程序与那些依赖项没有太大区别,为什么不 运行 它的析构函数呢?可能这就是为什么 __libc_csu_fini
是 disabled around 17 years ago 的原因。我不确定为什么它没有完全删除 - 可能是为了保持与现有编译器的兼容性。
在最近发布的 glibc 2.34 中,__libc_csu_init
和 __libc_csu_fini
函数都被完全删除,因为它们的任务现在由 运行time 的其他部分完成。
那么为什么动态链接器不运行我们程序在dl_init
中的构造函数呢?
好吧,在我们应用程序的入口点 _start
之前 dl_init
运行s - 运行time 的几个重要部分尚不可用(初始化已完成在 __libc_start_main
)。所以我们的构造函数需要是独立的,避免调用外部函数。由于这会对可靠性和安全性构成相当大的风险,因此在所有其他初始化完成后执行构造函数。
其实有is support for initialization functions which are executed by dl_init
- these may be specified via the PREINIT
and PREINIT_ARRAY
dynamic table entries, and run before our _start
function. However, there does not appear to be a straightforward way to register these with the compiler,反正以上原因不推荐
注意: 回答这个问题需要深入研究 glibc 的内部工作原理,结果比我最初预期的还要复杂。为了使这个答案连贯,我不得不简化一些事情并跳过其他事情。如果您发现任何不准确的地方,请随时编辑或在评论中提出。
这是一个简单的程序:
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
?
我指的是撰写本文时最新的 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 文件不包含它调用的所有函数),因此我们还必须将所有依赖项加载到进程的虚拟地址 space 中。不过内核本身对ELF格式的理解也很有限,无论如何都不应该对用户space环境做太多的假设。于是,ELF指定了一个特殊的interpreter程序,其路径在INTERP
段
在 Linux 上,这通常恰好是 动态链接器 lib64/ld-linux-x86-64.so.2
。内核随后将该动态链接器 ELF 加载到与我们的应用程序相同的虚拟地址 space,然后调用动态链接器的入口点( 而不是 我们应用程序的入口点)。
动态链接器:加载和初始化依赖项
动态链接器现在读取我们程序的DYNAMIC
段(动态table),其中包含有关所需依赖项的信息,符号tables,搬迁等:
$ 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文件映射到虚拟内存中
- 解析其动态 table 并加载依赖项。
- 运行
dl_init
,它调用INIT
/INIT_ARRAY
动态 table 条目(即库的构造函数)中的所有函数。
动态链接器完成并加载和初始化所有依赖项后,它将控制权移交给我们应用程序的入口点 (_start
)。
我们的程序:初始化 libc 和 运行 构造函数
_start
得到一些参数,最值得注意的是指向 _dl_fini
in rdx
. _start
then prepares the stack, places some arguments in registers and finally calls __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
(等于_start
的rdx
参数和因此指向_dl_fini
)
该函数对 libc 进行一些初始化,设置线程本地存储和堆栈金丝雀等等。这里我们只关心两个调用:
__cxa_atexit ((void (*) (void *)) rtld_fini, NULL, NULL);
,在程序退出 后将- A call either to
init
=__libc_csu_init
(< glibc 2.34) or tocall_init
(>= glibc 2.34)
_dl_fini
注册为运行的析构函数
__libc_csu_init
和 call_init
做的事情基本相同:它们 运行 所有在动态 table 条目中注册的构造函数 INIT
和 INIT_ARRAY
.然而,虽然 __libc_csu_init
被静态编译到我们的程序中,但 call_init
存在于 libc 中,因此位于不同的内存区域中。这是 __libc_csu_init
的汇编代码中的 changed after security researchers found a ROP gadget。
因此,我们观察到每个构造函数的以下回溯:
my_constructor()
__libc_csu_init()
(< glibc 2.34) 或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));
}
我们现在已经看到了当一个 executable 被初始化时会发生什么。但是结局呢?
运行宁终结器
正如我们在上面的代码片段中看到的,exit
运行 与 main
returns 一样快。那么exit
是做什么的呢?
结果是 only transfers control to __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
是动态链接器的finalizer,它会遍历所有的依赖and我们executable和运行s的析构函数来自 FINI
和 FINI_ARRAY
他们每个人。
因此我们得到每个析构函数的以下回溯:
my_destructor()
_dl_fini()
__run_exit_handlers()
exit()
__libc_start_main()
_start()
这回答了“是什么”,但没有回答“为什么”。
为什么不一致不调用__libc_csu_fini
?
(请对以下内容持保留态度 - 我找不到原始推理的来源,但从源代码、提交消息和一些评论中推断)
我认为实际上恰恰相反:为了更加一致。动态链接器负责 运行 所有依赖项的构造函数,因此它也应该 运行 它们的析构函数。由于我们的程序与那些依赖项没有太大区别,为什么不 运行 它的析构函数呢?可能这就是为什么 __libc_csu_fini
是 disabled around 17 years ago 的原因。我不确定为什么它没有完全删除 - 可能是为了保持与现有编译器的兼容性。
在最近发布的 glibc 2.34 中,__libc_csu_init
和 __libc_csu_fini
函数都被完全删除,因为它们的任务现在由 运行time 的其他部分完成。
那么为什么动态链接器不运行我们程序在dl_init
中的构造函数呢?
好吧,在我们应用程序的入口点 _start
之前 dl_init
运行s - 运行time 的几个重要部分尚不可用(初始化已完成在 __libc_start_main
)。所以我们的构造函数需要是独立的,避免调用外部函数。由于这会对可靠性和安全性构成相当大的风险,因此在所有其他初始化完成后执行构造函数。
其实有is support for initialization functions which are executed by dl_init
- these may be specified via the PREINIT
and PREINIT_ARRAY
dynamic table entries, and run before our _start
function. However, there does not appear to be a straightforward way to register these with the compiler,反正以上原因不推荐
注意: 回答这个问题需要深入研究 glibc 的内部工作原理,结果比我最初预期的还要复杂。为了使这个答案连贯,我不得不简化一些事情并跳过其他事情。如果您发现任何不准确的地方,请随时编辑或在评论中提出。