1. struct
结构体方法调用探究
- 探究方法调用的过程,我们知道在
OC
中,调用一个方法的本质是消息传递,底层通过objc_msgSend
函数去查找方法并调用。而Swift
是一门静态语言,没有运行时的机制,那原生的Swift
方法又是如何调用的呢?
我们通过汇编先来看一下,调用结构体的方法时,底层是如何调用的。代码如下:
汇编代码分析方法调用的过程
可以发现,在 Swift
中,调用一个结构体的方法是直接拿到函数的地址直接调用,包括初始化方法,没有 OC
那么复杂的流程。
需要注意的是,结构体的类方法调用也和实例方法的调用一样,都是直接拿函数的地址调用。在 Swift
中声明一个类方法需要在 func
前加上 static
关键字。
Swift
是一门静态语言,许多东西在运行的时候就可以确定了,所以才可以直接拿到函数的地址进行调用,这个调用的形式也可以称作静态派发。
2. class
类方法调用探究
-
新建一个 Swift 项目,使用真机运行
- 添加断点,跟进汇编
blr
指令是跳转到某地址(无返回),也就是那么这个过程中的 x8,x9 寄存器存的值就是函数的地址。
//第 33行。
0x100ccef18 <+124>: ldr x8, [x0]
这句代码的意思是,取 x0 的地址,存到 x8,注意,这里是取 x0 地址的开始,往后算 8 个字节。那么这个时候 x8 的地址是谁的地址呢,是 metadata
的地址。
那咋就知道是 metadata
呢,把断点打在第 34 行,重新运行后,读取 x8 的值:
那此时 x8 就是
metadata
的地址,接下来断点走到第 37 行在 lldb
输入si
跳进去。42,47 行的代码一样的操作,看看是不是就是对应 func1,func2,func3
方法。
确实是
func1,func2,func3
方法。虽然也是拿到函数的地址调用,但是很明显,系统是通过拿到 LGTeacher
的实例对象,并且拿到 metadata
的地址后,通过内存平移的方式,拿到函数地址再进行调用。
函数的地址是连续存储的,不像
OC
,是存放在无序的哈希表里。那么,函数的地址存放在哪里呢?
- 虚函数表的引入,生成
ViewController.sil
文件后,我们打开文件,看到文件的最底部。
swiftc -emit-sil -target x86_64-apple-ios13.5-simulator -sdk $(xcrun --show-sdk-path --sdk iphonesimulator) ViewController.swift > ViewController.sil
生成 ViewController.sil
文件后,我们打开文件,看到文件的最底部。
sil_vtable
是啥,根据感觉翻译的话,vtable
应该是虚表或者叫虚函数表,在 C++
类的函数是放在虚函数表中的,那么 Swift
类的方法就是会不会存储在 vtable
里面呢。
- 源码查找虚函数表
swift
类的本质,它里面有一个metadata
,metadata
里有一个成员变量,这个成员变量应该是这样:
var typeDescriptor: UnsafeMutableRawPointer
这个成员量存放的是对自己的描述,类、结构体、枚举内部都有这个成员变量。
找到源码中typeDescriptor
的定义,查找流程为:
找到 HeapObject
。
从 HeapObject
中找到 HeapMetadata
。
继续跟进,HeapMetadata
为 TargetHeapMetadata
的别名。
找到 TargetHeapMetadata
结构体。
那么找到 TargetHeapMetadata
后其内部有一个成员变量,如下:
TargetSignedPointer * __ptrauth_swift_type_descriptor> Description;
此时,发现
Description
是一个为 TargetClassDescriptor
的类,并且继承了一堆的东西。
其中的结构可以去慢慢对比,对比出来的结果,
TargetClassDescriptor
大概长这样:
class TargetClassDescriptor {
var flags: UInt32
var parent: UInt32
var name: Int32
var accessFunctionPointer: Int32
var fieldDescriptor: Int32
var superClassType: Int32
var metadataNegativeSizeInWords: UInt32
var metadataPositiveSizeInWords: UInt32
var numImmediateMembers: UInt32
var numFields: UInt32
var fieldOffsetVectorOffset: UInt32
var Offset: UInt32
// var size: UInt32
//V-Table
}
那么 TargetClassDescriptor
其实有一个别名为 ClassDescriptor
,其定义如下:
using ClassDescriptor = TargetClassDescriptor;
我们全局搜索 ClassDescriptor
,找到了GenMeta.cpp
文件,通过名字,可以猜到 GenMeta.cpp
文件的代码就是生成元数据的地方。
我们进入到
GenMeta.cpp
文件,直接定位到 ClassContextDescriptorBuilder
这个类-内容的描述建立者,这个类就是创建 metadata
和 Descriptor
的类。
在类中找到 layout
这个方法:
void layout() {
super::layout();
addVTable();
addOverrideTable();
addObjCResilientClassStubInfo();
maybeAddCanonicalMetadataPrespecializations();
}
在里面调用了 super::layout()
,我们来看一下父类的实现:
void layout() {
asImpl().computeIdentity();
super::layout();
asImpl().addName();
asImpl().addAccessFunction();
asImpl().addReflectionFieldDescriptor();
asImpl().addLayoutInfo();
asImpl().addGenericSignature();
asImpl().maybeAddResilientSuperclass();
asImpl().maybeAddMetadataInitialization();
}
void layout() {
asImpl().addFlags();
asImpl().addParent();
}
到这里,基本就和 TargetClassDescriptor
类的成员变量对应起来了。那我们的重点当然还是 addVTable
函数,这个函数就是将 Swift
类的方法添加到虚函数表,其实现如下:
void addVTable() {
LLVM_DEBUG(
llvm::dbgs() << "VTable entries for " << getType()->getName() << ":\n";
for (auto entry : VTableEntries) {
llvm::dbgs() << " ";
entry.print(llvm::dbgs());
llvm::dbgs() << '\n';
}
);
// Only emit a method lookup function if the class is resilient
// and has a non-empty vtable, as well as no elided methods.
if (IGM.hasResilientMetadata(getType(), ResilienceExpansion::Minimal)
&& (HasNonoverriddenMethods || !VTableEntries.empty()))
IGM.emitMethodLookupFunction(getType());
if (VTableEntries.empty())
return;
auto offset = MetadataLayout->hasResilientSuperclass()
? MetadataLayout->getRelativeVTableOffset()
: MetadataLayout->getStaticVTableOffset();
B.addInt32(offset / IGM.getPointerSize());
B.addInt32(VTableEntries.size());
for (auto fn : VTableEntries) {
emitMethodDescriptor(fn);
}
}
前面的代码可以先不用管,我们注意看最后的几行。计算 offset
之后,调用了 addInt32
函数,这个函数就是去计算添加方法到虚函数表的偏移量,具体可以看源码实现。最后 for
循环,添加函数的指针。
这个偏移量是 TargetClassDescriptor
这个结构中的成员变量所有内存大小之和,并且在最后还拿到了 VTableEntries.size()
。
也就意味着,虚函数表的内存地址,是 TargetClassDescriptor
中的最后一个成员变量,并且,添加方法的形式是追加到数组的末尾。所以这个虚函数表是按顺序连续存储类的方法的指针。
-
Mach-O
文件查找方法内存结构地址
在运行项目之后,会生成一个可执行文件,用MachOView
这个工具打开它。
swift5_types
这里存放的是结构体、枚举、类的 Descriptor
,那么我们可以在 swift5_types
这里找到类的 Descriptor
的地址信息。
前面的四个字节 90 FB FF FF 就是 LGteacher
的 Descriptor
信息,那用 90 FB FF FF 加上前面的 0000BBDC 得到的就是 Descriptor
在当前 Mach-O
文件的内存地址。
它们怎么相加呢,iOS 属于小端模式,所以 28 FC FF FF 要从右边往左读。也就是:
FFFFFC28 + 0000BC4C = 0x10000B874
那么
0x100000000
是 Mach-O
文件中虚拟内存的基地址,如图所示。
我们用 0x10000B874 - 0x100000000 = B874 就是
LGTeacher
在整个 Data 区的内存地址。我们找到 TEXT, const
。
如图所示,这个就是B874
的首地址,也是意味着,它后面的数据是 TargetClassDescriptor
的数据,所以我们可以在这里拿到 LGTeacher
的虚函数表 - LGTeacher
方法的地址。
计算 TargetClassDescriptor
中 VTable
前面的数据大小,求得偏移量。一共 12 个 4 字节(48字节)的成员变量,12 个四字节的成员变量再加上 size(4字节)得到 52 字节,在往后的 24 字节就是 func1,func2,func3
方法的结构地址(一个函数地址占 8 字节)。如图所示:
如图中所示,B8A8
是 func1
结构在Mach-O
文件的地址。
- 验证
Mach-O
分析的函数地址是否和程序运行的一致
ASLR 是一个随机偏移地址,这个随机偏移地址的目的是为了给应用程序一个随机内存地址。
我们打一个断点,程序运行起来后,输入LLDB
命令: image list
。如图所示:
image list
是列出应用程序运行的模块,我们找到第一个,其内存地址为 0x0000000100a70000
,那我们可以把这个地址当作应用程序的基地址。
接下来在源码中找到这么一个结构体。TargetMethodDescriptor
是 Swift
的方法在内存中的结构,Impl
不是真正的 imp
,而是相对指针 offset
。
struct TargetMethodDescriptor {
/// Flags describing the method.
// 占 4 字节,Flags 标识是什么方法。
MethodDescriptorFlags Flags;
/// The method implementation.
// 不是真正的 imp,这里存储的是相对指针,offset。
TargetRelativeDirectPointer Impl;
// TODO: add method types or anything else needed for reflection.
};
到这里,TargetMethodDescriptor
结构体的地址就可以确定了,那么要找到函数地址,还需要偏移Flags + Impl
,得到的就是函数的地址。 综合以上的逻辑开始计算:
// 应用程序的基地址:0x0000000100a70000,func1 结构地址:`B8A8,Flags:0x4,offset:94 B2 FF FF
// 注意!小端模式要从右往左,所以为 FF FF B2 94
0x100a70000 + B8A8 + 0x4 + FF FF B2 94 = 0x200A76B40
// 接下来需要减掉 Mach-O 文件的虚拟地址 0x100000000,得到的就是函数的地址。
0x200A76B40 - 0x100000000 = 0x100A76B40
打开汇编调试,读取汇编中 func1
的地址,验证 0x100A76B40是否就是 func1
的地址。如图所示:
通过汇编验证0x100A76B40
就是 func1
的函数地址。到这里就完全验证了 Swift
类的方法确实是存放在 VTable
- 虚函数表里面的。