分析可执行文件mach-o

6,149 阅读18分钟

一、什么是可执行文件

  • Mach-O 是针对不同运行时可执行文件的文件类型。在Windows上.exe(Portable Executable )是可直接执行的文件扩展名,而在Linux(以及很多版本的Unix)系统上ELF (Executable and Linking Format)是可直接执行的文件格式,那么在苹果的操作系统上又是怎样的呢?在iOS(和Mac OS X)上,主要的可执行文件格式是Mach-O格式。
  • 【mx:就是说以mach-o结尾的文件都是可执行文件
  • 【mx:是不是一个app就只有一个mach-o文件,不是的,一个导出的ipa文件,里面会有一个二进制文件,就是mach-o类型,还有平时导出上传到appstore的包里面,有个DYSM的文件,也是mach-o类型的】

1.1 mach与Mach-O

  • 这里先提醒大家一下,Mach不是Mac,Mac是苹果电脑Macintosh的简称,而Mach则是一种操作系统内核。Mach内核被NeXT公司的NeXTSTEP操作系统使用。在Mach上,一种可执行的文件格是就是Mach-O(Mach Object file format)。1996年,乔布斯将NeXTSTEP带回苹果,成为了OS X的内核基础。所以虽然Mac OS X是Unix的“后代”,但所主要支持的可执行文件格式是Mach-O。
  • iOS是从OS X演变而来,所以同样是支持Mach-O格式的可执行文件。

1.2 iOS可执行文件初探

  • 作为iOS客户端开发者,我们比较熟悉的一种文件是ipa包(iPhone Application)。但实际上这只是一个变相的zip压缩包,我们可以把一个ipa文件直接通过unzip命令解压。
  • 解压之后,会有一个Payload目录,而Payload里则是一个.app文件,而这个实际上又是一个目录,或者说是一个完整的App Bundle。
  • 在这个目录中,里面体积最大的文件通常就是和ipa包同名的一个二进制文件。找到它,我们用file命令来看一下这个文件的类型:
coderidingdeMacBook-Pro:Desktop coderiding$ file /Users/coderiding/Desktop/xbb
/Users/coderiding/Desktop/xbb: Mach-O 64-bit executable arm64

别人的例子

XXX: Mach-O universal binary with 2 architecturesXXX (for architecture armv7): Mach-O executable armXXX (for architecture armv7s): Mach-O executable arm
  • 由此看来,这是一个支持armv7和armv7s两种处理器架构的通用程序包,里面包含的两部分都是Mach-O格式。
  • 对于一个二进制文件来讲,每个类型都可以在文件最初几个字节来标识出来,即“魔数”。比如PNG图片的最初几个字节是/211 P N G /r /n /032 /n (89 50 4E 47 0D 0A 1A 0A)。我们再来看下这个Mach-O universal binary的:
0000000 ca fe ba be 00 00 00 02 00 00 00 0c 00 00 00 09
  • 没错,开始的4个字节是cafe babe,即“Cafe baby”。了解Java或者说class文件格式的同学可能会很熟悉,这也是.class文件开头的“魔数”,但貌似是Mach-O在更早的时候就是用了它。在OS X上,可执行文件的标识有这样几个魔数(也就是文件格式):
    • cafebabe
    • feedface
    • feadfacf
    • 还有一个格式,就是以#!开头的脚本
  • cafebabe就是跨处理器架构的通用格式,feedface和feedfacf则分别是某一处理器架构下的Mach-O格式,脚本的就很常见了,比如#!/bin/bash开头的shell脚本。
  • 这里注意一点是,feedface和cafebabe的字节顺序不同,我们可以用lipo把上面cafebabe的文件拆出armv7架构的,看一下开头的几个字节:
0000000 ce fa ed fe 0c 00 00 00 09 00 00 00 02 00 00 00

1.3 什么是FatFile/FatBinary?

* 简单来说,就是一个由不同的编译架构后的Mach-O产物所合成的集合体。一个架构的mach-O只能在相同架构的机器或者模拟器上用,为了支持不同架构需要一个集合体。
* FAT 二进制文件,将多种架构的 Mach-O 文件合并而成。它通过 Fat Header 来记录不同架构在文件中的偏移量,Fat Header 占一页的空间。
* 按分页来存储这些 segement 和 header 会浪费空间,但这有利于虚拟内存的实现。

二、可执行文件mach-o里面有什么数据

  • 看图大概分成3块,header,load command,data mach-o组成

  • 用mach-o查看工具看到的是 mach-o查看工具结果

结合可知 Mach-O 文件包含了三部分内容:

  • Header(头部),指明了 cpu 架构、大小端序、文件类型、Load Commands 个数等一些基本信息
  • Load Commands(加载命令),正如官方的图所示,描述了怎样加载每个 Segment 的信息。在 Mach-O 文件中可以有多个 Segment,每个 Segment 可能包含一个或多个 Section。
  • Data(数据区),Segment 的具体数据,包含了代码和数据等。

  • Mach-O 被划分成一些 segement,每个 segement 又被划分成一些 section。
  • segment 的名字都是大写的,且空间大小为页的整数。页的大小跟硬件有关,在 arm64 架构一页是 16KB,其余为 4KB。
  • section 虽然没有整数倍页大小的限制,但是 section 之间不会有重叠。
  • 几乎所有 Mach-O 都包含这三个段(segment): __TEXT,__DATA 和 __LINKEDIT:
    • __TEXT 包含 Mach header,被执行的代码和只读常量(如C 字符串)。只读可执行(r-x)。
    • __DATA 包含全局变量,静态变量等。可读写(rw-)。
    • __LINKEDIT 包含了加载程序的『元数据』,比如函数的名称和地址。只读(r–)。

2.1 我们来看看header头部

  • 我们分别结合源文件和mach-o查看工具来看,否则看着你就会蒙了
  • Mach-O 文件的头部定义如下 Mach-O 文件的头部定义

mach-o查看工具下的Mach-O 文件的头部定义

  • magic 标志符 0xfeedface 是 32 位, 0xfeedfacf 是 64 位。

  • cputype 和 cpusubtype 确定 cpu 类型、平台

  • filetype 文件类型,可执行文件、符号文件(DSYM)、内核扩展等

  • ncmds 加载 Load Commands 的数量

  • flags dyld 加载的标志

    • MH_NOUNDEFS 目标文件没有未定义的符号,
    • MH_DYLDLINK 目标文件是动态链接输入文件,不能被再次静态链接,
    • MH_SPLIT_SEGS 只读 segments 和 可读写 segments 分离,
    • MH_NO_HEAP_EXECUTION 堆内存不可执行…
  • filetype 的定义有: filetype 的定义

  • flags 的定义有: flags 的定义


2.2 我们来看看load commands加载命令区

  • 我们分别结合源文件和mach-o查看工具来看,否则看着你就会蒙了

  • load_commands定义 load_commands定义


mach-o查看工具下的load commands

  • cmd 字段,如上图它指出了 command 类型
    • LC_SEGMENT、LC_SEGMENT_64 将 segment 映射到进程的内存空间,
    • LC_UUID 二进制文件 id,与符号表 uuid 对应,可用作符号表匹配,
    • LC_LOAD_DYLINKER 启动动态加载器,
    • LC_SYMTAB 描述在 __LINKEDIT 段的哪找字符串表、符号表,
    • LC_CODE_SIGNATURE 代码签名等
  • cmdsize 字段,主要用以计算出到下一个 command 的偏移量。

segment的组成

  • segment 的定义: segment 的定义

mach-o查看工具下的segment

  • cmd 就是上面分析的 command 类型
  • segname 在源码中定义的宏
#define SEG_PAGEZERO "__PAGEZERO" // 可执行文件捕获空指针的段
#define SEG_TEXT "__TEXT" // 代码段,只读数据段
#define SEG_DATA "__DATA" // 数据段
#define SEG_LINKEDIT "__LINKEDIT" // 包含动态链接器所需的符号、字符串表等数据
  • vmaddr 段的虚存地址(未偏移),由于 ALSR,程序会在进程加上一段偏移量(slide),真实的地址 = vm address + slide

  • vmsize 段的虚存大小

  • fileoff 段在文件的偏移

  • filesize 段在文件的大小

  • nsects 段中有多少个 section

  • segment定义的宏,用查看mach-o的软件截图你看看

  • 注意看,__Text 和 __Data 都有自己的 section segment定义的宏


section的组成

  • section 的定义: section 的定义

  • 注意看,__Text 和 __Data 都有自己的 section
  • 每个segment下面可能会有一个或者多个section,也可能没有,比如LC_SEGMENT_64(_LINKEDIT)这个segment

mach-o查看工具下section 的定义

  • section下的一些数据,包括了__Text 和 __Data中的section
  • segname 就是所在段的名称
  • sectname section名称,部分列举:
    • Text.__text 主程序代码
    • Text.__cstring c 字符串
    • Text.__stubs 桩代码
    • Text.__stub_helper
    • Data.__data 初始化可变的数据
    • Data.__objc_imageinfo 镜像信息 ,在运行时初始化时 objc_init,调用 load_images 加载新的镜像到 infolist 中
    • Data.__la_symbol_ptr
    • Data.__nl_symbol_ptr
    • Data.__objc_classlist 类列表
    • Data.__objc_classrefs 引用的类

2.3 我们来看看Data数据区

  • 我们分别结合源文件和mach-o查看工具来看,否则看着你就会蒙了

  • 这个就是iOS app里面的方法列表 Data数据区中的section里面的Section64(_DATA,_objc_classlist)

三、加载mach-o的步骤

  • 所以在多个进程加载 Mach-O 镜像时 __TEXT 和 __LINKEDIT 因为只读,都是可以共享内存的。而 __DATA 因为可读写,就会产生 dirty page。当 dyld 执行结束后,__LINKEDIT 就没用了,对应的内存页会被回收。
  • ASLR(Address Space Layout Randomization):地址空间布局随机化,镜像会在随机的地址上加载。这其实是一二十年前的旧技术了。
  • 代码签名:可能我们认为 Xcode 会把整个文件都做加密 hash 并用做数字签名。其实为了在运行时验证 Mach-O 文件的签名,并不是每次重复读入整个文件,而是把每页内容都生成一个单独的加密散列值,并存储在 __LINKEDIT 中。这使得文件每页的内容都能及时被校验确并保不被篡改。

3.1 从 exec() 到 main()

  • exec() 是一个系统调用。系统内核把应用映射到新的地址空间,且每次起始位置都是随机的(因为使用 ASLR)。并将起始位置到 0x000000 这段范围的进程权限都标记为不可读写不可执行。如果是 32 位进程,这个范围至少是 4KB;对于 64 位进程则至少是 4GB。NULL 指针引用和指针截断误差都是会被它捕获。

3.2 dyld 加载 dylib 文件

  • Unix 的前二十年很安逸,因为那时还没有发明动态链接库。有了动态链接库后,一个用于加载链接库的帮助程序被创建。在苹果的平台里是 dyld,其他 Unix 系统也有 ld.so。 当内核完成映射进程的工作后会将名字为 dyld 的Mach-O 文件映射到进程中的随机地址,它将 PC 寄存器设为 dyld 的地址并运行。dyld 在应用进程中运行的工作是加载应用依赖的所有动态链接库,准备好运行所需的一切,它拥有的权限跟应用一样。
  • 下面的步骤构成了 dyld 的时间线:
  • Load dylibs -> Rebase -> Bind -> ObjC -> Initializers

3.3 加载 Dylib

  • 从主执行文件的 header 获取到需要加载的所依赖动态库列表,而 header 早就被内核映射过。然后它需要找到每个 dylib,然后打开文件读取文件起始位置,确保它是 Mach-O 文件。接着会找到代码签名并将其注册到内核。然后在 dylib 文件的每个 segment 上调用 mmap()。应用所依赖的 dylib 文件可能会再依赖其他 dylib,所以 dyld 所需要加载的是动态库列表一个递归依赖的集合。一般应用会加载 100 到 400 个 dylib 文件,但大部分都是系统 dylib,它们会被预先计算和缓存起来,加载速度很快。

3.4 Fix-ups

  • 在加载所有的动态链接库之后,它们只是处在相互独立的状态,需要将它们绑定起来,这就是 Fix-ups。代码签名使得我们不能修改指令,那样就不能让一个 dylib 的调用另一个 dylib。这时需要加很多间接层。
  • 现代 code-gen 被叫做动态 PIC(Position Independent Code),意味着代码可以被加载到间接的地址上。当调用发生时,code-gen 实际上会在 __DATA 段中创建一个指向被调用者的指针,然后加载指针并跳转过去。
  • 所以 dyld 做的事情就是修正(fix-up)指针和数据。Fix-up 有两种类型,rebasing 和 binding。

3.5 Rebasing 和 Binding

  • Rebasing:在镜像内部调整指针的指向
  • Binding:将指针指向镜像外部的内容
  • 可以通过命令行查看 rebase 和 bind 等信息:
xcrun dyldinfo -rebase -bind -lazy_bind myapp.app/myapp
  • 通过这个命令可以查看所有的 Fix-up。rebase,bind,weak_bind,lazy_bind 都存储在 __LINKEDIT 段中,并可通过 LC_DYLD_INFO_ONLY 查看各种信息的偏移量和大小。
  • 建议用 MachOView 查看更加方便直观。
  • 从 dyld 源码层面简要介绍下 Rebasing 和 Binding 的流程。
  • ImageLoader 是一个用于加载可执行文件的基类,它负责链接镜像,但不关心具体文件格式,因为这些都交给子类去实现。每个可执行文件都会对应一个 ImageLoader 实例。ImageLoaderMachO 是用于加载 Mach-O 格式文件的 ImageLoader 子类,而 ImageLoaderMachOClassic 和 ImageLoaderMachOCompressed 都继承于 ImageLoaderMachO,分别用于加载那些 __LINKEDIT 段为传统格式和压缩格式的 Mach-O 文件。
  • 因为 dylib 之间有依赖关系,所以 ImageLoader 中的好多操作都是沿着依赖链递归操作的,Rebasing 和 Binding 也不例外,分别对应着 recursiveRebase() 和 recursiveBind() 这两个方法。因为是递归,所以会自底向上地分别调用 doRebase() 和 doBind() 方法,这样被依赖的 dylib 总是先于依赖它的 dylib 执行 Rebasing 和 Binding。传入 doRebase() 和 doBind() 的参数包含一个 LinkContext 上下文,存储了可执行文件的一堆状态和相关的函数。
  • 在 Rebasing 和 Binding 前会判断是否已经 Prebinding。如果已经进行过预绑定(Prebinding),那就不需要 Rebasing 和 Binding 这些 Fix-up 流程了,因为已经在预先绑定的地址加载好了。

  • ImageLoaderMachO 实例不使用预绑定会有四个原因:

    • Mach-O Header 中 MH_PREBOUND 标志位为 0
    • 镜像加载地址有偏移(这个后面会讲到)
    • 依赖的库有变化
    • 镜像使用 flat-namespace,预绑定的一部分会被忽略
    • LinkContext 的环境变量禁止了预绑定
  • ImageLoaderMachO 中 doRebase() 做的事情大致如下:

    • 如果使用预绑定,fgImagesWithUsedPrebinding 计数加一,并 return;否则进入第二步
    • 如果 MH_PREBOUND 标志位为 1(也就是可以预绑定但没使用),且镜像在共享内存中,重置上下文中所有的 lazy pointer。(如果镜像在共享内存中,稍后会在 Binding 过程中绑定,所以无需重置)
    • 如果镜像加载地址偏移量为0,则无需 Rebasing,直接 return;否则进入第四步
    • 调用 rebase() 方法,这才是真正做 Rebasing 工作的方法。如果开启 TEXT_RELOC_SUPPORT 宏,会允许 rebase() 方法对 __TEXT 段做写操作来对其进行 Fix-up。所以其实 __TEXT 只读属性并不是绝对的。
  • ImageLoaderMachOClassic 和 ImageLoaderMachOCompressed 分别实现了自己的 doRebase() 方法。实现逻辑大同小异,同样会判断是否使用预绑定,并在真正的 Binding 工作时判断 TEXT_RELOC_SUPPORT 宏来决定是否对 __TEXT 段做写操作。最后都会调用 setupLazyPointerHandler 在镜像中设置 dyld 的 entry point,放在最后调用是为了让主可执行文件设置好 __dyld 或 __program_vars。

3.6 Rebasing

  • 在过去,会把 dylib 加载到指定地址,所有指针和数据对于代码来说都是对的,dyld 就无需做任何 fix-up 了。如今用了 ASLR 后会将 dylib 加载到新的随机地址(actual_address),这个随机的地址跟代码和数据指向的旧地址(preferred_address)会有偏差,dyld 需要修正这个偏差(slide),做法就是将 dylib 内部的指针地址都加上这个偏移量,偏移量的计算方法如下:
  • Slide = actual_address - preferred_address
  • 然后就是重复不断地对 __DATA 段中需要 rebase 的指针加上这个偏移量。这就又涉及到 page fault 和 COW。这可能会产生 I/O 瓶颈,但因为 rebase 的顺序是按地址排列的,所以从内核的角度来看这是个有次序的任务,它会预先读入数据,减少 I/O 消耗。

3.7 Binding

  • Binding 是处理那些指向 dylib 外部的指针,它们实际上被符号(symbol)名称绑定,也就是个字符串。之前提到 __LINKEDIT 段中也存储了需要 bind 的指针,以及指针需要指向的符号。dyld 需要找到 symbol 对应的实现,这需要很多计算,去符号表里查找。找到后会将内容存储到 __DATA 段中的那个指针中。Binding 看起来计算量比 Rebasing 更大,但其实需要的 I/O 操作很少,因为之前 Rebasing 已经替 Binding 做过了。

3.8 ObjC Runtime

  • Objective-C 中有很多数据结构都是靠 Rebasing 和 Binding 来修正(fix-up)的,比如 Class 中指向超类的指针和指向方法的指针。
  • ObjC 是个动态语言,可以用类的名字来实例化一个类的对象。这意味着 ObjC Runtime 需要维护一张映射类名与类的全局表。当加载一个 dylib 时,其定义的所有的类都需要被注册到这个全局表中。
  • C++ 中有个问题叫做易碎的基类(fragile base class)。ObjC 就没有这个问题,因为会在加载时通过 fix-up 动态类中改变实例变量的偏移量。
  • 在 ObjC 中可以通过定义类别(Category)的方式改变一个类的方法。有时你想要添加方法的类在另一个 dylib 中,而不在你的镜像中(也就是对系统或别人的类动刀),这时也需要做些 fix-up。
  • ObjC 中的 selector 必须是唯一的。

3.9 Initializers

  • C++ 会为静态创建的对象生成初始化器。而在 ObjC 中有个叫 +load 的方法,然而它被废弃了,现在建议使用 +initialize。对比详见:stackoverflow.com/questions/1…
  • 现在有了主执行文件,一堆 dylib,其依赖关系构成了一张巨大的有向图,那么执行初始化器的顺序是什么?自底向上!按照依赖关系,先加载叶子节点,然后逐步向上加载中间节点,直至最后加载根节点。这种加载顺序确保了安全性,加载某个 dylib 前,其所依赖的其余 dylib 文件肯定已经被预先加载。
  • 最后 dyld 会调用 main() 函数。main() 会调用 UIApplicationMain()。

四、分析mach-o可以实现什么

4.1 通过分析mach-o文件查找未使用的方法

4.2 通过分析mach-o文件提高启动速度

通过分析mach-o文件在加载 Dylib阶段提速

  • 之前提到过加载系统的 dylib 很快,因为有优化。但加载内嵌(embedded)的 dylib 文件很占时间,所以尽可能把多个内嵌 dylib 合并成一个来加载,或者使用 static archive。使用 dlopen() 来在运行时懒加载是不建议的,这么做可能会带来一些问题,并且总的开销更大。

通过分析mach-o文件在Rebase/Binding阶段提速

  • 之前提过 Rebaing 消耗了大量时间在 I/O 上,而在之后的 Binding 就不怎么需要 I/O 了,而是将时间耗费在计算上。所以这两个步骤的耗时是混在一起的。
  • 之前说过可以从查看 __DATA 段中需要修正(fix-up)的指针,所以减少指针数量才会减少这部分工作的耗时。对于 ObjC 来说就是减少 Class,selector 和 category 这些元数据的数量。从编码原则和设计模式之类的理论都会鼓励大家多写精致短小的类和方法,并将每部分方法独立出一个类别,其实这会增加启动时间。对于 C++ 来说需要减少虚方法,因为虚方法会创建 vtable,这也会在 __DATA 段中创建结构。虽然 C++ 虚方法对启动耗时的增加要比 ObjC 元数据要少,但依然不可忽视。最后推荐使用 Swift 结构体,它需要 fix-up 的内容较少。

通过分析mach-o文件在ObjC Setup阶段提速

  • 针对这步所能事情很少,几乎都靠 Rebasing 和 Binding 步骤中减少所需 fix-up 内容。因为前面的工作也会使得这步耗时减少。

通过分析mach-o文件在Initializer阶段提速

显式初始化

  • 使用 +initialize 来替代 +load
  • 不要使用 atribute((constructor)) 将方法显式标记为初始化器,而是让初始化方法调用时才执行。比如使用 dispatch_once(),pthread_once() 或 std::once()。也就是在第一次使用时才初始化,推迟了一部分工作耗时。

隐式初始化

  • 对于带有复杂(non-trivial)构造器的 C++ 静态变量:

    • 在调用的地方使用初始化器。
    • 只用简单值类型赋值(POD:Plain Old Data),这样静态链接器会预先计算 __DATA 中的数据,无需再进行 fix-up 工作。
    • 使用编译器 warning 标志 -Wglobal-constructors 来发现隐式初始化代码。
    • 使用 Swift 重写代码,因为 Swift 已经预先处理好了,强力推荐。
  • 不要在初始化方法中调用 dlopen(),对性能有影响。因为 dyld 在 App 开始前运行,由于此时是单线程运行所以系统会取消加锁,但 dlopen() 开启了多线程,系统不得不加锁,这就严重影响了性能,还可能会造成死锁以及产生未知的后果。所以也不要在初始化器中创建线程。

五、Reference