GLIB入口函数

GLIB入口函数

关于全局变量引发的内存问题探源 – 采蕨 (joydig.com)

main函数并不是程序运行的第一个函数,在main之前,还有初始化函数_init()main函数之后还有_fini()函数收尾。

入口函数

操作系统在装载程序之后,首先运行的代码并非 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++ 运行库。

_init() 和 _fini()

运行库在每个目标文件中引入了两个特殊的段:”.init” 段和 “.fini” 段,运行库会保证所有位于这两个段中代码先/后于 main 函数执行。对于全局、静态对象,又引入了两个特殊的段 “.ctors” 和 “.dtors”,分别处理这类对象的构造和析构。这两个段经过编译器的层层处理,最终会分别合入到 “.init” 段和 “.fini” 段当中。

void _init(void);
void _fini(void);

_init():负责初始化全局变量、加载动态库、分配内存等。位于 .init

  • dlopen()返回前或库被装载时调用

_fini():负责释放内存、卸载动态库等。位于 .fini

  • dlclose()返回前调用
  • main()返回后调用
  • exit()被调用时

定义自己的版本

_init()/_fini()是共享库的初始化和析构函数。但这两个函数是给GCC编译器用的,我们不能直接使用它们,但可以用下述另外两种方法来实现。

(1)C库应该使用 __attribute__((constructor))__attribute__((destructor)) 属性来输出它的构造函数和析构函数。如下所示:

void __attribute__((constructor)) my_init(void);
void __attribute__((destructor)) my_fini(void);

gdb调试结果:

(gdb) bt 构造函数
#0  my_init () at test.cpp:4
#1  0x00007ffff7de8cb6 in call_init (env=<optimized out>, argv=0x7fffffffc7a8, argc=1) at ../csu/libc-start.c:145
#2  __libc_start_main_impl (main=0x555555555165 <main()>, argc=1, argv=0x7fffffffc7a8, init=<optimized out>, fini=<optimized out>, 
    rtld_fini=<optimized out>, stack_end=0x7fffffffc798) at ../csu/libc-start.c:347
#3  0x0000555555555071 in _start ()
(gdb) bt 析构函数
#0  my_fini () at test.cpp:8
#1  0x00007ffff7fcc0ca in _dl_call_fini (closure_map=closure_map@entry=0x7ffff7ffe2c0) at ./elf/dl-call_fini.c:43
#2  0x00007ffff7fcfc8e in _dl_fini () at ./elf/dl-fini.c:114
#3  0x00007ffff7e005f5 in __run_exit_handlers (status=0, listp=0x7ffff7f96680 <__exit_funcs>, run_list_atexit=run_list_atexit@entry=true, 
    run_dtors=run_dtors@entry=true) at ./stdlib/exit.c:111
#4  0x00007ffff7e0072a in __GI_exit (status=<optimized out>) at ./stdlib/exit.c:141
#5  0x00007ffff7de8b91 in __libc_start_call_main (main=main@entry=0x555555555165 <main()>, argc=argc@entry=1, argv=argv@entry=0x7fffffffc7a8)
    at ../sysdeps/nptl/libc_start_call_main.h:74
#6  0x00007ffff7de8c45 in __libc_start_main_impl (main=0x555555555165 <main()>, argc=1, argv=0x7fffffffc7a8, init=<optimized out>, 
    fini=<optimized out>, rtld_fini=<optimized out>, stack_end=0x7fffffffc798) at ../csu/libc-start.c:360
#7  0x0000555555555071 in _start ()

(2)C++库可以利用全局变量的构造和析构函数来实现类似的功能:

struct InitFini
{
    InitFini() { ... }
    ~InitFini() { ... }
};
InitFini s_global; // 全局变量的构造和析构会发生在main函数之外,即.init段和.fini段

gdb调试结果:

(gdb) bt # 构造函数
#0 InitFini (this=0xa507bc) at test.cpp:7
#1 0x00a4f5e0 in __static_initialization_and_destruction_0 (__initialize_p=1, __priority=65535) at test.cpp:15
#2 0x00a4f611 in global constructors keyed to test () at test.cpp:21
#3 0x00a4f66a in __do_global_ctors_aux () from ./libtest.so
#4 0x00a4f4a9 in _init () from ./libtest.so
#5 0x002c8b4b in call_init () from /lib/ld-linux.so.2
#6 0x002c8c4a in _dl_init_internal () from /lib/ld-linux.so.2
#7 0x002bb83f in _dl_start_user () from /lib/ld-linux.so.2
(gdb) bt # 析构函数
#0 ~InitFini (this=0x0) at test.cpp:9
#1 0x00a4f5b3 in __tcf_0 () at test.cpp:15
#2 0x00303e6f in __cxa_finalize () from /lib/libc.so.6
#3 0x00a4f532 in __do_global_dtors_aux () from ./libtest.so
#4 0x00a4f692 in _fini () from ./libtest.so
#5 0x002c9058 in _dl_fini () from /lib/ld-linux.so.2
#6 0x00303c69 in exit () from /lib/libc.so.6
#7 0x002eddee in __libc_start_main () from /lib/libc.so.6
#8 0x080483b5 in _start ()

_exit()和exit()

_exit():直接进入内核,其会关闭进程所有的文件描述符,清理内存以及其他一些内核清理函数,但不会刷新流(stdin,stdout,stderr)

exit():则先执行一些刷新处理(在进程退出之前要检查文件状态,将文件缓冲区中的内容写回文件)再调用_exit()进入内核。

简单记就是 _exit() 是即刻退出,exit() 是优雅退出。差别主要在于流是否刷新,且后者会调用前者

exit()与_exit()函数最大的区别在于:exit()函数在调用exit系统之前要检查文件的打开情况,把文件缓冲区的内容写回文件。由于Linux标准函数中,“缓冲I/O”的操作,其特征即对应每一个打开的文件,在内存中都有一片缓冲区。每次读文件时,会连续读出若干条记录,在下次读文件时就可以直接从内存的缓冲区读取;同样每次写文件的时候也仅仅是写入内存的缓冲区,等满足了一定的条件(如达到了一定数量或遇到特定字符等),再将缓冲区中的内容一次性写入文件。这种技术大大增加了文件读写的速度。

这时如果用_exit()函数直接将进程关闭,缓冲区的数据将会丢失。

image

所以上面两个程序,第一个用exit(0)的,终端会输出“Hello Leap!”,而第二个用_exit(0)的不会有输出。
因为_exit()没有执行__exit_funcs链表中的函数,如关闭fd、刷新IO缓冲区,导致printf使用的stdout缓存没有得到刷新。

入口函数总体流程分析

  1. 初始化和OS版本有关的全局变量。
  2. 初始化堆。
  3. 初始化I/O
  4. 获取命令行参数和环境变量。
  5. 初始化C库的一些数据。
  6. 调用main并记录返回值。
  7. 检查错误并将main的返回值返回。

代码讲解

glibc的程序入口为_start(由ld链接器默认的链接脚本所指定)。_start由汇编实现,实现如下:

;libc\sysdeps\i386\elf\Start.S:
_start:
    xorl %ebp, %ebp
    popl %esi
    movl %esp, %ecx

    pushl %esp
    pushl %edx
    pushl $__libc_csu_fini
    pushl $__libc_csu_init
    pushl %ecx
    pushl %esi
    pushl main
    call __libc_start_main

    hlt

改写后的伪代码如下:

void _start()
{
    %ebp = 0;
    int argc = pop from stack
    char** argv = top of stack;
    __libc_start_main( main, argc, argv, __libc_csu_init, __libc_csu_fini, edx, top of stack );
}

其中argv除了指向参数表外,还隐含紧接着环境变量表。这个环境变量表要在__libc_start_main里从argv内提取出来。

所以实际执行代码的函数是__libc_start_main,代码很长:

int __libc_start_main(
    int (*main) (int, char**, char**),
    int argc,
    char* __unbounded* __unbounded ubp_av, // argc、参数表和环境变量表
    __typeof (main) init,		// _init()的函数指针
    void (*fini) (void),		// _fini()的函数指针
    void (*rtld_fini) (void),	// 和动态加载有关的收尾工作,rtld是RunTime LoaDer。
    void* __unbounded stack_end)// 栈底地址(即最高的栈基址)
{
#if __BOUNDED_POINTERS__
    char **argv;
#else
#define argv ubp_av
#endif
    int result;
    char** ubp_ev = &ebp_av[argc + 1]; // 计算环境变量的开始地址
    INIT_ARGV_and_ENVIRON; // 宏展开后是 _environ = ubp_ev; 绕过环境变量数组__environ指针指向原来紧跟在argv数组之后的环境变量部分
    __libc_stack_end = stack_end;
    
    // ...很长一段,主要函数如下:
    __pthread_initialize_minimal();
    cxa_atexit(rtld_fini, NULL, NULL); // 把rtld_fini注册为exit回调
    libc_init_first (argc, argv, __environ);
    __cxa_atexit(fini, NULL, NULL); // 把fini注册为exit回调
    (*init)(argc, argv, __environ);
    // 这部分代码进行了一连串的系统调用,注意到,__cxa_atexit()函数是glibc的内部函数,等同于atexit,用于将参数指定的函数簇在main()结束之后调用。所以以参数传入的fini和rtld_fini均是用于main结束之后调用的
    
    result = main (argc, argv, __environ); // 执行main()函数并收集返回值
    exit (result);
}

__libc_start_main参数中,initfini 这两个函数指针实际分别指向函数 __libc_csu_init__libc_csu_fini,csu 为 “C start up” 缩写。

紧接着,__libc_csu_init 函数又调用了 _init 函数,内部调用了一个叫作 __do_global_ctorx_aux 的函数。这个函数并不属于 glibc,而是来自 gcc 提供的一个目标文件,简化后的代码如下:

// gcc/Crtstuff.c
void __do_global_ctors_aux(void){
  /* call constructor functions */
  unsigned long nptrs = (unsigned long) __CTOR_LIST__[0];
  unsigned i;
 
  for (i = nptrs; i >= 1; i--)
    __CTOR_LIST__[i]();
}

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

_libc_csu_fini 同理调用 __do_global_dtors_aux 做相反的事情。

可以看到,main() 函数 return 的结果是给CRT运行时(runtime)的。另外也能看出,即使main()返回了,exit()也会被调用。exit()是进程正常退出的必经之路。

exit()函数的实现如下:

void exit(int status) {
    while (__exit_funcs != NULL) {
        ...
        __exit_funcs = __exit_funcs->next; //__exit_funcs其实是一个链表,里面挂着一个一个函数,这些函数是默认就有的,例如清理io缓存关闭文件等等,如果想要在这个链表中添加自己的函数,可以用atexit()函数来添加。
    }
    ...
    _exit(status);
}

最后执行的是_exit,和_start一样由汇编实现:

_exit:
    movl 4(%esp), %ebp
    movl $__NR_exit, %eax ; __NR_exit 是 exit() 系统调用,是操作系统用来回收资源,杀死进程的
    int $0x80 ;中断指令陷入内核,执行%eax寄存器中设置的系统调用编号对应的系统调用(就是前一句设置的)
    hlt

总结

graph TD _start:参数和环境变量压栈 --> __libc_start_main:拷贝参数和环境变量,执行初始化工作 --> _init:具体的初始化工作 --> main:程序代码 --> exit:执行退出回调 --> _fini:具体的清理工作 --> _exit:陷入内核,回收资源,杀死进程

本文作者:3to4

本文链接:https://www.cnblogs.com/3to4/p/18377869

版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。

posted @ 2024-08-24 16:00  3的4次方  阅读(7)  评论(0编辑  收藏  举报