Swift:结构体与类(下)

180 阅读9分钟

结构体与类(上) 中我们了解到结构体与类区的区别,并且深入分析了 swift 类的结构,接下来我们来窥探一下类和结构体的方法区别,因为会用到一些ARM 汇编所以先了解一些简单的汇编指令

常用汇编指令

常见指令

  • 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: 比较指令
  • br: (branch)跳转到某地址(无返回)
  • blr: 跳转到某地址(有返回)
  • ret: 子程序(函数调用)返回指令,返回地址已默认保存在寄存器 lr (x30) 中

异变方法 mutating

在 swift 中 值类型的修改属性是会直接修改实例的内存,相当于修改自身的值,所以值类型的属性是不能被自身的方法修改的,需要加上 mutating 关键字修饰 

为什么加上 mutating之后就可以修改自身的值呢?通过下面的代码来窥探一下 mutating 的原理

struct Point {
  var x = 0.0, y = 0.0
  func moveBy1(x deltaX: Double, y deltaY: Double) {
    var x =  deltaX
    var y =  deltaY
    x += deltaX
    y += deltaY
    print(x,y)
    
  }
  mutating func moveBy(x deltaX: Double, y deltaY: Double) {
     x += deltaX
     y += deltaY
  }
}

sil 分析

获取sil 文件并比较两个函数的区别

  • moveBy1 函数

16410390531203

  • moveBy 函数

16410389999248

两个方法对比说明

这两个方法最明显的有以下两点

  • 前两个红框标记说明,实例函数默认传入 self 参数的类型有区别

    • moveBy1 传入的是 Point类型 传入的是实例对象本身
    • moveBy 传入的是 inout Point类型 传入的是实例对象的地址
  • 在两个函数第三个红框标记除可以发现,函数底层声明的行参类型不同

    • inout 参数会声明一个 var 常量
    • 普通的参数则都是let 常量

这两种区别,可以通过如下的伪代码表示

//moveBy1  
let self = Point  

//moveBy
var self = &Point

总结:值类型中的属性都是直接存储在实例中,所以在方法内部修改属性相当于修改 self,而 self 在非mutating 的实例方法中传入的是值本身,并且默认是通过 let 修饰,所以无法修改,在mutating 实例方法中 传入的 self 被标记为 inout 参数,这里传入的 self 是传入了实例的指针,并且是用 var 来声明,所以可以在才可以在内部修改值类型的属性。

关于 输入输出参数: inout 的说明

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

@inout 的官方解释 An @inout parameter is indirect. The address must be of an initialized object.(当前参数 类型是间接的,传递的是已经初始化过的地址)

类的实例方法

汇编代码分析方法调用的过程

  • 探究方法调用的过程
class ClassTeacher {
    func teach() {
        print("teach")
    }
}

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        let t = ClassTeacher()
        t.teach()
    }
}

结合类与结构体(上)分析上述代码创建类的实例和方法调用的部分,其中会调用 __allocating_init 方法和 teach 方法,所以会会出现两个 blr/br 的位置,那么通过汇编代码来验证我们的猜想是否正确

观察上述汇编代码,除了上述我们分析的两处外,还遗忘了实例对象的销毁release 函数, 我们进入第二个 blr/br 处的函数,进入该函数下可得到函数的名称为 teach方法,说明我们猜想正确

我们知道 OC 是通过 runtime 的消息机制来调用方法,那么 swift、函数如何被调用的?

上面可以看到 ldr X8 是函数的调用,那么 x8 的内容是如何获取的,也就是函数如何获取的,我们再次分析汇编代码,这里可以看 x8 最终是通过 x0 存入 x20, 然后存入 x8 ,之后再通过 x8 加上一个0x50 的偏移量获取的

我们从 x0 开始分析,由于 x0 寄存器一般用来存储函数的返回值,所以 x0 可能是 __alloc_init函数的返回值,也就是实例对象,将断点设置在 mov x20, x0的位置,通过  register read 可以读取存储的值,得到确实是实例对象的 metaldata 所以 x0 ,x20 中都是类的实例对象

1171641212199_.pic

那么 再看 ldr x8 [x20] 获取了 x20 中的值读取到 x8 中 也是就说拿到实例对象的metadata 存储到 x8,最后通过 实例对象的metadata一个偏移量再获取到函数地址,进行调用

总结:类的实例方法是通过 metadata 然后通过一个偏移量找到方法地址进行调用

  • 猜测类的实例方法存储结构
class ClassTeacher {
    func teach() {
        print("teach")
    }
    func teach1() {
        print("teach")
    }
    func teach2() {
        print("teach")
    }
}
class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        let t = ClassTeacher()
        t.teach()
        t.teach1()
        t.teach2()
    }
}

上面知道了类的方法是通过类的 metadata 和一个偏移量去获取的,由此猜测实例方法是不是存储在一个函数表的结构中,调用方法就是去这个结构中通过方法存储的位置偏移量去找到找到方法那,么就多增加实例两个方法,通过汇编再窥探这些方法是被寻找到的

1191641216625_.pic_hd

通过上面的汇编分析可得三个红框位置即是三个方法调用的位置,我们可以清晰的看到,三个方法的偏移量为 0x50,0x58,0x60是三个连续的位置所以,给我们上述的猜想提供了一个侧面的验证

sil 分析实例方法存储的结构(引入虚函数表)

由于是需要将 UIKit 相关内容编译成 sil 所以用下面的命令行

swiftc -emit-sil -target x86_64-apple-ios13.5-simulator -sdk $(xcrun --show-sdk-path --sdk iphonesimulator) ${SRCROOT}/SwiftDemo/ViewController.swift > ViewController.sil

获取到 sil 的文件,可以看到文件最后有个 sil_vtable 的结构,里面存放了类的实例方法,由名称来看是应该是虚表,进一步验证了之前关于类的实例方法的猜想

sil_vtable ClassTeacher {
  #ClassTeacher.teach: (ClassTeacher) -> () -> () : @$s14ViewController12ClassTeacherC5teachyyF	// ClassTeacher.teach()
  #ClassTeacher.teach1: (ClassTeacher) -> () -> () : @$s14ViewController12ClassTeacherC6teach1yyF	// ClassTeacher.teach1()
  #ClassTeacher.teach2: (ClassTeacher) -> () -> () : @$s14ViewController12ClassTeacherC6teach2yyF	// ClassTeacher.teach2()
  #ClassTeacher.init!allocator: (ClassTeacher.Type) -> () -> ClassTeacher : @$s14ViewController12ClassTeacherCACycfC	// ClassTeacher.__allocating_init()
  #ClassTeacher.deinit!deallocator: @$s14ViewController12ClassTeacherCfD	// ClassTeacher.__deallocating_deinit
}

通过源码分析虚函数表

源码分析虚函数表的添加

类与结构体(上) 我们通过 TargetClassMetadata 了解了 Metadata 结构,现在我们又知道了 实例方法是通过 Metadata 和偏移量来从 Vtable 中获取,那么Vtable 应该也是存放在 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
}

Metadata 中有个成员是 typeDescriptor 不管是 class 、struct 、enum 都有自己的 Descriptor ,它是对于类型的详细描述

TargetClassMetadata 还有一个TargetClassDescriptor类型的私有属性 Description

我们可以通过 TargetClassDescriptor 结构的源码得知其继承关系 TargetClassDescriptor :TargetTypeContextDescriptor: TargetContextDescriptor 根据继承关系可以获取  Descriptor 的大致结构如下

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
}

到此我们还没有看到有关 Vtable 的内容,那么我么全局搜索  TargetClassDescriptor 可以看到 他有一个别名 ClassDescriptor

using ClassDescriptor = TargetClassDescriptor<InProcess>;

那就再全局搜索别名  ClassDescriptor 可以找到一个 GenMeta.cpp 的文件

在这里可以看到一个 ClassContextDescriptorBuilder 类的结构体,这个类就是创建 metadata 和 Descriptor 的类。

在这个类中可以找到一个  layout 方法  它又调用了 super::layout() 

对比TargetClassDescriptor 的成员和 layout 中的方法 应该是对应关系,我们看到了我们一直在寻找的  addVTable() ,它将 swift 类的方法添加到虚函数表中

那么现在重点关注 addVTable() 

通过ClassContextDescriptorBuilder 的最终父类,可以看到这个 B 就是 ClassContextDescriptorBuilder 的类型本身 

我们重点看最后的几行,计算 offset 之后,调用了 addInt32 函数,这个函数就是去计算添加方法到虚函数表的偏移量,最后 for 循环,添加函数的指针。 这个偏移量是 TargetClassDescriptor把 这个结构中的成员变量所有内存大小之和,并且在最后还拿到了VTableEntries.size()。这就是说TargetClassDescriptor 中的最后一个成员变量,就是 vtable 的地址,并且,实例方法最终是添加到 vtable 的末尾。

layout()中还有addOverrideTable 这里就是把父类的方法加入到了子类的vtable

由此可以猜测类会存储类的实例方法,并且把父类的可继承的实例方法也添加到子类中

类的实例方法的底层结构

在源码中找到TargetMethodDescriptor结构体 。它是 Swift 的方法在内存中的结构,

  • Flags 一个UInt32 类型,占4个字节
  • Impl 不是真正的 imp,而是相对指针 offset
truct TargetMethodDescriptor {
  /// Flags describing the method.
  MethodDescriptorFlags Flags;

  /// The method implementation.
  TargetRelativeDirectPointer<Runtime, void> Impl;

  // TODO: add method types or anything else needed for reflection.
};

using MethodDescriptor = TargetMethodDescriptor<InProcess>;

查看 MethodDescriptorFlags可以发现, 有一个属性为value 还有关于方法的作用类型内部枚举,

  • Method:声明的方法
  • Init:初始化
  • Getter:getter方法
  • Setter:setter方法
  • ModifyCoroutine, 暂时不明
  • ReadCoroutine,暂时不明

根据value计算 kind的方法的值方法的来源或作用

Kind getKind() const { return Kind(Value & KindMask); }

不可继承的方法如何存储和调用

class ClassTeacher {
    final  func teach() {
        print("teach")
    }
    func teach1() {
        print("teach1")
    }
    func teach2() {
        print("teach2")
    }
}

class SubTeacher: ClassTeacher{
}
class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        let t = SubTeacher()
        t.teach()
        t.teach1()
        t.teach2()
    }
}

上面我们看的sil文件中的vtable 中会列出类的所有方法,我们通过下面的代码生成 sil 查看不能被继承的方法会不会在 vtable 列表中 

通过sil 可以看到 fianl修饰的不可继承的方法不会被类和子类添加到 vtable中,那么这类的方法如何被调用呢,再通过汇编代码来查看得知,这类方法直接通过方法地址静态派发调用了,

MachOView 分析类的方法存储

Mach-O

Mach-O 其实是Mach Object文件格式的缩写,是 mac 以及 iOS 上可执行文件的格 式, 类似于 windows 上的 PE 格式 (Portable Executable ), linux 上的 elf 格式 (Executable and Linking Format) 。常⻅的 .o,.a .dylib Framework,dyld .dsym。

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

Mach-O 字段说明

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      //代码签名

xcode 项目中找到 .app 文件,如果没有找到就 通过下面的方式获取 

显示包内容里面的得到可执行文件 

Mach-O 文件查找方法内存结构地址

把上面的可执行文件拖进 MachOView 中 查看Mach-O文件

Section64(_TEXT,__swift5_types) 这里存放的是Swift 结构体、枚举、类的 Descriptor,那么我们可以在这里找到类的 Descriptor 的地址信息

当前的代码中只有一个 ClassTeacher 类,所以 这里的前四个字节就是我们的 ClassTeacher 的 Descriptor信息

那么用 前面的 BB50 + 48 FB FF FF 就是Descriptor 在当前 Mach-O 文件的内存地址。

//由于iOS属于小端模式,所以 前四个字节是 FFFFFB48
0xFFFFFB48 + 0x0000BB50 = 0x10000B698

在每个Mach-O 文件中有虚拟内存的基地址,当前得到的地址还需要减去 这个基地址才是ClassTeacher的 Descriptor在Data 区的首地址 0xB698

0x10000B698 - 0x100000000 =0xB698

通过这个地址 我们找到了 Section64(_TEXT,__const) 这个区

上面的源码分析得到了  Descriptor 的结构,我们知道了,vtable 的位置应该是  Descriptor ,Descriptor 中有 13 个 UInt32,也就是需要便宜 13个四字节,所以 我们的 teach方法就应该在红框位置往后数 13个四字节,且方法的指针应该是8个字节,所以后面红框标记的的8个字节,B6CC就是  teach方法 Mach-O 文件中的偏移量

由前面的源码分析得到了方法在swift底层中的结构是TargetMethodDescriptor,所以这里 存储的值 前四个字节00000001就是 flag的地址,后面四个存储的 FFFFFAD78 就是 impl 的地址

在iOS中每个应用程序都有一个ASLR(随机偏移地址)teach的真实调用地址应该是teach方法 Mach-O 文件中的偏移量加上ASLR,然后偏移 TargetMethodDescriptor中  flag 的4个字节,加上impl中的偏移量,最后再减去 Mach-O 的虚拟基地址

通过 image list 命令得到 ASLR 程序运行的基地址 0x0000000104a70000 

所以 teach() 函数的调用地址是: 0x104a76448

//应用程序的基地址:0x0000000104a70000,teach函数结构地址:B6CC,Flags:0x4,offset:FFFFFAD78

0x0000000104a70000 + B6CC + 4 + FFFFFAD78 = 0x1104A76448 

//减掉 Mach-O 文件的虚拟地址 0x100000000,得到的就是函数的地址

0x1104A76448 - 0x100000000 =  0x104a76448

前面我们通过汇编分析知道实例方法如何被调用的,我们只需要在汇编代码中找到 调用该方法的位置查看寄存器的内容是否和计算结果相同,就可以验证Mach-O分析是否正确

这里可以得出结论,以Mach-o的分析是正确的

结构体的方法调用

struct StructTeacher {
    func teach() {
        print("teach")
    }
    func teach1() {
        print("teach1")
    }
    func teach2() {
        print("teach2")
    }
}

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        let t = StructTeacher()
        t.teach()
        t.teach1()
        t.teach2()
    }
}

把类改为结构体,再进入汇编调试 

可以发现,在 Swift 中,调用一个结构体的方法是直接拿到函数的地址直接调用,包括初始化方法,Swift 是一门静态语言,许多东西在编译的时候就可以确定了,所以才可以直接拿到函数的地址进行调用,这个调用的形式也可以称作静态派发

其他函数的调度方式

extension 函数调用

class ClassTeacher {
}
extension ClassTeacher{
    func calssTeach() {
        print("teach")
    }
}
struct StructTeacher {
  
}
extension StructTeacher{
    func structTeach() {
        print("teach")
    }
}
class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        let ct = ClassTeacher()
        ct.calssTeach()
        let st = StructTeacher()
        st.structTeach()
    }
}

进入汇编调试 

可以看到 无论是 class或者是struct 在extension 中的的方法都是通过静态调用的方式

继承自NSObject 的类

class ClassTeacher: NSObject {
    func teach() {
        print("teach")
    }
}
extension ClassTeacher{
    func extensionTeach() {
        print("teach")
    }
}

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        let ct = ClassTeacher()
        ct.teach()
        ct.extensionTeach()
  
  
    }
}

sil_vtable ClassTeacher {
  #ClassTeacher.teach: (ClassTeacher) -> () -> () : @$s14ViewController12ClassTeacherC5teachyyF	// ClassTeacher.teach()
  #ClassTeacher.deinit!deallocator: @$s14ViewController12ClassTeacherCfD	// ClassTeacher.__deallocating_deinit
}

sil_vtable ViewController {
  #ViewController.deinit!deallocator: @$s14ViewControllerAACfD	// ViewController.__deallocating_deinit
}

可以发现继承自NSObject 类的sil_vtable和纯swift的类一样,类的实例方法会存储在Vtable中,通过Vtable函数表派发, extension的方法不会存储,通过静态派发调用

方法调度方式总结

关键字对派发方式的影响

class ClassTeacher {
    final  func finalTeach() { }
    static func staticTeach(){ }
    dynamic  func dynamicTeach() { }
    @objc func objcTeach(){ }
    @objc  dynamic func objcDynamicTeach(){ }
}
sil_vtable ClassTeacher {
  #ClassTeacher.dynamicTeach: (ClassTeacher) -> () -> () : @$s14ViewController12ClassTeacherC12dynamicTeachyyF	// ClassTeacher.dynamicTeach()
  #ClassTeacher.objcTeach: (ClassTeacher) -> () -> () : @$s14ViewController12ClassTeacherC9objcTeachyyF	// ClassTeacher.objcTeach()
  #ClassTeacher.init!allocator: (ClassTeacher.Type) -> () -> ClassTeacher : @$s14ViewController12ClassTeacherCACycfC	// ClassTeacher.__allocating_init()
  #ClassTeacher.deinit!deallocator: @$s14ViewController12ClassTeacherCfD	// ClassTeacher.__deallocating_deinit
}
  • final

之前通过final观察不可继承方法的存储和调用,发现了添加了 final 关键字的函数无法被重写,使用静态派发,不会在 vtable中出现,再次不再赘述

  • static

static 方法不会存在vTable中,也是通过静态派发

  • dynamic

为非 objc 类和值类型的函数赋予动态性,修饰的方法会存在vtable中,通过函数表派发调用

  • @objc

该关键字可以将 Swift 函数暴露给 objc 运行时,与 OC 交互,修饰的方法会存在vtable中,通过函数表派发调用 

  • @objc + dynamic

@objc + dynamic 就会变成消息派发的方式-也就是 OC 中的消息机制

内联函数

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

swift 内联

  • Swift 中的内联函数是默认行为,我们无需执行任何操作. Swift 编译器可能会自动内联函数作为优化
  • always - 将确保始终内联函数。通过在函数前添加 @inline(__always) 来实现此行为
  • never - 将确保永远不会内联函数。这可以通过在函数前添加 @inline(never) 来实现。
  • 如果函数很⻓并且想避免增加代码段大小,请使用@inline(never)(使用@inline(never))

可以通过xcode 更设置 

private 的优化操作

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