Linux内核深度解析
上QQ阅读APP看书,第一时间看更新

2.5.2 装载程序

当调度器调度新进程时,新进程从函数ret_from_fork开始执行,然后从系统调用fork返回用户空间,返回值是0。接着新进程使用系统调用execve装载程序。

Linux内核提供了两个装载程序的系统调用:

    int execve(const char *filename, char *const argv[], char *const envp[]);
    int execveat(int dirfd, const char *pathname, char *const argv[], char *const envp
    [], int flags);

两个系统调用的主要区别是:如果路径名是相对的,那么execve解释为相对调用进程的当前工作目录,而execveat解释为相对文件描述符dirfd指向的目录。如果路径名是绝对的,那么execveat忽略参数dirfd。

参数argv是传给新程序的参数指针数组,数组的每个元素存放一个参数字符串的地址,argv[0]应该指向要装载的程序的名称。

参数envp是传给新程序的环境指针数组,数组的每个元素存放一个环境字符串的地址,环境字符串的形式是“键=值”。

argv和envp都必须在数组的末尾包含一个空指针。

如果程序的main函数被定义为下面的形式,参数指针数组和环境指针数组可以被程序的main函数访问:

    int main(int argc, char *argv[], char *envp[]);

可是,POSIX.1标准没有规定main函数的第3个参数。根据POSIX.1标准,应该借助外部变量environ访问环境指针数组。

两个系统调用最终都调用函数do_execveat_common,其执行流程如图2.11所示。

图2.11 装载程序的执行流程

(1)调用函数do_open_execat打开可执行文件。

(2)调用函数sched_exec。装载程序是一次很好的实现处理器负载均衡的机会,因为此时进程在内存和缓存中的数据是最少的。选择负载最轻的处理器,然后唤醒当前处理器上的迁移线程,当前进程睡眠等待迁移线程把自己迁移到目标处理器。

(3)调用函数bprm_mm_init创建新的内存描述符,分配临时的用户栈。

如图2.12所示,临时用户栈的长度是一页,虚拟地址范围是[STACK_TOP_MAX−页长度,STACK_TOP_MAX), bprm->p指向在栈底保留一个字长(指针长度)后的位置。

图2.12 临时用户栈

(4)调用函数prepare_binprm设置进程证书,然后读文件的前面128字节到缓冲区。

(5)依次把文件名称、环境字符串和参数字符串压到用户栈,如图2.13所示。

图2.13 把文件名称、环境和参数压到用户栈

(6)调用函数exec_binprm。函数exec_binprm调用函数search_binary_handler,尝试注册过的每种二进制格式的处理程序,直到某个处理程序识别正在装载的程序为止。

1.二进制格式

在Linux内核中,每种二进制格式都表示为下面的数据结构的一个实例:

    include/linux/binfmts.h
    struct linux_binfmt {
          struct list_head lh;
          struct module *module;
          int (*load_binary)(struct linux_binprm *);
          int (*load_shlib)(struct file *);
          int (*core_dump)(struct coredump_params *cprm);
          unsigned long min_coredump;    /* 核心转储文件的最小长度 */
    };

每种二进制格式必须提供下面3个函数。

(1)load_binary用来加载普通程序。

(2)load_shlib用来加载共享库。

(3)core_dump用来在进程异常退出时生成核心转储文件。程序员使用调试器(例如GDB)分析核心转储文件以找出原因。min_coredump指定核心转储文件的最小长度。

每种二进制格式必须使用函数register_binfmt向内核注册。

下面介绍常用的二进制格式:ELF格式和脚本格式。

2.装载ELF程序

(1)ELF文件:ELF(Executable and Linkable Format)是可执行与可链接格式,主要有以下4种类型。

❑ 目标文件(object file),也称为可重定位文件(relocatable file),扩展名是“.o”,多个目标文件可以链接生成可执行文件或者共享库。

❑ 可执行文件(executable file)。

❑ 共享库(shared object file),扩展名是“.so”。

❑ 核心转储文件(core dump file)。

如图2.14所示,ELF文件分成4个部分:ELF首部、程序首部表(program header table)、节(section)和节首部表(section header table)。实际上,一个文件不一定包含全部内容,而且它们的位置也不一定像图2.14中这样安排,只有ELF首部的位置是固定的,其余各部分的位置和大小由ELF首部的成员决定。

图2.14 ELF文件的格式

程序首部表就是我们所说的段表(segment table),段(segment)是从运行的角度描述,节(section)是从链接的角度描述,一个段包含一个或多个节。在不会混淆的情况下,我们通常把节称为段,例如代码段(text section),不称为代码节。

32位ELF文件和64位ELF文件的差别很小,本书只介绍64位ELF文件的格式。

ELF首部的成员及说明如表2.4所示。

表2.4 ELF首部的成员及说明

程序首部表中每条表项的成员及说明如表2.5所示。

表2.5 程序首部表中每条表项的成员及说明

节首部表中每条表项的成员及说明如表2.6所示。

表2.6 节首部表中每条表项的成员及说明

重要的节及说明如表2.7所示。

表2.7 重要的节及说明

可以使用程序“readelf”查看ELF文件的信息。

1)查看ELF首部:readelf -h <ELF文件的名称>。

2)查看程序首部表:readelf -l <ELF文件的名称>。

3)查看节首部表:readelf -S <ELF文件的名称>。

(2)代码实现:内核中负责解析ELF程序的源文件,如表2.8所示。

表2.8 解析ELF程序的源文件

如图2.15所示,源文件“fs/binfmt_elf.c”定义的函数load_elf_binary负责装载ELF程序,主要步骤如下。

图2.15 装载ELF程序

1)检查ELF首部。检查前4字节是不是ELF魔幻数,检查是不是可执行文件或者共享库,检查处理器架构。

2)读取程序首部表。

3)在程序首部表中查找解释器段,如果程序需要链接动态库,那么存在解释器段,从解释器段读取解释器的文件名称,打开文件,然后读取ELF首部。

4)检查解释器的ELF首部,读取解释器的程序首部表。

5)调用函数flush_old_exec终止线程组中的所有其他线程,释放旧的用户虚拟地址空间,关闭那些设置了“执行execve时关闭”标志的文件。

6)调用函数setup_new_exec。函数setup_new_exec调用函数arch_pick_mmap_layout以设置内存映射的布局,在堆和栈之间有一个内存映射区域,传统方案是内存映射区域向栈的方向扩展,另一种方案是内存映射区域向堆的方向扩展,从两种方案中选择一种。然后把进程的名称设置为目标程序的名称,设置用户虚拟地址空间的大小。

7)以前调用函数bprm_mm_init创建了临时的用户栈,现在调用函数set_arg_pages把用户栈定下来,更新用户栈的标志位和访问权限,把用户栈移动到最终的位置,并且扩大用户栈。

8)把所有可加载段映射到进程的虚拟地址空间。

9)调用函数setbrk把未初始化数据段映射到进程的用户虚拟地址空间,并且设置堆的起始虚拟地址,然后调用函数padzero用零填充未初始化数据段。

10)得到程序的入口。如果程序有解释器段,那么把解释器程序中的所有可加载段映射到进程的用户虚拟地址空间,程序入口是解释器程序的入口,否则就是目标程序自身的入口。

11)调用函数create_elf_tables依次把传递ELF解释器信息的辅助向量、环境指针数组envp、参数指针数组argv和参数个数argc压到进程的用户栈。

12)调用函数start_thread设置结构体pt_regs中的程序计数器和栈指针寄存器。当进程从用户模式切换到内核模式时,内核把用户模式的各种寄存器保存在内核栈底部的结构体pt_regs中。因为不同处理器架构的寄存器不同,所以各种处理器架构必须自定义结构体pt_regs和函数start_thread, ARM64架构定义的函数start_thread如下:

    arch/arm64/include/asm/processor.h
    static inline void start_thread_common(struct pt_regs *regs, unsigned long pc)
    {
          memset(regs, 0, sizeof(*regs));
          regs->syscallno = 0UL;
          regs->pc = pc;                   /* 把程序计数器设置为程序的入口 */
    }
    static inline void start_thread(struct pt_regs *regs, unsigned long pc,
                        unsigned long sp)
    {
          start_thread_common(regs, pc);
          regs->pstate = PSR_MODE_EL0t;  /* 把处理器状态设置为0,其中异常级别是0 */
          regs->sp = sp;                  /*设置用户栈指针 */
    }

3.装载脚本程序

脚本程序的主要特征是:前两字节是“#!”,后面是解释程序的名称和参数。解释程序用来解释执行脚本程序。

如图2.16所示,源文件“fs/binfmt_script.c”定义的函数load_script负责装载脚本程序,主要步骤如下。

图2.16 装载脚本程序

(1)检查前两字节是不是脚本程序的标识符。

(2)解析出解释程序的名称和参数。

(3)从用户栈删除第一个参数,然后依次把脚本程序的文件名称、传给解释程序的参数和解释程序的名称压到用户栈。

(4)调用函数open_exec打开解释程序文件。

(5)调用函数prepare_binprm设置进程证书,然后读取解释程序文件的前128字节到缓冲区。

(6)调用函数search_binary_handler,尝试注册过的每种二进制格式的处理程序,直到某个处理程序识别解释程序为止。