在上篇Swift-类与结构体(上)了解了类与结构体在初始化和类型上的区别,本篇主要分析一下函数的调用。
异变方法
上一篇我们了解到,Swift中class和struct都能定义方法。但是有一点区别的是,默认情况下值类型属性不能被自身的实例方法修改。
看上面的定义编译会报错,提示当前对象是不可变的,
struct是值类型,当改变实例的属性时,相当于改变了实例本身,这是不被允许的。如果需要修改struct的属性值,需要在方法定义前添加mutating关键字。
SIL代码分析
下面在代码里增加一个有mutating关键字的函数,一个没有mutating关键字的,通过编译成SIL查看两者的区别。代码如下:
struct Point {
var x = 0.0, y = 0.0
func test() {
let temp = self.x
print(temp)
}
mutating func moveBy(x X: Double, y Y: Double) {
x += X
y += Y
}
}
# SIL编译命令(把main.swift编译成sil,并输出到main.sil文件)
swiftc -emit-sil main.swift > ./main.sil
从SIL类的定义来看,test函数和moveBy只是有无mutating关键字,其他没有区别,我们再看看具体函数的定义:
1. test()
2. moveBy(x:y:)
从SIL代码里分析2个函数
- test函数传了默认self参数(Point类型)
- moveBy函数有3个参数,第一个参数是x,第2个参数是y,第3个也有默认的self参数(@inout Point类型),只是多了
inout关键字
下面看一下inout关键字的官方解释:
An @inout parameter is indirect. The address must be of an initialized object.(当前参数类型是间接的,传递的是已经初始化过的地址) 也就是用
inout关键字修饰的接收的是对象的地址,而没有inout修饰的接收的是值,因此上面test函数接收的是Point值,而moveBy函数接收的是Point地址。可以通过一个简单的示例来验证一下。根据上面示例的结果,给
p.x重新赋值后,x1.x还是0,而x2.x已经变为30.0,所以在struct类型的实例中通过mutating修饰的函数,通过inout修饰的获取其地址,也就可以改变其值。
异变方法的本质
对于异变方法, 传入的self被标记为inout参数。无论在mutating方法内部发生什么,都会影响外部依赖类型的一切。
输入输出参数:如果我们想函数能够修改一个形式参数的值,而且希望这些改变在函数结束之后依然生效,那么就需要将形式参数定义为输入输出形式参数。在形式参数定义开始的时候在前边添加一个inout关键字可以定义一个输入输出形式参数。
方法调度
对于Objective-C是通过objc_msgSend消息机制去调用方法的,Swift是通过什么方式去调用方法的,我们通过一个简单的示例去了解Swift的方法调用。代码如下:
class ATTeacher {
func teach() {
print("teach")
}
}
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let t = ATTeacher()
t.teach()
}
}
在t.teach()处打个断点,以汇编的模式开启debug模式。
常用汇编指令
mov x1, x0: 将寄存器x0的值复制到寄存器x1中add x0, x1, x2: 将寄存器x1和x2的值相加后保存到寄存器x0中sub x0, x1, x2: 将寄存器x1和x2的值相减后保存到寄存器X中and x0, x0, #0x1: 将寄存器x0的值和常量1按位与后保存到寄存器x0中orr x0, x0, #0x1: 将寄存器 x0的值和常量1按位或后保存到寄存器x0中str x0, [x0, x8]: 将寄存器x0中的值保存到栈内存[x0 + x8]处ldr x0, [x1, x21]: 将寄存器x1和寄存器x2的值相加作为地址,取该内存地址的值放入寄存器x0中cbz: 和0比较.如果结果为零就转移(只能跳到后面的指令)cbnz: 和非0比较,如果结果非零就转移(只能跳到后面的指令)cmp: 比较指令blr: 跳转到某地址(无返回)bl: 跳转到某地址(有返回)ret: 子程序(函数调用)返回指令,返回地址已默认保存在寄存器lr(x30)中
案例分析
结合上面的汇编指令,真机运行上面的项目,可以看到arm64架构下的方法的调用过程。
上面
bl是类的初始化,下面bl执行了release操作,那中间的blr猜测就是调用了teach函数,接下来在33行bl x8打个断点,然后按住control键,选择step into,发现就是调用了teach函数。
接下来再增加2个函数,然后在3个函数调用的地方分别打上断点,再真机运行看一下对应的汇编代码。
class ATTeacher {
func teach() {
print("teach")
}
func teach1() {
print("teach1")
}
func teach2() {
print("teach2")
}
}
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let t = ATTeacher()
t.teach()
t.teach1()
t.teach2()
}
}
上下2个
bl还是对应的初始化和release,中间的3个blr就是对应就是teach、teach1和teach2,x8在汇编里对应的就是寄存器,那x8的值是怎么获取的?
结合上面的汇编指令:
- x0的值赋值给x20(x0就是初始化的实例对象)
- x20的值存储在x8中
- x8偏移0x50地址后,再把值放入x8中。 从第2步执行后,通过
register read x8,控制台输出得到的值是metadata,metadata偏移0x50地址后就得到我们的teach函数。 因此Swift函数的调用过程大致是这样的:找到metadata~>确定函数地址(metadata+偏移量)~>执行函数
基于函数表的调度
还是通过上面的案例,发现三个地址每个相差8个字节,这8个字节就相当于函数指针的大小,而且是连续的内存空间,所以Swift函数是基于函数表的调度。
SIL代码分析
我们把示例代码ViewController编译成SIL文件,可以看到有个sil_vtable,包含了示例中定义的方法。这个sil_vtable就是每个类自己的函数表。
在上一篇中已经分析了
metadata的数据结构,那么v-table存放在哪里?把之前metadata的数据结构复制过来:
struct Metadata {
var kind: Int
var superClass: Any.Type
var cacheData: (Int, Int)
var data: Int
var classFlags: Int32
var instanceAddressPoint: UInt32
var instanceSize: UInt32
var instanceAlignmentMask: UInt16
var reserved: UInt16
var classSize: UInt32
var classAddressPoint: UInt32
var typeDescriptor: UnsafeMutableRawPointer
var iVarDestroyer: UnsafeRawPointer
}
这里需要关注一下typeDescriptor,不管是Class、Struct还是Enum都有自己的Descriptor。
源码解析
打开Swift源码找到Metadata.h文件,找到Description的定义。
TargetSignedPointer<Runtime, const TargetClassDescriptor<Runtime> * __ptrauth_swift_type_descriptor> Description;
可以看到它属于TargetClassDescriptor,再点进去可以看到关于这个结构的定义:
struct 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
// VTable
}
当前结构没有看到v-table,通过搜索TargetClassDescriptor可以找到有个别名定义:
using ClassDescriptor = TargetClassDescriptor<InProcess>;
再全局查找ClassDescriptor,找到了GenMeta.cpp文件里ClassContextDescriptorBuilder类的定义,这个就是类和描述相关的定义。
class ClassContextDescriptorBuilder
: public TypeContextDescriptorBuilderBase<ClassContextDescriptorBuilder,
ClassDecl>,
public SILVTableVisitor<ClassContextDescriptorBuilder>
{
......
省略部分代码
void layout() {
assert(!getType()->isForeignReferenceType());
super::layout();
// 创建v-table
addVTable();
addOverrideTable();
addObjCResilientClassStubInfo();
maybeAddCanonicalMetadataPrespecializations();
}
省略部分代码
.......
}
这里的layout就是布局,并创建了v-table,再看一下super::layout的定义。
void layout() {
asImpl().computeIdentity();
super::layout();
asImpl().addName();
asImpl().addAccessFunction();
asImpl().addReflectionFieldDescriptor();
asImpl().addLayoutInfo();
asImpl().addGenericSignature();
asImpl().maybeAddResilientSuperclass();
asImpl().maybeAddMetadataInitialization();
}
这也就是上面的TargetClassDescriptor的结构。回到addVTable调用,先看一下它的定义:
void addVTable() {
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这个结构体
B.addInt32(offset / IGM.getPointerSize());
// addVTable size
B.addInt32(VTableEntries.size());
// 遍历数组,添加函数指针
for (auto fn : VTableEntries)
emitMethodDescriptor(fn);
}
这里的B其实就是上面的Descriptor,为这个结构体添加内容,也就是offset添加完成后,后面就是Method。
以上是通过源码得到的Swift类的结构,下面通过Mach-O文件进一步的验证。
Mach-O验证
Mach-O其实是Mach Object文件格式的缩写是Mac以及iOS上可执行文件的格式。常⻅的有.o、.a、.dylib、.Framework、.dyld、.dsym等等。
把Xcode编译出来的可执行文件拖入
MachOView可以查看具体的信息,如下:
- 首先是文件头,表明该文件是
Mach-O格式,指定目标架构,还有一些其他的文件属性信息,文件头信息影响后续的文件结构安排。 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区主要就是负责代码和数据记录的。Mach-O是以Segment这种结构来组织数据的,一个Segment可以包含0个或多个Section。根据Segment映射的哪一个Load Command,Segment中Section就可以被解读为代码、常量或者一些其他的数据类型。在装载在内存中时,也是根据Segment做内存映射的。 | 表字段 | 表信息 | | --- | --- | | Section64(__TEXT,__text) | 汇编指令 | | Section64(__TEXT,__cstring) | 代码 | | Section64(__DATA_CONST,__objc_classlist) | Objective-C类 | | Section64(__TEXT,__swift5_types) | Swift类、结构体、Enum的Descriptor的地址信息 |
结合项目编译出来的Mach-O文件找到Data区的__swift5_types的地址信息,以4字节存放,第1个4字节就是ATTeacher的Descriptor信息。这里用到了MachOView工具,需要把可执行文件拖入到这个工具中。由于Xcode13在项目结构中去除了Product文件目录,可执行文件放在了Xcode/DerivedData里。(Xcode ~> Preferences ~> Locations ~> DerivedData)
把
0xFFFFFB80(iOS是小端模式,字节读取从后往前读),加上0xBBCC,得到0x10000B74C,再减去Mach-O文件的虚拟基地址0x100000000,虚拟基地址可以在Mach-O文件的Load Commands ~> LC_SEGMENT_64(__PAGEZERO)看到,得到0xB74C,这个0xB74C就是Descriptor在整个Data区的内存地址。
接下来找一下
0xB74C在Mach-O文件中的位置,在Section64(__TEXT,__const)找到了0xB740,按4字节读取,0xB74C就在第4段中。
0xB74C就是上面提到的TargetClassDescriptor的首地址,VTable就是在这个数据段的后面。
按照
TargetClassDescriptor的结构,在Mach-O文件以每4字节数12个,size后面标记的1、2、3分别是3个VTable,也就是对应的teach、teach1和teach2。
我们通过计算来验证一下这里是不是就是函数的地址,需要用VTable对应的
虚拟地址+ASLR(程序运行时的偏移地址),这里第一个VTable对应的地址就是上图中1对应前面的地址0xB780,再运行项目通过image list找到第一个就是程序运行时的基地址。
运行程序得到的基地址是
0x0000000104134000,加上Mach-O文件里的偏移量0xB780,得到的就是第一个VTable函数的地址。
0x0000000104134000 + 0xB780 = 0x10413F780
接下来在源码找一下函数的结构定义:
template <typename Runtime>
struct TargetMethodDescriptor {
// 占用4字节
MethodDescriptorFlags Flags;
// Offset
TargetRelativeDirectPointer<Runtime, void> Impl;
};
所以上面计算出来的0x10413F780就是TargetMethodDescriptor结构体的首地址。要找到imp还需要做偏移,首先偏移Flags,根据Flags的定义占用4字节,而TargetRelativeDirectPointer存储的不是实际的imp,而是Offset,我们拿到首地址加上Flag和Offset就是实际函数的地址。
0x10413F780 + 0x4(Flag) + 0xFFFFC030(Offset) = 0x20413B7B4
// 0x20413B7B4再减去Mach-O的虚拟基地址0x100000000
0x20413B7B4 - 0x100000000 = 0x10413B7B4
以上计算得出的0x10413B7B4就是函数的指针地址,我们再运行程序通过register read获取一下寄存器的地址。
根据程序的运行结果可以看出和我们上面计算的结果一致,也就是
ATTeacher的teach方法。
回顾
- 通过
Mach-O文件发现VTable是一个连续函数表- 通过源码找到了
GenMate.cpp里ClassContextDescriptorBuilder函数定义,调用父类layout,找到了Descriptor的结构- 调用
addVTable可以看到添加Offset,添加函数指针- 通过
Mach-O文件的__swift5_types找到Decriptor的地址信息- 根据
Mach-O的信息计算出方法的地址- 结合程序运行最终验证计算结果的正确性。
方法调度方式总结
| 类型 | 调度方式 | extension |
|---|---|---|
| 值类型 | 静态派发 | 静态派发 |
| 类 | 函数表派发 | 静态派发 |
| NSObject子类 | 函数表派发 | 静态派发 |
结合示例来说明一下,把之前的案例class改成struct,然后断点运行,可以看到是直接的地址调用(也就是静态调用)
struct是值类型,没有继承关系,在编译的时候就已经确定。再加一个extension
extension ATTeacher {
func teach3() {
print("teach3")
}
}
再次运行,发现还是地址调用。
如果在
class类加个extension,同样增加teach3方法,发现不管是struct还是class对应的extension都是地址调用。
影响函数派发方式
final: 添加了final关键字的函数无法被重写,使用静态派发,不会在vtable中出现,且对objc运行时不可⻅。dynamic: 函数均可添加dynamic关键字,为非objc类和值类型的函数赋予动态性,但派发方式还是函数表派发。@objc: 该关键字可以将Swift函数暴露给Objc运行时,依旧是函数表派发。@objc+dynamic: 消息派发的方式 实际开发过程中如果属性、方法、类不需要被重载就可以使用final修饰。
函数内联
函数内联是一种编译器优化技术,它通过使用方法的内容替换直接调用该方法,从而优化性能。
- 将确保有时内联函数。这是默认行为,我们无需执行任何操作。Swift编译器可能会自动内联函数作为优化。
- always: 将确保始终内联函数。通过在函数前添加
@inline(__always)来实现此行为 - never: 将确保永远不会内联函数。这可以通过在函数前添加
@inline(never)来实现。 - 如果函数很⻓并且想避免增加代码段大小,请使用
@inline(never)(使用@inline(never))
案例说明
下面结合个案例说明一下,创建一个简单的OC项目,在main.m增加个函数,代码如下:
int sum(int a, int b) {
return a + b;
}
int main(int argc, char * argv[]) {
NSString * appDelegateClassName;
@autoreleasepool {
int x = sum(1, 2);
NSLog(@"%d", x);
appDelegateClassName = NSStringFromClass([AppDelegate class]);
}
return UIApplicationMain(argc, argv, nil, appDelegateClassName);
}
在debug编译模式按默认的优化等级,默认是不优化。
然后以汇编的模式打开debug,在
main函数调用sum打个断点。运行
可以看到把
0x1传给寄存器w0,0x2传给寄存器w1,然后bl调用sum函数。我们再把优化等级(Optimization Level)改成Fastest, Smallest[-Os]再次运行代码:
发现编译器已经把
sum函数优化了,直接把结果0x3返回出来然后传给寄存器w8。同样Swift项目也可以设置优化等级,Optimize for Speed[-O]和Optimize for Size[-Osize],一个是对速度的优化,一个是对大小的优化。
当然
Swift还可以手动指定函数的内联。
// 始终内联
@inline(__always) func test() {
print("test")
}
// 始终不内联
@inline(never) func test1() {
print("test1")
}
如果对象只在声明的文件中可⻅,可以用private或fileprivate进行修饰。编译器会对private或fileprivate对象进行检查,确保没有其他继承关系的情形下,自动打上final标记,进而使得对象获得静态派发的特性(fileprivate: 只允许在定义的源文件中访问,private:定义的声明中访问)
总结
本篇主要从SIL代码了解了Swift方法的定义及结构,通过示例结合Mach-O文件验证了方法在内存中存储以及方法的调度方式,再了解了影响函数的派发方式,结合案例说明函数内联优化的异同点。