# 目录
## 装载概述
## 装载理论篇
## Mach-O文件的装载
## * Linux ELF文件的装载(了解)
# 装载概述
在链接完成之后,应用开始运行之前,有一段装载过程,我们都知道程序执行时所需要的指令和数据必须在内存中才能够被正常运行。
最简单的办法就是将程序运行所需要的指令和数据全都装入内存中,这样程序就可以顺利运行,这就是最简单的静态装入的办法。
但是很多情况下程序所需要的内存数量大于物理内存的数量,当内存的数量不够时,根本的解决办法就是添加内存。相对于磁盘来说,内存是昂贵且稀有的,这种情况自计算机磁盘诞生以来一直如此。所以人们想尽各种办法,希望能够在不添加内存的情况下让更多的程序运行起来,尽可能有效地利用内存。后来研究发现,程序运行时是有局部性原理的,所以我们可以将程序最常用的部分驻留在内存中,而将一些不太常用的数据存放在磁盘里面,这就是动态装入的基本原理。(这也是虚拟地址空间机制要解决的问题,这里不再赘述,大学都学过)
覆盖装入(Overlay)和页映射(Paging)是两种很典型的动态装载方法,它们所采用的思想都差不多,原则上都是利用了程序的局部性原理。动态装入的思想是程序用到哪个模块,就将哪个模块装入内存,如果不用就暂时不装入,存放在磁盘中。
# 装载理论篇
在虚拟存储中,现代的硬件MMU都提供地址转换的功能。有了硬件的地址转换和页映射机制,操作系统动态加载可执行文件的方式跟静态加载有了很大的区别。
事实上,从操作系统的角度来看,一个进程最关键的特征是它拥有独立的虚拟地址空间,这使得它有别于其他进程。很多时候一个程序被执行同时都伴随着一个新的进程的创建,那么我们就来看看这种最通常的情形:创建一个进程,然后装载相应的可执行文件并且执行。在有虚拟存储的情况下,上述过程最开始只需要做三件事情:
- 创建一个独立的虚拟地址空间。
- 读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系。
- 将CPU的指令寄存器设置成可执行文件的入口地址,启动运行。
首先是创建虚拟地址空间。一个虚拟空间由一组页映射函数将虚拟空间的各个页映射至相应的物理空间,所以创建一个虚拟空间实际上并不是创建空间而是创建映射函数所需要的相应的数据结构,在i386 的Linux下,创建虚拟地址空间实际上只是分配一个页目录(Page Directory)就可以了,甚至不设置页映射关系,这些映射关系等到后面程序发生页错误的时候再进行设置。
读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系。上面那一步的页映射关系函数是虚拟空间到物理内存的映射关系,这一步所做的是虚拟空间与可执行文件的映射关系。我们知道,当程序执行发生页错误时,操作系统将从物理内存中分配一个物理页,然后将该“缺页”从磁盘中读取到内存中,再设置缺页的虚拟页和物理页的映射关系,这样程序才得以正常运行。
但是很明显的一点是,当操作系统捕获到缺页错误时,它应知道程序当前所需要的页在可执行文件中的哪一个位置。这就是虚拟空间与可执行文件之间的映射关系。从某种角度来看,这一步是整个装载过程中最重要的一步,也是传统意义上“装载”的过程。
由于可执行文件在装载时实际上是被映射的虚拟空间,所以可执行文件很多时候又被叫做映像文件(Image)。
很明显,这种映射关系只是保存在操作系统内部的一个数据结构。Linux中将进程虚拟空间中的一个段叫做虚拟内存区域(VMA, Virtual Memory Area);在Windows中将这个叫做虚拟段(Virtual Section),其实它们都是同一个概念。
VMA是一个很重要的概念,它对于我们理解程序的装载执行和操作系统如何管理进程的虚拟空间有非常重要的帮助。
操作系统在内部保存这种结构,很明显是因为当程序执行发生段错误时,它可以通过查找这样的一个数据结构来定位错误页在可执行文件中的位置。
将CPU指令寄存器设置成可执行文件入口,启动运行。第三步其实也是最简单的一步,操作系统通过设置CPU的指令寄存器将控制权转交给进程,由此进程开始执行。这一步看似简单,实际上在操作系统层面上比较复杂,它涉及内核堆栈和用户堆栈的切换、CPU运行权限的切换。不过从进程的角度看这一步可以简单地认为操作系统执行了一条跳转指令,直接跳转到可执行文件的入口地址(通常是text区的地址)。
- ELF文件头中,有
e_entry字段保存入口地址 - Mach-O文件中的
LC_MAIN加载指令作用就是设置程序主程序的入口点地址和栈大小)
# Mach-O文件的装载
## 加载器mach_loader
(二) Mach-O 文件结构一文中已经说到Mach Heade中的Load Command加载命令,结合其用途,就可以简单看出可执行文件的装载流程(此刻,新进程已经创建,进入执行Mach-O可执行文件阶段):
-
首先,是由内核加载器(定义在
bsd/kern/mach_loader.c文件中)来处理一些需要由内核加载器直接使用的加载命令,由这些命令的用途可以看到在装载过程中,内核加载器所处理的工作:加载过程在内核的部分负责新进程的基本设置——分配虚拟内存,创建主线程,以及处理任何可能的代码签名/加密的工作。(这也是本篇内容主要讲的) -
接着,对于需要动态链接(使用了动态库)的可执行文件(大部分可执行文件都是动态链接的)来说,控制权会转交给链接器,链接器进而接着处理文件头中的其他加载命令。真正的库加载和符号解析的工作都是通过
LC_LOAD_DY LINKER命令指定的动态链接器在用户态完成的。(下一篇文章再细讲dyld及动态链接)
接下来,就结合Load Command来看看内核加载器主要做的几件事:
## 一、LC_CODE_SIGNATURE、数字签名
Mach-O二进制文件有一个重要特性就是可以进行数字签名。尽管在 OS X 中仍然没怎么使用数字签名,不过由于代码签名和新改进的沙盒机制绑定在一起,所以签名的使用率也越来越高。在 iOS 中,代码签名是强制要求的,这也是苹果尽可能对系统封锁的另一种尝试:在 iOS 中只有苹果自己的签名才会被认可。在 OS X 中,code sign(1) 工具可以用于操纵和显示代码签名。man手册页,以及 Apple's code signing guide 和 Mac OS X Code Signing In Depth文档都从系统管理员的角度详细解释了代码签名机制。
LC_CODE_SIGNATURE 包含了 Mach-O 二进制文件的代码签名,如果这个签名和代码本身不匹配(或者如果在iOS上这条命令不存在),那么内核会立即给进程发送一个SIGKILL信号将进程杀掉,没有商量的余地,毫不留情。
在iOS 4之前,还可以通过两条sysctl(8)命令覆盖负责强制执行(利用内核的MAC,即Mandatory AccessControl)的内核变量,从而实现禁用代码签名检查:
sysctl -w security.mac.proc_enforce = 0 //禁用进程的MAC
sysctl -w security.mac.vnode_enforce=0 //禁用VNode的MAC
而在之后版本的iOS中,苹果意识到只要能够获得root权限,越狱者就可以覆盖内核变量。因此这些变量变成了只读变量。untethered越狱(即完美越狱)因为利用了一个内核漏洞所以可以修改这些变量。由于这些变量的默认值都是启用签名检查,所以不完美越狱会导致非苹果签名的应用程序崩溃——除非i设备以完美越狱的方式引导。
此外,通过 Saurik 的 ldid 这类工具可以在 Mach-O 中嵌入伪代码签名。这个工具可以替代OS X的code sign(1),允许生成自我签署认证的伪签名。这在iOS中尤为重要,因为签名和沙盒模型的应用程序“entitlement”绑定在一起, 而后者在iOS中是强制要求的。entitlement 是声明式的许可(以plist的形式保存),必须内嵌在Mach-O中并且通过签名盖章,从而允许执行安全敏感的操作时具有运行时权限。
OS X 和 iOS 都有一个特殊的系统调用csops(#169)用于代码签名的操作
## 二、LC_SEGMENT、进程虚拟内存设置
LC_SEGMENT(或LC_SEGMENT_64) 命令是最主要的加载命令,这条命令指导内核如何设置新运行的进程的内存空间。这些“段”直接从Mach-O二进制文件加载到内存中。
每一条LC_SEGMENT[64] 命令都提供了段布局的所有必要细节信息。
struct segment_command_64 { /* for 64-bit architectures */
uint32_t cmd; / Load Command类型,这里LC_SEGMENT_64代表将文件中64位的段映射到进程的地址空间。LC_SEGMENT_64和LC_SEGMENT的结构差别不大 /
uint32_t cmdsize; / 代表Load commands的大小 /
char segname[16]; / 16字节的段名称 /
uint64_t vmaddr; / 段映射到虚拟地址中的内存起始地址 /
uint64_t vmsize; / 段映射到虚拟地址中的内存大小 /
uint64_t fileoff; / 段在当前架构(MachO)文件中的偏移量,如果是胖二进制文件,也指的是相对于当前MachO文件的偏移 /
uint64_t filesize; / 段在文件中的大小 /
vm_prot_t maxprot; / 段页面的最高内存保护,用八进制表示(4=r(read),2=w(write),1=x(execute执行权限)) /
vm_prot_t initprot; / 段页面最初始的内存保护 /
uint32_t nsects; / segment包含的区(section)的个数(如果存在的话) /
uint32_t flags; / 段页面标志 /
};
有了LC_SEGMENT命令,设置进程虚拟内存的过程就变成遵循LC_SEGMENT命令的简单操作。对于每一个段,将文件中相应的内容加载到内存中:从偏移量为 fileoff 处加载 filesize 字节到虚拟内存地址 vmaddr 处的 vmsize 字节。每一个段的页面都根据 initprot 进行初始化,initprot 指定了如何通过读/写/执行位初始化页面的保护级别。段的保护设置可以动态改变,但是不能超过 maxprot 中指定的值(在iOS中,+x和+w是互斥的)。
__PAGEZERO段(空指针陷阱)、_TEXT段(程序代码)、_DATA段(程序数据)和_LINKEDIT(链接器使用的符号和其他表)段提供了LC_SEGMENT命令。段有时候也可以进一步分解为区(section)。
关于 Segment 与 Section 的介绍,在上一篇《(二) Mach-O 文件结构》中已经介绍过,不再赘述
## 三、LC_UNIXTHREAD与LC_MAIN、启动主线程
当所有的库都完成加载之后,dyld的工作也完成了,之后由LC_UNIXTHREAD命令负责启动二进制程序的主线程(因此主线程总是在可执行文件中,而不会在其他二进制文件中,例如库文件)。
根据架构的不同,这条命令会列出所有初始化寄存器的状态,不同架构的寄存器状态不同,这些不同的架构包括i386_THREAD_STATE(32位)、x86_THREAD_STATE64(64位)以及iOS中的ARM_THREAD_STATE。在任何一种架构中, 大部分寄存器应该都会初始化为0,其中指令指针(Intel的IP)或程序计数器(ARM的r15)是例外,这些寄存器保存了程序入口点的地址。
从Mountain Lion开始,一条新的加载命令LC_MAIN替代了LC_UNIX_THREAD命令。这条命令的作用是设置程序主线程的入口点地址和栈大小。这条命令比LC_UNIXTHREAD命令更实用一些, 因为无论如何除了程序计数器之外所有的寄存器都设置为0了。由于没有LC_UNIXTHREAD命令, 所以不可以在之前版本的OSX上运行使用了LC_MAIN的二进制文件(在加载时会导致dyld(1)崩溃)。
# * Linux ELF文件的装载(了解)
首先在用户层面,bash进程会调用fork()系统调用创建一个新的进程,然后新的进程调用 execve()系统调用执行指定的ELF文件,原先的bash进程继续返回等待刚才启动的新进程结束,然后继续等待用户输入命令。 execve() 系统调用被定义在unistd.h,它的原型如下:
/*
* 三个参数分别是被执行的程序文件名、执行参数和环境变量。
*/
int execve(const char *filename, char *const argv[], char *const envp[]);
Glibc对该系统调用进行了包装,提供了 execl()、execlp()、execle()、execv()、execvp()等5个不同形式的exec系列API,它们只是在调用的参数形式上有所区别,但最终都会调用到 execve() 这个系统中。
在进入 execve() 系统调用之后,Linux内核就开始进行真正的装载工作。
-
sys_execve(),在内核中,该函数是execve()系统调用相应的入口,定义在arch\i386\kernel\Process.c。 该函数进行一些参数的检查复制之后,调用 do_execve()。 -
do_execve(),该函数会首先查找被执行的文件,如果找到文件,则读取文件的前128个字节。目的是判断文件的格式,每种可执行文件的格式的开头几个字节都是很特殊的,特别是开头4个字节,常常被称做魔数(Magic Number),通过对魔数的判断可以确定文件的格式和类型。比如:- ELF的可执行文件格式的头4个字节为0x7F、’e’、’l’、’f’;
- Java的可执行文件格式的头4个字节为’c’、’a’、’f’、’e’;
- 如果被执行的是Shell脚本或perl、python等这种解释型语言的脚本,那么它的第一行往往是 “#!/bin/sh” 或 “#!/usr/bin/perl” 或 “#!/usr/bin/python” ,这时候前两个字节
'#'和'!'就构成了魔数,系统一旦判断到这两个字节,就对后面的字符串进行解析,以确定具体的解释程序的路径。
当do_execve()读取了这128个字节的文件头部之后,然后调用search_binary_handle()。
-
search_binary_handle(),该函数会去搜索和匹配合适的可执行文件装载处理过程。Linux中所有被支持的可执行文件格式都有相应的装载处理过程,此函数会通过判断文件头部的魔数确定文件的格式,并且调用相应的装载处理过程。比如:- ELF可执行文件的装载处理过程叫做 load_elf_binary();
- a.out可执行文件的装载处理过程叫做 load_aout_binary();
- 装载可执行脚本程序的处理过程叫做 load_script()。
-
load_elf_binary(),这个函数被定义在fs/Binfmt_elf.c,代码比较长,它的主要步骤是:- 检查ELF可执行文件格式的有效性,比如魔数、程序头表中段(Segment)的数量。
- 寻找动态链接的“.interp”段,设置动态链接器路径。
- 根据ELF可执行文件的程序头表的描述,对ELF文件进行映射,比如代码、数据、只读数据。
- 初始化ELF进程环境,比如进程启动时EDX寄存器的地址应该是 DT_FINI 的地址(动态链接相关)。
- 将系统调用的返回地址修改成ELF可执行文件的入口点,这个入口点取决于程序的链接方式,对于静态链接的ELF可执行文件,这个程序入口就是ELF文件的文件头中
e_entry所指的地址;对于动态链接的ELF可执行文件,程序入口点是动态链接器。
当 load_elf_binary() 执行完毕,返回至 do_execve() 再返回至 sys_execve() 时, 上面的第5步中已经把系统调用的返回地址改成了被装载的ELF程序(或动态链接器)的入口地址了。所以当 sys_execve()系统调用从内核态返回到用户态时,EIP 寄存器直接跳转到了ELF程序的入口地址,于是新的程序开始执行,ELF可执行文件装载完成。