Mach-O探索

Mach-O探索

前言

我们都知道在iOS应用程序中的可执行文件的格式是Mach-O,那么Mach-O到底存储了哪些数据,又是怎么工作的呢?下面我们来探索一下。

1.Mach-O简介

维基百科对于Mach-O的描述:

Mach-OMach Object文件格式的缩写,它是一种用于可执行文件,目标代码,动态库,内核转储的文件格式。作为a.out格式的替代,Mach-O提供了更强的扩展性,并提升了符号表中信息的访问速度。

Mach-O曾经为大部分基于Mach核心的操作系统所使用。NeXTSTEPDarwinMac OS X等系统使用这种格式作为其原生可执行文件,库和目标代码的格式。而同样使用GNU Mach作为其微内核的GNU Hurd系统则使用ELF而非Mach-O作为其标准的二进制文件格式。

Mach-O苹果官方图片.jpg
  • Header 包含了 Mach-O 文件的基本信息,如 CPU 架构,文件类型,加载指令数量等
  • Load Commands 是跟在 Header 后面的加载命令区,包含文件的组织架构和在虚拟内存中的布局方式,在调用的时候知道如何设置和加载二进制数据
  • Data 包含 Load Commands 中需要的各个 Segment 的数据。

绝大多数 Mach-O 文件包括以下三种 Segment:

  • __TEXT: 代码段,包括头文件、代码和常量。只读不可修改。
  • __DATA:数据段,包括全局变量, 静态变量等。可读可写。
  • __LINKEDIT: 如何加载程序, 包含了方法和变量的元数据(位置,偏移量),以及代码签名等信息。只读不可修改。

以下内容参考自:WWDC 2016 Optimizing App Startup Time

1.1 Mach-O 的几种类型

Mach-O的几种类型.jpg
  • Executable类型

Executable 主程序的二进制文件,就是我们iOS应用程序显示包内容的MachO文件
可以通过Products->xxx.app->Show in Finder->显示包内容 查看

  • Dylib 类型

Dylib 动态库,在其他平台上也叫DSO或者DLL

  • Bundle 类型

Bundle 不能被连接的Dylib,只能在Runtime运行时通过dlopen函数来加载它,它可以在macOS上用于插件。

  • Image 类型

Image 是一种可执行的二进制文件或者包,包含了上述三种文件类型

  • Framework 类型

Framework其实也是一种dylib, 它周围有一个特殊的目录结构来保存该dylib所需的文件。

那么这些都有什么区别和应用呢,请参考我的另一篇文章iOS开发中『库』的区别应用

1.2 Mach-O结构分析

1.2.1 segment 段

segment.jpg

Mach-O 文件是由 segment 段组成的,分别是TEXT段、DATA段、LINKEDIT段

  • 段的名称为大写格式
  • 所有段都是page size的倍数
  • arm64上段的大小为16K
  • 其他架构均为4K

此处实际上是指的虚拟内存的一页

1.2.2 section

section.jpg

segment段内部还有许多section节,section的名称为小写。

But sections are really just a subrange of a segment, they don't have any of the constraints of being page size, but they are non-overlapping.
但是sections实际上只是segment段的子范围,它们没有页面大小的限制,也不会重叠在一起。

通过MachOView也可以看出上述的结构:

MachOView.jpg

1.2.3 常见的 segment 与作用

  • __TEXT 代码段,包括头文件、代码和常量以及mach header。 read-only(只读的)
__TEXT.jpg
  • __DATA数据段,包括全局变量、静态变量,是可读可写的。
__DATA.jpg
  • __LINKEDIT 如何加载程序,包括了方法和变量的元数据(位置、偏移量),以及代码签名等信息。只读不可修改。
__LINKEDIT.jpg

1.2.4 Mach-O Universal Files

Universal Files.jpg

因为有时候我们需要构建多种架构的Mach-O文件,这个时候的做法是通过Mach-O Universal Files来实现的,Xcode会重新生成不同架构的二进制文件,然后合并到一起,简称Fat(胖)二进制文件。它通过header来记录不同架构在文件中的偏移量,segment占多个分页,header占用一页空间,那么header占用一页是不是浪费了很多空间?答案是肯定的,那么为什么还要占用一页空间呢?所有东西都基于页面的好处是什么呢?下面我们通过虚拟内存来解释它。

1.3 virtual memory 虚拟内存

virtual memory.jpg

PS: 软件工程格言
every problem can be solved by adding a level of indirection.
每个问题都可以通过添加中间层来解决

所以说虚拟内存是通过中间层间接寻址的一种技术

虚拟内存解决是管理所有进程使用物理内存的问题。通过添加间接层来让每个进程使用逻辑地址空间,它可以映射到RAM上的某个物理页面上,这种映射不是一对一的,逻辑地址也有可能映射不到RAM上,也有可能有多个逻辑地址映射到同一个物理RAM上。

virtual memory 应用:

  1. 一个逻辑地址不映射任何物理RAM时,进程要访问该地址时时会触发page fault页面错误,内核将停止该线程,并试图找出解决方案,或者通过CPU调度去物理磁盘读取缺失的内容,或者其他处理
  2. 多个逻辑地址映射到同一物理RAM时,两个进程共享一样比特的RAM,通常就是我们说的共享缓存技术,比如说我们的多个APP同时访问UIKit
  3. 另一个就是文件的映射,不用把整个文件读入RAM,而是可以调用mmap()函数告诉虚拟内存系统,我想把这部分文件映射到进程里的这段地址,为什么要这样做呢?不用读取整个文件,通过设置该映射第一次访问这些不同的地址时,如果已经在内存里读过,每次访问未访问过的地址时,都会触发page fault,内核会处理该page fault,时间文件的懒加载
  4. 通过以上的介绍我们可以知道任意一个dylib或者image的TEXT段都可以映射到多个进程中,并且可以实现懒加载,也可以实现进程间共享。
  5. 那么DATA段呢?有一个策略叫写入时复制,这个和APP的文件系统的克隆很相似,写入时复制所做的是它积极地在所有进程里共享DATA页面,只要进程只读有共享内容的全局变量,但是一旦有进程想要写入其DATA页面,写入时复制就是内核会把该页面进行复制,放入另一个物理RAM并重定向映射,所以该进程有了该页面的副本,这把我们带入了干净页面,该副本被认为是脏页面。脏页面是指含有进程特定信息,干净页面是指内核可以按照需要重新建立页面,比如重新读取磁盘,所以脏页面比干净页面要昂贵许多。
  6. 页面的权限界限,这指的是可以标记一个页面可读可写可执行,或者它们的任何组合。

1.4 virtual memory & Mach-O 之间的映射

首先我们拥有一个Dylib文件,我们还没有把他读取到物理内存中,只是先进行了映射。这时候静态链接器会把所有值为0的全局变量都移动到了尾端。

15984323319905.jpg

当我们第一次访问的时候,虚拟内存会触发page fault,这个时候内核意识到它被映射到了一个文件,这个时候内核会读取这个文件将它放入物理RAM设置其映射。

first read.jpg

当我们还需要读取其他页面的时候,比如读取LINKEDITDATA的时候也是同样的流程。

read other.jpg

但是当我们要在DATA段写入一些内容的时候,就会触发写入时复制,这个时候DATA这个页面就变为脏页面了,这个时候我们只有一个脏页面和两个干净的页面,如果一开始就加载全部,可能就都是脏页面了。

脏页面.jpg

此时如果另一个进程也要加载该Dylib,就可以复用RAM1和RAM2,内核只是简单的把映射重定向,不需要任何IO操作,如果DATA页面那个RAM3没有变成脏页面也可以直接复用,如果变成脏页面内核会查看RAM3的副本是否在内存中,如果还在就可以治截止使用,如果不在就会重新读取。

15984334097424.jpg

这就实现了不同进程共享这些Dylib,当进程都不需要使用某一段时比如LINKEDIT,在别的进程需要RAM时,就会将其释放。

1.5 安全如何影响DYLD

Security.jpg

1.5.1 ASLR

ASLR (Address Space Layout Randomization) 地址空间布局随机化,镜像会在随机的地址上加载。内存偏移量还需要计算ASLR的位置。

1.5.1 Code Signing

在Xcode中 Code Signing是指对整个文件运行一个加密哈希算法,然后对文件进行一个签名。为了在运行时进行验证,整个文件都必须要重新读取,所以在编译阶段,在每个Mach-O文件的每一个页面都进行自己的加密哈希算法,所有哈希都存储在LINKEDIT里,这使得你的每个未被修改的页面,在被读取的过程中都能得到及时验证。

1.6 exec()

exec.jpg

Exec is a system call. When you trap into the kernel, you basically say I want to replace this process with this new program.

exec 是一个系统调用,当你进入内核,我想把这个进程换成这个新程序,内核会抹去整个地址,映射指定的可执行程序,ASLR把它映射到一个随机地址,下一步是从该随机地址回溯到0地址把整个区域标记为不可访问,就是不可读,不可写,不可执行,该区域在32位处理器下至少4KB大小,64位处理器下至少4GB大小,这样就可以捕获任何空指针引用,捕获任何指针截断。

2. dyld

2.1 dyld 简介

dyld.jpg

Unix 诞生初期一切都很简单,我只需映射一个程序,把指针引用指向它,开始运行即可,后来人们有发明了共享缓存库,那么谁来加载Dylibs呢?这是件很复杂的事情,人们意识到不能让内核来做这件事,所以帮助程序就诞生了在Unix平台人们叫它LD.SO,在iOS上他被叫做DYLD

当内核完成进程的映射,它现在映射到另一个Mach-O文件,调用Dyld进入该进程到另一个随机地址,把指针引用指向Dyld,让Dyld完成进程的启动,Dyld的工作是加载所有依赖的Dylib,让它们全部准备好,开始运行。

2.2 dyld加载Mach-O流程

2.2.1 加载主流程(时间轴)

dyld加载流程.jpg

2.2.2 Load dylibs

Load dylibs done.jpg
  1. Dyld首先要根据内核映射好的主可执行文件的头文件,该头文件里有一个所有依赖的库的列表,根据这个列表映射所有Dylib
  2. 找到所有Dylib后,确定它是一个MachO文件后,通过代码签名对他进行验证并注册到内核。
  3. 然后它可以在该Dylib里的每一段调用mmap(),将其读入内存。
  4. Dyld还会对每个Dylib进行递归加载,因为每个不同的Dylib还有可能依赖Dylib(已加载的或者未加载的),直到全部加载完毕。
  5. 其实我们需要加载的Dylib有很多,大约有100400个,但是大部分都是OS Dylib,这里系统为我们做了足够多的优化,以确保加载速度非常非常的快。

2.2.3 Fix-Ups

现在Dylibs都已经加载完毕了,但是它们都是彼此独立的,我们下一条把它们绑定在一起。这就是Fix-Ups(修复)。

code sign.jpg

由于代码签名的存在,我们无法修改指令,那么dylib该如何调用另一dylib呢?这个时候我们使用code-gen,即动态PIC(Position Independent Code) 地址无关代码,代码可以加载到该地址,并且是动态的,也就是说地址间接的被分配,为了一个调用另一个,code-gen实际上在DATA段新建了一个指向被调用者的指针,任何加载该指针并跳转过去。

Rebasing and Binding.jpg

所以所有的dyld都在修复指针和数据,修复有两种,一种是重设基址,另一种是绑定重设基址是指如果有一个指针指向Image内,需要作出所有修改;绑定是指 如果指针指向Image范围外,也需要进行不同的修复。

dyldinfo还有很多选项参数,我们可以在任何二进制文件上运行,就可以看到所有的修复。

fixup.jpg

过去你可以为每一个dylib指定首选加载地址,该首选加载地址是一个静态指针和dyld一起合作,比如若把它加载到该首选加载地址,所有指针和数据本应该是内部编码的,都是正确的,那么dyld就不用做任何修复。现在有了ASLR ,dylib被加载到随机地址上,它偏移到了其他的地址,也就是说所有的指针和数据都依然指向旧地址,所以为了修复它们,我们需要计算偏移值,并且对每一个内部指针都添加该偏移值,所以重设基址就是指遍历所有内部数据指针,然后为它们添加一个偏移值。概念非常简单,就是读取一个指针,添加偏移值,在写入新值。那么这些数据指针都在哪里呢?这些指针都在LINKEDIT段里存储着。此时所有的映射都已经结束,当我们开始重设基址的时候实际上所有DATA页面上都产生了错误,然后对页面进行修改,触发写入时复制,所有的重设基址有时会非常昂贵,由于这些都需要I/O操作,但是有一个技巧,就是按顺序操作,从内核的角度来看,它认为数据错误顺序按照产生,当它如此认为时,内核会进行预读,这样就会降低很多I/O成本

Rebasing.jpg

2.2.4 Binding

Binding.jpg

绑定是针对那些指向dylib范围外的指针而言的,这些指针通过名称就是绑定,实际就是字符串,本例中LINKEDIT段里的malloc,也就是说该数据指针需要指向malloc,所以在运行时dyld需要找到实现该符号的位置,这需要很多的计算,遍历查找符号表,一旦找到就把值存到该数据指针里,计算复杂度比重设基址高的多。

2.2.5 Notify ObjC Runtime

Notify ObjC Runtime.jpg
  1. Objc有很多DATA结构,DATA结构类,也就是指向方法的指针,以及高光指针,几乎都已经被修复,通过重设基址或者绑定。
  2. 但是在Objc运行时还需要一些额外的操作,首先Objc是一门动态语言,可以把一个类用名称实例化,即Objc运行时需要维护一张表,包含所有名称及其映射的类,每次加载的名称都将定义一个类,名称需要登记在一个全局表格里。
  3. 在C++中你可能听说过脆弱的基类问题,但是在Objc中就不存在该问题,因为我们做的其中一种修复就是,在加载时动态改变所有ivar的偏移量。
  4. 在Objc里可以定义Categories,有时候它们在另一个dylib里,此时那些方法修复必须已经完成。
  5. Objc基于选择器是唯一的,所以我们需要唯一的选择器

2.2.6 Initializers

So 我们现在完成了所有所有静态描述的DATA的修复,现在是进行动态DATA修复的时机。

Initializers.jpg
  1. 在C++中有一个叫做Initializers的初始化器,可以指定你想要的任何表达式,在这里我们可以通过运行初始化器来完成那些抽象表达式的初始化。
  2. 在Objc有一种方法叫+load方法,但是现在+load方法已经不在建议使用(建议使用+initialize),如果使用了它现在将开始运行
  3. 顶端是主可执行文件,所有的dylibs依照这张大图,必须要运行初始化器从下往上运行,原因是当初始化器运行时可能会调用一些dylib,你需要确保那些dylib已经准备好被调用。从下往上一直到类,可以很安全的调用依赖的内容
  4. 但所有初始化器完成时,我们实际已经最终调用的主Dylib程序

dyld是一个帮助程序:

  1. 可以加载所有的依赖库
  2. 修复所有DATA页面
  3. 运行初始化器,跳转到主函数

2.3 dyld2 && dyld3

详见WWDC2017 - 413 - App Startup Time: Past, Present, and Future

dyld2 && dyld3.jpg

iOS 13之前,所有APP都是通过dyld2来启动的,主要过程如下:

  1. 解析MachOHeaderLoad Commands,找到其依赖的库,并递归找到所有依赖的库
  2. 加载MachO文件
  3. 进行符号查找
  4. 绑定和重设基址
  5. 运行初始化程序

dyld3被分为了三个组件:

  • 一个进程外的MachO解析器
    • 预先处理了所有可能影响启动速度的Search Path@rpaths和环境变量
    • 开始分析MachOHeader和依赖,并完成了所有符号查找的工作
    • 最后将这些结果创建成了一个启动包
    • 这是一个普通的 daemon 进程,可以使用通常的测试架构
  • 一个进程内的引擎,用来运行启动闭包
    • 这部分在进程中处理
    • 验证启动闭包的安全性,然后映射到dylib中,在跳转到main函数
    • 不需要解析Mach-OHeader和依赖,也不需要符号查找
  • 一个启动闭包缓存服务
    • 系统APP的启动闭包被构建在一个Shared Cache 中,我们甚至不需要打开一个单独的文件
    • 对于第三方的APP,我们会在APP安装或者升级的时候构建这个启动闭包
    • 在iOS、tvOS、watchOS中,这一切都是APP启动之前完成的,在macOS上,由于有Side Load App,进程内引擎会在首次启动的时候启动一个daemon进程,之后就可以启动闭包了。

你可能感兴趣的:(Mach-O探索)