漫谈兼容内核之八:
ELF映像的装入(一)
毛德操
http://www.longene.org/index.php
上一篇漫谈中介绍了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数据结构开始:
#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
};
这个数据结构表明: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()是个比较大的函数,我们分段阅读。
[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)类型必须相符。
. . . . . .
首先是认领。ELF映像文件的头部应该是个struct elfhdr数据结构,对于32位映像这实际上是struct elf32_hdr数据结构、即Elf32_Ehdr,其定义如下所示:
#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;
这个数据结构的前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进入其程序入口的过程。读者将会看到,这个过程很可能是嵌套的,因为在装入一个映像的过程中很可能需要装入另一个或另几个别的映像。
我们继续往下看:
[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;
这里通过kernel_read()读入的是目标映像的整个程序头表,这是一个struct elf_phdr、实际上是struct elf32_phdr结构数组。这种数据结构的定义为:
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;
这里的p_type表示部的类型。
同时,这里还为已打开的目标映像文件在当前进程的打开文件表中另外分配一个表项,类似于执行了一次dup(),目的在于为目标文件维持两个不同的上下文,以便从不同的位置上读出。
接着是对elf_bss 、elf_brk、start_code、end_code等等变量的初始化。这些变量分别纪录着当前(到此刻为止)目标映像的bss段、代码段、数据段、以及动态分配“堆” 在用户空间的位置。除start_code的初始值为0xffffffff外,其余均为0。随着映像内容的装入,这些变量也会逐步得到调整,读者不妨自己留意这些变量在整个过程中的变化。
读入了程序头表,并对start_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++;
}
显然,这个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格式。
[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 */
至此,我们已为目标映像和解释器映像的装入作好了准备。可以让当前进程(线程)与其父进程分道扬镳,转化成真正意义上的进程,走自己的路了。
[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);
. . . . . .
可想而知,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机制的作用下,这个过程甚至并不需要真的把映像的内容读入物理页面,而把实际的读入留待将来的缺页中断。
首先装入的是目标映像本身。
[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
the image should be loaded at fixed address, not at a variable address. */
for(i = 0, elf_ppnt = elf_phdata; i < loc->elf_ex.e_phnum; i++, elf_ppnt++) {
int elf_prot = 0, elf_flags;
unsigned long k, vaddr;
if (elf_ppnt->p_type != PT_LOAD)
continue;
. . . . . .
vaddr = elf_ppnt->p_vaddr;
if (loc->elf_ex.e_type == ET_EXEC || load_addr_set) {
elf_flags |= MAP_FIXED;
} else if (loc->elf_ex.e_type == ET_DYN) {
/* Try and get dynamic programs out of the way of the default mmap
base, as well as whatever program they might try to exec. This
is because the brk will follow the loader, and is not movable. */
load_bias = ELF_PAGESTART(ELF_ET_DYN_BASE - vaddr);
}
error = elf_map(bprm->file, load_bias + vaddr, elf_ppnt, elf_prot, elf_flags);
. . . . . .
if (!load_addr_set) {
load_addr_set = 1;
load_addr = (elf_ppnt->p_vaddr - elf_ppnt->p_offset);
if (loc->elf_ex.e_type == ET_DYN) {
load_bias += error -
ELF_PAGESTART(load_bias + vaddr);
load_addr += load_bias;
reloc_func_desc = load_bias;
}
}
k = elf_ppnt->p_vaddr;
if (k < start_code) start_code = k;
if (start_data < k) start_data = k;
. . . . . .
k = elf_ppnt->p_vaddr + elf_ppnt->p_filesz;
if (k > elf_bss)
elf_bss = k;
if ((elf_ppnt->p_flags & PF_X) && end_code < k)
end_code = k;
if (end_data < k)
end_data = k;
k = elf_ppnt->p_vaddr + elf_ppnt->p_memsz;
if (k > elf_brk)
elf_brk = k;
} //end for() loop
loc->elf_ex.e_entry += load_bias;
elf_bss += load_bias;
elf_brk += load_bias;
start_code += load_bias;
end_code += load_bias;
start_data += load_bias;
end_data += load_bias;
/* Calling set_brk effectively mmaps the pages that we need
* for the bss and break sections. We must do this before
* mapping in the interpreter, to make sure it doesn't wind
* up getting placed where the bss needs to go.
*/
retval = set_brk(elf_bss, elf_brk);
. . . . . .
还是从目标映像的程序头表中搜索,这一次是寻找类型为PT_LOAD的部(Segment)。在二进制映像中,只有类型为PT_LOAD的部才是需要装入的。
找到一个PT_LOAD片以后,先要确定其装入地址。正如代码前面的注释所述,这里先假定装入地址是固定的,然后再根据映像是否允许浮动而作出调整。具体片头数据结构中的p_vaddr提供了映像在连接时确定的装入地址vaddr。如果映像的类型为ET_EXEC,(或者load_addr_set已经被设置成1,见下)那么装入地址就是固定的。而若类型为ET_DYN、即共享库,那么即使装入地址固定也要加上一个偏移量,代码中给出了计算方法,其中ELF_ET_DYN_BASE对于x86定义为(TASK_SIZE / 3 * 2),所以这是2GB边界,而ELF_PAGESTART表示按页面边界对齐。
确定了装入地址以后,就通过elf_map()、实际上是elf32_map()、建立用户空间虚存区间与目标映像文件中某个连续区间之间的映射。这个函数基本上就是do_mmap(),其返回值就是实际映射的(起始)地址。对于类型为ET_EXEC的可执行程序映像而言,代码中的load_bias是0,所以装入的起点就是映像自己提供的地址vaddr。另一方面,对于ET_EXEC,由于参数中的elf_flags中的MAP_FIXED标志位为1,所以给定的映射地址是刚性的而不容许变通,如果与已经映射的区间有冲突就以失败告终。不过,目标映像的映射是从一片空白开始的,所以实际上不可能失败。顺便提一下,现在又多了一种ELF格式的目标映像,称为FDPIC,其装入地址就是可浮动的。
即使总的装入地址是浮动的,一旦装入了第一个Segment以后,下一个Segment的装入地址就应该是固定的了,所以这里一方面把load_addr_set设置成1,
我们不妨以程序wine为例看一下映像的装入。GNU提供了一个很有用的工具readelf,可以用来观察各种ELF映像的内部结构。我们就用它来看/usr/local/bin/wine 的各种头部。首先是它的ELF头部:
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: Intel 80386
Version: 0x1
Entry point address: 0x8048750
Start of program headers: 52 (bytes into file)
Start of section headers: 114904 (bytes into file)
Flags: 0x0
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
Number of program headers: 6
Size of section headers: 40 (bytes)
Number of section headers: 36
Section header string table index: 33
可见,这是EXEC型的映像,其装入地址是固定的、不可浮动的。这个映像有6个程序头、36个section头。我们先看程序头表:
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000034 0x08048034 0x08048034 0x000c0 0x000c0 R E 0x4
INTERP 0x0000f4 0x080480f4 0x080480f4 0x00013 0x00013 R 0x1
[Requesting program interpreter: /lib/ld-linux.so.2]
LOAD 0x000000 0x08048000 0x08048000 0x011cc 0x011cc R E 0x1000
LOAD 0x0011cc 0x0804a1cc 0x0804a1cc 0x00158 0x00160 RW 0x1000
DYNAMIC 0x0011d8 0x0804a1d8 0x0804a1d8 0x000d8 0x000d8 RW 0x4
NOTE 0x000108 0x08048108 0x08048108 0x00020 0x00020 R 0x4
所以需要装入的是两个Segment,从它们在映像中的起始地址和大小可以看出,它们在映像中是连续的。但是,从它们的装入地址却可以看出,装入到用户空间之后它们就分开了。第一个Segment的装入地址是0x08048000,装入以后应该占据0x08048000-0x080491cc,而第二个Segment的装入地址却是0x0804a1cc。再看区段头表:
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .interp PROGBITS 080480f4 0000f4 000013 00 A 0 0 1
. . . . . .
[10] .init PROGBITS 080485e8 0005e8 000017 00 AX 0 0 4
[11] .plt PROGBITS 08048600 000600 000150 04 AX 0 0 4
[12] .text PROGBITS 08048750 000750 0008d8 00 AX 0 0 4
[13] .fini PROGBITS 08049028 001028 00001b 00 AX 0 0 4
[14] .rodata PROGBITS 08049060 001060 000166 00 A 0 0 32
[15] .eh_frame PROGBITS 080491c8 0011c8 000004 00 A 0 0 4
[16] .data PROGBITS 0804a1cc 0011cc 00000c 00 WA 0 0 4
. . . . . .
[21] .got PROGBITS 0804a2c4 0012c4 000060 04 WA 0 0 4
[22] .bss NOBITS 0804a324 001324 000008 00 WA 0 0 4
. . . . . .
[34] .symtab SYMTAB 00000000 01c678 000890 10 35 5c 4
. . . . . .
前面说装入的第一个Segment在映像中的位置是0x0,长度是0x0011cc。跟区段头表中的信息一对照,就可以知道在第16项.data以前的所有区段都是要装入用户空间的。这里面包括了大家所熟知的.text即“代码段”。此外,.init、.fini两个区段也有着特殊的重要性,因为映像的程序入口就在.init段中,实际上在进入main()之前的代码都在这里。而从main()返回之后的代码,包括对exit()的调用,则在.fini中。还有一个区段.plt也十分重要,plt是“Procedure Linkage Table”的缩写,这就是用来为目标映像跟共享库建立动态连接的。再看第二个Segment,这是从.data、即“数据段”开始的。第二个Segment的长度是0x00160,所以应该包括.got和.bss。这里的.got又是个重要的区段,got是“Global Offset Table”的缩写,里面纪录着供动态连接的函数在映像中的位置。显然,这对于共享库是必不可少的。所以,除大家所熟知的.text、.data、.bss等区段以外,映像中还有许多信息都是要装入到用户空间的。这么多的信息给谁用呢?这主要是给“解释器”用的,下一片漫谈我将为读者介绍解释器ld-linux.so.2。另一方面,映像中还有包括符号表.symtab在内的许多别的信息,但是因为不在类型为LOAD的Segment中而不会被装入用户空间。
回到load_elf_binary()的代码。当程序中的for循环结束时,目标映像本身需要装入的内容都已经映射到了用户空间合适的位置上。如果是类型为ET_DYN的映像,则elf_bss等等变量以及映像的程序入口地址都还需要加上偏移量load_bias。
现在该装入解释器的映像了,我们再往下看。
[sys_execve() > do_execve() > search_binary_handler() > load_elf_binary()]
if (elf_interpreter) {
if (interpreter_type == INTERPRETER_AOUT)
elf_entry = load_aout_interp(&loc->interp_ex, interpreter);
else
elf_entry = load_elf_interp(&loc->interp_elf_ex, interpreter, &interp_load_addr);
. . . . . .
reloc_func_desc = interp_load_addr;
allow_write_access(interpreter);
fput(interpreter);
kfree(elf_interpreter);
} else {
elf_entry = loc->elf_ex.e_entry;
}
.这段程序的逻辑很简单:如果需要装入解释器,并且解释器的映像是ELF格式的,就通过load_elf_interp()装入其映像,并把将来进入用户空间时的入口地址设置成load_elf_interp()的返回值,那显然是解释器的程序入口。而若不装入解释器,那么这个地址就是目标映像本身的程序入口。
显然,关键的操作是由load_elf_interp()完成的,所以我们追下去看load_elf_interp()的代码。
[sys_execve() > do_execve() > search_binary_handler() > load_elf_binary() > load_elf_interp()]
static unsigned long load_elf_interp(struct elfhdr * interp_elf_ex,
struct file * interpreter, unsigned long *interp_load_addr)
{
struct elf_phdr *elf_phdata;
struct elf_phdr *eppnt;
unsigned long load_addr = 0;
int load_addr_set = 0;
unsigned long last_bss = 0, elf_bss = 0;
unsigned long error = ~0UL;
int retval, i, size;
/* First of all, some simple consistency checks */
if (interp_elf_ex->e_type != ET_EXEC && interp_elf_ex->e_type != ET_DYN)
goto out;
. . . . . .
size = sizeof(struct elf_phdr) * interp_elf_ex->e_phnum;
. . . . . .
elf_phdata = (struct elf_phdr *) kmalloc(size, GFP_KERNEL);
. . . . . .
retval = kernel_read(interpreter,interp_elf_ex->e_phoff,(char *)elf_phdata,size);
. . . . . .
eppnt = elf_phdata;
for (i=0; i<interp_elf_ex->e_phnum; i++, eppnt++) {
if (eppnt->p_type == PT_LOAD) {
. . . . . .
vaddr = eppnt->p_vaddr;
if (interp_elf_ex->e_type == ET_EXEC || load_addr_set)
elf_type |= MAP_FIXED;
map_addr = elf_map(interpreter, load_addr + vaddr, eppnt, elf_prot, elf_type);
error = map_addr;
if (BAD_ADDR(map_addr))
goto out_close;
if (!load_addr_set && interp_elf_ex->e_type == ET_DYN) {
load_addr = map_addr - ELF_PAGESTART(vaddr);
load_addr_set = 1;
}
/*
* Check to see if the section's size will overflow the
* allowed task size. Note that p_filesz must always be
* <= p_memsize so it is only necessary to check p_memsz.
*/
k = load_addr + eppnt->p_vaddr;
if (k > TASK_SIZE || eppnt->p_filesz > eppnt->p_memsz ||
eppnt->p_memsz > TASK_SIZE || TASK_SIZE - eppnt->p_memsz < k) {
error = -ENOMEM;
goto out_close;
}
/*
* Find the end of the file mapping for this phdr, and keep
* track of the largest address we see for this.
*/
k = load_addr + eppnt->p_vaddr + eppnt->p_filesz;
if (k > elf_bss)
elf_bss = k;
/*
* Do the same thing for the memory mapping - between
* elf_bss and last_bss is the bss section.
*/
k = load_addr + eppnt->p_memsz + eppnt->p_vaddr;
if (k > last_bss)
last_bss = k;
} //end if
} //end for
/*
* Now fill out the bss section. First pad the last page up
* to the page boundary, and then perform a mmap to make sure
* that there are zero-mapped pages up to and including the
* last bss page.
*/
if (padzero(elf_bss)) {
error = -EFAULT;
goto out_close;
}
elf_bss = ELF_PAGESTART(elf_bss + ELF_MIN_ALIGN - 1);
* What we have mapped so far */
/* Map the last of the bss segment */
if (last_bss > elf_bss) {
down_write(¤t->mm->mmap_sem);
error = do_brk(elf_bss, last_bss - elf_bss);
up_write(¤t->mm->mmap_sem);
if (BAD_ADDR(error))
goto out_close;
}
*interp_load_addr = load_addr;
error = ((unsigned long) interp_elf_ex->e_entry) + load_addr;
out_close:
kfree(elf_phdata);
out:
return error;
}
代码中的do_brk()从用户空间分配一段空间。这段代码总体上与前面映射目标映像的那一段相似,就把它留给读者细细研究吧。注意解释器映像的类型一般都是ET_DYN,所以load_addr可能不等于0。
回到load_elf_binary()的代码中。
[sys_execve() > do_execve() > search_binary_handler() > load_elf_binary()]
compute_creds(bprm);
current->flags &= ~PF_FORKNOEXEC;
create_elf_tables(bprm, &loc->elf_ex, (interpreter_type == INTERPRETER_AOUT),
load_addr, interp_load_addr);
/* N.B. passed_fileno might not be initialized? */
if (interpreter_type == INTERPRETER_AOUT)
current->mm->arg_start += strlen(passed_fileno) + 1;
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;
在完成装入,启动用户空间的映像运行之前,还需要为目标映像和解释器准备好一些有关的信息,这些信息包括常规的argc、argv[]、envc、envp[]、还有一些所谓的“辅助向量(Auxiliary Vector)”。这些信息已经存在于内核中,但是需要把它们复制到用户空间,使它们在CPU进入解释器或目标映像的程序入口时出现在用户空间堆栈上。这里的create_elf_tables()就起着这个作用。
[sys_execve() > do_execve() > search_binary_handler() > load_elf_binary() > create_elf_tables()]
static int create_elf_tables(struct linux_binprm *bprm, struct elfhdr * exec, int interp_aout,
unsigned long load_addr, unsigned long interp_load_addr)
{
unsigned long p = bprm->p;
int argc = bprm->argc;
int envc = bprm->envc;
elf_addr_t __user *argv;
elf_addr_t __user *envp;
elf_addr_t __user *sp;
elf_addr_t __user *u_platform;
const char *k_platform = ELF_PLATFORM;
int items;
elf_addr_t *elf_info;
int ei_index = 0;
struct task_struct *tsk = current;
. . . . . .
/* Create the ELF interpreter info */
elf_info = (elf_addr_t *) current->mm->saved_auxv;
#define NEW_AUX_ENT(id, val) \
do { elf_info[ei_index++] = id; elf_info[ei_index++] = val; } while (0)
NEW_AUX_ENT(AT_HWCAP, ELF_HWCAP);
NEW_AUX_ENT(AT_PAGESZ, ELF_EXEC_PAGESIZE);
NEW_AUX_ENT(AT_CLKTCK, CLOCKS_PER_SEC);
NEW_AUX_ENT(AT_PHDR, load_addr + exec->e_phoff);
NEW_AUX_ENT(AT_PHENT, sizeof (struct elf_phdr));
NEW_AUX_ENT(AT_PHNUM, exec->e_phnum);
NEW_AUX_ENT(AT_BASE, interp_load_addr);
NEW_AUX_ENT(AT_FLAGS, 0);
NEW_AUX_ENT(AT_ENTRY, exec->e_entry);
NEW_AUX_ENT(AT_UID, (elf_addr_t) tsk->uid);
NEW_AUX_ENT(AT_EUID, (elf_addr_t) tsk->euid);
NEW_AUX_ENT(AT_GID, (elf_addr_t) tsk->gid);
NEW_AUX_ENT(AT_EGID, (elf_addr_t) tsk->egid);
NEW_AUX_ENT(AT_SECURE, (elf_addr_t) security_bprm_secureexec(bprm));
. . . . . .
if (bprm->interp_flags & BINPRM_FLAGS_EXECFD) {
NEW_AUX_ENT(AT_EXECFD, (elf_addr_t) bprm->interp_data);
}
#undef NEW_AUX_ENT
/* AT_NULL is zero; clear the rest too */
memset(&elf_info[ei_index], 0,
sizeof current->mm->saved_auxv - ei_index * sizeof elf_info[0]);
/* And advance past the AT_NULL entry. */
ei_index += 2;
sp = STACK_ADD(p, ei_index); //实际上是(p - ei_index),因为堆栈向下伸展。
items = (argc + 1) + (envc + 1);
if (interp_aout) {
items += 3; /* a.out interpreters require argv & envp too */
} else {
items += 1; /* ELF interpreters only put argc on the stack */
}
bprm->p = STACK_ROUND(sp, items); //计算(sp - items)并与16字节边界对齐。
/* Point sp at the lowest address on the stack */
#ifdef CONFIG_STACK_GROWSUP
. . . . . .
#else
sp = (elf_addr_t __user *)bprm->p;
#endif
/* Now, let's put argc (and argv, envp if appropriate) on the stack */
if (__put_user(argc, sp++))
return -EFAULT;
if (interp_aout) {
. . . . . .
} else {
argv = sp; //用户空间堆栈上的argv[]从这里开始
envp = argv + argc + 1; //用户空间堆栈上的envp[]从这里开始
}
/* Populate argv and envp */
p = current->mm->arg_end = current->mm->arg_start;
while (argc-- > 0) {
size_t len;
__put_user((elf_addr_t)p, argv++);
len = strnlen_user((void __user *)p, PAGE_SIZE*MAX_ARG_PAGES);
if (!len || len > PAGE_SIZE*MAX_ARG_PAGES)
return 0;
p += len;
}
if (__put_user(0, argv))
return -EFAULT;
current->mm->arg_end = current->mm->env_start = p;
while (envc-- > 0) {
size_t len;
__put_user((elf_addr_t)p, envp++);
len = strnlen_user((void __user *)p, PAGE_SIZE*MAX_ARG_PAGES);
if (!len || len > PAGE_SIZE*MAX_ARG_PAGES)
return 0;
p += len;
}
if (__put_user(0, envp))
return -EFAULT;
current->mm->env_end = p;
/* Put the elf_info on the stack in the right place. */
sp = (elf_addr_t __user *)envp + 1; //用户空间堆栈上的elf_info[]从这里开始
if (copy_to_user(sp, elf_info, ei_index * sizeof(elf_addr_t)))
return -EFAULT;
return 0;
}
这个函数的代码大体上可以分成前后两半。
前一半是准备阶段,特别是对诸多辅助向量的准备。辅助向量是以编号加值的形式成对出现的。例如,AT_PHDR是个编号,表示目标映像中程序头数组在用户空间的位置(可想而知这是解释器需要的信息),而(load_addr + exec->e_phoff)是它的值,二者占据相继的两个32位长字。同样,AT_PHNUM是个编号,而exec->e_phnum是它的值,余类推。当然,这些编号对于内核和解释器都有着相同的意义。这里先把这些向量准备好在一个数组elf_info[]中,最后以编号AT_NULL即0作为数组的结尾。
后一半则是复制阶段,从代码中的注释行“/* Now, let's put argc (and argv, envp if appropriate) on the stack */”开始。这个阶段的目的是把这些信息复制到用户空间,把它们“种”在堆栈上,为解释器和目标映像的运行做好准备。代码中的变量bprm->p实质上是个指针,它代表着用户空间的堆栈指针。进入create_elf_tables()以后,就把bprm->p的值赋给了这里的变量p,所以在这里p也代表着用户空间的堆栈指针。注意这里通过__put_user()写入用户空间堆栈上的argv[]和envp[]中的只是一些指针,而相应的字符串则已经由do_execve()通过copy_strings()从用户空间拷贝到内核空间的某些页面中,后来这些页面又被映射到了用户空间(新的地址上),这里写入用户空间argv[]和envp[]中的那些指针就是指向各个字符串在用户空间的新的起点。函数strnlen_user()的作用是获取用户空间字符串的长度。
再回到load_elf_binary()的代码,剩下的只是“临门一脚”了。
[sys_execve() > do_execve() > search_binary_handler() > load_elf_binary()]
. . . . . .
start_thread(regs, elf_entry, bprm->p);
retval = 0;
. . . . . .
}
最后的start_thread()是个宏操作,其定义如下:
#define start_thread(regs, new_eip, new_esp) do { \
__asm__("movl %0,%%fs ; movl %0,%%gs": :"r" (0)); \
set_fs(USER_DS); \
regs->xds = __USER_DS; \
regs->xes = __USER_DS; \
regs->xss = __USER_DS; \
regs->xcs = __USER_CS; \
regs->eip = new_eip; \
regs->esp = new_esp; \
} while (0)
这几条指令把作为参数传下来的用户空间程序入口和堆栈指针设置到regs数据结构中,这个数据结构实际上在系统堆栈中,是在当前进程通过系统调用进入内核时由SAVE_ALL形成的,而指向所保存现场的指针regs则作为参数传给了sys_execve(),并逐层传了下来。把所保存现场中的eip和esp改成了新的地址,就使得CPU在返回用户空间时进入新的程序入口。如果有解释器映像存在,那么这就是解释器映像的程序入口,否则就是目标映像的程序入口。那么什么情况下有解释器映像存在,什么情况下没有呢?如果目标映像与各种库的连接是静态连接,因而无需依靠共享库、即动态连接库,那就不需要解释器映像,启动目标映像运行的条件已经具备;否则就一定要有解释器映像存在。现代的二进制映像一般都使用共享库,所以一般都需要有解释器映像。
现在,对于需要动态连接的目标映像,目标映像和解释器映像都已映射到了当前进程的用户空间,并且“井水不犯河水”、同时并存。但是要启动目标映像的运行则条件还不具备。因为还需要装入(映射)某些共享库的映像,并使目标映像与这些共享库映像之间建立起动态连接,而这需要由解释器在用户空间完成,好在启动解释器运行的条件已经具备了。
漫谈兼容内核之九:
ELF映像的装入(二)
毛德操
上一篇漫谈介绍了在通过execve()系统调用启动一个ELF格式的目标映像时发生于Linux内核中的活动。简而言之,内核根据映像头部所提供的信息把目标映像映射到(装入)当前进程用户空间的某个位置上;并且,如果目标映像需要使用共享库的话,还要(根据映像头部所提供的信息)将所需的“解释器”的映像也映射到用户空间的某个位置上,然后在从系统调用返回用户空间的时候就“返回”到解释器的入口,下面就是解释器的事了。如果目标映像不使用共享库,那么问题就比较简单,返回用户空间的时候就直接“返回”到目标映像的入口。现代的应用软件一般都要使用共享库,所以我们把这当作常态,而把不使用共享库的应用软件作为一种简化了的特例。
映像装入用户空间的位置有些是固定的、在编译连接时就确定好了的;有些则是“浮动”的、可以在装入时动态决定;具体要看编译时是否使用了-fPIC选项。一般应用软件主体的映像都是固定地址的,而共享库映像的装入地址都是浮动的。特别地,解释器映像的装入地址也是浮动的。
2.ELF映像的结构
每个操作系统对于在其内核上运行的可执行程序二进制映像都有特定的要求和规定,包括例如映像的格式,映像在用户空间的布局(程序段、数据段、堆栈段的划分等等),映像装入用户空间的地址是否可以浮动、以及如何浮动,是否支持动态连接、以及如何连接,如何进行系统调用,等等。这些要求和规定合在一起就构成了具体操作系统的“应用(软件)二进制界面(Application Binary Interface)”,缩写成ABI。显然,ABI是二进制映像的“生产者”即编译/连接工具和使用者即映像装入/启动手段之间的一组约定。而我们一般所说的二进制映像格式,实际上并不仅仅是指字面意义上的、类似于数据结构定义那样的“格式”,还包括了跟映像装入过程有关的其它约定。所以,二进制映像格式是ABI的主体。
目前的Linux ABI是在Unix系统5的时期(大约在1980年代)发展起来的,其主体就是ELF,这是“可执行映像和连接格式(Executable and Lnking Format)”的缩写。
读者已经看到,ELF映像文件的开始是个ELF头,这是一个数据结构,结构中有个指针(位移量),指向文件中的一个“程序头”数组(表)。各个程序头表项当然也是数据结构,这是对映像文件中各个“节(Segment)”的(结构性)描述。
从映像装入的角度看,一个映像是由若干个Segment构成的。有些Segment需要被装入、即被映射到用户空间,有些则不需要被装入。在前一篇漫谈中读者已经看到,只有类型为PT_LOAD的Segment才需要被装入。所以,映像装入的过程只“管”到Segment为止。而从映像的动态连接、重定位(即浮动)、和启动运行的角度看,则映像是由若干个“段(Section)”构成的。我们通常所说映像中的“代码段”、“数据段”等等都是Section。所以,动态连接和启动运行的过程所涉及的则是Section。一般而言,一个Segment可以包含多个Section。其实,Segment和Section都是从操作/处理的角度对映像的划分;对于不同的操作/处理,划分的方式也就可以不同。所以,读者在后面将会看到,一个Segment里面也可以包含几个别的Segment,这就是因为它们是按不同的操作/处理划分的、不同意义上的Segment。Section也是一样。
在Linux系统中,(应用软件主体)目标映像本身的装入是由内核负责的,这个过程读者已经看到;而动态连接的过程则由运行于用户空间的“解释器”负责。这里要注意:第一,“解释器”是与具体的映像相连系的,其本身也有个映像,也需要被装入。与目标映像相连系的“解释器”也是由内核装入的,这一点读者也已看到。第二,动态连接的过程包括了共享库映像的装入,那却是由“解释器”在用户空间实现的。
本来,看了内核中与装入目标映像有关的代码以后,应该接着看“解释器”的代码了。但是后者比前者复杂得多,也繁琐得多,原因是牵涉到许多ELF和ABI的原理和细节,所以有必要先对ELF动态连接的原理作一介绍。明白了有关的原理和大致的方法以后,具体的代码实现倒在其次了。
前面讲过,Linux提供了两个很有用的工具,即readelf和objdump。下面就用这两个工具对映像/usr/local/bin/wine进行一番考察,以期在此过程中逐步对ELF和ABI有所了解和理解,这也是进一步阅读、理解“解释器”的代码所需要的。
我们用命令行“readelf –a /usr/local/bin/wine”和“objdump –d /usr/local/bin/wine”产生两个文件(把结果重定向到文件中),然后察看这两个文件的部分内容。
首先是目标映像的ELF头:
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: Intel 80386
Version: 0x1
Entry point address: 0x8048750
Start of program headers: 52 (bytes into file)
Start of section headers: 114904 (bytes into file)
Flags: 0x0
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
Number of program headers: 6
Size of section headers: 40 (bytes)
Number of section headers: 36
Section header string table index: 33
这就是映像文件开头处的ELF头,其最初4个字节为‘0x7f’、和‘E’、‘L’、‘F’。从其余字段中我们可以看出:
l OS是Unix、其实是Linux、而ABI是系统5的ABI。ABI的版本号为0。
l CPU为x86。
l 映像的类型为EXEC,即带有主函数main()的应用软件映像(若是共享库则类型为DYN、即动态连接库)。
l 映像的程序入口地址为0x8048750。如前所述,EXEC映像的装入地址是固定的、不能浮动。
l 程序头数组起点在文件中的位移为52(字节),而ELF头的大小正好也是52,所以紧接ELF头的后面就是程序头数组。数组的大小为6,即映像中有6个Segment。
l Section头的数组则一直在后面位移位114904的地方,映像中有36个Section。
于是,我们接下去看程序头数组:
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000034 0x08048034 0x08048034 0x000c0 0x000c0 R E 0x4
INTERP 0x0000f4 0x080480f4 0x080480f4 0x00013 0x00013 R 0x1
[Requesting program interpreter: /lib/ld-linux.so.2]
LOAD 0x000000 0x08048000 0x08048000 0x011cc 0x011cc R E 0x1000
LOAD 0x0011cc 0x0804a1cc 0x0804a1cc 0x00158 0x00160 RW 0x1000
DYNAMIC 0x0011d8 0x0804a1d8 0x0804a1d8 0x000d8 0x000d8 RW 0x4
NOTE 0x000108 0x08048108 0x08048108 0x00020 0x00020 R 0x4
一个程序头就是关于一个Segment的说明,所以这就是6个Segment。第一个Segment的类型是PHDR,在文件中的位移为0x34、即52,这就是程序头数组本身。其大小为0xc0、即192。前面说每个程序头的大小为32字节,而6 X 32 = 192。第二个Segment的类型是INTERP,即“解释器”的文件/路径名,是个字符串,这里说是“/lib/ld-linux.so.2”。
下面是两个类型为LOAD的Segment。如前所述,只有这种类型的Segment才需要装入。但是,看一下前者的说明,其起点在文件中的位移是0,大小是0x011cc,显然是把ELF头和前两个Segment也包含在里面了。再看后者,其起点的位移是0x011cc,所以是和前者连在一起的;其大小为0x158,这样两个Segment合在一起是从0到0x1324。计算一下就可知道,实际上是把所有的Segment都包括进去了。所以,对于这个特定的映像,说是只装入类型为LOAD的Segment,实际上装入的却是整个映像。那么,映像中的什么内容可以不必装入呢?例如bss段,那是无初始内容的数据段,就不用装入;还有(与动态连接无关的)符号表,那也不需要装入。注意两个LOAD类Segment的边界(Alignment)都是0x1000,即4KB,那正好是存储页面的大小。还有个问题,既然两个LOAD类的Segment是连续的,那为什么不合并成一个呢?看一下它们的特性标志位就可以知道,第一个Segment的映像是可读可执行、但是不可写;第二个则是可读可写、但是不可执行,这当然不能合并。
再往下看,下一个Segment的类型是DYNAMIC,那就是跟动态连接有关的信息。如上所述,这个Segment其实是包含在前一个Segment中的,所以也会被装入。最后一个Segment的类型是NOTE,那只是注释、说明一类的信息了。
当然,跟动态连接有关的信息是我们最为关心的,所以我们看一下这个Segment的具体内容:
Dynamic segment at offset 0x11d8 contains 22 entries:
Tag Type Name/Value
0x00000001 (NEEDED) Shared library: [libwine.so.1]
0x00000001 (NEEDED) Shared library: [libpthread.so.0]
0x00000001 (NEEDED) Shared library: [libc.so.6]
0x0000000c (INIT) 0x80485e8
0x0000000d (FINI) 0x8049028
0x00000004 (HASH) 0x8048128
0x00000005 (STRTAB) 0x8048368
0x00000006 (SYMTAB) 0x80481d8
0x0000000a (STRSZ) 301 (bytes)
0x0000000b (SYMENT) 16 (bytes)
0x00000015 (DEBUG) 0x0
0x00000003 (PLTGOT) 0x804a2c4
0x00000002 (PLTRELSZ) 160 (bytes)
0x00000014 (PLTREL) REL
0x00000017 (JMPREL) 0x8048548
0x00000011 (REL) 0x8048538
0x00000012 (RELSZ) 16 (bytes)
0x00000013 (RELENT) 8 (bytes)
0x6ffffffe (VERNEED) 0x80484c8
0x6fffffff (VERNEEDNUM) 3
0x6ffffff0 (VERSYM) 0x8048496
0x00000000 (NULL) 0x0
这个Segment中有22项数据,开头几项类型为NEEDED的数据是我们此刻最为关心的,因为这些数据告诉了我们目标映像要求装入那一些共享库,例如libwine.so.1。读者已经看过内核怎样装入用户空间映像,解释器只不过是在用户空间做同样的事,所以共享库的装入对于读者并不复杂,问题是怎样实现动态连接,这是我后面要着重讲的。
前面说过,Segment是从映像装入角度考虑的划分,Section才是从连接/启动角度考虑的划分,现在我们就来看Section。先看Section与Segment的对应关系:
Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .note.ABI-tag .hash .dynsym .dynstr .gnu.version .gnu.version_r
.rel.dyn .rel.plt .init .plt .text .fini .rodata .eh_frame
03 .data .dynamic .ctors .dtors .jcr .got .bss
04 .dynamic
05 .note.ABI-tag
Section的名称都以’.’开头,例如.interp;名称中间也可以有’.’,例如rel.dyn。
这说明,Segment 0不含有任何Section,因为这就是程序头数组。Segment 1只含有一个Section,那就是.interp,即解释器的文件/路径名。而Segment 2所包含的Section就多了。而且,这个Segment还包含了前面两个Segmrnt,所以.interp又同时出现在这个Segment中。余类推。
前面ELF头中说一共有36个Section,下面就是一份清单:
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .interp PROGBITS 080480f4 0000f4 000013 00 A 0 0 1
[ 2] .note.ABI-tag NOTE 08048108 000108 000020 00 A 0 0 4
[ 3] .hash HASH 08048128 000128 0000b0 04 A 4 0 4
[ 4] .dynsym DYNSYM 080481d8 0001d8 000190 10 A 5 1 4
[ 5] .dynstr STRTAB 08048368 000368 00012d 00 A 0 0 1
[ 6] .gnu.version VERSYM 08048496 000496 000032 02 A 4 0 2
[ 7] .gnu.version_r VERNEED 080484c8 0004c8 000070 00 A 5 3 4
[ 8] .rel.dyn REL 08048538 000538 000010 08 A 4 0 4
[ 9] .rel.plt REL 08048548 000548 0000a0 08 A 4 b 4
[10] .init PROGBITS 080485e8 0005e8 000017 00 AX 0 0 4
[11] .plt PROGBITS 08048600 000600 000150 04 AX 0 0 4
[12] .text PROGBITS 08048750 000750 0008d8 00 AX 0 0 4
[13] .fini PROGBITS 08049028 001028 00001b 00 AX 0 0 4
[14] .rodata PROGBITS 08049060 001060 000166 00 A 0 0 32
[15] .eh_frame PROGBITS 080491c8 0011c8 000004 00 A 0 0 4
[16] .data PROGBITS 0804a1cc 0011cc 00000c 00 WA 0 0 4
[17] .dynamic DYNAMIC 0804a1d8 0011d8 0000d8 08 WA 5 0 4
[18] .ctors PROGBITS 0804a2b0 0012b0 000008 00 WA 0 0 4
[19] .dtors PROGBITS 0804a2b8 0012b8 000008 00 WA 0 0 4
[20] .jcr PROGBITS 0804a2c0 0012c0 000004 00 WA 0 0 4
[21] .got PROGBITS 0804a2c4 0012c4 000060 04 WA 0 0 4
[22] .bss NOBITS 0804a324 001324 000008 00 WA 0 0 4
[23] .stab PROGBITS 00000000 001324 004878 0c 24 0 4
[24] .stabstr STRTAB 00000000 005b9c 014cd4 00 0 0 1
[25] .comment PROGBITS 00000000 01a870 000165 00 0 0 1
[26] .debug_aranges PROGBITS 00000000 01a9d8 000078 00 0 0 8
[27] .debug_pubnames PROGBITS 00000000 01aa50 000025 00 0 0 1
[28] .debug_info PROGBITS 00000000 01aa75 000a98 00 0 0 1
[29] .debug_abbrev PROGBITS 00000000 01b50d 000138 00 0 0 1
[30] .debug_line PROGBITS 00000000 01b645 000284 00 0 0 1
[31] .debug_frame PROGBITS 00000000 01b8cc 000014 00 0 0 4
[32] .debug_str PROGBITS 00000000 01b8e0 0006be 01 MS 0 0 1
[33] .shstrtab STRTAB 00000000 01bf9e 00013a 00 0 0 1
[34] .symtab SYMTAB 00000000 01c678 000890 10 35 5c 4
[35] .strtab STRTAB 00000000 01cf08 0005db 00 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings)
I (info), L (link order), G (group), x (unknown)
O (extra OS processing required) o (OS specific), p (processor specific)
这是按Section的名称列出的,其中跟动态连接有关的Section也出现在前面名为Dynamic的Segment中,只是在那里是按类型列出的。例如,前面类型为HASH的表项说与此有关的信息在0x8048128处,而这里则说有个名为.hash的Section,其起始地址为0x8048128。还有,前面类型为PLTGOT的表项说与此有关的信息在0x804a2c4处,这里则说有个名为.got的Section,其起始地址为0x804a2c4,不过Section表中提供的信息更加详细一些,有些信息则互相补充。在Section表中,只要类型为PROGBITS,就说明这个Section的内容都来自映像文件,反之类型为NOBITS就说明这个Section的内容并非来自映像文件。
有些Section名是读者本来就知道的,例如.text、.data、.bss;有些则从它们的名称就可猜测出来,例如.symtab是符号表、.rodata是只读数据、还有.comment和.debug_info等等。还有一些可能就不知道了,这里择其要者先作些简略的介绍:
l .hash。为便于根据函数/变量名找到有关的符号表项,需要对函数/变量名进行hash计算,并根据计算值建立hash队列。
l .dynsym。需要加以动态连接的符号表,类似于内核模块中的INPORT符号表。这是动态连接符号表的数据结构部分,须与.dynstr联用。
l .dynstr。动态连接符号表的字符串部分,与.dynsym联用。
l .rel.dyn。用于动态连接的重定位信息。
l .rel.plt。一个结构数组,其中的每个元素都代表着GOP表中的一个表项GOTn(见下)。
l .init。在进入main()之前执行的代码在这个Section中。
l .plt。“过程连接表(Procedure Linking Table)”,见后。
l .fini。从main()返回之后执行的代码在这个Section中,最后会调用exit()。
l .ctors。表示“Constructor”,是一个函数指针数组,这些函数需要在程序初始化阶段(进入main()之前,在.init中)加以调用。
l .dtors。表示“Distructor”,也是一个函数指针数组,这些函数需要在程序扫尾阶段(从main()返回之后,在.fini中)加以调用。
l .got。“全局位移表(Global Offset Table)”,见后。
l .strtab。与符号表有关的字符串都集中在这个Section中。
其中我们最关心的是“过程连接表(Procedure Linking Table)”PLT和“全局位移表(Global Offset Table)”GOT。程序之间的动态连接就是通过这两个表实现的。
下面我们通过一个实例来说明程序之间的动态连接。目标映像/usr/local/bin/wine的main()函数中调用了一个库函数getenv(),这个函数在C语言共享库libc.so.6中。下面是main()经编译/连接以后的汇编代码:
08048ce0 <main>:
8048ce0: 55 push %ebp
8048ce1: 89 e5 mov %esp,%ebp
. . . . . .
8048cef: 68 20 91 04 08 push $0x8049120
8048cf4: e8 47 f9 ff ff call 8048640 <_init+0x58>
本来,这里call指令机器代码的后4个字节应该是目标函数getenv()的入口地址。可是,这个目标函数在共享库libc.so.6中,而这个共享库的装入地址是浮动的,要到装入了以后才能知道其地址。怎么办?一个不必很有天分的人就能想到的简单办法是:编译时先让这条call指令空着,但是创建一个带有字符串“getenv”的数据结构,并让这个数据结构中有个指针反过来指向这条call指令;而在动态连接时,则让“解释器”在共享库的导出符号表中寻找这个符号,找到后根据其装入后的位置计算出应该填入这条call指令的数值,再把结果填写到这里的call指令中、即地址为0x8048cf5的地方。当然,程序中调用getenv()的地方可能不止一个,所以在调用者的映像中需要把所有调用getenv()的地方都记下来。然而,不幸的是这样的地方可能成百上千,而类似于getenv()这样由共享库提供的函数也可能成百上千。更何况一个共享库可能还要用到别的共享库,从而形成一个多层次的共享库“图”。这样一来,动态连接的效率就大成问题了,显然这不是个好办法。
那么实际采用的办法是什么样的呢?这里的call指令采用的是相对寻址,调用的子程序入口地址为0x8048640,我们就循着这个地址看过去:
8048640: ff 25 dc a2 04 08 jmp *0x804a2dc
8048646: 68 18 00 00 00 push $0x18
804864b: e9 b0 ff ff ff jmp 8048600 <_init+0x18>
这就已经在wine映像的PLT表中了,这几条指令就构成getenv()在PLT中的表项,程序中凡是对getenv()的调用都先来到这里。当然,PLT表中有许多这样的表项,对应着许多需要通过动态连接引入的函数,凡是这样的表项都以PLTn表示之。所有的PLTn都是相似的,但是PLT表中的第一个表项、即PLT0、却是特殊的:
08048600 <.plt>:
8048600: ff 35 c8 a2 04 08 pushl 0x804a2c8
8048606: ff 25 cc a2 04 08 jmp *0x804a2cc
804860c: 00 00 add %al,(%eax)
804860e: 00 00 add %al,(%eax)
8048610: ff 25 d0 a2 04 08 jmp *0x804a2d0
8048616: 68 00 00 00 00 push $0x0
804861b: e9 e0 ff ff ff jmp 8048600 <_init+0x18>
8048620: ff 25 d4 a2 04 08 jmp *0x804a2d4
8048626: 68 08 00 00 00 push $0x8
804862b: e9 d0 ff ff ff jmp 8048600 <_init+0x18>
8048630: ff 25 d8 a2 04 08 jmp *0x804a2d8
8048636: 68 10 00 00 00 push $0x10
804863b: e9 c0 ff ff ff jmp 8048600 <_init+0x18>
8048640: ff 25 dc a2 04 08 jmp *0x804a2dc
8048646: 68 18 00 00 00 push $0x18
804864b: e9 b0 ff ff ff jmp 8048600 <_init+0x18>
. . . . . .
可以看出,除PLT0以外,所有的PLTn的形式都是一样的,而且最后的jmp指令都是以0x8048600、即PLT0为目标,所不同的只是第一条jmp指令的目标和push指令中的数据。PLT0则与之不同,但是包括PLT0在内的每个表项都占16个字节,所以整个PLT就像是个数组。其实PLT0只需要12个字节,但是为了大小划一而补了4个字节的0。
注意每个PLTn中的第一条jmp指令是间接寻址的。以getenv()的表项为例,是以地址0x804a2dc处的内容为目标地址进行跳转。这样,只要把getenv()装入用户空间后的入口地址填写在0x804a2dc处,就可以实现正确的跳转,即实现了与共享库中函数getenv()的动态连接。这样,对于共享库函数的每次调用,额外的消耗只是执行一条间接寻址的jmp指令所需的时间。另一方面,这是不涉及堆栈的跳转指令,堆栈的内容在跳转的过程中保持不变,所以当getenv()执行ret指令返回时就直接回到了调用它的地方,在这里是前面的main()中。
由此可见,解释器的任务就是事先把getenv()装入用户空间后的入口地址填写在0x804a2dc处。不仅是getenv(),共享库提供的库函数可能有很多,对于每个这样的库函数都得保存一个用于间接寻址跳转的指针。保存这些指针的地方就是GOT。与PLT相对应,每个PLTn在GOT中都有个相应的GOTn,但是每个GOTn只是一个函数指针。同样,GOT0也是特殊的,而且GOT0的大小也不一样,有12个字节,相当于三个GOTn那么大。在“解释器”ld-linux.so的代码中把GOT0的三个长字表示成got[0]、got[1]、和got[2],注意不要跟GOTn相混淆。显然、解释器负有正确设置所有GOTn的责任。
既然如此,每个PLTn中只要一条指令就行了,代码中为什么有三条呢?还有,PLT0和GOT0又是干什么用的呢?原来,那都是为实现“懒惰式”的动态连接、即“懒连接”而存在的。简而言之,“懒连接”就是解释器并不事先完成对共享库函数的动态连接、即不事先设置GOTn、而把对具体共享库函数的动态连接拖延到真正要用的时候才来进行,需要用哪一个函数就连接哪一个函数,绝不“积极主动”,以免劳而无功。
然而,要是不事先设置好GOTn的内容,PLTn中的(间接寻址)跳转指令会跳到什么地方去?这要看GOTn中的原始内容,这内容来自目标映像(对外进行库函数调用的映像)。
我们看wine映像所提供的GOT原始内容。这个映像的GOT起始地址为0x0804a2c4,跳过GOT0的12个字节,GOTn是从0x0804a2d0开始的:
Relocation section '.rel.plt' at offset 0x548 contains 20 entries:
Offset Info Type Sym.Value Sym. Name
0804a2d0 00000107 R_386_JUMP_SLOT 08048610 strchr
0804a2d4 00000207 R_386_JUMP_SLOT 08048620 getpid
0804a2d8 00000307 R_386_JUMP_SLOT 08048630 fprintf
0804a2dc 00000407 R_386_JUMP_SLOT 08048640 getenv
0804a2e0 00000507 R_386_JUMP_SLOT 08048650 pthread_create
. . . . . .
从这里地址为0x0804a2dc的这一表项看,似乎这个指针所指向的是0x08048640,这正是指令“jmp *0x804a2dc”所在的位置。设想如果CPU在尚未完成对getenv()的动态连接之前就调用了这个函数,从而在PLT中执行了指令“jmp *0x804a2dc”,那岂不就是执行了一条指向其自身的跳转指令?这可是一个最紧扣的死循环!
然而事实并非如此,这里的信息是经过readelf整理的,旨在让使用者知道这一表项跟PLT表中地址为0x08048640的表项相对应,并且相应的函数名为getenv。这是readel综合了好几方面的信息才形成的报告,不幸的是在有些情况下这就成了很误导的报告。而实际上存储在映像中这个位置上的数据却有所不同。为此,我们通过另一个工具od去观察这个位置上真实的、原始的内容。根据前面关于.got的信息,地址0x 0804a2c4在映像中的位移是0x0012c4,所以从0x0804a2d0开始的几个指针是:
0012d0 8616 0804 8626 0804 8636 0804 8646 0804
0012e0 8656 0804 8666 0804 8676 0804 8686 0804
可见,地址0x0804a2dc处的内容其实指向0x08048646。这个地址同样在getenv()的PLT表项中,但这是第二条指令“push $0x18”所在的地址。所以,即使在动态连接之前就试图调用getenv(),至少在这里是不会出问题的。
回过去看getenv()在PLT中的表项。在执行了“push $0x18”以后,下一条指令是“jmp 0x8048600”,这就是PLT0的起点。注意每个PLTn表项的最后一条指令都是相同的,都是跳转到PLT0的起点,所不同的只是压入堆栈的数值,所以这里0x18就代表着getenv()。另一方面,PLT中在getenv()之前的几个表项压入堆栈的数值分别为0x0、0x8、0x10,所以0x18表示这是PLT中的第4个函数。相应地,我们从.dynsym的内容也可以得到验证:
Symbol table '.dynsym' contains 25 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 00000000 0 NOTYPE LOCAL DEFAULT UND
1: 08048610 359 FUNC GLOBAL DEFAULT UND strchr@GLIBC_2.0 (2)
2: 08048620 8 FUNC GLOBAL DEFAULT UND getpid@GLIBC_2.0 (2)
3: 08048630 23 FUNC GLOBAL DEFAULT UND fprintf@GLIBC_2.0 (2)
4: 08048640 229 FUNC GLOBAL DEFAULT UNDgetenv@GLIBC_2.0 (2)
5: 08048650 312 FUNC GLOBAL DEFAULT UND pthread_create@GLIBC_2.1 (3)
. . . . . .
再看PLT0,从getenv()的PLT表项跳转到PLT0以后,先把地址0x804a2c8处的内容压入堆栈,这就是got[1]的内容,是由解释器事先设置好了的,这是一个指向代表着本映像的数据结构的指针。然后又是间接寻址的跳转指令,这次使用的地址是0x804a2cc,即&got[2],其内容是个指针,也是由解释器事先设置好的,指向一个函数_dl_runtime_resolve(),这就是用来实现懒连接的函数。注意每次进入_dl_runtime_resolve()只完成一个共享库函数的懒连接,那就是把目标函数的实际地址填写到相应的GOTn中,并且跳转到这个函数。
这样,当目标程序对外调用某个共享库函数时,如果对该函数的动态连接业已完成,那么CPU通过相应的PLTn表项和GOTn表项进行间接寻址的跳转。而若是在尚未建立连接之前,那就临时实行“懒连接”。此时先后由PLTn和PLT0压入堆栈的两项数据分别代表着具体的(调用者)映像和需要调用的具体函数。
下面就是_dl_runtime_resolve()的事了。
_dl_runtime_resolve:\n\
pushl %eax # Preserve registers otherwise clobbered.\n\
pushl %ecx\n\
pushl %edx\n\
movl 16(%esp), %edx # Copy args pushed by PLT in register. Note\n\
movl 12(%esp), %eax # that `fixup' takes its parameters in regs.\n\
call fixup # Call resolver.\n\
popl %edx # Get register content back.\n\
popl %ecx\n\
xchgl %eax, (%esp) # Get %eax contents end store function address.\n\
ret $8 # Jump to function address.\n\
由于前面的三条push指令,这里的12(%esp)就是PLT0所压入的数据结构指针,也就是下面fixup()的第一个调用参数;而16(%esp)就是PLTn所压入的目标函数标识(位移)。函数fixup()一方面从共享库映像中找到目标函数的入口、并将其填写在GOTn中,使得下一次再调用同一函数时可以直接从PLTn通过间接寻址进入目标函数;一方面通过寄存器%eax返回这个函数指针。然后,指令“xchgl %eax, (%esp)”把这个指针交换到了堆栈上。这样一来,下面的“ret $8”指令就使CPU“返回”到了目标函数中,同时又从堆栈上清除了由PLT0和PLTn压入的两项数据。当CPU进入目标函数时,堆栈上的内容首先是调用点的返回地址,然后是对于目标函数的调用参数,就像目标函数直接受到调用时一样。
下面是fixup()的伪代码,有兴趣的读者可以在GLIBC-2.3/elf/dl-runtime.c中找到它的源代码。
fixup (struct link_map *map, unsigned reloc_offset)
{
在本映像(由第一个参数map给定)中找到其“字符串表”strtab。
根据第二个参数reloc_offset在本映像中的GOT和符号表中找到其表项。
/* 这些表项给出了目标共享库和目标函数的名字。 */
通过_dl_lookup_versioned_symbol()或_dl_lookup_symbol()找到目标共享库的映像,
并找到目标函数的地址。
/* 对于x86处理器,elf_machine_plt_value()不起作用。 */
通过elf_machine_fixup_plt()把目标函数的装入地址填写到本映像GOT的相应表项中。
返回目标函数的地址。
}
这整个过程完成了对一个目标函数的懒连接,并且实施了对于目标函数的调用。懒连接对于大型的软件往往能节约许多用于动态连接的时间。大型软件就其设计和编程而言常常是面面俱到的,所以在代码中要调用成百上千的共享库子程序,而且一个共享库又可能调用许多别的共享库,把所有这些共享库全都连接好可能很费时间,从而使得软件的启动速度明显变慢。但是,在实际的运行中,却可能只是集中在对一小部分共享库函数的调用,因为有许多共享库函数只是在特殊的条件下才会受到调用。这样,按实际需要进行的懒连接就显出其优越性来了。当然,就具体的函数而言,懒连接所需的时间反倒更长,但是因为需要连接的数量大大减少了,总的消耗就降低了。另一方面,懒连接是分散、零星地进行的,即使所消耗的时间总量不变,也比较不容易被使用者感觉到,因而更能被接受。所以有时候懒也有懒的好处。
总结两种动态连接的库函数调用过程,可以把它们表示为简明的流程如下:
懒连接:
调用点 —〉PLTn —〉GOTn —〉PLT0 —〉GOT0 —〉
_dl_runtime_resolve() —〉fixup()—〉被调用函数入口 —〉返回调用点
完成了动态连接之后:
调用点 —〉PLTn —〉GOTn —〉被调用函数入口 —〉返回调用点
这里所涉及的PLT和GOT都在调用者所在的映像中,GOTn的原始内容是在编译/连接的过程中生成的,但是GOT0的内容则要由解释器予以填写,并且_dl_runtime_resolve()和fixup()存在于解释器的映像中。所以,如果没有解释器,无论是正常的动态连接还是懒连接都无法实现。
顺便还要提一下,按编译时所使用的选项,由解释器设置到got[2]中的函数指针也可以不是指向_dl_runtime_resolve(),而是指向_dl_runtime_profile();而_dl_runtime_profile()所调用的不是fixup(),而是profile_fixup()。函数profile_fixup()不但实现懒连接,还使得以后每次通过PLTn/GOTn进行共享库函数调用时可以进行计数,从而统计出对每个共享库函数的调用次数。
上面说的是从固定地址的目标映像中调用共享库中的子程序。如前所述,从共享库中也可以调用别的共享库中的子程序,此时的过程只是略有不同。下面以共享库libwine.so为例作一些说明。
先看ELF头:
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: DYN (Shared object file)
Machine: Intel 80386
Version: 0x1
Entry point address: 0x1a60
Start of program headers: 52 (bytes into file)
Start of section headers: 311932 (bytes into file)
Flags: 0x0
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
Number of program headers: 3
Size of section headers: 40 (bytes)
Number of section headers: 32
Section header string table index: 29
不同之处首先在于映像的类型是DYN、表示动态连接库、而不是EXEC。另一方面,由于共享库是浮动的,没有固定的装入地址,所以程序入口0x1a60只是该入口在映像中的位移,而不像前面那样是0x8048750一类的目标地址。至于装入以后到底在什么位置上,那要取决于当时(用户空间)虚拟地址区间的动态分配。
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .hash HASH 00000094 000094 000428 04 A 2 0 4
[ 2] .dynsym DYNSYM 000004bc 0004bc 000850 10 A 3 1d 4
[ 3] .dynstr STRTAB 00000d0c 000d0c 0005aa 00 A 0 0 1
. . . . . .
[13] .rodata PROGBITS 00005140 005140 0004ec 00 A 0 0 32
[14] .eh_frame PROGBITS 0000562c 00562c 000004 00 A 0 0 4
[15] .data PROGBITS 00006630 005630 000060 00 WA 0 0 4
[16] .dynamic DYNAMIC 00006690 005690 0000e0 08 WA 3 0 4
. . . . . .
[20] .got PROGBITS 00006784 005784 000104 04 WA 0 0 4
. . . . . .
这里.data以前各项的“地址”与“位移”都是一致的,但是.data在映像中的位移为0x5630而“地址”为0x6630,二者相差0x1000,即4K字节、或一个页面。其实这毫不奇怪,只是说在装入用户空间时要在.eh_frame的终点与.data的起点之间空出一个页面。这里所谓“地址”0x6630是在装入用户空间以后的映像中的位移,而“位移”0x5630是在映像文件中的位移。除此以外,就没有什么特殊的了。
但是在PLT方面却有些不同。下面是libwine.so的PLT:
00001700 <.plt>:
1700: ff b3 04 00 00 00 pushl 0x4(%ebx)
1706: ff a3 08 00 00 00 jmp *0x8(%ebx)
170c: 00 00 add %al,(%eax)
170e: 00 00 add %al,(%eax)
1710: ff a3 0c 00 00 00 jmp *0xc(%ebx)
1716: 68 00 00 00 00 push $0x0
171b: e9 e0 ff ff ff jmp 1700 <_init+0x18>
1720: ff a3 10 00 00 00 jmp *0x10(%ebx)
1726: 68 08 00 00 00 push $0x8
172b: e9 d0 ff ff ff jmp 1700 <_init+0x18>
1730: ff a3 14 00 00 00 jmp *0x14(%ebx)
1736: 68 10 00 00 00 push $0x10
173b: e9 c0 ff ff ff jmp 1700 <_init+0x18>
1740: ff a3 18 00 00 00 jmp *0x18(%ebx)
1746: 68 18 00 00 00 push $0x18
174b: e9 b0 ff ff ff jmp 1700 <_init+0x18>
. . . . . .
与前面的PLT作一比较,就可以看到:无论是PLT0或PLTn,在形式上都与前面的一样,只是现在要用到GOT时均须使用基地址加位移的寻址方式。同样是间接寻址,在固定地址的映像中GOT的位置是预定的,而在浮动的共享库中则无法预先确定其地址。但是,共享库的GOT是共享库映像的一部分,随着共享库映像一起浮动,而GOT与PLT及代码之间的相对位移则保持不变,所以只要使寄存器%ebx中的基地址也一起浮动,就总是可以正确地寻访到GOT。所以这里PLT中的汇编代码有个前提,就是当通过call指令进入任何一个PLTn时寄存器%ebx的内容就是GOT的起点。
那么怎样保证使%ebx的内容指向GOT呢?我们看一个实例。这一次在使用objdump时加了-S可选项,把编译前的C语言源程序也一起打印出来。
/* print the usage message */
static void debug_usage(void)
{
2e84: 55 push %ebp
2e85: 89 e5 mov %esp,%ebp
2e87: 53 push %ebx
2e88: 83 ec 08 sub $0x8,%esp
2e8b: e8 00 00 00 00 call 2e90 <debug_usage+0xc>
2e90: 5b pop %ebx
2e91: 81 c3 f4 38 00 00 add $0x38f4,%ebx
static const char usage[] =
"Syntax of the WINEDEBUG variable:\n"
" WINEDEBUG=[class]+xxx,[class]-yyy,...\n\n"
"Example: WINEDEBUG=+all,warn-heap\n"
" turns on all messages except warning heap messages\n"
"Available message classes: err, warn, fixme, trace\n";
write( 2, usage, sizeof(usage) - 1 );
2e97: 68 d7 00 00 00 push $0xd7
2e9c: 8d 83 bc ec ff ff lea 0xffffecbc(%ebx),%eax
2ea2: 50 push %eax
2ea3: 6a 02 push $0x2
2ea5: e8 86 e8 ff ff call 1730 <_init+0x48>
这次要调用的函数是write()。这个函数的本身在另一个共享库libc.so中,而libwine.so映像中为调用该函数而设的PLTn表项则在位移为0x1730处。为了在进入PLT之前使%ebx指向GOT,这里玩了一个小小的“诡计”。在位移0x2e8b处有一条call指令,这条指令用的是相对寻址,相对位移为0表明所调用的目标就是它的下一条指令,即位移0x2e90处的指令。从CPU的执行轨迹看,这条指令的执行与否并没有什么影响,因为它的下一条指令本来就在0x2e90处。可是,另一方面,由于是call指令,堆栈上就有了它的返回地址,那也是0x2e90(确切地说是映像在用户空间的起点加上位移0x2e90,见下)。所以,这条call指令的意图和作用只是把地址0x2e90放到了堆栈上,接着的pop指令则又把它放到了寄存器%ebx中。注意真正在运行时放入%ebx中的数值其实并不是0x2e90,而是这个映像在用户空间的起点加上位移0x2e90。这样,就达到了让%ebx的内容“水涨船高”的目的。可是这样放入%ebx的还只是位移为0x2e90处在用户空间的实际地址,而不是GOT在用户空间的实际地址,所以接着又在上面加上了二者的差距0x38f4。我们不妨算一下:0x2e90加0x38f4是0x6784,而前面所列.got的相对地址恰好是0x6784。
至于GOT的内容,那些已经完成了动态连接的GOTn表项所持有的就是指向受调用共享库映像中相应函数的指针,这与固定地址映像中的GOTn并无不同,所不同的只是从相应PLTn中引用这个指针时的寻址方式不同。然而GOTn中用于懒连接的原始内容就有些不同了,下面仍用od观察libwine.so映像文件中GOT所在处的原始内容。注意GOT的起点0x6784是在装入用户空间以后的映像中的位移,而在映像文件中的位移则为0x5784:
005780 0000 0000 6690 0000 0000 0000 0000 0000
005790 1716 0000 1726 0000 1736 0000 1746 0000
0057a0 1756 0000 1766 0000 1776 0000 1786 0000
0057b0 1796 0000 17a6 0000 17b6 0000 17c6 0000
0057c0 17d6 0000 17e6 0000 17f6 0000 1806 0000
. . . . . .
对于共享库函数write(),这里的指针指向0x001736,但是那只是相应PLTn中的push指令在映像文件中的位移。显然,将映像装入(映射到)用户空间之后,还需要根据装入的位置对这些指针作出调整,这也是由解释器完成的。具体地,这是解释器通过一个函数_dl_relocate_object()完成的。对于装入的每一个共享库,解释器都要通过这个函数对其执行重定位(relocate),其中就包括了对其各个GOTn表项的重定位。
最后还要说明,同一个共享库的映像可以同时被映射到多个进程的用户空间。比方说,要是映像中的某个页面此刻存在于某个物理页面,那么这个物理页面就被映射到所有装入了这个共享库的进程中,只是在各个进程中的虚拟地址可能不同(但都在用户空间)。也就是说,一个拷贝为多个进程所共享,所以才叫“共享”库。不过这只是大体上而言,实际的情况还要复杂一些。读者在前面看到,wine映像有两个类型为LOAD的Segment。前者的访问权限为可读可执行、但是不可写,这当然可以为多个进程所共享。而后者的访问权限却是可读可写,这就不能由多个进程共享了。所以,凡属这个Segment中的页面,每个有关的进程就各有其自己的物理页面。再看这两个Segment中的内容。前者有(例如) .text和.plt等Section。这是可以理解的,因为.text是程序代码,这对于所有共享这个程序库的进程都一样;而.plt就是PLT,里面的内容也是对于所有共享这个程序库的进程都一样。再说,这些Section的内容也不会随着程序的运行而改变(不可写)。后者的内容则有(例如).data、.bss、.got等Section。不言而喻,.data和.bss中都是数据,当然不能让不同的进程互相干扰,必须得各有各的物理空间。至于.got的内容,那也得因进程而异。因为这是用来建立动态连接的,但是同一共享库在不同进程中的映射地址却可能不同,从而引起.got的内容也不相同。
在与别的进程共享程序段等等信息之余,每个进程都需要有些私有的、“本地的”信息,不能与别的进程共享,这是很自然、也比较容易实现的,因为毕竟不同的进程“生活”在不同的空间中。而同在一个空间的若干线程,则一般是不分彼此、“肝胆相照”的。但是,有时候也会需要有些“私房”,特别是在对一些全局量的使用上需要有只属于本线程的拷贝。为了解决这样的问题,就发展起来一种技术称为“线程本地存储(Thread Local Storage)”、即TLS。当然,解释器对于支持TLS的共享库有特殊的处理,这里就不深入进去了。
现在读者对ELF动态连接的过程已经有了个大致的认识。在此基础上,解释器的作用就可想而知了,大体上就是:
l 检查目标映像中类型为DYNAMIC的Segment,其中每个类型为NEEDED的表项都指定了一个需要用到的共享库。
l 对于所需的每个共享库,装入该共享库,并根据具体的装入地址对其实行重定位操作,包括修正其GOT中的原始内容。
l 如果不是懒连接,就根据目标映像的动态符号表对(目标映像中)每个相应的GOTn表项实施动态连接。
l 检查目标映像直接使用的每个(一级)共享库,如果又要用到别的(二级)共享库,就对其递归实施上述操作。余类推。
l 最后转入目标映像的程序入口。
至于解释器的具体代码,则一来比较冗长,二来并非我们当务之急,就留待以后空一些时候再说吧。