ELF文件加载过程

加载和动态链接

从编译/链接和运行的角度看,库分为动态链接和静态链接。相应的两种不同的ELF格式映像:

1)一种是静态链接的,在装入/启动其运行时无需装入函数库映像、也无需进行动态连接。
2)另一种是动态连接,需要在装入/启动其运行时同时装入函数库映像并进行动态链接。

Linux内核既支持静态链接的ELF映像,也支持动态链接的ELF映像,GNU规定:

1)把ELF映像的装入/启动入在Linux内核中;
2)把动态链接的实现放在用户空间(glibc),并为此提供一个称为”解释器”(ld-linux.so.2)的工具软件,而解释器的装入/启动也由内核负责。

execve系统调用

用户空间ELF文件加载 函数调用栈(/fs/exec.c):
在这里插入图片描述
在函数search_binary_handler() 中,遍历系统注册的binary formats handler链表,直到找到匹配的格式。elf文件的处理函数就是load_elf_binary() 。

Linux可执行文件类型的注册机制

linux提供来了一种可执行文件类型的注册机制,核心数据结构是struct linux_binfmt :

static struct linux_binfmt elf_format = {
	.module		= THIS_MODULE,
	.load_binary	= load_elf_binary,
	.load_shlib	= load_elf_library,
	.core_dump	= elf_core_dump,
	.min_coredump	= ELF_EXEC_PAGESIZE,
};

所有注册的的linux_binfmt对象都处于一个链表中(全局链表变量 formats),通过registre_fmt()注册一种linux_binfmt新类型。系统初始化时为每个编译进内核的可执行格式都执行registre_fmt()函数。

当我们执行一个可执行程序的时候, 内核会list_for_each_entry遍历所有注册的linux_binfmt对象, 对其调用load_binrary方法来尝试加载, 直到加载成功为止(前面介绍的search_binary_handler() 函数)。
核心函数是load_elf_binary,它通过读存放在ELF文件中的信息为当前进程建立一个新的执行环境。下面以此函数为核心分析ELF文件加载过程。

进程创建角度看elf加载

在分析load_elf_binary 函数之前,先从进程创建角度,看elf文件加载。
elf是静态文件,程序执行时所需要的指令和数据必需在内存中才能够正常运行。load_elf_binary 就是将elf里的指令和数据加载到内存中。
我们知道进程的建立需要做下面三件事情:

1)创建一个独立的虚拟地址空间(先共享父进程的页框,即COW机制)

2)读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系(在子进程需要加载新elf文件时)。

3)将CPU的指令寄存器设置成可执行文件的入口地址,启动运行。

注意,子进程创建时,如果没有调用execve() 等系统调用,执行新的elf文件,则只会复制父进程的VMA,不会调用load_elf_binary 建立新的虚拟空间与可执行文件的映射。

shell执行命令的完整生命周期

此处先列出shell执行一个命令的完整过程,后续再针对进入内核空间的ELF文件加载过程,进行详细分析。

(1)当shell运行一个程序时,父shell进程生成一个子进程,它是父进程的一个复制品。子进程通过execve系统调用启动加载器;

(2)内核中实际执行execv()或execve()系统调用的程序是do_execve(),这个函数先打开目标映像文件,并从目标文件的头部(第一个字节开始)读入若干(当前Linux内核中是128)字节(实际上就是填充ELF文件头,下面的分析可以看到);

(3)然后调用另一个函数search_binary_handler(),在此函数里面,它会搜索我们上面提到的Linux支持的可执行文件类型链表,elf文件会调用到
load_elf_binary函数;

(4)load_elf_binary 过程如下:
[1] 填充并且检查目标程序ELF头部

[2] load_elf_phdrs加载目标程序的程序头表

[3] 如果需要动态链接, 则寻找和处理解释器段

[4] 检查并读取解释器的程序表头

[5] 装入目标程序的段segment

[6] 填写程序的入口地址

[7] create_elf_tables填写目标文件的参数环境变量等必要信息

[8] start_thread 准备进入新的程序入口

load_elf_binary 函数详细解析

(1)函数原型是:
static int load_elf_binary (struct linux_binprm *bprm)
参数是struct linux_binprm 类型,该结构体用于传递加载elf加载所需要的参数,比如elf文件struct file指针,credentials等。
注:用户空间到内核的陷入通过系统调用execv系统调用通过寄存器传参然后调用sys_open拷贝数据到内核空间填充bprm数据结构
(2)读取并检查目标程序ELF头部(struct elfhdr)

	/* Get the exec-header */
	loc->elf_ex = *((struct elfhdr *)bprm->buf);
	if (memcmp(loc->elf_ex.e_ident, ELFMAG, SELFMAG) != 0)
		goto out;
//#define	ELFMAG		"\177ELF"
	if (loc->elf_ex.e_type != ET_EXEC && loc->elf_ex.e_type != ET_DYN)
		goto out;

bprm是函数入参,在load_elf_binary之前,内核已经使用映像文件的前128个字节对bprm->buf进行了填充(__do_execve_file中)。此部分代码就是将文头开始部分的elf头加载进内存,并做一些判断。
首先检查文件头的前四个字节,查看是否是ELF文件类型定义的“\177ELF”;
然后检查elf文件类型是否是“ET_EXEC”和“ET_DYN”,只有可执行映像和共享库需要加载进内存。

(3)加载目标程序的program header table

elf_phdata = load_elf_phdrs(&loc->elf_ex, bprm->file);

参数是 前面读取的elf header 和elf文件打开对象file
load_elf_phdrs函数就是通过kernel_read读入整个program header table,核心代码如下:

	/* Sanity check the number of program headers... */
	if (elf_ex->e_phnum < 1 ||
		elf_ex->e_phnum > 65536U / sizeof(struct elf_phdr))
		goto out;
	/* ...and their total size. */
	size = sizeof(struct elf_phdr) * elf_ex->e_phnum;
    elf_phdata = kmalloc(size, GFP_KERNEL);
    retval = kernel_read(elf_file, elf_phdata, size, &pos)

从函数代码中可以看到,一个可执行程序必须至少有一个段(segment),而所有段的大小之和不能超过64K(65536u),64位系统中大概 1024个
segments是执行视图,每个段用一个struct elf_phdr结构体表示,共有elfhdr->e_phnum个段。此函数分配一段连续的地址空间,加载所有的段数据。
(4)处理动态链接(解析器段)
完成加载segments后,遍历所有段,检查如果需要动态链接, 则寻找和处理解释器段:

for (i = 0; i < loc->elf_ex.e_phnum; i++) {
		if (elf_ppnt->p_type == PT_INTERP) {

PT_INTERP是解释器段类型,需要加载解析器段,用于加载共享库。找到后就根据其位置的p_offset和大小p_filesz把整个”解释器”段的内容读入缓冲区。
“解释器”段实际上只是一个字符串,即解释器的全路径名,在32位系统上是“/lib/ld-linux.so.2”,在x86_64系统中是“/lib64/ld-linux-x86-64.so.2”,可以通过readelf -l 查看解释器段:
在这里插入图片描述
核心代码节选:

        elf_interpreter = kmalloc(elf_ppnt->p_filesz,    GFP_KERNEL);
	if (!elf_interpreter)
		goto out_free_ph;

	pos = elf_ppnt->p_offset;
	retval = kernel_read(bprm->file, elf_interpreter,  elf_ppnt->p_filesz, &pos);
	
	interpreter = open_exec(elf_interpreter);
	。。。 。。。
        break;

有了解释器的文件名以后,就通过open_exec()打开这个文件,再通过kernel_read()读入其开头的128个字节,即解释器文件的elf头部。一个elf文件中最多只有一个解析器段,静态编译的程序不需要动态链接,也就没有解析器段。
后面还会有部分代码对解析器进行进一步的一致性检查,比如是否是ELF格式解析器,解析器ELF文件的段中有无保留给处理器的段范围“PT_LOPROC … PT_HIPROC ”。这里不再分析。
(5)检查是否是可执行栈
GCC 编译选项中,开始/关闭可执行栈的选项是 “-z execstack/noexecstack”,默认情况下 GCC 是关闭可执行栈的。在加载elf文件时,会遍历所有的segment,找到“PT_GNU_STACK”,即栈段,检查flags:

for (i = 0; i < loc->elf_ex.e_phnum; i++, elf_ppnt++)
	switch (elf_ppnt->p_type) {
	case PT_GNU_STACK:
		if (elf_ppnt->p_flags & PF_X)
			executable_stack = EXSTACK_ENABLE_X;
		else
			executable_stack = EXSTACK_DISABLE_X;
		break;

	case PT_LOPROC ... PT_HIPROC:
		retval = arch_elf_pt_proc(&loc->elf_ex, elf_ppnt,
			  bprm->file, false,
			  &arch_state);
		if (retval)
			goto out_free_dentry;
		break;
        }

如果存在这个这个段的话,看这个段的 flags 是否有可执行权限,来设置对应的值
PT_LOPROC … PT_HIPROC 范围的类型保留给处理器专用语义,调用架构相关函数处理(x86和ARM中均为空函数)。
跳过中间一些处理器相关的检查操作,直接来到ELF段载入阶段。

(6)载入目标程序必要的段segment

for(i = 0, elf_ppnt = elf_phdata;  i < loc->elf_ex.e_phnum; i++, elf_ppnt++) {
        /*  搜索PT_LOAD的段, 这个是需要装入的 */
        if (elf_ppnt->p_type != PT_LOAD)
            continue;

            /* 检查地址和页面的信息  */ 
	if (elf_ppnt->p_flags & PF_R)
		elf_prot |= PROT_READ;
	if (elf_ppnt->p_flags & PF_W)
		elf_prot |= PROT_WRITE;
	if (elf_ppnt->p_flags & PF_X)
		elf_prot |= PROT_EXEC;
		
	vaddr = elf_ppnt->p_vaddr;
            // ......
            ///

         /*  虚拟地址空间与目标映像文件的映射 确定了装入地址后,就通过elf_map()建立用户空间虚拟地址空间与目标映像文件中某个连续区间之间的映射,其返回值就是实际映射的起始地址 */
        error = elf_map(bprm->file, load_bias + vaddr, elf_ppnt,
                elf_prot, elf_flags, total_size);

        }

这段代码从目标映像的程序头中搜索类型为PT_LOAD的段(Segment)。在二进制映像中,只有类型为PT_LOAD的段才是需要装入的。
在装入之前,先做一些检查和标志位赋值工作:
1、根据segment 的p_flags,保存段的rwx权限信息;
2、确定要装入的地址,初始值是程序头里的elf_ppnt->p_vaddr成员。这里忽略对地址进行对齐的一些代码。
确定了装入地址后,就通过elf_map()建立用户空间虚拟地址空间与目标映像文件中某个连续区间之间的映射,其返回值就是实际映射的起始地址。elf_map 里调用的是vm_mmap来查找用户空间虚拟地址并建立映射,和mmap 系统调用有的路线是一样的。
有一点需要注意,在映射到进程的虚拟地址空间时,栈、堆、mmap、解析器段 的起始地址往往加上一个随机偏移量。因为在i386系统上,文本基地址(.text)固定为0x08048000,敏感的堆栈区域容易被推算出入口地址,从而被黑客利用。

if (elf_interpreter) {
	load_bias = ELF_ET_DYN_BASE;
	if (current->flags & PF_RANDOMIZE)
		load_bias += arch_mmap_rnd();
	elf_flags |= elf_fixed;
	}
	。。。 。。。
if ((current->flags & PF_RANDOMIZE) && (randomize_va_space > 1)) {
	current->mm->brk = current->mm->start_brk =
arch_randomize_brk(current->mm);

(7)填写程序的入口地址
完成了目标程序和解释器的加载, 以及各个段也已经加载到内存了, 我们的目标程序已经准备好了要执行了。接下来就是要找到程序的入口地址。

if (elf_interpreter) {
	unsigned long interp_map_addr = 0;

	elf_entry = load_elf_interp(&loc->interp_elf_ex,
		    interpreter,
		    &interp_map_addr,
		    load_bias, interp_elf_phdata);
	} else {
		elf_entry = loc->elf_ex.e_entry;
}

1)如果需要装入解释器,就通过load_elf_interp装入其映像, 并把将来进入用户空间的入口地址设置成load_elf_interp()的返回值(elf_entry),即解释器映像的入口地址。这样返回用户空间时先执行解析器程序,将需要的share lib 映射到进程的mmap虚拟地址空间中;可通过cat /proc//maps 查看。
2)若不需要装入解释器,那么这个入口地址就是目标映像本身的入口地址,即elf_hdr->e_entry。

(8)做执行前的准备
调用create_elf_tables函数填写目标文件的参数环境变量等必要信息。

__do_execve_file中获取:
	bprm->argc = count(argv, MAX_ARG_STRINGS);
bprm->envc = count(envp, MAX_ARG_STRINGS);

即从execve SYSCALL拿到的参数、环境变量等等,还有一些“辅助向量,经过一些设置后,压入进程栈中。

/* Now, let's put argc (and argv, envp if appropriate) on the stack */
	if (__put_user(argc, sp++))
。。。 。。。

这些信息需要复制到用户空间,使它们在CPU进入解释器或目标映像的程序入口时出现在用户空间堆栈上。
并将整理好的参数、环境变量等信息拷贝到新进程的内存描述符内(mm_struct):

current->mm->env_end = current->mm->env_start = p;
//其他入栈操作
… …
current->mm->env_end = p;

在create_elf_tables函数结束后。另外填充新进程内存描述符(mm_struct)中的堆栈等重要成员:

current->mm->end_code = end_code;
current->mm->start_code = start_code;
current->mm->start_data = start_data;
current->mm->end_data = end_data;
current->mm->start_stack = bprm->p;

if ((current->flags & PF_RANDOMIZE) && (randomize_va_space > 1)) {
	current->mm->brk = current->mm->start_brk =
arch_randomize_brk(current->mm);

(9)调用start_thread准备执行此ELF程序
start_thread是一个体系相关的函数,在x86_64中定义如下:

load_elf_binary:
	struct pt_regs *regs = current_pt_regs();
	… …
        start_thread(regs, elf_entry, bprm->p);
——————————————————————————————————————
void start_thread(struct pt_regs *regs, unsigned long new_ip, unsigned long new_sp)
{
	start_thread_common(regs, new_ip, new_sp,
		    __USER_CS, __USER_DS, 0);
}
——————————————————————————————————————
start_thread_common核心:
	regs->ip		= new_ip;
	regs->sp		= new_sp;
	regs->cs		= _cs;
	regs->ss		= _ss;
        regs->flags		= X86_EFLAGS_IF;

赋予指令寄存器regs->ip 中的new_ip 就是elf_entry,前面说了该地址是解释器程序的入口(静态编译的则是elf文件头中的e_entry)。前面《x86寄存器和栈帧》章节,我们知道pt_regs在进程内核栈中,缓存的是进程用户空间栈和其他寄存器的值。在SYSCALL返回后,指令寄存器中的elf_entry 就会得到执行。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值