Swift类与结构体(下)

212 阅读11分钟

Swift类与结构体(上)我们研究了Swift类和结构体的区别和类的生命周期后,这篇我们继续对Swift的方法进行研究

1、变异方法

  • SwiftClassStruct 都能定义方法,但稍有区别的是,默认情况下,值类型属性(structenum) 不能被自身实例方法修改

  • 如果你确实需要在某个特定的方法中修改struct或者enum的属性,你可以为这个方法选择可变(mutating)行为,然后就可以从其方法内部改变它的属性;并且这个方法做的任何改变都会在方法执行 结束时写回到原始结构中。方法还可以给它隐含的self属性赋予一个全新的实例,这个 新实例在方法结束时会替换现存实例 image.png

  • 举例分析添加mutating后,SIL文件中的变化,Swift代码如下:(值类型才能用mutating) image.png

    拿到SIL文件后找到两个func,发现添加mutating的func多出1个@inout来修饰Point image.png

    • @inout:当前参数类型是间接的,传递的是已经初始化过的地址&

    通过分析我们这回知道了,不加mutating修饰的func里Point代表这个struct的值,而加了mutating修饰的func里Point代表这个struct的地址,能拿到地址当然就可以直接修改属性值了

    • 输入输出参数:如果想函数能够修改一个形式参数的值,并希望这些改变在函数结束之后依然生效,那么就需要将形式参数定义为输入输出形式参数.在形式参数定义开始的时候在前边添加一个inout关键字可以定义一个输入输出形式参数 image.png

总结

  • 类型的每一个实例都有一个隐藏属性self,代表的是实例本身 image.png

    • 使用self的主要场景是,实例方法的某个参数名称与实例的某个属性名同名时,参数名享有优先权,并且在引用属性时必须使用一种更严格的方式;上图中,无参addCount函数中就可以直接使用count进行+1操作,但有参addCount函数中调用属性就不能省略self.
  • 使用inout关键字定义的输入输出参数,底层会改成调用地址,因此传参也需传入地址

2、方法调度

OC 中,底层是通过objc_msgSend函数去查找方法并调用,但 Swift静态语言,没有运行时机制又是如何调度的呢?

2.1、汇编查看方法调度

  • 首先,我们准备一段简单的代码定义3个方法,打断点真机运行查看ARM64下的汇编 image.png

  • 查看函数调用,我们需要寻找汇编指令blblr image.png 图中#0x50#0x58#0x60就是3个自定义方法在内存中存储时,相对Metadata的偏移量,16进制下它们 相互差8字节 ,因为它们在函数表中紧邻(即函数地址 = Metadata + 函数偏移量)

  • 为了证明blblr下是函数调用,我们按住control键向下步进,可以发现跳入的就是我们定义的方法 image.png

常用汇编指令

  • 函数的返回值是放在x0这个寄存器中的,存放函数实例(所以可以知道上边的所有开始x0就是代码中创建的LZPerson实例p)
    • mov: 将某一寄存器的 值复制 到另一寄存器(只能用于寄存器与寄存器或者寄存器与常量之间传值,不能用于内存地址)
      mov x1, x0         // 将寄存器 x0 的值复制到寄存器 x1 中
      
    • add: 将某一寄存器的值和另一寄存器的值 相加 ,并将结果保存在另一寄存器中
      add x0, x1, x2     // 将寄存器 x1 和 x2 的值相加后保存到寄存器 x0 中
      
    • sub: 将某一寄存器的值和另一寄存器的值 相减 ,并将结果保存在另一寄存器中
      sub x0, x1, x2     // 将寄存器 x1 和 x2 的值相减后保存到寄存器 x0 中
      
    • and: 将某一寄存器的值和另一寄存器的值 按位与 ,并将结果保存到另一寄存器中,
      and x0, x0, #0x1   // 将寄存器 x0 的值和常量 1 按位与后保存到寄存器 x0 中
      
    • orr: 将某一寄存器的值和另一寄存器的值 按位或 ,并将结果保存到另一寄存器中
      orr x0, x0, #0x1   // 将寄存器 x0 的值和常量 1 按位或后保存到寄存器 x0 中 
      
    • str: 将寄存器中的值 写入到内存中
      str x0, [x0, x8]   // 将寄存器 x0 中的值保存到栈内存 [x0, x8] 处
      
    • ldr: 将 内存中的值读取到寄存器中
      ldr x0, [x1, x2]   // 将寄存器 x1 和寄存器 x2 的值相加作为地址,取该内存地址的值放入寄存器 x0 中
      
    • cbz: 和 0 比较,如果结果为零就转移(只能跳到后面的指令)
    • cbnz: 和非 0 比较,如果结果非零就转移(只能跳到后面的指令)
    • cmp: 比较指令
    • bl: (branch)跳转到某地址(无返回)
    • blr: 跳转到某地址(有返回)
    • ret: 子程序(函数调用)返回指令,返回地址已默认保存在寄存器 lr (x30) 中

2.2、SIL查看函数表

经过上边的分析,我们可以通过地址偏移成功找到方法函数,并知道函数存在 vtable 中,那么我们在 SIL 中看一下具体代码

2.2.1、生成SIL中的问题

Swift类与结构体(上)中,介绍了生成SIL的方法,并运行在MAC环境下,这次则是在模拟器下,报出了错误 image.png

  • 解决方法:将脚本按下边进行修改
    swiftc -emit-silgen -Onone -target x86_64-apple-ios14.2-simulator -sdk $(xcrun --show-sdk-path --sdk iphonesimulator) ${SRCROOT}/LZPerson/ViewController.swift > ./ViewController.sil && open ViewController.sil
    
2.2.2、查看SIL虚函数表vtable
  • 拖动到 SIL 最后,可以找到虚函数表vtable image.png

2.3、源码查看方法调度

看过了 vtable ,让我们再来探索一下 vtable 在源码中存在什么地方

  • 在上篇中,我们知道了 Metadata 的数据结构,其中需要特别关注typeDescriptor,不管是 Class , Struct , Enum 都有自己的 Descriptor ,就是对类的一个详细描述
    struct Metadatavar kind: Int 
        var superClass: Any.Type 
        var cacheData: (Int, Intvar 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 
    }
    
2.3.1、分析Descriptor
  • 分析 Descriptor 则需要在源码中找到它,思路是从HeapObject入手

    • HeapObject --> HeapMetadata --> TargetHeapMetadata(别名) --> TargetHeapMetadata --> Description --> TargetClassDescriptor(父类)
    • 文件中搜索 TargetClassDescriptor 发现它还被起了个别名ClassDescriptor,再全局搜索 ClassDescriptor image.png
    • GenMeta.cpp文件中找到了ClassContextDescriptorBuilder类对 ClassDescriptor 进行使用,是类创建描述信息的地方 image.png
    • 在这个类中我们在layout()方法中找到了添加 vtable 的方法addVTable() image.png
    • 看到super::layout();可以知道其父类也有 layout 方法进行一些操作,这里不进去看了,让我们看一下关键的 addVTable 方法: image.png

    结合父类的layout内容,最后确定 TargetClassDescriptor 的结构体为:

    struct TargetClassDescriptorvar 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 
    }
    

2.4、Mach-O文件验证vtable

  • Mach-O: 是 mac 以及 iOS 上可执行文件的格式,常见的 .o.a.dylibFrameworkdyld.dsym

    • 首先是文件头Header,表明该文件是 Mach-O 格式,指定目标架构,还有一些其他的文件属性信息,文件头信息影响后续的文件结构安排
    • Load commands是一张包含很多内容的表,内容包括 区域的位置符号表动态符号表image.png
    • Data 区主要负责代码和数据记录的。 Mach-O 是以 Segment 这种结构来组织数据的,1个 Segment 可以包含0个或多个 Section。根据 Segment 是映射的哪一个 Load Command ,Segmentsection 就可以被解读为是是代码、常量或者一些其他的数据类型。在装载在内存中时,也是根据 Segment 做内存映射的
2.4.1、MachOView查看可执行文件
  • 将黑色的可执行文件拖到 MachOView 软件中 image.png

  • Section64(_TEXT,__swift5_types) 中存放的就是 Descriptor 地址信息 image.png

  • 每4个字节一组,前4个字节是LZPerson的 DescriptorMach-O 的这个Section中的偏移地址(小端模式:从右向左读)

    • pFile : 每个Section的偏移地址
    • Descriptor 在 Mach-O中的内存地址: 0xBBCC + 0xFFFFFBAC = 0x10000B778 (pFile + DataLO前4个字节)
    • 0x10000是虚拟地址的开端,相减后得到0xB778就是 Descriptor在Mach-O 中的偏移量,在Section64(_TEXT,_const)定位如下: image.png
      • B778比B770偏移8字节,所以得到从 50 00 00 00 这个地址开始,就是 Descriptor存放在Mach-O中的结构体信息
  • 找到了 Descriptor,我们再找其中存的 vtable 信息,根据上边2.3.1中分析出的 TargetClassDescriptor 结构,可以知道 vtable前有 13个Int32 类型的各种信息,咱们直接略过,向下数13个4字节(Int32为4字节大小)可得 vtable 的起始地址:(后边每个函数地址占8个字节) image.png

  • 有了 vtable 在Mach-O中的偏移地址后,我们还需要拿到并加上一个ASLR(随机偏移地址),这个地址是为了程序安全设置的,保证不是从0开始而容易被人摸清内存,而我们要拿到 ASLR 则可以通过image list指令拿到第一个加载的地址得到 image.png

  • 最后一步,我在源码中找到这么一个结构体。TargetMethodDescriptor 是 Swift 方法在内存中的结构,是上边一通找后被地址指向的真正内容,而最终要找的方法存在其中,Impl 不是真正的 imp,而是相对指针 offsetimage.png

  • 好了,准备完毕,让我们计算一下MachOView中算的地址和XCode调试拿到的是否一致:

    • 0x100a70000[ASLR基地址] + 0xB7A0[vtable所在的pFile] + 0xC[vtable在pFile行前边多出的字节数] + 0x4[Flags] + 0xFFFFC250[偏移地址offset] - 0x100000000[虚拟地址开端] = 0x100A77A00 与上边图中调试时register read x8中拿到的 run 方法地址一致,验证通过
  • 注意:

    • Flags 也就是 10 00 00 00所占用的,所以Impl的偏移地址是0xFFFFC250
    • MachOView 里的一个 Section 中推导的地址是相对地址,也就是偏移量,需要通过pFile + 前边空出的字节数 + 偏移地址的计算才能拿到在其他Section中的地址
2.4.2、将Method的Descriptor加载到Metadata

image.png

3、派发方式

  • 类通过虚数表进行函数调用,而值类型的 structenum 则是通过静态派发( 编译时就确定下来 ),并不创建虚数表,汇编中不会存到寄存器中,而是直接有一个地址

  • extension 也是静态派发;因为假设extension有虚数表,有场景B继承于A,A被extension增加了方法,对B来说,这个extension的方法按道理应该放在继承的A虚数表最后、B虚数表之前的位置,这样整合起来性能消耗大,所以直接把extension设计成静态派发,独立于虚数表性能更好,但占用空间更大一些,属于用空间换取时间 image.png

  • 总结: image.png

3.1、影响函数派发方式
  • final: 添加了 final 关键字的函数无法被重写,使用静态派发,不会在 vtable 中出现,且对 objc 运行时不可见。  image.png

    • 实际开发中如果属性、方法、类不需要被重载,则可使用 final ,这样效率更高
  • dynamic: 函数均可添加 dynamic 关键字,不改变原本的派发方式。 image.png

    • @_dynamicReplacement(for:函数名): 将该函数替换成后续的函数
  • @objc: 该关键字可以将Swift函数暴露给Objc运行时。 

  • @objc + dynamic: 消息派发的方式,更常用,使Swift能使用OC的runtime的API image.png

4、函数内联

函数内联 是一种编译器优化技术,它通过使用方法的内容替换直接调用该方法,从而优化性能。  image.png

  • 将确保有时内联函数。这是默认行为,我们无需执行任何操作. Swift 编译器可能会自动内联函数作为优化。 

  • always:将确保始终内联函数。通过在函数前添加 @inline(__always) 来实现此行为 

  • never: 将确保永远不会内联函数。这可以通过在函数前添加@inline(never)来实现。 

  • 如果函数很长并且想避免增加代码段大小,请使用 @inline(never) image.png

  • 如果对象只在声明的文件中可见,可以用privatefileprivate进行修饰。编译器会对 private 或 fileprivate 对象进行检查,确保没有其他继承关系的情形下,自动打上 final 标记,进而使得对象获得静态派发的特性fileprivate: 只允许在定义的源文件中访问,private: 定义的声明中访问)