📄
字号:
漫谈兼容内核之八:ELF映像的装入(一)
[align=center][b][size=4]漫谈兼容内核之八:ELF映像的装入(一)[/size][/b][/align]
[align=center]毛德操[/align]
上一篇漫谈中介绍了Wine的二进制映像装入和启动,现在我们来看看ELF映像的装入和启动。
一般而言,应用软件的编程不可能是“一竿子到底”、所有的代码都自己写的,程序员不可避免地、也许是不自觉地、都会使用一些现成的程序库。对于C语言的编程,至少C程序库是一定会用到的。从编译/连接和运行的角度看,应用程序和库程序的连接有两种方法。一种是固定的、静态的连接,就是把需要用到的库函数的目标(二进制)代码从程序库中抽取出来,连接进应用软件的目标映像中,或者甚至干脆把整个程序库都连接进应用软件的映像中。这里所谓的连接包括两方面的操作,一是把库函数的目标代码“定位”在应用软件目标映像中的某个位置上。由于不同应用软件本身的大小和结构都可能不同,库函数在目标映像中的位置是无法预先确定的。为此,程序库中的代码必须是可以浮动的,即“与位置无关”的,在编译时必须加上-fPIC选项,这里PIC是“Position-Independent Code”的缩写。一旦一个库函数在映像中的位置确定以后,就要使应用软件中所有对此函数的调用都指向这个函数。早期的软件都采用这种静态的连接方法,好处是连接的过程只发生在编译/连接阶段,而且用到的技术也比较简单。但是也有缺点,那就是具体库函数的代码往往重复出现在许多应用软件的目标映像中,从而造成运行时的资源浪费。另一方面,这也不利于软件的发展,因为即使某个程序库有了更新更好的版本,已经与老版本静态连接的应用软件也享受不到好处,而重新连接往往又不现实。再说,这也不利于将程序库作为商品独立发展的前景。于是就发展起了第二种连接方法,那就是动态连接。所谓动态连接,是指库函数的代码并不进入应用软件的目标映像,应用软件在编译/连接阶段并不完成跟库函数的连接;而是把函数库的映像也交给用户,到启动应用软件目标映像运行时才把程序库的映像也装入用户空间(并加以定位)、再完成应用软件与库函数的连接。说到程序库,最基本、最重要的当然是C语言库、即libc或glibc。
这样,就有了两种不同的ELF格式映像。一种是静态连接的,在装入/启动其运行时无需装入函数库映像、也无需进行动态连接。另一种是动态连接的,需要在装入/启动其运行时同时装入函数库映像并进行动态连接。显然,Linux内核应该既支持静态连接的ELF映像、也支持动态连接的ELF映像。进一步的分析表明:装入/启动ELF映像必需由内核完成,而动态连接的实现则既可以在内核中完成,也可在用户空间完成。因此,GNU把对于动态连接ELF映像的支持作了分工:把ELF映像的装入/启动放在Linux内核中;而把动态连接的实现放在用户空间,并为此提供一个称为“解释器”的工具软件,而解释器的装入/启动也由内核负责。
大家知道,在Linux系统中,目标映像的装入/启动是由系统调用execve()完成的,但是可以在Linux内核上运行的二进制映像有a.out和ELF两种。由于篇幅的关系,在“情景分析”一书中对于二进制映像只讲了a.out格式映像的装入/启动,而没有讲ELF格式映像的装入/启动。这是因为如果讲了ELF映像就不可避免地要讲到动态连接、讲到“解释器”,那样一来篇幅就大了。从对于装入/启动可执行映像的过程的一般了解而言,光讲a.out也许就够了;可是考虑到ELF映像(以及Windows软件的PE映像)对于兼容内核开发的重要意义,还是有必要补上这一课。
本文先介绍装入/启动一个ELF映像时发生于Linux内核中的操作,下一篇漫谈则介绍发生于用户空间的操作、即“解释器”对于共享库的操作。
1.系统空间的操作
内核中实际执行execv()或execve()系统调用的程序是do_execve(),这个函数先打开目标映像文件,并从目标文件的头部(从第一个字节开始)读入若干(128)字节,然后调用另一个函数search_binary_handler(),在那里面让各种可执行程序的处理程序前来认领和处理。内核所支持的每种可执行程序都有个struct linux_binfmt数据结构,通过向内核登记挂入一个队列。而search_binary_handler(),则扫描这个队列,让各个数据结构所提供的处理程序、即各种映像格式、逐一前来认领。如果某个格式的处理程序发现特征相符而,便执行该格式映像的装入和启动。
我们从ELF格式映像的linux_binfmt数据结构开始:
[code]#define load_elf_binary load_elf32_binary
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
};[/code]
这个数据结构表明:ELF格式的二进制映像的认领、装入和启动是由load_elf_binary()完成的。而“共享库”、即动态连接库映像的装入则由load_elf_library()完成。实际上共享库的映像也是二进制的,但是一般说“二进制”映像是指带有main()函数的、可以独立运行并构成一个进程主体的可执行程序的二进制映像。另一方面,尽管装入/启动二进制映像的过程中蕴含了共享库的装入(否则无法运行),但是在此过程中却并没有调用load_elf_library(),而是通过别的函数进行,这个函数只是在sys_uselib()、即系统调用uselib()中通过函数指针load_shlib受到调用。所以,load_elf_library()所处理的是应用软件在运行时对于共享库的动态装入,而不是启动进程时的静态装入。
下面我们就来看load_elf_binary()代码,这个函数在fs/binfmt_elf.c中。由于篇幅的关系,本文只能以近似于伪代码的形式列出经过简化整理的代码(下同),有需要或兴趣的读者不妨结合源文件中的原始代码阅读。由于load_elf_binary()是个比较大的函数,我们分段阅读。
[code][sys_execve() > do_execve() > search_binary_handler() > load_elf_binary()]
static int load_elf_binary(struct linux_binprm * bprm, struct pt_regs * regs)
{
. . . . . .
struct {
struct elfhdr elf_ex;
struct elfhdr interp_elf_ex;
struct exec interp_ex;
} *loc;
loc = kmalloc(sizeof(*loc), GFP_KERNEL);
. . . . . .
/* Get the exec-header */
loc->elf_ex = *((struct elfhdr *) bprm->buf);
. . . . . .
/* First of all, some simple consistency checks */
if (memcmp(loc->elf_ex.e_ident, ELFMAG, SELFMAG) != 0)
goto out; //比对四个字符,必须是0x7f、‘E’、‘L’、和‘F’。
if (loc->elf_ex.e_type != ET_EXEC && loc->elf_ex.e_type != ET_DYN)
goto out; //映像类型必须是ET_EXEC或ET_DYN。
if (!elf_check_arch(&loc->elf_ex))
goto out; //机器(CPU)类型必须相符。
. . . . . .[/code]
首先是认领。ELF映像文件的头部应该是个struct elfhdr数据结构,对于32位映像这实际上是struct elf32_hdr数据结构、即Elf32_Ehdr,其定义如下所示:
[code]#define elfhdr elf32_hdr
typedef struct elf32_hdr{
unsigned char e_ident[EI_NIDENT]; // EI_NIDENT = 16
Elf32_Half e_type; // 即unsigned shout
Elf32_Half e_machine; // 即 unsigned int
Elf32_Word e_version;
Elf32_Addr e_entry; /* Entry point */
Elf32_Off e_phoff;
Elf32_Off e_shoff;
Elf32_Word e_flags;
Elf32_Half e_ehsize;
Elf32_Half e_phentsize;
Elf32_Half e_phnum;
Elf32_Half e_shentsize;
Elf32_Half e_shnum;
Elf32_Half e_shstrndx;
} Elf32_Ehdr;[/code]
这个数据结构的前16个字节是ELF映像的标志e_ident[ ],其中开头的4个字节就是所谓“Magic Number”,应该是“\177ELF”。除这4个字符比对相符以外,还要看映像的类型是否ET_EXEC和ET_DYN之一;前者表示可执行映像,后者表示共享库(此外还有ET_REL和ET_CORE,分别表示浮动地址模块和dump映像)。同时,映像所适用的CPU类型(如x86或PPC)也须相符。如果这些条件都满足,就算认领成功,下面就是进一步的处理了。进一步的处理当然需要更多的信息,在Elf32_Ehdr中提供了两个指针,或者说两个(文件内的)位移量,即e_phoff和e_shoff。如果非0的话,前者指向“程序头(Program Header)”数组的起点;后者指向“区段头(Section Header)”数组的起点。两个数组的大小(元素的个数)分别由e_phnum和e_shnum提供,而每个数组元素(表项)的大小由e_phentsize和e_shentsize提供。至于e_ehsize,则是映像头部本身的大小。还有个值得特别说明的成分是e_entry,那就是该映像的程序入口,一般是_start()的起点。
人们常常提到二进制代码映像中有所谓“程序段”“数据段”等等,那都属于映像中的“区段”即“Section”。但是区段的种类远远不止这些而有很多,例如“符号表”就是一个区段,再如用于动态连接的信息、用于Debug的信息等等,都属于不同的区段。而区段头数组、或曰区段头表,则为映像中的每一个区段都提供一个描述性的数据结构。
而程序头数组或曰程序头表中的每一个表项,则是对一个“部(Segment)”的描述。一个部可以包含若干个区段,也可以只是一个简单的数据结构。整个ELF映像就是由文件头、区段头表、程序头表、一定数量的区段、以及一定数量的部构成。而ELF映像的装入/启动过程,则就是在各种头部信息的指引下将某些部或区段装入一个进程的用户空间,并为其运行做好准备(例如装入所需的共享库),最后(在目标进程首次受调度运行时)让CPU进入其程序入口的过程。读者将会看到,这个过程很可能是嵌套的,因为在装入一个映像的过程中很可能需要装入另一个或另几个别的映像。
我们继续往下看:
[code][sys_execve() > do_execve() > search_binary_handler() > load_elf_binary()]
/* Now read in all of the header information */
. . . . . .
size = loc->elf_ex.e_phnum * sizeof(struct elf_phdr);
retval = -ENOMEM;
elf_phdata = (struct elf_phdr *) kmalloc(size, GFP_KERNEL);
if (!elf_phdata)
goto out;
retval = kernel_read(bprm->file, loc->elf_ex.e_phoff, (char *) elf_phdata, size);
. . . . . .
files = current->files; /* Refcounted so ok */
. . . . . .
retval = get_unused_fd();
. . . . . .
get_file(bprm->file);
fd_install(elf_exec_fileno = retval, bprm->file);
elf_ppnt = elf_phdata;
elf_bss = 0;
elf_brk = 0;
start_code = ~0UL;
end_code = 0;
start_data = 0;
end_data = 0;[/code]
这里通过kernel_read()读入的是目标映像的整个程序头表,这是一个struct elf_phdr、实际上是struct elf32_phdr结构数组。这种数据结构的定义为:
[code]typedef struct elf32_phdr{
Elf32_Word p_type;
Elf32_Off p_offset;
Elf32_Addr p_vaddr;
Elf32_Addr p_paddr;
Elf32_Word p_filesz;
Elf32_Word p_memsz;
Elf32_Word p_flags;
Elf32_Word p_align;
} Elf32_Phdr;[/code]
这里的p_type表示部的类型。
同时,这里还为已打开的目标映像文件在当前进程的打开文件表中另外分配一个表项,类似于执行了一次dup(),目的在于为目标文件维持两个不同的上下文,以便从不同的位置上读出。
接着是对elf_bss 、elf_brk、start_code、end_code等等变量的初始化。这些变量分别纪录着当前(到此刻为止)目标映像的bss段、代码段、数据段、以及动态分配“堆” 在用户空间的位置。除start_code的初始值为0xffffffff外,其余均为0。随着映像内容的装入,这些变量也会逐步得到调整,读者不妨自己留意这些变量在整个过程中的变化。
读入了程序头表,并对start_code等变量进行初始化以后,下面的第一步就是在程序头表中寻找“解释器”部、并加以处理的过程。
[code][sys_execve() > do_execve() > search_binary_handler() > load_elf_binary()]
for (i = 0; i < loc->elf_ex.e_phnum; i++) {
if (elf_ppnt->p_type == PT_INTERP) {
. . . . . .
retval = -ENOMEM;
elf_interpreter = (char *) kmalloc(elf_ppnt->p_filesz, GFP_KERNEL);
. . . . . .
retval = kernel_read(bprm->file, elf_ppnt->p_offset,
elf_interpreter, elf_ppnt->p_filesz);
. . . . . .
interpreter = open_exec(elf_interpreter);
retval = PTR_ERR(interpreter);
if (IS_ERR(interpreter))
goto out_free_interp;
retval = kernel_read(interpreter, 0, bprm->buf, BINPRM_BUF_SIZE);
. . . . . .
/* Get the exec headers */
loc->interp_ex = *((struct exec *) bprm->buf);
loc->interp_elf_ex = *((struct elfhdr *) bprm->buf);
break;
}
elf_ppnt++;
}[/code]
显然,这个for循环的目的仅在于寻找和处理目标映像的“解释器”部。ELF格式的二进制映像在装入和启动的过程中需要得到一个工具软件的协助,其主要的目的在于为目标映像建立起跟共享库的动态连接。这个工具称为“解释器”。一个ELF映像在装入时需要用什么解释器是在编译/连接是就决定好了的,这信息就保存在映像的“解释器”部中。“解释器”部的类型为PT_INTERP,找到后就根据其位置p_offset和大小p_filesz把整个“解释器”部读入缓冲区。整个“解释器”部实际上只是一个字符串,即解释器的文件名,例如“/lib/ld-linux.so.2”。有了解释器的文件名以后,就通过open_exec()打开这个文件,再通过kernel_read()读入其开头128个字节,这就是映像的头部。早期的解释器映像是a.out格式的,现在已经都是ELF格式的了,/lib/ld-linux.so.2就是个ELF映像。
下面是对解释器映像头部的处理,首先要确认其为ELF格式还是a.out格式。
[code][sys_execve() > do_execve() > search_binary_handler() > load_elf_binary()]
. . . . . .
/* Some simple consistency checks for the interpreter */
if (elf_interpreter) {
interpreter_type = INTERPRETER_ELF | INTERPRETER_AOUT;
/* Now figure out which format our binary is */
if ((N_MAGIC(loc->interp_ex) != OMAGIC) &&
(N_MAGIC(loc->interp_ex) != ZMAGIC) &&
(N_MAGIC(loc->interp_ex) != QMAGIC))
interpreter_type = INTERPRETER_ELF;
if (memcmp(loc->interp_elf_ex.e_ident, ELFMAG, SELFMAG) != 0)
interpreter_type &= ~INTERPRETER_ELF;
. . . . . .
} else {
. . . . . .
}
/* OK, we are done with that, now set up the arg stuff,
and then start this sucker up */[/code]
至此,我们已为目标映像和解释器映像的装入作好了准备。可以让当前进程(线程)与其父进程分道扬镳,转化成真正意义上的进程,走自己的路了。
[code][sys_execve() > do_execve() > search_binary_handler() > load_elf_binary()]
/* Flush all traces of the currently running executable */
retval = flush_old_exec(bprm);
. . . . . .
/* OK, This is the point of no return */
current->mm->start_data = 0;
current->mm->end_data = 0;
current->mm->end_code = 0;
current->mm->mmap = NULL;
current->flags &= ~PF_FORKNOEXEC;
current->mm->def_flags = def_flags;
. . . . . .
/* Do this so that we can load the interpreter, if need be. We will
change some of these later */
retval = setup_arg_pages(bprm, randomize_stack_top(STACK_TOP), executable_stack);
. . . . . .[/code]
可想而知,flush_old_exec()把当前进程用户空间的页面都释放了。这么一来,当前进程的用户空间是“一片白茫茫大地真干净”,什么也没有了,原有的物理页面映射都已释放。
现在要来重建用户空间的映射了。一个新的映像要能运行,用户空间堆栈是必须的,所以首先要把用户空间的一个虚拟地址区间划出来用于堆栈。进一步,当CPU进入新映像的程序入口时,堆栈上应该有argc、argv[]、envc、envp[]等参数。这些参数来自老的程序,需要通过堆栈把它们传递给新的映像。实际上,argv[]和envp[]中是一些字符串指针,光把指针传给新映像,而不把相应的字符串传递给新映像,那是毫无意义的。为此,在进入search_binary_handler()、从而进入load_elf_binary()之前,do_execve()已经为这些字符串分配了若干页面,并通过copy_strings()从用户空间把这些字符串拷贝到了这些页面中。现在则要把这些页面再映射回用户空间(当然是在不同的地址上),这就是这里setup_arg_pages()要做的事。这些页面映射的地址是在用户空间堆栈的最顶部。对于x86处理器,用户空间堆栈是从3GB边界开始向下伸展的,首先就是存放着这些字符串的页面,再往下才是真正意义上的用户空间堆栈。而argc、argv[]这些参数,则就在这真正意义上的用户空间堆栈上。
下面就可以装入新映像了。所谓“装入”,实际上就是将映像的(部分)内容映射到用户(虚拟地址)空间的某些区间中去。在MMU的swap机制的作用下,这个过程甚至并不需要真的把映像的内容读入物理页面,而把实际的读入留待将来的缺页中断。
首先装入的是目标映像本身。
[code][sys_execve() > do_execve() > search_binary_handler() > load_elf_binary()]
/* Now we do a little grungy work by mmaping the ELF image into
the correct location in memory. At this point, we assume that
⌨️ 快捷键说明
复制代码
Ctrl + C
搜索代码
Ctrl + F
全屏模式
F11
切换主题
Ctrl + Shift + D
显示快捷键
?
增大字号
Ctrl + =
减小字号
Ctrl + -