Fork me on GitHub

Linux中main是如何执行的

Linux中main是如何执行的#

这是一个看似简单的问题,但是要从Linux底层一点点研究问题比较多。找到了一遍研究这个问题的文章,但可能比较老了,还是在x86机器上进行的测试。

原文链接

开始##

问题很简单:linux是怎么执行我的main()函数的?
在这片文档中,我将使用下面的一个简单c程序来阐述它是如何工作的。这个c程序的文件叫做"simple.c"

main()
{
    return (0);
}

编译##

gcc -o simple simple.c

生成可执行文件simple.

在可执行文件中有些什么?##

为了看到在可执行文件中有什么,我们使用一个工具"objdump"

objdump -f simple

simple:     file format elf32-i386
architecture: i386, flags 0x00000112:
EXEC_P, HAS_SYMS, D_PAGED
start address 0x080482d0

输出给出了一些关键信息。首先,这个文件的格式是"ELF64"。其次是给出了程序执行的开始地址 "0x080482d0"

什么是ELF?##

ELF是执行和链接格式(Execurable and Linking Format)的缩略词。它是UNIX系统的几种可执行文件格式中的一种。对于我们的这次探讨,有关ELF的有意思的地方是它的头格式。每个ELF可执行文件都有ELF头,像下面这个样子:

typedef struct
{
	unsigned char	e_ident[EI_NIDENT];	/* Magic number and other info */
	Elf32_Half	e_type;			/* Object file type */
	Elf32_Half	e_machine;		/* Architecture */
	Elf32_Word	e_version;		/* Object file version */
	Elf32_Addr	e_entry;		/* Entry point virtual address */
	Elf32_Off	e_phoff;		/* Program header table file offset */
	Elf32_Off	e_shoff;		/* Section header table file offset */
	Elf32_Word	e_flags;		/* Processor-specific flags */
	Elf32_Half	e_ehsize;		/* ELF header size in bytes */
	Elf32_Half	e_phentsize;		/* Program header table entry size */
	Elf32_Half	e_phnum;		/* Program header table entry count */
	Elf32_Half	e_shentsize;		/* Section header table entry size */
	Elf32_Half	e_shnum;		/* Section header table entry count */
	Elf32_Half	e_shstrndx;		/* Section header string table index */
} Elf32_Ehdr;

上面的结构中,"e_entry"字段是可执行文件的开始地址。

地址"0x080482d0"上存放的是什么?是程序执行的开始地址么?##

对于这个问题,我们来对"simple"做一下反汇编。有几种工具可以用来对可执行文件进行反汇编。我在这里使用了objdump:

objdump --disassemble simple

输出结果有点长,我不会分析objdump的所有输出。我们的意图是看一下地址0x080482d0上存放的是什么。下面是输出:

080482d0 <_start>:
 80482d0:       31 ed                   xor    %ebp,%ebp
 80482d2:       5e                      pop    %esi
 80482d3:       89 e1                   mov    %esp,%ecx
 80482d5:       83 e4 f0                and    $0xfffffff0,%esp
 80482d8:       50                      push   %eax
 80482d9:       54                      push   %esp
 80482da:       52                      push   %edx
 80482db:       68 20 84 04 08          push   $0x8048420
 80482e0:       68 74 82 04 08          push   $0x8048274
 80482e5:       51                      push   %ecx
 80482e6:       56                      push   %esi
 80482e7:       68 d0 83 04 08          push   $0x80483d0
 80482ec:       e8 cb ff ff ff          call   80482bc <_init+0x48>
 80482f1:       f4                      hlt    
 80482f2:       89 f6                   mov    %esi,%esi

看上去开始地址上存放的是叫做"_start"的启动例程。它所做的是清空寄存器,向栈中push一些数据并且调用一个函数。

Stack Top	-------------------
		0x80483d
		-------------------
		esi
		-------------------
		ecx
		-------------------
		0x8048274
		-------------------
		0x8048420
		-------------------
		edx
		-------------------
		esp
		-------------------
		eax
		-------------------

三个问题##

现在,可能你已经想到了,关于这个栈帧我们有一些问题。

  • 这些16进制数是什么?
  • 地址80482bc上存放的是什么,哪个函数被_start调用了?
  • 看起来这些汇编指令并没有用一些有意义的值来初始化寄存器。那么谁来初始化这些寄存器?

让我们来一个一个回答这个问题。

Q1>关于16进制数###

如果你仔细研究了用objdump得到的反汇编输出,你就能很容易回答这个问题。
下面是这个问题的回答:

0x80483d0: 这是main()函数的地址。

0x8048274: _init()函数的地址。

0x8048420: _finit()函数地址。

_init和_finit是GCC提供的initialization/finalization 函数。

现在,我们不要去关心这些东西。基本上所有这些16进制数都是函数指针。

Q2>地址80482bc上存放的是什么?###

让我们再次在反汇编输出中寻找地址80482bc。
如果你看到了,汇编代码如下:

80482bc:	ff 25 48 95 04 08    	jmp    *0x8049548

这里的*0x8049548是一个指针操作。它跳到地址0x8049548存储的地址值上。

更多关于ELF和动态链接####

使用ELF,我们可以编译出一个可执行文件,它动态链接到几个libraries上。这里的"动态链接"意味着实际的链接过程发生在运行时。否则我们就得编译出一个巨大的可执行文件,这个文件包含了它所调用的所有libraries("一个『静态链接的可执行文件』")。如果你执行下面的命令:

ldd simple

      libc.so.6 => /lib/i686/libc.so.6 (0x42000000)
	  /lib/ld-linux.so.2 => /lib/ld-linux.so.2 (0x40000000)

你就能看到simple动态链接的所有libraries。所有动态链接的数据和函数都有『动态重定向入口(dynamic relocation entry)』。

这个概念粗略的讲述如下:

  1. 在链接时我们不会得知一个动态符号的实际地址。只有在运行时我们才能知道这个实际地址。
  2. 所以对于动态符号,我们为其实际地址预留出了存储单元。加载器会在运行时用动态符号的实际地址填充存储单元。
  3. 我们的应用通过使用一种指针操作来间接得知动态符号的存储单元。在我们的例子中,在地址80482bc上,有一个简单的jump指令。jump到的单元由加载器在运行时存储到地址0x8049548上。

我们通过使用objdump命令可以看到所有的动态链接入口:

objdump -R simple

	simple:     file format elf32-i386

	DYNAMIC RELOCATION RECORDS
	OFFSET   TYPE              VALUE 
	0804954c R_386_GLOB_DAT    __gmon_start__
	08049540 R_386_JUMP_SLOT   __register_frame_info
	08049544 R_386_JUMP_SLOT   __deregister_frame_info
	08049548 R_386_JUMP_SLOT   __libc_start_main

这里的地址0x8049548被叫做"JUMP SLOT",非常贴切。根据这个表,实际上我们想调用的是 __libc_start_main。

__libc_start_main是什么?####

我们在玩一个接力游戏,现在球被传到了libc的手上。__libc_start_main是libc.so.6中的一个函数。如果你在glibc中查找__libc_start_main的源码,它的原型可能是这样的:

extern int BP_SYM (__libc_start_main) (int (*main) (int, char **, char **),
		int argc,
		char *__unbounded *__unbounded ubp_av,
		void (*init) (void),
		void (*fini) (void),
		void (*rtld_fini) (void),
		void *__unbounded stack_end)
__attribute__ ((noreturn));

所有汇编指令需要做的就是建立一个参数栈然后调用__libc_start_main。这个函数需要做的是建立/初始化一些数据结构/环境然后调用我们的main()。让我们看一下关于这个函数原型的栈帧,

Stack Top	   	-------------------
                        0x80483d0	                              main
                 ------------------- 
                        esi	                                 argc
                 ------------------- 
                        ecx	                                 argv 
                 ------------------- 
                        0x8048274	                            _init
                 ------------------- 
                        0x8048420	                            _fini
                 ------------------- 
                        edx	                                _rtlf_fini
                 ------------------- 
                        esp	                                stack_end
                 ------------------- 
                        eax	                                this is 0
                 ------------------- 


根据这个栈帧我们得知,esi,ecx,edx,esp,eax寄存器在函数 __libc_start_main()被执行前需要被填充合适的值。很清楚的是这些寄存器不是被前面我们所展示的启动汇编指令所填充的。那么,谁填充了这些寄存器呢?现在只留下唯一的一个地方了——内核。现在让我们回到第三个问题上。

Q3>内核做了些什么?###

当我们通过在shell上输入一个名字来执行一个程序时,下面是Linux接下来会发生的:

  1. Shell调用内核的带argc/argv参数的系统调用"execve"。
  2. 内核的系统调用句柄开始处理这个系统调用。在内核代码中,这个句柄为"sys_execve".在x86机器上,用户模式的应用会通过以下寄存器将所有需要的参数传递到内核中。
  • ebx:执行程序名字的字符串
  • ecx:argv数组指针
  • edx:环境变量数组指针
  1. 通用的execve内核系统调用句柄——也就是do_execve——被调用。它所做的是建立一个数据结构,将所有用户空间数据拷贝到内核空间,最后调用search_binary_handler()。Linux能够同时支持多种可执行文件格式,例如a.out和ELF。对于这个功能,存在一个数据结构"struct linux_binfmt",对于每个二进制格式的加载器在这个数据结构都会有一个函数指针。search_binary_handler()会找到一个合适的句柄并且调用它。在我们的例子中,这个合适的句柄是load_elf_binary()。解释函数的每个细节是非常乏味的工作。所以我在这里就不这么做了。如果你感兴趣,阅读相关的书籍即可。接下来是函数的结尾部分,首先为文件操作建立内核数据结构,来读入ELF映像。然后它建立另一个内核数据结构,这个数据结构包含:代码容量,数据段开始处,堆栈段开始处,等等。然后为这个进程分配用户模式页,将argv和环境变量拷贝到分配的页面地址上。最后,argc和argv指针,环境变量数组指针通过create_elf_tables()被push到用户模式堆栈中,使用start_thread()让进程开始执行起来。

当执行_start汇编指令时,栈帧会是下面这个样子。

Stack Top	       -------------
                            argc
                        -------------
                            argv pointer
                        -------------
                            env pointer
                        ------------- 

汇编指令通过以下方式从栈中获取所有信息:

pop %esi 		<--- get argc
move %esp, %ecx		<--- get argv
			  actually the argv address is the same as the current
			  stack pointer.

现在所有东西都准备好了,可以开始执行了。

其他的寄存器呢?##

对于esp来说,它被用来当做应用程序的栈底。在弹出所有必要信息之后,_start例程简单的调整了栈指针(esp)——关闭了esp寄存器4个低地址位,这完全是有道理的,对于我们的main程序,这就是栈底。对于edx,它被rtld_fini使用,这是一种应用析构函数,内核使用下面的宏定义将它设为0:

#define ELF_PLAT_INIT(_r)	do { \
	_r->ebx = 0; _r->ecx = 0; _r->edx = 0; \
	_r->esi = 0; _r->edi = 0; _r->ebp = 0; \
	_r->eax = 0; \
} while (0)

0意味着在x86 Linux上我们不会使用这个功能。

关于汇编指令##

这些汇编codes来自哪里?它是GCC codes的一部分。这些code的目标文件通常在/usr/lib/gcc-lib/i386-redhat-linux/XXX 和 /usr/lib下面,XXX是gcc版本号。文件名为crtbegin.o,crtend.o和gcrt1.o。

总结##

我们总结一下整个过程。

  1. GCC将你的程序同crtbegin.o/crtend.o/gcrt1.o一块进行编译。其它默认libraries会被默认动态链接。可执行程序的开始地址被设置为_start。
  2. 内核加载可执行文件,并且建立正文段,数据段,bss段和堆栈段,特别的,内核为参数和环境变量分配页面,并且将所有必要信息push到堆栈上。
  3. 控制流程到了_start上面。_start从内核建立的堆栈上获取所有信息,为__libc_start_main建立参数栈,并且调用__libc_start_main。
  4. __libc_start_main初始化一些必要的东西,特别是C library(比如malloc)线程环境并且调用我们的main函数。
  5. 我们的main会以main(argv,argv)来被调用。事实上,这里有意思的一点是main函数的签名。__libc_start_main认为main的签名为main(int, char **, char **),如果你感到好奇,尝试执行下面的程序。
main(int argc, char** argv, char** env)
{
    int i = 0;
    while(env[i] != 0)
    {
       printf("%s\n", env[i++]);
    }
    return(0);
}

结论##

在Linux中,我们的C main()函数由GCC,libc和Linux二进制加载器的共同协作来执行。

参考##

objdump	                        "man objdump" 

ELF header	                    /usr/include/elf.h 

__libc_start_main	         glibc source 
                                       ./sysdeps/generic/libc-start.c 

sys_execve	                    linux kernel source code 
                                       arch/i386/kernel/process.c 
do_execve	                     linux kernel source code 
                                       fs/exec.c 
struct linux_binfmt	      linux kernel source code 
                                       include/linux/binfmts.h 
load_elf_binary	            linux kernel source code
                                       fs/binfmt_elf.c 
create_elf_tables	          linux kernel source code 
                                       fs/binfmt_elf.c 
start_thread	                  linux kernel source code 
                                      include/asm/processor.h


posted @ 2017-11-02 22:19  HarlanC  阅读(10794)  评论(0编辑  收藏  举报