在Linux系统中运行一个可执行的ELF文件时,内核首先需要识别这个文件,然后解析并装载它以构建进程的内存空间,最后切换到新的进程来运行。

在fs/binfmt_elf.c中定义了函数load_elf_binary()和load_elf_library()分别用于装载和解析ELF格式的可执行文件和动态连接库。下面来研究一下在load_elf_binary()中做了哪些事情,一个新的进程的内存空间是布局是怎样计算出来的。

下图是一个典型的Linux程序的进程空间模型。一个进程的虚拟空间包含以下区域(从地址由底到高):

  1. 代码区(text):存放可执行的代码;
  2. 数据区(data):存放经过初始化的数据;
  3. 数据区(bss):bss区存放的也是数据,不过在这里的数据是没有初始它的,而且是全局的。即那些在代码里面声明了但是没有赋初始值全局变量。未初始化全局变量在ELF文件中不占有存储空间,但是在内存空间里必须占有一席之地。
  4. 堆(heap):进程运行期间动态分配内存的区域,当进程需要分配更多的内存时,它将向上扩展;
  5. 栈(stack):进程的栈,它的扩展方向与堆刚好相反,当有新的函数调用时,它将向下扩展。

ELF文件装载的最终目的有两个:

  1. 确定各个区域的边界:text区的起始和终止位置,data区的起始和终止位置,bss区的起始和终止位置,heap和stack的起始位置(它们的终止位置是动态变化的)。
  2. 把text区和data区的内容做mmap映射:ELF文件的内容不会被真地拷贝到内存,只有当真正需要的时候,内核才会通过page fault的形式把文件内存复制到内存中去。

下面来一步步分析load_elf_binary()函数的代码。
load_elf_binary()函数有两个参数:

[c] static int load_elf_binary(struct linux_binprm *bprm, struct pt_regs *regs) [/c]

其中第一个参数bprm含有很多装载二进制文件所需的信息,结构如下:

[c] struct linux_binprm {
char buf[BINPRM_BUF_SIZE]; #ifdef CONFIG_MMU struct vm_area_struct *vma; unsigned long vma_pages; #else # define MAX_ARG_PAGES 32 struct page *page[MAX_ARG_PAGES]; #endif struct mm_struct *mm; unsigned long p; unsigned int cred_prepared:1, cap_effective:1; #ifdef __alpha__ unsigned int taso:1; #endif unsigned int recursion_depth; struct file * file; struct cred *cred; int unsafe; unsigned int per_clear; int argc, envc; const char * filename; const char * interp; unsigned interp_flags; unsigned interp_data; unsigned long loader, exec; }; [/c]

此结构的第一个成员buf中含有ELF文件开关处的一段内容,共128个字节,这段内容包含了ELF文件头和程序头表,这些信息足够用于构造进程的虚拟空间结构。
接下来,首先读入ELF文件头,其数据结构定义为:

[c] typedef struct elf32_hdr{
unsigned char e_ident[EI_NIDENT]; Elf32_Half e_type; Elf32_Half e_machine; Elf32_Word e_version; Elf32_Addr e_entry; 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; [/c]

然后对文件头做一些必要的检查,比如文件类型(magic number)、体系结构等:

[c]     if (memcmp(loc->elf_ex.e_ident, ELFMAG, SELFMAG) != 0)                goto out;  if (loc->elf_ex.e_type != ET_EXEC && loc->elf_ex.e_type != ET_DYN)            goto out;     if (!elf_check_arch(&loc->elf_ex))            goto out;     if (!bprm->file->f_op || !bprm->file->f_op->mmap)             goto out; [/c]

接下来需要读入程序头表(段表),ELF文件头elf32_hdr结构中的e_phoff即是程序头表在文件中的位置偏移量,e_phnum是程序头表中表项的个数,也就是文件中段的数目。

[c]        size = loc->elf_ex.e_phnum * sizeof(struct elf_phdr);         retval = -ENOMEM;     elf_phdata = kmalloc(size, GFP_KERNEL);       retval = kernel_read(bprm->file, loc->elf_ex.e_phoff,                              (char *)elf_phdata, size); [/c]

读入的程序头表存放在elf_phdata中。
在逐个解析段表项之前,先初始它各个段的起始位置和终止位置:

[c]      elf_ppnt = elf_phdata;        elf_bss = 0;  elf_brk = 0;       start_code = ~0UL;    end_code = 0;         start_data = 0;       end_data = 0; [/c]

在开始解析之前,还需要查看ELF文件中是否指明了具体的解析器,如果是的话,需要装入这个解析器程序,用它来解析ELF文件:

[c]         for (i = 0; i < loc->elf_ex.e_phnum; i  ) {
if (elf_ppnt->p_type == PT_INTERP) {
[/c]

本文不考虑这种情况,所以略过外部解析器相关的代码。
下面开始一段非常重要的代码,这里开始计算进程空间各个区的位置:

[c]    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; [/c]

这个循环将遍历所有段表项,并且只处理那些可装载的段。

[c]               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;             elf_flags = MAP_PRIVATE | MAP_DENYWRITE | MAP_EXECUTABLE;          vaddr = elf_ppnt->p_vaddr; ......           error = elf_map(bprm->file, load_bias   vaddr, elf_ppnt,                              elf_prot, elf_flags, 0); [/c]

上面的代码,首先读取了这一段的读写权限,然后又读入了这一段在进程空间中的地址p_vaddr。接着,把此段的内容做了elf_map映射,即从文件中映射到了进程空间中,但要注意的是,映射到进程空间中的地址不只是vaddr,这里还加了一个偏移量load_bias,这个偏移量将被加在所有段的映射上,只所以设计这样一个偏移量,是为满足段的位置随机化的需要。如果没有打开随机化这一功能的话,load_bias的值将保持为0。

下面的一段代码用于计算代码区和数据区的开始位置:

[c]
k = elf_ppnt->p_vaddr;                if (k < start_code)                   start_code = k;               if (start_data < k)                   start_data = k;
[/c]

由于代码区在进程空间的最前面,如果当前映射的这一段的开始位置还位于当前的代码区之前,那么代码区的开始位置应该还要向前移,至少移到这一段的位置上。

而如果当前映射的这一段的开始位置还位于当前的数据区之后,那么数据区的开始位置还应该向后移,至少移到这一段的位置上。这是因为数据区在可装载的段的最后,不应该有哪个段的位置比较数据区还靠后。

接下来的代码是用于计算几个区的结束位置:

[c]
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;
[/c]

elf_bss变量记录的是BSS区的开始位置。BSS区排在所有可加载段的后面,即它的开始处也就是最后一个可加载段的结尾处。所以总是把当前加载段的结尾与它相比,如果当前加载段的结尾比较靠后的话,则还需要把BSS区往后推。

elf_brk变量记录的是堆(heap)的上边界,现在进程还没有运行起来,没有从堆上面申请内存,所以堆的大小是0,堆的上边界与下边界重合,而堆的位置还在BSS之后,即它的开始位置应该是BSS区的结构位置。

一般情况下,一个程序头的p_memsz与p_filesz如果不一样大小的话,其差值应是未初始化全局变量的大小,这段空间应归入BSS区。上面代码中两个k值的计算正是考虑到这一点,所以第二次k值(BRK)的计算是把BSS区大小也计算在内的。

最后,为所有计算出的区起止位置加上随机化偏移量:

[c]
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;
[/c]

进程空间中各区域起止位置的计算到此完成。