重学iOS系列之APP启动(四)Mach-O

前文讲解了dyld加载Mach-O的用户态过程,大家都知道Mach-O代表的是苹果系统的可执行文件,那你们了解Mach-O的内部组成吗?我们写的代码存储在Mach-O的什么位置,我们写的函数方法又是怎么找到具体位置调用执行的?本文将带大家深入了解Mach-O文件内部构造。

    Mach-O就是Mach object的简写,而Mach是早期的一个微内核。

    我们都知道Mach-O是可执行文件,那么它到底有几种文件类型呢?

可以在xnu源码中,查看到Mach-O格式的详细定义(https://opensource.apple.com/tarballs/xnu/)

可以在目录    /EXTERNAL_HEADERS/mach-o/loader.h    文件里查看到具体的宏定义。

文件类型比较多,在此笔者只介绍iOS开发中能接触到的类型

1、MH_OBJECT    :   我们写的代码 编译后得到的目标文件(.o) 静态库文件(.a),静态库其实就是N个.o合并在一起

2、MH_EXECUTE     :     这个是我们最熟悉的可执行文件

3、MH_DYLIB    :    动态库文件,XXX.dylib    XXX.framework/xx

4、MH_DYLINKER    :    动态链接编辑器,其实就是我们前文分析的dyld,mac存放的目录/usr/lib/dyld

5、MH_DSYM    :    存储着二进制文件符号信息的文件(常用于分析APP的崩溃信息)

我们可以在Xcode中查看或者修改target的Mach-O类型,如下图:

那么dyld可以加载哪几种类型的mach-o文件呢?

在dyld2.cpp文件中的loadPhase6函数中有相关的判断:

只有MH_EXECUTE、MH_DYLIB、MH_BUNDLE这3种类型的mach-o才能被dyld加载,其余的类型都会抛出错误。

MachOView

使用MachOView来具体分析Mach-O文件结构,我们可以在github上下载MachOView源码,编译后即可直接使用。

在具体分析之前,我们先看看苹果官方是怎么描述的:https://developer.apple.com/library/archive/documentation/DeveloperTools/Conceptual/MachOTopics/0-Introduction/introduction.html

官方是这样描述的

一个Mach-O文件包含3个主要区域:

1、Header :存储着mach-o的文件类型、目标架构类型等

2、Load commands :描述文件在虚拟内存中的逻辑结构、布局(其实就是一张索引表)

3、Raw segment data :在Load commands中定义的Segment的原始数据

真实情况是不是这样呢?我们打开MachOView,然后在顶部菜单选择file->open->xxx 来验证下。

咦,怎么好像跟文档说得不一样。其实这是因为加载的mach-o文件是个胖二进制文件,包含了2个架构,一个arm64,一个armv7。

胖二进制文件会比单架构的mach-o文件多一个Fat Header段。这个段从右侧的数据可以看到保存了每个架构的一些信息,比如cup type, subtype ,偏移量offset和大小size等。

我们可以在终端使用命令:

lipo -thin arm64(架构,也可以使用armv7) XXX(胖二进制文件路径) -output XXX(输出路径)

得到分离的单架构mach-o文件。

Header段

上图为Header在mach-o里的存储的内容。

从Description这一列可以看出Header有这么几项属性:Magic Number、CPU Type,CPU SubType ...... 等等。

再打开之前下载的mach-o源码头文件,看看里面Header的数据结构是怎么定义的。

flags为不同的文件标签的组合,每个标签占一个位,可以用位或来进行组合,常见的标签有:

MH_NOUNDEFS: 该文件没有未定义的引用

MH_DYLDLINK: 该文件将要作为动态链接器的输入,不能再被静态链接器修改

MH_TWOLEVEL: 该文件使用两级名字空间绑定

MH_PIE: 可执行文件会被加载到随机地址,只对MH_EXECUTE有效

更多的标签读者可以在文件中自行查看。

另外一个值得关注的就是ncmds和sizeofcmds,分别指定了 LOAD_COMMAND 的个数以及总大小,从这里也大概能猜到,每个 command 的大小是可变的。

Load Commands

在进行具体分析之前,笔者先说明一下几个重要的LC_段代表的含义。

__PAGEZERO段,__PAGEZERO是一个特殊的段,主要目的是将低地址占用,防止用户空间访问。个人理解这是对空指针引用类型漏洞的一种缓解措施(即常见的ESC_BAD_ACCESS错误)。

__TEXT段:一般用来存放不可修改的数据,比如代码和const字符串。

__DATA:数据段,一般包括可读写的内容,我们定义的静态变量,全局变量等都存储在这个段。

LC_LOAD_DYLIB(XXX):代表mach-o内部用到了这些库,需要进行链接,绑定

Load Commands是mach-o的一个索引,也是体现mach-o拓展性的地方,每个 command 的头两个word分别表示类型和大小。

所有的LC_SEGMENT_64在代码里都用上图的结构体来表示。

cmd:代表当前是LC_的哪个段

cmdsize:代表当前段的大小

segname:代表当前段的别名,即括号内的内容,例如__PAGEZERO

vmaddr:代表当前段在虚拟内存中的地址

vmsize:代表占用了虚拟内存的大小

fileoff:代表当前段映射到内存中在mach-o文件中的偏移量

filesize:代表当前段在mach-o文件中占用空间的大小

maxprot、initprot:当前段的权限,比如read、write、execute等

nsects:当前段包含多少个sections,只有__TEXT、__DATA这2个段才有sections

flags:一些标志位


从注释翻译的解释完全不能明白这些fileoff、filesize等成员变量的值到底有什么作用,在mach-o中如何表现,现在我们根据__TEXT的具体data来实战分析结构体各个成员变量的值到底有什么意义。

我们以__TEXT段为例:

cmd:LC_SEGMENT_64

cmdsize:1032,那么这个值的意义是什么呢?

从上图,我们可以知道,__TEXT段的地址为00000068,1032在内存中存储的16进制值是0x408,即Data那一列存储的值。

16进制的0x68+0x408 = 0x470,(不会算的读者可以打开Mac自带的计算器,在菜单栏选中  显示->编程器,则可以直接进行16进制的加减法 ),那么我们再看看__TEXT段的下一个段__DATA段的起始位置是多少?

上图可以看到__DATA段的起始位置就是00000470。

总结:这个值就是告诉我们当前segment段在mach-o中占用的总大小。

segname:__TEXT,这个没什么好解释的,就是当前段的名字。

vmaddr:值为  0000000100000000,我们知道mach-o映射到内存中就是4个G的大小,而vmaddr的value换算下就是4G。

vmsize: 值为  0000000000F58000

这2个值要放到一起说明。

看上图,在下方Section64(__DATA,__got)段的起始地址就是00F58000,是不是和vmsize一致?

再注意看__DATA段的上面那个段,是__TEXT段,请注意,Section64(XXX)段代表的是真正存放数据的段,与LC_xxx段有着本质的区别,LC_XXX段是索引,不存放具体的数据。

而vmaddr是什么呢?其实是Mach-o文件加载到虚拟内存的地址的起始位置,在这里每个mach-o文件都是固定的数值。读者肯定会有疑惑,如果内存起始地址写死在文件里,那就相当于我可以根据地址随意访问mach-o中的任意数据了吗?

苹果为了防止出现这种情况,对真实的内存地址是做了随机偏移的,也就是传说中的ASLR,全称为:Address Space Layout Randomization

也就是说,真实的地址 = vmaddr + ASLR的偏移量

但是要注意,debug调试模式下,ASLR的值是0。


fileoff:全0,也就是说TEXT段映射的时候会将当前文件头部分也映射到进程空间中。__TEXT段从0开始,不能很好的说明问题,我们再看看__DATA段的值

看到没,__DATA的起始位置地址就是__TEXT的大小。

filesize:0000000000F58000,前面已经分析过了,__TEXT段到__DATA段的长度就是这个长度。

maxprot、initprot:VM_PROT_READ、VM_PROT_EXECUTE,说明__TEXT段的数据允许读,允许执行。

读者可以再查看下__DATA段的值,为VM_PROT_READ、VM_PROT_WRITE,说明__DATA段的数据是有读写权限的。

nsects:值为12,说明__TEXT段有12个section。注意:只有__TEXT、__DATA这2个段才有sections

flags:为空

上面分析的是__TEXT、__DATA段的具体值代表的意思,以及怎么运用这些值在mach-o中查找相关数据。

Load Command段还有非常多的段,不同的段,数据结构也不一定相同,但是数据分析都是大同小异的。对于后续的其他段的数据结构不再一一详细分析,所有的数据结构都在XNU源码的"/EXTERNAL_HEADERS/mach-o/loader.h"这个目录下有定义,有兴趣的读者可以自行查阅。

Section64

需要注意的是如果segment包含一个或者多个section,那么在该segment结构体之后就紧跟着对应各个section头,总大小也包括在cmdsize之中,其结构如下

从上面的lc_segment_64结构体分析,发现很多成员变量都是相似的。相信读者肯定可以get到section_64结构体各个成员变量的具体含义。

笔者在此仅以Section64 Header(__text)为例

之前笔者已经告诉大家这其实是个索引,真正存放数据的位置不在这,那么在哪呢?看上图右侧,offset的值为00005940,真正的代码数据起始地址就在mach-o偏移00005940的位置。我们滑动鼠标滚轮往下找到Section64(__TEXT,__text)来验证一下。

没错吧。我们在将鼠标点到Assembly,看看这些数据到底是什么样子的。

从上图可以看到一个一个汇编指令,大家都知道我们写的代码在被编译的时候会被编译成机器语言也就是汇编语言存储在mach-o中,所以上图验证了__TEXT段存储的就是我们写的代码。


我们再看看__TEXT Segment 具体有哪些Section,这些Section又代表什么含义?

__text: 可执行文件的代码区域

__objc_methname: 方法名

__objc_classname: 类名

__objc_methtype: 方法签名

__cstring: 类 C 风格的字符串

LC_DYLD_INFO_ONLY

这个Command的信息主要是提供给动态链接器dyld的,其结构如下:

虽然看起来很复杂,但实际上它的目的就是为了给dyld提供能够加载目标MachO所需要的必要信息: 

1、因为可能加载到随机地址,所以需要rebase信息;

2、如果进程依赖其他镜像的符号,则绑定需要bind信息;

3、对于C++程序而言可能需要weak bind实现代码/数据复用;

4、对于一些外部符号不需要立即绑定的可以延时加载,这就需要lazy bind信息;

5、对于导出符号也需要对应的export信息。

xxx_off代表该信息在mach-o中的偏移位置,根据这个偏移值,我们可以在mach-o下面的Dynamic Load Info段找到我们要找的具体信息。

我们来看看Dynamic Load Info里面就是存放着什么

Dynamic Load Info存放的信息是不是和LC_DYLD_INFO_ONLY中的索引一样,我们完全可以这样理解:LC_DYLD_INFO_ONLY 就是 Dynamic Load Info段的索引


rebase/bind

为了描述这些rebase/bind信息,dyld定义了一套伪指令,用来描述具体的操作(opcode)及其操作数据。以延时绑定为例,我们从操作符看起来是这样:

从右侧我们可以获得以下信息:

name:_AUGraphInitialize

offset:uleb128编码的值 0xC006,如果我们直接以0xC006这个地址去查找,会发现找到的信息是不对的。因为uleb128编码的数据是不能直接使用的,需要经过转换才能使用。

对于uleb128编码来说,其特点如下:

1)一个uleb128编码的整形值,其占用的字节数是不确定的,长度有可能在1到5个字节之间变化;

2)一个uleb128编码的整形值,是以字节中最高位是否为0来表示字节流有没有结束的。

那么转换方法如下,以0xC006为例,先将其从小端转换成大端,得到0x06C0。

然后再展开成二进制的01数据:0000 0110 1100 0000,然后从低位往高位算,以1为起始开始,每第8位的值删除,然后再将删除后的所有7位组合起来。以0x06C0为例:

源数据:      0000 0110 1100 0000

删除第8位:  0000 0110 100 0000  --  转换成16进制为 0x340。

你以为就结束了吗?其实没有,这个数据只是一个相对的偏移量,还要加上一个起始地址才能找到真实存放地址。那么这个起始地址是什么呢?之前我们说过,数据段存放的是可以读写的数据,而rebase和bind是不是需要对指针重新计算,所以这些数据都是存放在__DATA段的,那这个起始位置就很清楚了,就是__DATA段的起始位置。上文已经查到__DATA的起始地址是0xF58000。

那么加上转换得到的值0x340,即得到真实数据的地址0xF58340。

我们找到__DATA段的这个地址去验证下:

完美!而且从上图不难发现,所有的symbol数据都是存放在(__DATA,__got)和(__DATA,__la_symbol_ptr)这两个段的。

(__DATA,__got)这个段是存放非懒加载的符号指针,即在加载阶段就已经绑定好了符号地址,比如dyld_stub_binder,这个函数是用于动态绑定函数符号地址的。

(__DATA,__la_symbol_ptr)是存放懒加载的符号指针的,即在运行过程中再进行动态查找具体的函数地址。


Binding

我们来看看binding在mach-o中具体是怎么做的!

看上图_AUGraphInitialize符号存放的数据:00000000 100BD6948。这是一段地址,我们来找找这个地址在哪个段,最后发现在Section64(__TEXT,__stub_helper)段

不难发现,这个地址存放的是一段汇编指令,但是他真实要执行的指令不是100BD6948,而是100BD694C,因为寄存器存放指令的地址也要算上,也就是说,要加寄存器的内存,一条指令占4个字节,所以要加上4个字节,即得到100BD694C,这条指令是 b  #0x100bd690c,b是跳转的意思,意思是跳转到0x100bd690c这个地址去执行。再看上面100BD6940的指令,也是 b  #0x100bd690c,这条指令其实就是其他符号被调用的时候执行的汇编代码。也就是说所有需要binging的符号都会执行到这条指令,这其实就是binging的中间跳板。然后通过这个地址的命令去寻址真实地址,通过dyld_stub_binder函数获取,dyld_stub_binder这个函数的符号是非延迟绑定的,会在dyld进行加载的时候就进行绑定(该函数符号存放在Section(__DATA,__got)段的最末尾)。最后会将通过dyld_stub_binder找到的真实地址写入到(__DATA,__la_symbol_ptr)或者(__DATA,__got)对应函数符号地址的data中。下次再调用这个函数的时候就可以根据这个存入的数据直接调用了。

以上就是Binging的具体过程了。


Rebase

那么Rebase的过程又是怎么样的呢?这就要提到Section64(__TEXT,__stubs)这个段了,这个段存放的全都是以 101CXXXXXXXXXXXX 开头的数据。101C其实就是汇编指令adrp。

事实上在代码执行到需要rebase的函数时,会跳转到

__stub段该函数的地址。然后经过一系列的地址计算,计算结果就是Section(__DATA,__la_symbol_ptr)中该函数的地址。然后按照上述Binging的过程就能查找到具体的地址了。

具体怎么计算的目前笔者还没弄明白,有了解过程的读者可以私信笔者,不胜感激!

可以参考深入理解Mach-O文件中的Rebase和Bind这篇博客。不过该博客是基于mac x86_64架构的,arm64上的计算方式有所不同。

LC_SYMTAB

这个commond同时指定了两个表(符号表、String表)的位置信息


LC_DYSYMTAB(动态符号表)

动态符号command定义了各种符号的偏移量和各种符号的个数(9种)。


LC_UUID

LC_UUID 用来标识唯一APP,命令的定义如下:

每个可执行程序都有一个uuid,这样根据不同的uuid能确定包。比如崩溃日志中就会包含uuid字段。表示是哪个包崩溃了


LC_LOAD_DYLINKER

该段定义了加载动态库的工具dyld,并且保存了dyld的物理地址

LC_XXX_DYLIB

LC_LOAD_{,WEAK_}DYLIB用来告诉内核(实际上是dyld)当前可执行文件需要使用哪些动态库,而其结构如下:

动态库(filetype为MH_DYLIB)中会包含 LC_ID_DYLIB command 来说明自己是个什么库,包括名称、版本、时间戳等信息。需要注意的是lc_str并不是字符串本身,而是字符串的偏移值,字符串信息在command的内容之后,该偏移指的是距离command起始位置的偏移。

其他段的意义:

LC_VERSION_MIN_IPHONEOS  :  存储着最低支持的iOS系统版本。

LC_MAIN   :    保存了main函数的进入地址。    

LC_PATH    :    保存Xcode上设置的相关路径。

LC_FUNCTION_STARTS    :    存储着方法的起始偏移地址。

LC_DATA_IN_CODE    :    存储运行中代码的存储空间,即栈和堆空间的offset

LC_CODE_SIGNATURE    :    存储mach-o文件以及代码签名在文件中的offset。

__DATA段

__nl_symbol_ptr: 非懒加载指针表,dyld 加载会立即绑定

__ls_symbol_ptr: 懒加载指针表

__mod_init_func: constructor 函数

__mod_term_func: destructor 函数

__objc_classlist: 类列表

__objc_nlclslist: 实现了 load 方法的类

__objc_protolist: protocol的列表

__objc_classrefs: 被引用的类列表

__objc _catlist: Category列表

Symbol Table 符号表

Dynamic Symbol Table 动态符号表

这个是重点中的重点,符号表是将地址和符号联系起来的桥梁。符号表并不能直接存储符号,而是存储符号位于字符串表的位置。


String Table 字符串表

String表顺序列出了二进制mach-O文件的中的所有可见字符串。串之间通过0x00分隔。可以通过相对String表起始位置的偏移量随机访问String表中的字符串。符号表结构中的n_strx指定的就是String表中的偏移量。通过这个偏移量可以访问到符号对应的具体字符串。

所有的变量名、函数名等,都以字符串的形式存储在字符串表中


总结

最后,以一张图作为总结吧

你可能感兴趣的:(重学iOS系列之APP启动(四)Mach-O)