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选项会导致全局构造和析构不能正常执行,除非你自己手工构造和析构。