「这是我参与2022首次更文挑战的第15天,活动详情查看:2022首次更文挑战」
MachO文件
MachO是Mach Object文件格式的缩写,是mac以及iOS上可执行文件的格式,类似于Windows上的PE(Portable Executable)格式,Linux上的elf(Executable and Linking Format)格式,在mac以及iOS中常见的可执行文件有.o、.a、.dylib、framework、dyld和.dsym;
MachO文件格式如下:
Header:包含二进制文件的一般信息:字节顺序,架构类型,加载指令的数量,处理器以及文件类型等信息;Load commands:一张包含很多内容的表,内容包含区域的位置,符号表,动态符号表等;
| LC_SEGMENT_64 | 将文件中32或者64位的段映射到进程地址空间中 |
|---|---|
| LC_DYLD_INFO_ONLY | 动态链接相关信息 |
| LC_SYMTAB | 符号地址 |
| LC_DYSYMTAB | 动态符号表地址 |
| LC_LOAD_DYLINKER | dyld加载 |
| LC_UUID | 文件的UUID |
| LC_VERSION_MIN_MACOSX | 支持最低的操作系统版本 |
| LC_SOURCE_VERSION | 源代码版本 |
| LC_MAIN | 设置程序诛仙城 的入口地址和栈大小 |
| LC_LOAD_DYLIB | 依赖库的路径,包含第三方库 |
| LC_FUNCTION_STARTS | 函数起始地址表 |
| LC_CODE_SIGNATURE | 代码签名 |
Data:主要负责代码和数据记录;MachO是以Segment这种结构来组织数据的,一个Segment可以包含0个或者多个Section。根据Segment是映射的哪一个Load command,Segment中section就可以被解读为代码、常量、或者其他数据类型。在装载在内存中时,也是根据Segment来做内存映射的;
MachO文件介绍
我们先来看一下上述代码生成得到MachO文件:
Header头文件
Magic Number:是32位还是64位;CPU Type:当前CPU类型;arm64File Type:当前文件类型;可执行文件MH_EXECUTENumber of Load Commands:需要加载的Load Commands命令的数量;Size of Load Commands:Load Commands指令的大小;Flags:标识;
代码的二进制指令
硬编码的字符串
__objc_classlist记录OC类
__swift5_types记录swift类和结构体
在这个里边存放的就是Swift类的Descriptor信息(TargetClassDescriptor);我们通过该地址进行计算就可以拿到类的方法;
验证函数地址
- 因为是小端模式,所以取地址要倒着;地址位:
0xFFFFFB8C,加上偏移0xBB8C,结果为:0x10000B718 - 因为虚拟内存地址是从
0x1000....开始的,所以我们减去虚拟内存地址之后得到0xB718,我们在MachO中找到0xB718的位置:
0xB718就是我们Teacher类的TargetClassDescriptor结构体的开始位置,我们根据此结构体的定义,偏移12个4字节就可以找到size和vTable:
size之后就是vTable,那么vTable中的第一个地址,就是0xB740加上12字节,也就是0xB74C,这个就是我们teach函数在MachO中的地址;- 通过
image list命令得到ASLR,然后加上0xB74C就能够得到teach在内存中的地址:
**0x0000000102188000**+0xB74C = 0x10219374C,这就是我们程序运行时teach函数的内存地址;
Swift中方法在内存中的数据结构如下:
那么,我们计算出的
0x10219374C也就是我们teach方法数据结构的首地址;我们要找到imp,还需要进行偏移Flags(4字节),需要注意的是,此结构体中的Impl是一个相对指针的offset,继续偏移offset才能找到imp;
Flags为4字节,根据MachO中的结构,我们可以看到0xB74C对应的第二个四字节,也就是0xFFFFAEF0就是offset,那么teach在内存中的地址为:0x10219374C+0x4+0xFFFFAEF0=0x20218E640;0x20218E640再进去程序运行的基地址0x1000....,结果为:0x10218E640;
- 通过以上步骤,我们验证了
Swift类的方法确实是存放在vTable表中的;
那么,在汇编中为什么是通过metadata进行偏移0x50,0x58,0x60这种来调用方法呢?
我们从源码中的initClassVTable方法中分析得到:
在程序运行过程中会生成一个vtableOffset,然后通过vtableOffset来加载vTable的;
结构体方法调度
我们将上述代码中的class改为struct,再来分析一下其方法的调用:
运行程序,查看汇编指令:
在struct中,方法的调用都是通过地址调用的,意味着struct中的方法都是静态调用;也就是在编译之后,函数的地址就已经确定了;这是因为struct是值类型,不能被继承,其内部方法只属于自己,不用在额外的开辟内存空间来记录函数表;所以直接采用静态调用的方式;
我们也可以通过源码中的StructContextDescriptorBuilder中的源码来验证:
可以看到在其内部没有vTable的调用方式;
extension中方法的调用
结构体的extension中方法的调用
代码修改如下,给struct添加一个extension,创建一个teach3方法:
查看汇编指令:
struct的extension的方法依然是直接调用(静态派发);
类的extension中方法的调用
我们将结构体修改为类,来看一下teach3方法的调用:
通过汇编指令可以看到:
class的extension中方法也是直接调用(静态派发);
我们创建一个Teacher类的子类,然后添加teach4的方法:
我们生成SIL文件,来看一下其vTable:
Teacher类中的方法也会出现在SubTeacher类的vtable中,而extension的没有在vtable中;
方法调度总结
| 类型 | 调度方式 | extension |
|---|---|---|
| 值类型struct | 静态派发 | 静态派发 |
| 类class | 函数表派发 | 静态派发 |
| NSObject子类 | 函数表派发 | 静态派发 |
影响函数派发的方式
final:添加了final关键字的函数无法被重写,使用静态派发,不会再vtable中出现,且对objc运行时不可见。dynamic:函数均可添加dynamic关键字,为非objc类和值类型的函数赋予动态性,但派发方式还是函数表派发。@objc:该关键字可以将Swift函数暴露给objc运行时,依旧是函数表派发;
在实际开发过程中
@objc经常与dynamic配合使用,此时方法将会变为消息调度objc_msgSend的机制,可以使用Method-Swizzling,但是如果想要被oc调用,那么类必须要继承自NSObject;