`

程序入口函数和glibc及C++全局构造和析构

阅读更多

1,程序入口函数和初始化

操作系统在装载可执行文件后,将把控制权交付给运行库的程序入口函数。

因此,程序首先运行的代码并不是main函数,而是负责为main函数执行创造环境,并负责调用main的入口函数(Entry Point)。main函数返回的值也会被这个入口函数所记录,然后调用atexit注册的函数,最终结束进程。

这样,程序的执行流程如下所示:

1,操作系统内核加载程序可执行文件,内核分析它的动态链接器地址(.interp段),将动态链接器映射到进程地址空间,并将控制权交付给ld动态链接器的e_entry,进行动态链接。ELF文件的动态链接器的e_entry是位于sysdeps/i386/dl-manchine.h中的_start。

2,动态链接器的_start调用elf/rtld.c中的_dl_start()函数,_dl_start函数首先进行ld的自举--由于没有人为ld进行动态链接重定位等,所以其自己进行自身重定位,称为自举。之后调用_dl_start_final收集基本的运行数值,然后调用_dl_sysdeps_start,这个函数则进行一些平台处理后,进入_dl_main。

3,_dl_main是真正意义上的链接器主函数。其将会装载共享对象,实现重定位和初始化。

4,_dl_main进行完动态链接器的重定位和初始化后,将控制权交给用户程序入口函数。这个程序入口函数就是上边所说的Entry Point。这个程序入口函数是ld链接器的默认链接脚本所指定的。我们也可以使用相关参数指定不同于这个函数的自己的入口函数。这个默认的入口函数与ld的e_entry不同,实际是/libc/sysdeps/i386/elf/Start.S中的_start,.S表明其是汇编源文件,是由汇编语言实现的。

_start:

xorl %ebp,%ebp (将ebp置0,表明是最外层函数)

popl %esi (装载器传入的argc和argv以及环境变量的数组在栈中,)

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

在调用_start前,装载器将argc和argv以及环境变量传入到栈中(从右向左压入参数),如下所示:

高地址 env n

env n-1

...

env 0

arg n

arg n-1

...

arg 0

低地址 argc <--now esp 、ecx

<--old esp

因此上述三个汇编指令,即将argc弹出到esi,这样esp回退了;然后将当前回退后的esp赋值给ecx,这样ecx指向arg的数组和env的数组的最开始。如上所示。

之后,为了调用__lib_start_main函数,需要进行参数压栈的7个指令。压栈后的栈如下所示:

esp (当前被调用函数的栈底元素所在,即env的最后一个元素的地址)

edx

$__libc_csu_fini

$__libc_csu_init

ecx (指向的是arg和env的数组最开始)

esi (保存的是argc)

main

5,程序入口函数_start将调用__lib_start_main函数,根据上述的传入参数可知,__lib_start_main函数可以如下表示:

__lib_start_main(main,esi,ecx,$__libc_csm_init,$__libc_csm_fini,edx,esp)

__lib_start_main函数的代码作用可以如下所示:

高地址 env n <--__libc_stack_end(全局变量)

env n-1

...

env 0 <--__environ (__lib_start_main局部变量)

arg n

arg n-1

...

低地址 arg 0 <--ubp_av(__lib_start_main局部变量)

之后,检查操作系统版本的宏并执行如下关键函数:

__pthread_initialize_minimal();

__cxa_atexit(rtld_fini,NULL,NULL);

__libc_init_first(argc,argv,__environ);

__cxa_atexit(fini,NULL,NULL);

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

其中__cxa_atexit是注册函数,注册的函数在main后被调用,并且先注册后调用。

之后,调用main:

result=main(argc,argv,__environ);

exit(result);

6,exit(result)函数实际如下:

void exit(int status){

while(__exit_funcs!=NULL)

{

...

__exit_funcs=__exit_funcs->next;

}

....

_exit(status);

}

__exit_funcs实际是由__cxa_atexit和atexit注册的函数的地址链表。这里就是遍历链表并进行调用。

_exit()由汇编实现,并且与平台相关,i386如下:

movl 4(%esp),%ebx

movl $__NR_exit,%eax

int $0x80

hlt (hlt是用于检测的指令,如果上述exit系统调用成功程序就会退出了,是不会执行到这里的,一旦执行到这里说明exit系统调用失败了,hlt会强制程序终止。_start汇编代码最后的hlt也是不会执行到的,因为执行main后的exit就会退出程序了,最后的hlt是用于检测exit函数是否调用成功)。

其实质即调用exit这个系统调用。这样进程就会结束。因此如果直接使用exit系统调用的话,显然,__cxa_atexit和atexit注册的函数就无法得到执行了。所以,应该使用libc运行库的exit或者使得main函数正常返回,而要避免直接使用exit系统调用。

上述就是入口函数的基本流程。入口函数中还包含堆初始化和io初始化的内容。分析如下。

操作系统内核中维护着进程打开的文件内核对象。这在Linux下称作文件描述符,而在Windows下即文件句柄。Linux下,值为0、1、2的文件描述符分别代表标准输入、标准输出和标准错误输出。因此程序打开的fd从3开始增长。fd实际上是进程打开文件表的下标。而打开文件表的每个条目都指向内核维护的文件内核对象。C语言库是处于用户空间的,其中维护的是FILE数据结构,如下为其关系的示意图:

为每个进程维护的打开文件列表和列表中每个条目指向的内核文件对象都是处于内核空间的。C语言库或者用户程序中使用的FILE结构与fd具有一 一对象的关系。

因此,C语言库的IO初始化部分,必须在用户空间为stdin,stdout和stderr的FILE结构体,使得程序进入main之后,可以使用printf和scanf等函数。

Windows的FILE与文件句柄的对应关系结构有所差别,这里不再讨论。

2,glibc

glic即GNU C Library,是GNU旗下的C标准库。最初由FSF(自由软件基金会)发起开发。最开始的内核是Hurd,因此最开始是为Hurd操作系统开发一个C标准库。后来Linux取代Hurd称为GNU操作系统的内核,glibc这个C标注库也在Linux中流行起来,取代了最开始为Linux开发的C标准库 Linux libc。后来Linux便采用了glic作为Linux的C语言库。最开始被称作libc6,因为名称为libc.so.6(/lib/libc.so.6)。

glic标准库的头文件一般在/usr/include下,而二进制文件的动态库版本,一般在/lib下的libc.so.6,静态版本在/usr/lib下的libc.a。此外,还有几个运行使用的目标文件,/usr/lib/crt1.o,/usr/lib/crti.o和/usr/lib/crtn.o。因此运行库包含程序所需要的目标文件、标准库和一些标准库中没有定义的系统库。

上述讨论的程序函数入口所在汇编源文件,编译成的二进制文件就是/usr/lib/crt1.o。开始的时候crt1.o名称为crt.o,后来又称作crt0.o,因为一般作为链接的首个参数,后来C++语言出现和ELF文件进行了改进,为了满足C++的全局构造和析构的需求,运行库在目标文件后引入了.init和.finit段,并保证这些段的代码在main函数前或者后执行,用于实现全局构造和析构。因此crt0.o不支持.init和.finit,而crt1.o支持。

链接器将所有目标文件的init段和finit段合并,并产生两个函数:_init()和_finit()。这两个段需要一些辅助代码帮助它们启动,比如计算GOT。帮助它们启动的目标文件分别就是crti.o和crtn.o。因此,最终形成_init和_finit函数的开始是来自crti.o的,而末尾是来自crtn.o的,中间才是真正程序的全局构造或者析构函数,也就是说程序的全局构造和析构仅仅是_init和_finit的中间部分,而不是全部。

我们在上边看到,_start给__libc_start_main传入了$__libc_csu_fini和$__libc_csu_init两个函数指针参数,这两个函数会负责调用_init()和_finit()。

实际上,我们可以将一些函数插入到_init和_finit段,以完成一些监控性能、调试等工具所用的功能。__attribute__((section".init"))将函数放入.init。注意这个放入.init的函数必须使用汇编指令,防止编译器产生了带有ret的指令,ret会使得init()函数提前返回而破坏了init()函数。

我们看到,init()和finit()函数的开始和结束部分来自于crti.o和crtn.o。而中间部分来自于用户程序。实际上,中间部分还必须使用位于/usr/lib/gcc/i484-Linux-gnu/4.1.3下的crtbeginT.o和crtend.o。crtbeginT.o和crtend.o是真正用于实现C++全局构造和析构的目标文件。这两个目标文件不属于glibc,而是GCC的一部分。glic只是一个c语言库,它并不了解C++的实现。GCC是C++的真正实现者。这两个文件是用于配合glibc实现C++全局构造和析构的。实际上,crti.o和crtn.o是提供了main之前和之后执行代码的机制,真正C++全局构造和析构是crtbeginT.o和crtend.o实现的。

另外,位于/usr/lib/gcc/i484-Linux-gnu/4.1.3下的libgcc.a、libgcc_eh.a是用于处理不同平台之间的差别的。例如32和64位指令不同。libgcc就是为了解决这种计算差异的辅助例程。libgcc_eh.a则是包含了支持C++的异常处理的平台相关函数。GCC目录下动态连接库版本的libgcc.a为libgcc_s.so。这样GCC才能支持多种不同的平台。

3,全局构造和析构的执行流程

从上边分析,我们知道,全局构造和析构函数是init()和finit(),其被调用的流程为:

_start-->__lib_start_main-->__libc_csu_init-->_init。 _init位于crti.o

而_init实际调用了位于crtbeginT.o中的__do_global_ctors_aux(位于gcc/Crtstuff.c)。

这个函数其实是将__CTOR_LIST__中的函数依次执行(数组中第一个条目不是函数指针,是函数指针的个数,其它条目是指针)直到遇到NULL,即__CTOR_END__。那么__CTOR_LIST__中条目指向的函数是什么?__CTOR_LIST__又是谁创建的呢?

实际上,编译器会遍历每个编译单元(.cpp文件)中的所有全局构造和析构,并产生该编译单元的一个特殊函数,负责该单元的所有全局变量的构造和析构。编译器将这个特殊函数的指针放置到该编译单元目标文件的.ctors段。链接器会收集所有目标文件的.ctors段,合并成一个。

因此__CTOR_LIST__中的函数指针就是指向的每个编译单元的特殊函数,而这个特殊函数负责的都是自己编译单元中的全局变量的构造和析构。

另外,crtbeginT.o和crtend.o中也有.ctors段,也将被合并到.ctors。而crtbeginT.o中的这个.ctors产生的也就是__CTOR_LIST__中的第一个,即存储函数指针的数目的值。链接器会将该值修改成正确的值,并将符号__CTOR_LIST__指向它。crtend.o的.ctors的内容则是0,并且链接器将__CTOR_END__指向它。

我们可以在.ctors段中加入函数,使得它在全局构造的时候执行。

__attribute__((section(".ctors"))) 函数名

实际上,gcc里的__attribute__((constructor))修饰更为直接,但是是将这个声明在函数名后边。

早期的版本中,析构与构造类似,是.finit调用__do_global_dtor_aux,而__do_global_dtor_aux则使用了__DTOR_LIST__。由__lib_start_main使用cxa_init()注册的__libc_csu_finit,会在进程退出的前的exit中被调用。

这样必须保证合并后的__DTOR_LIST__中的顺序是.ctor的反序。只有这样,不同的编译单元的特殊函数(用于构造的)调用的顺序,才能与析构的时候调用不同编译单元的另一个(用于析构的)特殊函数的顺序严格反序。这个合并是链接器进行的,因此增加了链接器的工作。

后期版本中,使用的是注册的方法,即我们看到的在每个编译单元的特殊函数(用于构造)中,不仅仅执行构造,还是用__cxa_atexit注册另外一个特殊函数(用于析构),保证了.dtor是.ctor的严格反序。这样不需要链接器保证.dtor的合并的顺序,因为哪个构造被先执行,哪个析构也被先执行了。

真正的构造析构要比上述所述复杂的多,并且静态链接和动态链接的情况还略有不同。但是基本原理是一致的。

由于全局构造和析构是运行库完成,使用-nonstartfiles或-nostdlib选项会导致全局构造和析构不能正常执行,除非你自己手工构造和析构。

分享到:
评论

相关推荐

    C++的全局构造与析构函数

     为了程序的顺利执行,首先要初始化执行环境,比如堆分配初始化(malloc, free) ,线程子系统等,这里先提一下:C++ 的全局对象构造函数是在这一时期被执行的。即C++ 的全局对象构造函数在main 函数之前执行,而...

    Glibc辅助运行库(CRunTimeLibrary)crt0.o,crt1.o,crti.ocrt.docx

    它们配合Glibc实现C++的全局构造函数和析构函数的调用。在程序链接时,crtbegin.o放置在用户对象代码之前,而crtend.o则位于系统库之后,确保C++对象的构造和析构按正确顺序执行。 在Android的Bionic C运行时库中,...

    gcc_glibc.pdf

    - GLIBC 提供了一种机制来报告库函数中的错误情况,通常通过设置全局变量 `errno` 来指示发生了什么类型的错误,并允许程序根据具体情况采取相应的措施。 - **内存管理 (Memory Management)** - 包括虚拟内存分配...

    Glibc辅助运行库(CRunTimeLibrary)crt0.o,crt1.o,crti.ocrt.pdf

    5. `crtbegin.o` 和 `crtend.o`:这两个文件是针对C++的,它们帮助管理C++对象的构造和析构,确保在程序开始和结束时正确地调用类的构造函数和析构函数。`crtbegin.o` 位于用户对象之前,`crtend.o` 则位于系统库...

    glibc

    9. **错误处理和诊断**:errno全局变量、perror和strerror函数,用于报告错误信息。 10. **POSIX接口**:提供符合POSIX标准的各种系统调用的封装,使得跨平台编程更加便捷。 **二、glibc的版本更新** glibc随着...

    libm-2.35 专门解决 libm.6.so 的问题 如GLIBC- 2.29

    GLIBC是Linux系统中不可或缺的一部分,它提供了C语言和C++运行时库,包括各种标准函数,如数学运算、字符串处理等。当系统缺少特定版本的GLIBC符号时,可能会导致依赖这些符号的程序无法正常运行。 描述中提到了几...

    Linux C 函数库中文手册 pdf + chm

    5. **错误处理**:讲解errno全局变量和perror()函数,用于识别和报告程序运行时的错误。 6. **数学运算**:涵盖数学函数如sin()、cos()、pow(),以及常量如M_PI、M_E等。 7. **指针操作**:详细解析指针的概念和...

    WINARM的使用方法

    同时,C++程序需要在用户代码之前调用全局对象的构造函数,在用户代码之后调用析构函数。这些功能是由newlib中的`atexit()`和`exit()`函数实现的,它们确保了程序的正确初始化和清理。 #### 七、GCC的collect2工具 ...

    c++与c的区别

    3. 函数模板和泛型编程:C++引入了函数模板,可以生成针对不同数据类型的通用函数,提高了代码的复用性。而C语言没有内置的泛型编程机制,通常需要通过宏定义来实现类似的功能。 4. 异常处理:C++提供了一套完整的...

    c语言函数库.rar

    5. **错误处理函数**:如`errno`全局变量和`perror`函数,它们用于记录和显示程序运行过程中的错误信息。 6. **文件操作函数**:如`fopen`、`fclose`、`fread`、`fwrite`、`fgets`、`fprintf`等,用于打开、关闭...

    libgcc_s.so.1.rar

    总结起来,libgcc_s.so.1是一个由GCC编译器生成的辅助库,主要负责C/C++程序的异常处理和一些低级服务。它需要与特定版本的GLIBC兼容,其在系统中的位置直接影响到依赖它的程序能否正常运行。理解libgcc_s.so.1的...

    强网杯:简单的UAF,fastbin attack打全局list,然后任意地址读写.zip

    简单的UAF,fastbin attack打全局list,然后任意地址读写.zip”揭示了网络安全竞赛中的一个常见技术挑战,主要涉及的是C/C++程序中的内存管理漏洞利用,特别是未初始化的指针引用(Use-After-Free, UAF)和Fastbin...

    Linux C库函数 htm格式

    5. **错误处理**:`errno`全局变量和`perror`函数帮助程序员检测和报告运行时错误。`assert`宏用于调试阶段的断言检查。 6. **文件系统操作**:`mkdir`、`rmdir`、`rename`、`unlink`等函数用于创建、删除、重命名...

    libgcc-s.so.1

    它包含了处理堆栈展开、异常对象的构造和销毁等异常流程的函数,确保了C++异常机制的正常工作。 3. **线程局部存储(TLS)** TLS是一种编程模型,允许全局变量在线程之间具有独立的副本。`libgcc_s.so.1`库提供了...

    checkmemoryleak

    内存泄漏是程序运行过程中的常见问题,特别是在C和C++这样的低级编程语言中,由于程序员需要手动管理内存,不正确的内存分配和释放可能导致内存泄漏。`__wrap_malloc`是一种调试技术,用于检测由`malloc()`函数引起...

    编译程序-GCC.docx

    它负责解决符号引用,如函数调用和全局变量,生成可执行的.out文件。 GCC提供了丰富的编译选项,允许程序员自定义编译过程,例如在特定阶段停止编译,以检查中间结果或调试信息。GCC还支持多级优化,如-O1、-O2、-...

    C 代码 比较存储方案.rar

    3. **静态内存**:全局变量和静态局部变量的存储,它们在整个程序生命周期内存在,即使函数结束也不释放。 4. **内存对齐**:为了提高访问效率,内存分配可能会进行对齐,确保变量地址是特定值(如编译器设定的对齐...

    程序运行中的异常处理.rar

    不过,现代C库(如glibc)有时会使用setjmp/longjmp函数来模拟异常处理。 至于C#,它的异常处理机制与Java非常相似,也是基于try-catch-finally结构。C#还引入了using关键字,用于自动管理实现了IDisposable接口的...

    大内高手(详细的内存知识)

    - **.bss**:未初始化的全局变量和静态变量存储在这里,程序启动时占用的空间为零。 - **.data**:已初始化的全局变量和静态变量。 - **.rodata**:只读数据,如常量字符串。 4. **内存分配算法** - **标准C...

    国嵌C_C++答疑经典问题周汇总20120617

    - **全局变量**:在整个程序的各个函数中都可以访问。 - **局部变量**:仅在其定义的函数内部有效。 2. **初始化**: - 编译器会给全局变量和静态变量赋予默认初始值`0`。 - 局部变量如果没有明确初始化,则其...

Global site tag (gtag.js) - Google Analytics