入口函数与程序初始化浅析
1.程序开始的真相
操作系统装载程序之后,首先运行的代码并不是main的第一行,而是某些别的代码。这些代码负责准备好main函数执行所需要的环境,并且负责调用main函数。
铁证1:
1 #include <stdio.h>
2 #include <stdlib.h>
3
4 int a = 3;
5
6 int main(int argc, char* argv[])
7 {
8 int* p = (int*)malloc(sizeof(int));
9 scanf("%d", p);
10 printf("%d", a + *p);
11 free(p);
12 }
铁证2:
1 #include <string>
2 using namespace std;
3 string v;
4 double foo()
5 {
6 return 1.0;
7 }
8
9 double g = foo();
10 int main(){}
铁证3:
1 #include <stdio.h>
2 #include <stdlib.h>
3 void foo(void)
4 {
5 printf("bye!\n");
6 }
7 int main()
8 {
9 atexit(&foo);
10 printf("endof main\n");
11 }
三个程序均可以正常/正确的输入输出。
一个典型的程序运行步骤大致如下:
- 操作系统在创建进程后, 把控制权交到了程序的入口,这个入口往往是运行库中的某个入口函数。
- 入口函数对运行库和程序运行环境进行初始化,包括堆、I/O、线程、全局变量构造等等。
- 入口函数在完成初始化之后,调用main函数,正式开始执行程序主体部分。
- main函数执行完毕之后,返回到入口函数,入口函数进行清理工作,包括全局变量析构、堆销毁、关闭I/O等,然后进行系统调用结束进程。
2.GLIBC入口函数的实现
以最简单的静态glibc用于可执行文件为例。
glibc的程序入口为_start(由ld链接器默认的链接脚本所指定)。_start由汇编实现,实现如下:
1 libc\sysdeps\i386\elf\Start.S:
2 _start:
3 xorl %ebp, %ebp
4 popl %esi
5 movl %esp, %ecx
6
7 pushl %esp
8 pushl %edx
9 pushl $__libc_csu_fini
10 pushl $__libc_csu_init
11 pushl %ecx
12 pushl %esi
13 pushl main
14 call __libc_start_main
15
16 hlt
改写后的伪代码如下:
1 void _start()
2 {
3 %ebp = 0;
4 int argc = pop from stack
5 char** argv = top of stack;
6 __libc_start_main( main, argc, argv, __libc_csu_init, __libc_csu_fini, edx, top of stack );
7 }
其中argv除了指向参数表外,还隐含紧接着环境变量表。这个环境变量表要在__libc_start_main里从argv内提取出来。
所谓“环境变量”,是存在于系统中的一些公用数据,任何程序都可以访问。通常来说,环境变量存储的都是一些系统的公共信息,例如系统搜索路径,当前OS版本等。在Linux下,直接在命令行中输入export即可查看。
实际执行代码的函数是__libc_start_main,代码很长,我们一段段看:
_start -> __libc_start_main:
①
1 int __libc_start_main(
2 int (*main) (int, char**, char**),
3 int argc,
4 char* __unbounded* __unbounded ubp_av,
5 __typeof (main) init,
6 void (*fini) (void),
7 void (*rtld_fini) (void),
8 void* __unbounded stack_end)
9 {
10 #if __BOUNDED_POINTERS__
11 char **argv;
12 #else
13 #define argv ubp_av
14 #endif
15 int result;
这是__libc_start_main的函数头部,可见和_start函数里的调用一致,一共有7个参数,其中main由第一个参数传入,紧接着是argc和argv(这里称为ubp_av,因为其中还包含了环境变量表)。除了main的函数指针外,外部还要传入3个函数指针,分别是:
- init:main调用前的初始化工作。
- fini:main结束后的收尾工作。
- rtld_fini:和动态加载有关的收尾工作,rtld是RunTime LoaDer。
最后的stack_end表明了栈底的地址,即最高的栈地址。
②
1 char** ubp_ev = &ebp_av[argc + 1];
2 INIT_ARGV_and_ENVIRON; // _environ = ubp_ev;
3 __libc_stack_end = stack_end;
第2行是一个宏定义,让__environ指针指向原来紧跟在argv数组之后的环境变量数组。
③
接下来有另一个宏:
DL_SYSDEP_OSCHECK (__libc_fatal);
用来检查操作系统的版本,宏的具体内容就不列出了。
④接下来的代码颇为复杂,我们过滤掉大量信息之后,将一些关键的函数调用列出:
1 __pthread_initialize_minimal();
2 cxa_atexit(rtld_fini, NULL, NULL);
3 libc_init_first (argc, argv, __environ);
4 __cxa_atexit(fini, NULL, NULL);
5 (*init)(argc, argv, __environ);
这部分代码进行了一连串的系统调用,注意到,__cxa_atexit函数是glibc的内部函数,等同于atexit,用于将参数指定的函数在main结束之后调用。所以以参数传入的fini和rtld_fini均是用于main结束之后调用的。
在__libc_start_main的末尾,关键的是这两行代码:
1 result = main (argc, argv, __environ);
2 exit (result);
3 }
在最后,main函数终于被调用,并退出。
然后我们来看看exit的实现:
_start -> __libc_start_main -> exit:
1 void exit (int status)
2 {
3 whie (__exit_funcs != NULL)
4 {
5 ...
6 __exit_funcs = __exit_funcs->next;
7 }
8 ...
9 _exit (status);
10 }
其中,__exit_funcs是存储由_cxa_atexit和atexit注册的函数的链表,而这里的这个while循环则遍历该链表并逐个调用这些注册的函数,由于其中琐碎代码过多,这里就不具体列出了。
最后的_exit函数由汇编实现,且与平台有关:
_start -> __libc_start_main -> exit -> _exit:
1 _exit:
2 movl 4(%esp), %ebp
3 movl $__NR_exit, %eax
4 int $0x80
5 hlt
可见,_exit的作用仅仅是调用了exit这个系统调用。也就是说,_exit调用后,进程就会直接结束。程序正常结束有两种情况,一种是main函数的正常返回,一种是程序中用exit退出。
在__libc_start_main里我们可以看到,即使main返回了,exit也会被调用。exit是进程正常退出的必经之路,因此把调用atexit注册的函数的任务交给exit来完成可以说万无一失。
总结:GLIBC入口函数的调用过程为 _start -> __libc_start_main -> main -> exit -> _exit 。
MSVC的入口函数的过程就不详细阐述了,总体流程如下:
- 初始化和OS版本有关的全局变量。
- 初始化堆。
- 初始化I/O
- 获取命令行参数和环境变量。
- 初始化C库的一些数据。
- 调用main并记录返回值。
- 检查错误并将main的返回值返回。