linux入口函数和程序初始化.docx
《linux入口函数和程序初始化.docx》由会员分享,可在线阅读,更多相关《linux入口函数和程序初始化.docx(9页珍藏版)》请在冰豆网上搜索。
linux入口函数和程序初始化
linux:
入口函数和程序初始化
整理自P57官方网站[]
如果把一个程序比作一个世界,那么程序的启动无疑就是“创世”。
在本章里,我们将从程序的创世开始,接触到在程序背后另一类默默服务的团体。
它们能够使得程序正常地启动,能够使得各种我们熟悉的函数发挥作用,它们就是应用程序的运行库。
11.1入口函数和程序初始化
11.1.1程序从main开始吗
正如基督徒认为世界的诞生起于7天创世一样,任何一个合格的C/C++程序员都应该知道一个事实:
程序从main函数开始。
但是事情的真相真是如此吗?
如果你善于观察,就会发现当程序执行到main函数的第一行时,很多事情都已经完成了:
从代码中我们可以看到,在程序刚刚执行到main的时候,全局变量的初始化过程已经结束了(a的值已经确定),main函数的两个参数(argc和argv)也被正确传了进来。
此外,在你不知道的时候,堆和栈的初始化悄悄地完成了,一些系统I/O也被初始化了,因此可以放心地使用printf和malloc。
在这里,对象v的构造函数,以及用于初始化全局变量g的函数foo都会在main之前调用。
【铁证3】atexit也是一个特殊的函数。
atexit接受一个函数指针作为参数,并保证在程序正常退出(指从main里返回或调用exit函数)时,这个函数指针指向的函数会被调用。
例如:
voidfoo(void)
{
printf("bye!
\n");
}
intmain()
{
atexit(&foo);
printf("endofmain\n");
}
用atexit函数注册的函数的调用时机是在main结束之后,因此这段代码的输出是:
endofmain
bye!
所有这些事实都在为“main创论”提供不利的证据:
操作系统装载程序之后,首先运行的代码并不是main的第一行,而是某些别的代码,这些代码负责准备好main函数执行所需要的环境,并且负责调用main函数,这时候你才可以在main函数里放心大胆地写各种代码:
申请内存、使用系统调用、触发异常、访问I/O。
在main返回之后,它会记录main函数的返回值,调用atexit注册的函数,然后结束进程。
运行这些代码的函数称为入口函数或入口点(EntryPoint),视平台的不同而有不同的名字。
程序的入口点实际上是一个程序的初始化和结束部分,它往往是运行库的一部分。
一个典型的程序运行步骤大致如下:
l操作系统在创建进程后,把控制权交到了程序的入口,这个入口往往是运行库中的某个入口函数。
l入口函数对运行库和程序运行环境进行初始化,包括堆、I/O、线程、全局变量构造,等等。
l入口函数在完成初始化之后,调用main函数,正式开始执行程序主体部分。
lmain函数执行完毕以后,返回到入口函数,入口函数进行清理工作,包括全局变量析构、堆销毁、关闭I/O等,然后进行系统调用结束进程。
11.1.2入口函数如何实现
大部分程序员在平时都接触不到入口函数,为了对入口函数进行详细的了解,本节我们将深入剖析glibc和MSVC的入口函数实现。
GLIBC入口函数
glibc的启动过程在不同的情况下差别很大,比如静态的glibc和动态的glibc的差别,glibc用于可执行文件和用于共享库的差别,这样的差别可以组合出4种情况,这里只选取最简单的静态glibc用于可执行文件的时候作为例子,其他情况诸如共享库的全局对象构造和析构跟例子中稍有出入,我们在本书中不一一详述了,有兴趣的读者可以根据这里的介绍自己阅读glibc和gcc的源代码,相信能起到举一反三的效果。
下面所有关于Glibc和MSVCCRT的相关代码分析在不额外说明的情况下,都默认为静态/可执行文件链接的情况。
读者可以免费下载到Linux下glibc的源代码,在其中的子目录libc/csu里,有关于程序启动的代码。
glibc的程序入口为_start(这个入口是由ld链接器默认的链接脚本所指定的,我们也可以通过相关参数设定自己的入口)。
_start由汇编实现,并且和平台相关,下面可以单独看i386的_start实现:
这里省略了一些不重要的代码,可以看到_start函数最终调用了名为__lib_start_main的函数。
加粗部分的代码是对该函数的完整调用过程,其中开始的7个压栈指令用于给函数传递参数。
在最开始的地方还有3条指令,它们的作用分别为:
lxor%ebp,%ebp:
这其实是让ebp寄存器清零。
xor的用处是把后面的两个操作数异或,结果存储在第一个操作数里。
这样做的目的表明当前是程序的最外层函数。
ebp设为0正好可以体现出这个最外层函数的尊贵地位J。
lpop%esi及mov%esp,%ecx:
在调用_start前,装载器会把用户的参数和环境变量压入栈中,按照其压栈的方法,实际上栈顶的元素是argc,而接着其下就是argv和环境变量的数组。
图11-1为此时的栈布局,其中虚线箭头是执行pop%esi之前的栈顶(%esp),而实线箭头是执行之后的栈顶(%esp)。
pop%esi将argc存入了esi,而mov%esp、%ecx将栈顶地址(此时就是argv和环境变量(env)数组的起始地址)传给%ecx。
现在%esi指向argc,%ecx指向argv及环境变量数组。
综合以上分析,我们可以把_start改写为一段更具有可读性的伪代码:
void_start()
{
%ebp=0;
intargc=popfromstack
char**argv=topofstack;
__libc_start_main(main,argc,argv,__libc_csu_init,__libc_csu_fini,
edx,topofstack);
}
其中argv除了指向参数表外,还隐含紧接着环境变量表。
这个环境变量表要在__libc_start_main里从argv内提取出来。
环境变量
环境变量是存在于系统中的一些公用数据,任何程序都可以访问。
通常来说,环境变量存储的都是一些系统的公共信息,例如系统搜索路径,当前OS版本等。
环境变量的格式为key=value的字符串,C语言里可以使用getenv这个函数来获取环境变量信息。
在Windows里,可以直接在控制面板→系统→高级→环境变量查阅当前的环境变量,而在Linux下,直接在命令行里输入export即可。
实际执行代码的函数是__libc_start_main,由于代码很长,下面我们一段一段地看:
这是__libc_start_main的函数头部,可见和_start函数里的调用一致,一共有7个参数,其中main由第一个参数传入,紧接着是argc和argv(这里称为ubp_av,因为其中还包含了环境变量表)。
除了main的函数指针之外,外部还要传入3个函数指针,分别是:
linit:
main调用前的初始化工作。
lfini:
main结束后的收尾工作。
lrtld_fini:
和动态加载有关的收尾工作,rtld是runtimeloader的缩写。
最后的stack_end标明了栈底的地址,即最高的栈地址。
boundedpointer
GCC支持bounded类型指针(bounded指针用__bounded关键字标出,若默认为bounded指针,则普通指针用__unbounded标出),这种指针占用3个指针的空间,在第一个空间里存储原指针的值,第二个空间里存储下限值,第三个空间里存储上限值。
__ptrvalue、__ptrlow、__ptrhigh分别返回这3个值,有了3个值以后,内存越界错误便很容易查出来了。
并且要定义__BOUNDED_POINTERS__这个宏才有作用,否则这3个宏定义是空的。
不过,尽管bounded指针看上去似乎很有用,但是这个功能却在2003年被去掉了。
因此现在所有关于bounded指针的关键字其实都是一个空的宏。
鉴于此,我们接下来在讨论libc代码时都默认不使用bounded指针(即不定义__BOUNDED_POINTERS__)。
接下来的代码如下:
char**ubp_ev=&ubp_av[argc+1];
INIT_ARGV_and_ENVIRON;
__libc_stack_end=stack_end;
INIT_ARGV_and_ENVIRON这个宏定义于libc/sysdeps/generic/bp-start.h,展开后本段代码变为:
char**ubp_ev=&ubp_av[argc+1];
__environ=ubp_ev;
__libc_stack_end=stack_end;
图11-2实际上就是我们根据从_start源代码分析得到的栈布局,让__environ指针指向原来紧跟在argv数组之后的环境变量数组。
图11-2中实线箭头代表ubp_av,而虚线箭头代表__environ。
另外这段代码还将栈底地址存储在一个全局变量里,以留作它用。
为什么要分两步赋值给__environ呢?
这又是为了兼容bounded惹的祸。
实际上,INIT_ARGV_and_ENVIRON根据bounded支持的情况有多个版本,以上仅仅是假定不支持bounded的版本。
接下来有另一个宏:
DL_SYSDEP_OSCHECK(__libc_fatal);
这是用来检查操作系统的版本,宏的具体内容就不列出了。
接下来的代码颇为繁杂,我们过滤掉大量信息之后,将一些关键的函数调用列出:
__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函数是glibc的内部函数,等同于atexit,用于将参数指定的函数在main结束之后调用。
所以以参数传入的fini和rtld_fini均是用于main结束之后调用的。
在__libc_start_main的末尾,关键的是这两行代码:
result=main(argc,argv,__environ);
exit(result);
}
在最后,main函数终于被调用,并退出。
然后我们来看看exit的实现:
其中__exit_funcs是存储由__cxa_atexit和atexit注册的函数的链表,而这里的这个while循环则遍历该链表并逐个调用这些注册的函数,由于其中琐碎代码过多,这里就不具体列出了。
最后的_exit函数由汇编实现,且与平台相关,下面列出i386的实现:
_start->__libc_start_main->exit->_exit:
_exit:
movl4(%esp),%ebx
movl$__NR_exit,%eax
int$0x80
hlt
可见_exit的作用仅仅是调用了exit这个系统调用。
也就是说,_exit调用后,进程就会直接结束。
程序正常结束有两种情况,一种是main函数的正常返回,一种是程序中用exit退出。
在__libc_start_main里我们可以看到,即使main返回了,exit也会被调用。
exit是进程正常退出的必经之路,因此把调用用atexit注册的函数的任务交给exit来完成可以说万无一失。
我们看到在_start和_exit的末尾都有一个hlt指令,这是作什么用的呢?
在Linux里,进程必须使用exit系统调用结束。
一旦exit被调用,程序的运行就会终止,因此实际上_exit末尾的hlt不会执行,从而__libc_start_main永远不会返回,以至_start末尾的hlt指令也不会执行。
_exit里的hlt指令是为了检测exit系统调用是否成功。
如果失败,程序就不会终止,hlt指令就可以发挥作用强行把程序给停下来。
而_start里的hlt的用处也是如此,但是为了预防某种没有调用exit(这里指的不是exit系统调用)就回到_start的情况(例如有人误删了__libc_main_start末尾的exit)。
MSVCCRT入口函数
相信读者对glibc的入口函数已经有了一些了解。
但可惜的是glibc的入口函数书写得不是非常直观。
事实上,我们也没从glibc的入口函数了解到多少内容。
为了从另一面看世界,我们再来看看Windows下的运行库的实现细节。
下面是MicrosoftVisualStudio2003里crt0.c(位于VC安装目录的crt\src)的一部分。
这里也删除了一些条件编译的代码,留下了比较重要的部分。
MSVC的CRT默认的入口函数名为mainCRTStartup:
被赋值的这些变量,是VC7里面预定义的一些全局变量,其中_osver和_winver表示操作系统的版本,_winmajor是主版本号,更具体的可以查阅MSDN。
这段代码通过调用GetVersionExA(这是一个WindowsAPI)来获得当前的操作系统版本信息,并且赋值给各个全局变量。
为什么这里为posvi分配内存不使用malloc而使用alloca呢?
是因为在程序的一开始堆还没有被初始化,而alloca是唯一可以不使用堆的动态分配机制。
alloca可以在栈上分配任意大小的空间(只要栈的大小允许),并且在函数返回的时候会自动释放,就好像局部变量一样。
由于没有初始化堆,所以很多事情没法做,当务之急是赶紧把堆先初始化了:
if(!
_heap_init(0))
fast_error_exit(_RT_HEAPINIT);
这里使用_heap_init函数对堆(heap)进行了初始化,如果堆初始化失败,那么程序就直接退出了。