Linux X86 程序启动 – main函数是如何被执行的?

欢迎访问我的个人博客: luomuxiaoxiao.com


一、目标读者
二、覆盖范围
三、调用过程分析

  • 3.1 main函数的调用 main函数如何被调用
  • 3.2 _start函数分析
    • 3.2.1 首先,_start是如何启动的?
    • 3.2.2 _start函数就是我们开始的地方
    • 3.2.3 调用__libc_start_main之前的设置
    • 3.2.4 环境变量哪里去了?
  • 3.3 __libc_start_main函数分析
    • 3.3.1 __libc_start_main功能概述
    • 3.3.2 调用init参数
  • 3.4 __libc_csu_init函数分析
    • 3.4.1 用户应用程序的构造函数
    • 3.4.2 这个函数到底是干什么的?
    • 3.4.3 但是__libc_csu_init里的循环是干什么的?
  • 3.5 _init函数分析
    • 3.5.1 init函数的调用
    • 3.5.2 _init函数起始于常规的C函数调用
  • 3.6 gmon_start函数分析 生成profile文件
  • 3.7 frame_dummy函数分析 函数并不是空的
  • 3.8 _do_global_ctors_aux函数分析
    • 3.8.1 终于到构造函数了!
    • 3.8.2 来看个例子
    • 3.8.3 prog2的_init函数,像极了prog1的
    • 3.8.4 这是将要调用的函数的源代码
    • 3.8.5 汇编语言也是这样
    • 3.8.6 函数开始的部分
    • 3.8.7 循环之前的设置
    • 3.8.8 此时执行到了loop的顶端
    • 3.8.9 函数谢幕
    • 3.8.10 承诺过你的使用debugger进入prog2
  • 3.9 回到__libc_csu_init__
  • 3.10 这是另一个函数的循环调用
  • 3.11 程序将返回__libc_start_main__
    • exit()函数运行了更多的循环

四、这个程序,把上面所有的过程联系了起来
五、结尾
六、参考阅读

译者注: 本文是我在理解可执行文件代码段时,从网上搜索到的一篇文章,原文是英文的,我将其翻译成了中文。文章详细介绍了X86系统main函数调用前后的一些细节,并阐述了C程序的构造函数和析构函数,以及 .init,.fini,init_arrayfini_array各section相对于main函数及彼此的执行顺序。遗憾的是这篇文章是基于32位CPU架构来研究的,而本博客的文章是以64位CPU架构来研究的。如果有时间我会顺着相同的思路在64位机器上将该过程整理出来。其实两者仅在汇编语言传参方式和位置无关码的生成方式上略有区别,所以这篇文章还是有很大借鉴意义的。

如果你对文章内容或者翻译的内容有任何问题,欢迎在我博客这篇文章下留言讨论。

原文链接:http://dbp-consulting.com/tutorials/debugging/linuxProgramStartup.html

一、目标读者

这篇文章主要面向对象是为了那些想深入了解linux下程序的加载过程的读者,它主要介绍了X86 ELF文件的动态加载过程。这篇文章将会使你理解如何debug main函数启动前发生的问题。本文基于事实描述,但是将会忽略一些与上述主题无关的细节。如果你是静态编译的,一些细节将会与本文的描述不符,这篇文章并不会列举出这些差异。当你读完这篇文章,你将会对X86的main函数启动前后非常了解。

二、覆盖范围

callgraph
当你读完,你将会理解上图。

三、调用过程分析

3.1 main函数的调用

main函数如何被调用

我们将编译一个最简单的C程序——空的main函数,然后,查看其反汇编代码以理解程序是如何从启动开始调用到main函数。从反汇编代码中,我们发现程序是由一个_start函数最终调用main函数执行的。

int main()
{
}

将上述代码保存为prog1.c,首先要做的是使用下面的命令编译这个文件:

gcc -ggdb -o prog1 prog1.c

我们首先查看其反汇编代码,通过这个程序来查看关于程序启动的一些过程,然后再用GDB去调试比这个版本稍微复杂一点的程序prog2。下面将会列举objdump -d prog1的输出,但是并不会按照该命令原本的顺序列举,而是会按照输出内容执行的顺序来输出(你可以自己dump这个结果,比如使用命令objdump -d prog1 > prog1.dump,就能保存objdump的输出,然后使用你熟悉的编辑器打开并查看它)。(但是RPUVI——一个真正的程序员是使用VI的)。

3.2 _start函数分析

3.2.1 首先,_start是如何启动的?

当你执行一个程序的时候,shell或者GUI会调用execve(),它会执行linux系统调用execve()。如果你想了解关于execve()函数,你可以简单的在shell中输入man execve。这些帮助来自于man手册(包含了所有系统调用)的第二节。简而言之,系统会为你设置栈,并且将argcargvenvp压入栈中。文件描述符0,1和2(stdin, stdout和stderr)保留shell之前的设置。加载器会帮你完成重定位,调用你设置的预初始化函数。当所有搞定之后,控制权会传递给_start(),下面是使用objdump -d prog1输出的_start函数的内容:

3.2.2 _start函数就是我们开始的地方
080482e0 <_start>:
80482e0:       31 ed                   xor    %ebp,%ebp
80482e2:       5e                      pop    %esi
80482e3:       89 e1                   mov    %esp,%ecx
80482e5:       83 e4 f0                and    $0xfffffff0,%esp
80482e8:       50                      push   %eax
80482e9:       54                      push   %esp
80482ea:       52                      push   %edx
80482eb:       68 00 84 04 08          push   $0x8048400
80482f0:       68 a0 83 04 08          push   $0x80483a0
80482f5:       51                      push   %ecx
80482f6:       56                      push   %esi
80482f7:       68 94 83 04 08          push   $0x8048394
80482fc:       e8 c3 ff ff ff          call   80482c4 <__libc_start_main@plt>
8048301:       f4

任何值xor自身得到的结果都是0。所以xor %ebp,%ebp语句会把%ebp设置为0。ABI(Application Binary Interface specification)推荐这么做,目的是为了标记最外层函数的页帧(frame)。接下来,从栈中弹出栈顶的值保存到%esi。在最开始的时候我们把argcargvenvp放到了栈里,所以现在的pop语句会把argc放到%esi中。这里只是临时保存一下,稍后我们会把它再次压回栈中。因为我们弹出了argc,所以%ebp现在指向的是argvmov指令把argv放到了%ecx中,但是并没有移动栈指针。然后,将栈指针和一个可以清除后四位的掩码做and操作。根据当前栈指针的位置不同,栈指针将会向下移动0到15个字节。这么做,保证了任何情况下,栈指针都是16字节的偶数倍对齐的。对齐的目的是保证栈上所有的变量都能够被内存和cache快速的访问。要求这么做的是SSE,就是指令都能在单精度浮点数组上工作的那个(扩展指令集)。比如,某次运行时,_start函数刚被调用的时候,%esp处于0xbffff770。在我们从栈上弹出argc后,%esp指向0xbffff774。它向高地址移动了(往栈里存放数据,栈指针地址向下增长;从栈中取出数据,栈指针地址向上增长)。当对栈指针执行了and操作后,栈指针回到了0xbffff770

3.2.3 调用__libc_start_main之前的设置

现在,我们把__libc_start_main函数的参数压入栈中。第一个参数%eax被压入栈中,里面保存了无效信息,原因是稍后会有七个参数将被压入栈中,但是为了保证16字节对齐,所以需要第八个参数。这个值也并不会被用到。__libc_start_main是在链接的时候从glibc复制过来的。在glibc的代码中,它位于csu/libc-start.c文件里。__libc_start_main的定义如下:

int __libc_start_main(  int (*main) (int, char * *, char * *),
			    int argc, char * * ubp_av,
			    void (*init) (void),
			    void (*fini) (void),
			    void (*rtld_fini) (void),
			    void (* stack_end));

所以,我们期望_start函数能够将__libc_start_main需要的参数按照逆序压入栈中。

__libc_start_main参数内容
%eax未知不关心
%espvoid (*stack_end)已被对齐的栈指针
%edxvoid (*rtld_fini)(void)加载器传到edx中的动态链接器的析构函数。
被__libc_start_main函数通过__cxat_exit()注册,
为我们已经加载的动态库调用FINI section
0x8048400void (*fini)(void)__libc_csu_fini——程序的析构函数。
被__libc_start_main 通过 __cxat_exit()注册
0x80483a0void (*init)(void)__libc_csu_init——程序的构造函数。
于main函数之前被__libc_start_main函数调用
%ecxchar **ubp_avargv相对栈的偏移值
%esiarcgargc相对栈的偏移值
0x8048394int(*main)(int, char**, char**)我们程序的main函数,被__libc_start_main函数调用
main函数的返回值被传递给exit()函数,用于终结我们的程序
调用__libc_start_main函数前,栈的内容

__libc_csu_fini函数也是从glibc被链接进我们代码的,它的源代码位于csu/elf-init.c中。稍后我们会看到它。

3.2.4 环境变量哪里去了?

你是否注意到我们并没有获取envp(栈里指向我们环境变量的指针)?它并不是__libc_start_main函数的参数。但是我们知道main函数的原型其实是int main(int argc, char** argv, char** envp)。所以,到底怎么回事?

void __libc_init_first(int argc, char *arg0, ...)
{
    char **argv = &arg0, **envp = &argv[argc + 1];
    __environ = envp;
    __libc_init (argc, argv, envp);
}

其实,__libc_start_main函数会调用__libc_init_first,这个函数会使用内部信息去找到环境变量(实际上环境变量就位于argv的终止字符null的后面),然后设置一个全局变量__environ,这个全局变量可以被__libc_start_main函数内部任何地方使用,包括调用main函数时。当envp建立了之后,__libc_start_main函数会使用相同的小技巧,越过envp数组之后的NULL字符,获取另一个向量——ELF辅助向量(加载器使用它给进程传递一些信息)。通过一个简单的方法可以查看里面的内容:运行程序前,设置环境变量LD_SHOW_AUXV=1。这是对于prog1运行的结果。

$ LD_SHOW_AUXV=1 ./prog1
AT_SYSINFO:      0xe62414
AT_SYSINFO_EHDR: 0xe62000
AT_HWCAP:    fpu vme de pse tsc msr pae mce cx8 apic
             mtrr pge mca cmov pat pse36 clflush dts
             acpi mmx fxsr sse sse2 ss ht tm pbe
AT_PAGESZ:       4096
AT_CLKTCK:       100
AT_PHDR:         0x8048034
AT_PHENT:        32
AT_PHNUM:        8
AT_BASE:         0x686000
AT_FLAGS:        0x0
AT_ENTRY:        0x80482e0
AT_UID:          1002
AT_EUID:         1002
AT_GID:          1000
AT_EGID:         1000
AT_SECURE:       0
AT_RANDOM:       0xbff09acb
AT_EXECFN:       ./prog1
AT_PLATFORM:     i686

有趣吧?各种各样的信息。AT_ENTRY_start的地址,还有我们的UID、有效UID和GID。而且,可以看出来我们的电脑是i686,times()的频率是100(每秒的clock-ticks数?稍后我调查一下)。AT_PHDR是ELF program header 的位置,它包括了程序中所有segment在内存中的位置信息,重定位条目和加载器需要的一些信息。AT_PHENT是header entry的字节数。接下来我们就不再顺着这个思路研究下去了,因为我们并不需要这些信息。

3.3 __libc_start_main函数分析

3.3.1 __libc_start_main功能概述

稍后本文会详细介绍__libc_start_main函数,但是,它的主要功能如下:

  1. 处理关于setuid、setgid程序的安全问题
  2. 启动线程
  3. 注册用户程序的finirtld_fini参数,然后被at_exit调用,从而完成用户程序和加载器的负责清理工作的函数
  4. 调用其init参数
  5. 调用main函数,并把argcargv参数、环境变量传递给它
  6. 调用exit函数,并将main函数的返回值传递给它
3.3.2 调用init参数

请点击此处继续阅读


想第一时间查看我的文章吗?请关注我的微信公众号号,搜索“落木萧萧技术论坛”或登陆我的个人博客:www.luomuxiaoxiao.com,更多精彩文章等你。

  • 3
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值