Swift进阶(二)—— 类与结构体(下)

567 阅读11分钟

类与结构体

异变方法

我们都知道类与结构体都可以定义方法,但有一点区别,值类型属性不能被自身的实例方法所修改。

截屏2022-01-21 下午5.28.50.png 若想要可以修改,可以添加mutating关键字:

截屏2022-01-21 下午5.45.14.png 我们可以看下添加mutating关键字的SIL文件:

没带mutating关键字的方法

截屏2022-01-21 下午5.51.47.png 可以看到函数中默认有个参数Point,这里其实就是结构体本身self,就像oc中方法带的两个默认参数selfcmd

debug_value %0 : $Point,let,name "self" 可以很明显看到接收的是一个结构体实例
mutating关键字

截屏2022-01-21 下午5.54.06.png 这里我们就可以很明显看到@inout Point,传入的是一个可变参数,本质其实是取地址传入

$*Point,var,name "self" 我们可以清楚看到这时候的self赋值的是*Point,取的是地址

异变方法的本质:对于异变方法,传入的self被标记为inout参数

方法调度

oc中通过msg_send来进行方法调度,那swift中是如何进行方法调度呢,这里需要涉及看一些汇编代码的东西,补充一些指令:

  • mov:将某一寄存器的值复制到另一寄存器(只能用于寄存器与寄存器或者寄存器与常量之间传值,不能用于内存地址)
  • add:将某一寄存器的值和另一寄存器的值相加并将结果在另一寄存器中
  • sub:将某一寄存器的值和另一寄存器的值相见并将结果保存在另一寄存器中
  • and:将某一寄存器的值与另一寄存器的值按位与(&)并将结果保存到另一寄存器中
  • orr:将某一寄存器的值和另一寄存器的值按位或(|)并将结果保存到另一寄存器中
  • str:将寄存器的值写入到内存中
  • ldr:将内存中的值读取到寄存器中
  • cbz:和0比较,如果结果为0就转移(只能跳动后面的指令)
  • cbnz:和非0比较,如果结果为非0就转移(同上)
  • cmp:比较指令
  • b:跳转到某地址(无返回)
  • bl:带返回的跳转指令, 返回地址保存到lrx30)中
  • blr:带返回的跳转指令,跳转到指令后边跟随寄存器中保存的地址(例:blr x8 ;跳转到x8保存的地址中去执行)
  • ret:子程序(函数调用)返回指令,返回地址已默认保存在寄存器lr(x30)

mov x1 x0 将寄存器x0的值复制到寄存器x1
add x0,x1,x2 将寄存器x1x2的值相加后保存在x0
sub x0,x1,x2 将寄存器x1x2的值相减保存在x0
and x0,x0,#0x1 将寄存器x0的值和常量1按位与后保存到寄存器x0
orr x0,x0,#0x1 将寄存器x0的值和常量1按位或后保存到寄存器x0
str x0,[x0,x8] 将寄存器x0中的值保存到栈内存[x0 + x8]
ldr x0,[x1,x2] 将寄存器x1x2的值相加作为地址,取该内存地址的值放入寄存器x0
那么我们在看汇编代码方法调试的时候,就主要看blblr所在的地方,如下:

截屏2022-01-24 下午2.56.40.png

截屏2022-01-24 下午2.55.13.png 之前我们就已经知道_allocating_init()是对象创建的地方,swift_release很明显能看出是对象释放的地方,那么中间只能看到blr x8,这里就是函数调用的地方,我们调试进去能看到:

截屏2022-01-24 下午2.59.40.png 毫无疑问,brl x8就是eat函数调用的地方,为了更方便验证swift方法调度的整个详细过程,我们多扩充几个方法,如下图:

截屏2022-01-24 下午3.09.27.png 那么我们在看下汇编调试的结果:

截屏2022-01-24 下午3.11.19.png 我们分析下这个x8的值来历过程,第一步bl 0x1008d2960 ... _allocating_init()可以看出实例对象被保存在了x0(函数返回值被放在x0这个寄存器中)这个寄存器中,x0的值赋值给了x20ldr x8,[x20]x20中地址的值,其实是取地址的前8个字节,因为知道寄存器是64位,接着放到x8中,那么就可以知道这8个字节就是metadata,当然我们也可以验证:

截屏2022-01-24 下午3.33.15.png 所以现在x8存放的就是我们的metadata,接着是ldr x8,[x8,#0x50]metadata加上#0x50放到x8中,其实就是偏移了#0x50,接着执行blr x8,总结下eat函数的调用过程:找到Metadata,确定函数地址(metadata+偏移量),执行函数。

截屏2022-01-24 下午3.50.31.png 我们细心观察的话,可以发现三个函数执行的地址都是相差8个字节,正好是函数指针的大小,所以相当于这三个函数其实是在一个连续的内存空间上执行的。所以我们可以知道swift方法的调度是基于函数表的调度。我们也可以通过其他方法来证明这个结果比如生成SIL文件,如下图:

mac下生产SIL文件并打开:swiftc -emit-sil SwiftEx/main.swift | xcrun swift-demangle > ./main.sil && open main.sil
ios下需要带参数:swiftc -emit-sil -target x86_64-apple-ios14.2-simulator -sdk $(xcrun --show-sdk-path --sdk iphonesimulator) ViewController.swift > ViewController.sil

截屏2022-01-24 下午6.13.27.png 我们看到SIL文件的sil_vtable,它就是该类的函数表,罗列了该类下都有哪些函数。在swift中,每个类都有自己的函数表。我们也可以从源码中看出端倪,首先我们通过源码来还原一个类:TargetClassDescriptor,这个类是我们从之前探索类的内存结构的源码中得来的如下图:

截屏2022-01-24 下午6.44.22.png 接着通过TargetClassMetadata这个类,找到TargetClassDescriptor,从而还原出这个TargetClassDescriptor

截屏2022-01-24 下午6.46.10.png

struct TargetClassDescriptor {

    var flags:UInt32

    var parent:UInt32

    var name:Int32

    var accessFunctionPointer:Int32

    var fieldDescriptor:Int32

    var superClassType:Int32

    var metadataNagatvieSizeInwords:UInt32

    var metadataPositiveSizeWords:UInt32

    var numImmediateMembers:UInt32

    var numField:UInt32

    var fieldOffsetVectorOffset:UInt32

    var offset:UInt32

    var size:UInt32

    //VTable(源码猜测,后续会论证)

}

通过在TargetClassDescriptor所在的文件中搜索该类获取到它的一个别名ClassDescriptor,在GenMeta.cpp中找到ClassContextDescriptorBuilder类,这个类中我们找到了添加VTable的地方:

截屏2022-01-24 下午7.06.18.png

截屏2022-01-24 下午7.07.06.png 这里我们看到虚函数表中添加的函数指针。上面我们是猜测VTable所在的位置,下面通过Mach-O文件来验证猜测的结果:

Mach-O文件和MachOView验证方法调度

Mach-O其实是Mach Object文件格式的缩写,是mac以及ios上可执行文件的格式。类似于windows上的PE格式,常见的有.a.dylibFrameworkdyld.dsym Mach-O文件格式:

截屏2022-01-25 上午11.01.07.png

  • 首先是文件头,表明该文件是Mach-O格式,指定目标架构
  • Load commands是一张包含很多内容的表。内容包括区域的位置、符号表、动态符号表等。
字段名注释
LC_SEGMENG_64将文件中(32或64位)的段映射到进程地址空间中
LC_DYLD_INFO_ONLY动态链接相关信息
LC_SYMTAB符号地址
LC_DYSYMTAB动态符号地址
LC_LOAD_DYLINKERdyld加载
LC_UUID文件的UUID
LC_MAIN设置程序主线程的入口地址和栈大小
LC_LOAD_DYLIB依赖库的路径,包含第三方库
LC_FUNCTION_STARTS函数起始地址表
LC_CODE_SIGNATURE代码签名
  • Data区主要是负责代码和数据记录的。Mach-O是以Segment这种结构来组织数据的,一个Segment可以包含0个或多个Section. 我们用MachOView打开项目的可执行文件可以看到如下图样式:

截屏2022-01-25 下午3.07.21.png Section64(_TEXT,_swift5_types)存放的就是swift下类、结构、枚举的descriptor信息 截屏2022-01-25 下午3.14.43.png 这里的0000BB8C加上FFFFFB80就是当前类的descriptor在Mach-O文件中的内存地址,经过两个地址相加,计算结果是0x10000B70C,但是本身Mach-O文件还有个虚拟基地址,我们必须用当前计算好的内存地址0x10000B70C减去虚拟基地址0x100000,才是Descriptor在当前Data区的真正内存中偏移的地址,也就是最终地址0xB70C

截屏2022-01-25 下午3.25.14.png 虚拟基地址我们在Load CommandsLG_SEGMENT_64(_PAGEZERO)中可看到。我们在Section(__TEXT,__const)找到0xB70C(descriptor的首地址)的位置,如下图:

截屏2022-01-25 下午3.58.34.png 这里开始其实就是descriptor里面放的内容了,图中标注的 50 00 00 00这里开始就是descriptor的内容,它一一对应TargetClassDescriptor这个结构体的成员。那我们通过内存偏移,就可以找到VTable所在的内存位置,根据TargetClassDescriptor这个结构体的成员,偏移12个4字节找到size的位置,如下图:

截屏2022-01-25 下午4.57.21.png 我们图中就可看到 10 00 00 00 B0 AE FF FF 就是VTable的起始位置,对应的就是项目中eatrunlook三个函数。

截屏2022-01-25 下午5.02.30.png

截屏2022-01-25 下午5.12.18.png 但是eat函数在程序运行中的内存地址不是这个,还要加上ASLR(随机内存地址),才可以得到它在程序真正的地址。接下来运行下项目,通过命令 image list(列举当前程序的运行模块)查看程序运行的基地址,如下图:

截屏2022-01-25 下午6.22.27.png

看到当前程序运行的基地址是0x0000000104454000(因为基地址真机运行总是改变,这是其中一次截取的新地址),可以理解为我们当前项目SwiftPhonePro加载进内存起始地址就是0x0000000104454000,那当前基地址0x0000000104454000加上eat函数在mach-O文件中偏移的内存地址0x0000B740就是当前eat函数在程序运行中真正的内存地址,那最终计算出的地址是0x10445F740,这里我们还要看下函数在源码中的结构,直接源码中全局搜索MethodDescriptor,如下图:

截屏2022-01-25 下午5.38.11.png 我们当前计算出的地址0x10445F740指向的是TargetMethodDescriptor的首地址,还要再偏移MethodDescriptorFlags flags(源码查看4字节)的大小,才能找到Impl(TargetRelativeDirectPointer<Runtime,void>),而Impl存放的并不是真正的函数指针,存放的是一个偏移量offset(4个字节),那么我们可以再次计算下,就是0x10445F740加上flags,再加上offset,最终计算出函数的内存地址是0x20445A5F4

截屏2022-01-25 下午5.53.51.png 当然这个地址还有减去当前程序运行的基地址0x100000,最终eat函数地址结果就是0x10445A5F4。那最终项目经过汇编代码调试打印结果与该结果相同:

截屏2022-01-25 下午6.56.20.png 所以说我们猜测的没错,类中的方法调度是通过函数表派发的。前面我们提到的函数调度说是通过Metadata加上偏移量offset执行的,在源码中我们也可以找都相关代码,在Metadata.cpp中找到函数initClassVTable,如下图:

截屏2022-01-26 上午9.54.48.png 看到传入的Metadata,然后通过self->getDescription,获得当前metadata的描述,然后VTable通过vatableOffset偏移加载到对应的位置,而offset是在程序加载的过程中就已经确定了的。

结构体方法调度

我们看下结构体的方法调度,直接通过汇编调试:

截屏2022-01-26 上午10.20.55.png 它是bl拿到内存地址进行调用的,相当于进行编译后直接确定进行调用的,所以是静态派发。因为本身来说结构体没有继承,当前函数只能是当前结构体自己的函数,所以没有必要通过函数表进行调度。结构体和类的extentsion也都是静态派发。

类型调度方式
值类型静态派发
函数表派发
NSObject子类函数表派发

影响函数的派发方式

  • final:添加了final关键字的函数无法被重写,使用静态派发,不会在vatable中出现,且对objc运行时不可见。实际开发中,属性,方法,类不需要被重载,一般用final

截屏2022-01-26 上午10.53.34.png

截屏2022-01-26 上午10.55.45.png 上图中我们可清楚看到run函数已被优化成直接的地址函数调用,我们从生成的SIL文件中也看不到run函数,如下图:

截屏2022-01-26 上午11.00.36.pngVTable中我们只能看到eat和look函数

  • dynamic:函数均可添加dynamic关键字,为非objc类和值类型的函数赋予动态性,但派发方式还是函数表派发

截屏2022-01-26 上午11.13.30.png 上图示例中eat函数添加了dynamic关键后,它的imp就指向了eat1函数,调用eat就相当于调用了eat1函数。

  • @objc:该关键字可以将Swift函数暴露给Objc运行时,依旧是函数表派发。
  • @objc + dynamic:变为消息派发的方式,一般是@objcdynamic配合使用。但是本身OC中是调用不了的,如果想oc调用,还必须继承NSObject