dyld背后的故事&源码分析

什么是dyld?

 dyld(the dynamic link editor)是苹果的动态连接器,是苹果操做系统的一个重要组成部分,当系统内核作好启动程序的准备工做以后,余下的工做会交给dyld来负责处理。那它存在的意义是什么?它又具体都负责作些什么呢?这一篇咱们一块儿来一探究竟。前方长篇预警~程序员


dyld存在的意义

 存在即合理,但咱们要弄清楚其合理性所在。先从可执行文件是如何由源码生成的提及。算法

1.可执行文件的生成--静态连接。

先看下面这段代码:编程

#include<stdio.h>

int main()
{
	printf("Hello World\n");
	return 0;
}
复制代码

 假设这段代码源文件为hello.c,咱们输入最简单的命令:$gcc hello.c $./a.out,那么终端会输出:Hello World,在这个过程当中,事实上通过了四个步骤:预处理、编译、汇编和连接。咱们来具体看每一步都作了些什么。bootstrap

预编译的主要处理规则以下:数组

  1. 删除全部#define,并将全部宏定义展开
  2. 将被包含的文件插入到预编译指令(#include)所在位置(这个过程是递归的)
  3. 删除全部注释:// 、/* */等
  4. 添加行号和文件名标识,以便于编译时编译器产生调试用的行号信息及编译时可以显示警告和错误的所在行号
  5. 保留全部的#pragma编译器指令,由于编译器需要使用它们

结合上述规则,当咱们没法判断宏定义是否正确或者头文件是否包含时能够查看预编译后的文件来肯定问题,预编译的过程至关于以下命令:
$gcc -E hello.c -o hello.i
$cpp hello.c > hello.i缓存

编译的过程就是把预处理完的文件进行一些列词法分析、语法分析、语义分析及优化后生产相应的汇编代码文件,这个过程每每是咱们整个程序构建的核心部分,也是最复杂的部分之一,编译的具体步骤涉及到编译原理等内容,这里就不展开了。咱们使用命令:
$gcc -S hello.c -o hello.s
能够获得汇编输出文件hello.s。bash

 对于 C 语言的代码来讲,这个预编译和编译的程序是 ccl,可是对于 C++ 来讲,对应的程序是 ccplus;Objective-C 的是 ccobjc;Java 是 jcl。因此实际上 gcc 这个命令只是这些后台程序的包装,它会根据不一样的参数要求去调用预编译编译程序 ccl、汇编器 as、连接器 ld。数据结构

汇编器是将汇编代码转变成机器能够执行的指令,每个汇编语句几乎都对应一条机器指令。因此汇编器的汇编过程相对于编译器来说比较简单,它没有复杂的语法,也没有语义,也不须要作指令优化,只是根据汇编指令和机器指令的对照表一一翻译就能够了,咱们使用命令:
$as hello.s -o hello.o
$gcc -c hello.s -o hello.o
来完成汇编,输出目标文件(Object File):hello.o。ide

连接是让不少人费解的一个过程,为何汇编器不直接输出可执行文件而是一个目标文件呢?连接过程到底包含了什么内容?为何要连接?函数

 这就要扯一下计算机程序开发的历史了,最先的时候程序员是在纸带上用机器语言经过打孔来实现程序的,连汇编语言都没有,每当程序修改的时候,修改的指令后面的位置要相应的发生移动,程序员要人工计算每一个子程序或跳转的目标地址,这个过程叫重定位。很显然这样修改程序的代价随着程序的增大会变得遥不可及,而且很容易出错,因而有先驱发明了汇编语言,汇编语言使用接近人类的各类符号和标记来帮助记忆,更重要的是,这种符号使得人们从具体的指令地址中逐步解放出来,当人们使用这种符号命名子程序或者跳转目标之后,无论目标指令以前修改了多少指令致使目标指令的地址发生了变化,汇编器在每次汇编程序的时候都会从新计算目标指令的地址,而后把全部引用到该指令的指令修正到正确的地址,这个过程不须要人工参与。

 有了汇编语言,生产力极大地提升了,随之而来的是软件的规模与日俱增,代码量快速膨胀,致使人们开始考虑将不一样功能的代码以必定的方式组织起来,使得更加容易阅读和理解,更便于往后修改和复用。天然而然的,咱们开始习惯用若干个变量和函数组成一个模块(好比类),而后用目录结构来组织这些源代码文件,在一个程序被多个模块分割之后,这些模块最终如何组合成一个单一的程序是需要解决的问题。这个问题归根结底是模块之间如何通讯的问题,也就是访问函数须要知道函数的地址,访问变量须要知道变量的地址,这两个问题都是经过模块间符号的引用的方式来解决。这个模块间符号引用拼接的过程就是连接

连接的主要内容就是把各个模块之间相互引用的部分处理好,使得各个模块之间可以正确地衔接。本质上跟前面描述的“程序员人工调整地址”没什么区别,只不过现代的高级语言的诸多特性和功能,使得编译器、连接器更为复杂,功能更强大。连接过程包括了地址和空间分配符号决议(也叫“符号/地址绑定”,“决议”更倾向于静态连接,而“绑定”更倾向于动态连接,即适用范围的区别)和重定位,连接器将通过汇编器编译成的全部目标文件和进行连接造成最终的可执行文件,而最多见的库就是运行时库(RunTime Library),它是支持程序运行的基本函数的集合。其实就是一组最经常使用的代码编译成目标文件后的打包存放。

 知道了可执行文件是如何生成的,咱们再来看看它又是如何被装载进系统中运行起来的。

2.可执行文件的装载与动态连接。

装载

 装载与动态连接其实内容特别多,不少细节须要对计算机底层有很是扎实的理解,鉴于目前个人能力尚浅,这里只作粗略的介绍,推荐有兴趣的同窗购买《程序员的自我修养--连接、装载与库》这本书了解更多细节。

 可执行文件(程序)是一个静态的概念,在运行以前它只是硬盘上的一个文件;而进程是一个动态的概念,它是程序运行时的一个过程,咱们知道每一个程序被运行起来后,它会拥有本身独立的虚拟地址空间,这个地址空间大小的上限是由计算机的硬件(CPU的位数)决定的,好比32位的处理器理论最大虚拟空间地址为0~2^32-1。即0x00000000~0xFFFFFFFF,固然,咱们的程序运行在系统上时是不可能任意使用所有的虚拟空间的,操做系统为了达到监控程序运行等一系列目的,进程的虚拟空间都在操做系统的掌握之中,且在操做系统中会同时运行着多个进程,它们彼此之间的虚拟地址空间是隔离的,若是进程访问了操做系统分配给该进程之外的地址空间,会被系统当作非法操做而强制结束进程。

 将硬盘上的可执行文件映射到虚拟内存中的过程就是装载,但内存是昂贵且稀有的,因此将程序执行时所需的指令和数据所有装载到内存中显然是行不通的,因而人们研究发现了程序运行时是有局部性原理的,能够只将最经常使用的部分驻留在内存中,而不太经常使用的数据存放在磁盘里,这也是动态装载的基本原理。覆盖装入页映射就是利用了局部性原理的两种经典动态装载方法,前者在发明虚拟内存以前使用比较普遍 ,如今基本已经淘汰,主要使用页映射。装载的过程也能够理解为进程创建的过程,操做系统只须要作如下三件事情:

  1. 建立一个独立的虚拟地址空间
  2. 读取可执行文件头,而且创建虚拟空间与可执行文件的映射关系
  3. 将CPU的指令寄存器设置成可执行文件的入口地址,启动运行

动态连接

 前面咱们在生成可执行文件时说的连接是静态连接。最后一步是将通过汇编后的全部目标文件与库进行连接造成可执行文件,这里的提到的库,包括了不少运行时库。运行时库一般是支持程序运行的基本函数的集合,也就意味着每一个进程都会用到它,若是每个可执行文件都将其打包进本身的可执行文件,都用静态连接的方式,虽然原理上更容易理解,可是这种方式对计算机的内存和磁盘的空间浪费很是严重!在如今的Linux系统中,一个普通的程序会使用到的C语言静态库至少在1M以上,若是系统中有2000个这样的程序在运行,就要浪费将近2G的空间。为了解决这个问题,把运行时库的连接过程推迟到了运行时在进行,这就是动态链接(Dynamic Linking)的基本思想。动态连接的好处有如下几点:

  1. 解决了共享的目标文件存在多个副本浪费磁盘和内存空间的问题
  2. 减小物理页面的换入换出,还增长了CPU的缓存命中率,由于不一样进程间的数据和指令访问都集中在了同一个共享模块上
  3. 系统升级只须要替换掉对应的共享模块,当程序下次启动时新版本的共享模块会被自动装载并连接起来,程序就无感的对接到了新版本。
  4. 更方便程序插件(Plug-in)的制做,为程序带来更好的可扩展性和兼容性。

 至此,终于说回了咱们今天的主角:dyld,如今我们知道了它存在的意义——动态加载的支持。


动态连接的步骤

 如今,咱们理解了为何须要动态连接,dyld做为苹果的动态连接器,但本质上dyld也是一个共享对象:

上图是dyld在系统中的路径,在iPhone中只有获取root权限(也就是越狱)的用户才能访问,后面在逆向实战中会给你们演示。
 既然dyld也是一个共享对象,而普通共享对象的重定位工做又是由dyld来完成的,虽然也能够依赖于其余共享对象,但被依赖的共享对象仍是要由dyld来负责连接和装载。那么dyld的重定向由谁来完成呢?dyld是否能够依赖其余的共享对象呢?这是一个“鸡生蛋,蛋生鸡”的问题,为了解决这个问题, 动态连接器须要有些特殊性:

  • 动态连接器自己不依赖其余任何共享对象
  • 动态连接器自己所须要的全局和静态变量的重定位工做由它自己完成

上述第一个条件在编写动态连接器时能够人为的控制,第二个条件要求动态连接器在启动时必须有一段代码能够在得到自身的重定位表和符号表的同时又不能用到全局和静态变量,甚至不能调用函数,这样的启动代码被称为自举Bootstrap)。当操做系统将进程控制权交给动态连接器时,自举代码开始执行,它会找到动态连接器自己的重定位入口(具体过程和原理暂未深究),进而完成其自身的重定位,在此以后动态连接器中的代码才能够开始使用本身的全局、静态变量和各类函数了。

 完成基本的自举之后,动态连接器将可执行文件和连接器自己的符号表合并为一个,称为全局符号表。而后连接器开始寻找可执行文件所依赖的共享对象,若是咱们把依赖关系看做一个图的话,那么这个装载过程就是一个图的便利过程,连接器可能会使用深度优先或者广度优先也可能其余的算法来遍历整个图,比较常见的算法都是广度优先的。

 每当一个新的共享对象被装载进来,它的符号表会被合并到全局符号表中,装载完毕后,连接器开始从新遍历可执行文件和共享对象的重定位表,将每一个须要从新定位的位置进行修正,这个过程与静态连接的重定位原理基本相同。重定位完成以后,动态连接器会开始共享对象的初始化过程,但不会开始可执行文件的初始化工做,这将由程序初始化部分的代码负责执行。当完成了重定位和初始化以后,全部的准备工做就宣告完成了,这时动态连接器就如释重负,将进程的控制权交给程序的入口而且开始执行。


dyld源码分析

 咱们来经过分析dyld的源码验证上述过程:

新建一个Objective-C的iOS项目做为示例,在任意参与编译的类中重写 +load 方法并添加断点,运行起来进入断点便可看到上图所示的dyld调用堆栈信息。

 从图中frame9的汇编信息中,你必定发现了在dyld的入口函数__dyld_start里出现了dyldbootstrap::start(macho_header const*, int, char const**, long, macho_header const*, unsigned long*)的函数调用,那这段代码是干吗的呢?上源码:

这个函数作了这么几件事:dyld的 自举(slideOfMainExecutable、rebaseDyld 完成自身的 重定位)、开放函数使用:mach_init、设置堆栈保护:__guard_setup、开始装载共享对象:dyld::_main。

在dyld::_main中主要作了如下几件事

  1. 配置环境:
  2. 加载动态库(共享缓存):
  3. 实例化主程序:
  4. 插入动态库:(越狱中编写插件就是修改这个配置让本身写的库被加载,这个配置也只有root用户才有权限修改,原本是苹果给本身预留插入动态库用的)
  5. 重定位完全部须要重定位的库,而后初始化主程序:
    1. 通过一系列初始化函数的调用,到notifySingle函数
      • 经过断点调试发现此函数的回调是load_images这个函数
      • load_images里执行call_load_methods函数
        • 循环调用各个类的 load 方法
    2. 而后调用了 doModInitFunctions 函数
      • 内部会调用全局C++对象的构造函数(带__attribute__((constructor))的c函数)
    3. 返回主程序的入口函数,进入主程序的main函数:
      历经千辛万苦,咱们抵达了最熟悉的main函数:

总结

 这一篇咱们从dyld出发,将程序从编译到装载的整个过程串了一遍,并结合分析了dyld的源码,这些资源都是开源的,有兴趣必定要本身去本身啃一下,经过看苹果对数据结构的使用和设计,仍是有不少启发的。在后续的逆向学习中,这一篇的研究或许能让我不只知其然,并且知其因此然。路过的大神还望多多指教~

下篇速递:fishhook的实现原理浅析

相关文章
相关标签/搜索