[Swift进阶]类与结构体的探究(下)

435 阅读7分钟

前言

  1. 着重介绍了Swift的方法调度。通过汇编调试,Mach-O文件解析来验证方法调度的内存地址。
  2. 异变方法。
  3. 函数派发方式。
  4. 函数内联。

异变方法-mutating

Swift 中 class 和 struct 都能定义方法。但是有一点区别的是默认情况 下,值类型属性不能被自身的实例方法修改。 官方文档中,mutating是属于协议一类的。作用于方法的关键字。在值类型(即结构体和枚举)的实例方法中, 方法内部可以修改实例及其属性。 首先尝试不加关键字能不能改值:

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

这段代码在编译的时候会提示:left side of mutating operator isn't mutable: 'self' is immutable,意味着不加关键字无法修改self,以及self的属性。修改代码如下:

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

    mutating func mutableMoveBy(x deltaX: Double, y deltaY: Double) {
        x += deltaX
        y += deltaY
        print(x,y)
    }
}

// 要想使用异变方法,实例声明必须是var,否则提示错误:cannot use mutating member on immutable value: 'p' is a 'let' constant
var p = YFPoint()
p.moveBy(x: 1, y: 5)
p.mutableMoveBy(x: 1, y: 5)

通过生成SIL文件来对比一下异变方法有啥区别

struct YFPoint {
  @_hasStorage @_hasInitialValue var x: Double { get set }
  @_hasStorage @_hasInitialValue var y: Double { get set }
  func moveBy(x deltaX: Double, y deltaY: Double)
  mutating func mutableMoveBy(x deltaX: Double, y deltaY: Double)
  init()
  init(x: Double = 0.0, y: Double = 0.0)
}

@_hasStorage @_hasInitialValue var p: YFPoint { get set }

声明方面,就多了个关键字,其他没区别;接着看main函数(节选)

// main
sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>):
  // function_ref YFPoint.moveBy(x:y:)
  %15 = function_ref @$s4main7YFPointV6moveBy1x1yySd_SdtF : $@convention(method) (Double, Double, YFPoint) -> () // user: %16
  // function_ref YFPoint.mutableMoveBy(x:y:)
  %22 = function_ref @$s4main7YFPointV13mutableMoveBy1x1yySd_SdtF : $@convention(method) (Double, Double, @inout YFPoint) -> () // user: %23
} // end sil function 'main'

看%22这行,function_ref 代表方法引用计数。对比%15发现,异变方法结尾多了一个@inout修饰符。

再看这两方法的初始化代码(节选):

// YFPoint.moveBy(x:y:)
sil hidden @$s4main7YFPointV6moveBy1x1yySd_SdtF : $@convention(method) (Double, Double, YFPoint) -> () {
bb0(%0 : $Double, %1 : $Double, %2 : $YFPoint):
  debug_value %0 : $Double, let, name "deltaX", argno 1 // id: %3
  debug_value %1 : $Double, let, name "deltaY", argno 2 // id: %4
  debug_value %2 : $YFPoint, let, name "self", argno 3 // id: %5
} 

// YFPoint.mutableMoveBy(x:y:)
sil hidden @$s4main7YFPointV13mutableMoveBy1x1yySd_SdtF : $@convention(method) (Double, Double, @inout YFPoint) -> () {
bb0(%0 : $Double, %1 : $Double, %2 : $*YFPoint):
  debug_value %0 : $Double, let, name "deltaX", argno 1 // id: %3
  debug_value %1 : $Double, let, name "deltaY", argno 2 // id: %4
  debug_value_addr %2 : $*YFPoint, var, name "self", argno 3 // id: %5
}

debug_value_addr代表取地址,debug_value代表取值 两个方法的%2这行,mutableMoveBy使用的是debug_value_addr指令来获取@inout修饰的YFPoint指针指向的内存对象。 看到C语言上熟悉的型""号了吗,代表取指针地址。这区别类似swift中的语法:

let self = YFPoint
var self = &YFPoint

那么,@inout这玩意到底是干啥的?

inout输入输出参数

官方文档解释:函数参数默认是常量。试图在函数体中更改参数值将会导致编译错误。这意味着你不能错误地更改参数值。如果你想要一个函数可以修改参数的值,并且想要在这些修改在函数调用结束后仍然存在,那么就应该把这个参数定义为输入输出参数(In-Out Parameters)

SIL文档的解释

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

  • 注意,如果inout修饰的变量实际是值类型,方法内部再赋值给到一个新变量,就变成了普通赋值,而不是引用地址。
var age = 10
func add(- age: inout int) {
    // 因为是值类型赋值,等价于 tmp = &age.Int,也就是取地址,然后获取地址中的值
    var tmp = age
    // 此时,tmp = 11, age还是10。
    tmp++
    // 这样age才是11
    age++
}

方法调度

OC的方法调度函数objc_msgSend想必大家再熟悉不过了。通过runtime运行时来查找方法,方便hook。也不太安全。 Swift是静态语言,没有该特性。我们通过ARM汇编debug来看一下方法调用。先简单介绍2个指令。

  • bl:跳转到某地址(有返回)
  • blr:跳转到某地址(无返回)

真机ARM汇编调试

调试代码:

class YFCoder{
    func method1(){
        print("method1")
    }
    
    func method2(){
        print("method2")
    }
    
    func method3(){
        print("method3")
    }
}

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        
        let coder = YFCoder()
        coder.method1()
        coder.method2()
        coder.method3()
    }
}

断点coder.method1():

// 节选
YFArmDemo`ViewController.viewDidLoad():
    // 调用__allocating_init方法且有返回值,一般情况下存在x0寄存器
    0x1000e3538 <+100>: bl     0x1000e3484               ; YFArmDemo.YFCoder.__allocating_init() -> YFArmDemo.YFCoder at ViewController.swift:10
    0x1000e353c <+104>: mov    x20, x0
    0x1000e3540 <+108>: str    x20, [sp, #0x18]
    0x1000e3544 <+112>: str    x20, [sp, #0x20]
    // 读取x20地址的值,存放到x8寄存器。
->  0x1000e3548 <+116>: ldr    x8, [x20]
    // 读取x8地址与#0x50相加的地址值,存放到x8寄存器。
    0x1000e354c <+120>: ldr    x8, [x8, #0x50]
    // 跳转到x8且无返回,此处就是进入方法调用了。
    0x1000e3550 <+124>: blr    x8
    0x1000e3554 <+128>: ldr    x20, [sp, #0x18]
    0x1000e3558 <+132>: ldr    x8, [x20]
    0x1000e355c <+136>: ldr    x8, [x8, #0x58]
    0x1000e3560 <+140>: blr    x8
    0x1000e3564 <+144>: ldr    x20, [sp, #0x18]
    0x1000e3568 <+148>: ldr    x8, [x20]
    0x1000e356c <+152>: ldr    x8, [x8, #0x60]
    0x1000e3570 <+156>: blr    x8
    0x1000e3574 <+160>: ldr    x0, [sp, #0x18]
    0x1000e3578 <+164>: bl     0x1000e5e18               ; symbol stub for: swift_release
    0x1000e357c <+168>: ldp    x29, x30, [sp, #0x50]
    0x1000e3580 <+172>: ldp    x20, x19, [sp, #0x40]
    0x1000e3584 <+176>: add    sp, sp, #0x60             ; =0x60 
    0x1000e3588 <+180>: ret    

断点在<+116>和<+120>这两行,分别读取一下x8来验证

(lldb) register read x8
      x8 = 0x0000000281d70bd8
(lldb) register read x8
      x8 = 0x00000001009c9578  type metadata for YFArmDemo.YFCoder

由此可见x8是metadata指针,继续断点0x1000e3550 <+124>: blr x8到这行,按住ctrl键,点击step into按钮进入单步调用;

YFArmDemo`YFCoder.method1():
->  0x1009c3124 <+0>:   sub    sp, sp, #0x50             ; =0x50 
    0x1009c3128 <+4>:   stp    x29, x30, [sp, #0x40]
    0x1009c312c <+8>:   add    x29, sp, #0x40            ; =0x40 
    0x1009c3130 <+12>:  adrp   x8, 5

debug就进入到了method1方法,证明前面的推论。统一的方法,断点0x1000e3560 <+140>: blr x80x1000e3570 <+156>: blr x8, 能进入到method2和method3。

由此推测函数的调用过程:找到 Metadata ,确定函数地址(metadata + 偏移量), 执行函数。而偏移量,如#0x50、#0x58、#0x60都是8字节递增的,说明函数的地址是连续存储的,相比之下OC是存放在无序的哈希表里。

V-Table虚拟函数表的引入

为了进一步验证,通过生成的SIL来查看源码。添加run script脚本,以iOS 15.0模拟器为目标生成SIL代码:

swiftc -emit-silgen -Onone -target x86_64-apple-ios15.0-simulator -sdk $(xcrun --show-sdk-path --sdk iphonesimulator) ${SRCROOT}/YFArmdemo/ViewController.swift > ./ViewController.sil && open ViewController.sil

通过ViewController.sil发现:

sil_vtable YFCoder {
  #YFCoder.method1: (YFCoder) -> () -> () : @$s14ViewController7YFCoderC7method1yyF	// YFCoder.method1()
  #YFCoder.method2: (YFCoder) -> () -> () : @$s14ViewController7YFCoderC7method2yyF	// YFCoder.method2()
  #YFCoder.method3: (YFCoder) -> () -> () : @$s14ViewController7YFCoderC7method3yyF	// YFCoder.method3()
  #YFCoder.init!allocator: (YFCoder.Type) -> () -> YFCoder : @$s14ViewController7YFCoderCACycfC	// YFCoder.__allocating_init()
  #YFCoder.deinit!deallocator: @$s14ViewController7YFCoderCfD	// YFCoder.__deallocating_deinit
}

这里记录了所有的方法,很有可能Swift的函数都是记在一张表上调用的。在Metadata结构中有个属性:

var typeDescriptor: UnsafeMutableRawPointer

其大致结构如下:

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
}

在Swift源码中可以发现它有个别名:

using ClassDescriptor = TargetClassDescriptor<InProcess>;

搜索ClassDescriptor,在GenMeta.cpp文件找到并发现ClassContextDescriptorBuilder:

class ClassContextDescriptorBuilder
    : public TypeContextDescriptorBuilderBase<ClassContextDescriptorBuilder,
                                              ClassDecl>,
      public SILVTableVisitor<ClassContextDescriptorBuilder>
  {
    // 省略其他代码
    void layout() {
      assert(!getType()->isForeignReferenceType());
      super::layout();
      addVTable();
      addOverrideTable();
      addObjCResilientClassStubInfo();
      maybeAddCanonicalMetadataPrespecializations();
    }
  }

layout布局方法中发现addVTable()添加虚拟函数表的方法。再通过super::layout();进入父类的layout方法

// 继承自ContextDescriptorBuilderBase
class TypeContextDescriptorBuilderBase
    : public ContextDescriptorBuilderBase<Impl> {
    
    void layout() {
      asImpl().computeIdentity();

      super::layout();
      asImpl().addName();
      asImpl().addAccessFunction();
      asImpl().addReflectionFieldDescriptor();
      asImpl().addLayoutInfo();
      asImpl().addGenericSignature();
      asImpl().maybeAddResilientSuperclass();
      asImpl().maybeAddMetadataInitialization();
    }
}
// 再跳转父类
class ContextDescriptorBuilderBase {
    void layout() {
      asImpl().addFlags();
      asImpl().addParent();
    }
}

回到重点ClassContextDescriptorBuilder类的addVTable方法里:

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);
}

只看最后几行,B.addInt32(offset / IGM.getPointerSize());代表添加偏移量, B.addInt32(VTableEntries.size()); 代表添加VTableEntries的大小,之后for循环添加其中的方法。 这个B的真面目:

namespace {
  template<class Impl>
  class ContextDescriptorBuilderBase {
  protected:
    Impl &asImpl() { return *static_cast<Impl*>(this); }
    IRGenModule &IGM;
  private:
    ConstantInitBuilder InitBuilder;
  protected:
    ConstantStructBuilder B;
  ...
}

在一开始的layout方法中还有addOverrideTable()这个方法:

void addOverrideTable() {
      LLVM_DEBUG(
        llvm::dbgs() << "Override Table entries for " << getType()->getName() << ":\n";
        for (auto entry : OverrideTableEntries) {
          llvm::dbgs() << "  ";
          entry.first.print(llvm::dbgs());
          llvm::dbgs() << " -> ";
          entry.second.print(llvm::dbgs());
          llvm::dbgs() << '\n';
        }
      );

      if (OverrideTableEntries.empty())
        return;

      B.addInt32(OverrideTableEntries.size());

      for (auto pair : OverrideTableEntries)
        emitMethodOverrideDescriptor(pair.first, pair.second);
}

说明子类重载的父类方法也会接到vtable里

Mach.o文件解析

Mahco: Mach-O 其实是Mach Object文件格式的缩写,是 mac 以及 iOS 上可执行文件的格 式, 类似于 windows 上的 PE 格式 (Portable Executable ), linux 上的 elf 格式 (Executable and Linking Format) 。常见的 .o,.a .dylib Framework,dyld .dsym。 Mahoc文件结构共分为三个区:

  • header: 表明该文件是 Mach-O 格式,指定目标架构,还有一些其他的文件属性信息,文件头信息影响后续的文件结构安排。
  • Load commands: 是一张包含很多内容的表。内容包括区域的位置、符号表、动态符号表 等。
NameValue
LC_SEGMENT_64将文件中(32位或64位)的段映射到进程地 址空间中
LC_DYLD_INFO_ONLY动态链接相关信息
LC_SYMTAB符号地址
LC_DYSYMTAB动态符号表地址
LC_LOAD_DYLINKERdyld加载
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 做内存映射的。

那么怎么找到项目的Mach-O文件?xcode工具栏选择Product->Show Build Folder in Finder进入到build文件夹。依次进入Product/Debug-iphoneos找到你的app文件,右键选择显示包内容,就能找到工程同名的可执行文件。将文件拖进查看工具,MachOView 中。 macho一览 swift5_types 这里存放的是结构体、枚举、类的 Descriptor,那么我们可以在这找到类的 Descriptor 的地址信息。 前四个字节08 FC FF FF就是当前类的 Descriptor 信息,加上前面pfile的BC58得到的就是Descriptor在当前Mach-O文件的内存地址。 由于iOS属于小端模式,所以08 FC FF FF要从右边往左读, 通过mac计算器(选择程序员型)得到的相加结果:

BC58 + FFFFFC08 = 0x10000B860

Mach-O包含虚拟内存的基地址, 如图所示的0x100000000: 虚拟内存的基地址 当前得到的地址还需要减去基地址才是当前类在Data区的首地址0xB860。前往Section64(_TEXT,__const) 这个区找到: 首地址0xB860

TargetClassDescriptor结构包含13个4字节属性共52字节。之后便是vtable其实地址,一共方法8字节,B894应该就是method1。接下来就是实际运行验证

验证函数地址

在iOS中应用程序会有一个随机偏移地址,也就是ASLR。意味着内存地址是随机的。 真机运行,lldb下输入image list列出程序运行地址,取第一个就是基地址:0x0000000100b38000

(lldb) image list
[  0] 0FE4EED0-B53B-37E8-9754-2D229EFA1008 0x0000000100b38000

TargetMethodDescriptor是Swift的方法在内存中的结构:

/// An opaque descriptor describing a class or protocol method. References to
/// these descriptors appear in the method override table of a class context
/// descriptor, or a resilient witness table pattern, respectively.
///
/// Clients should not assume anything about the contents of this descriptor
/// other than it having 4 byte alignment.
template <typename Runtime>
struct TargetMethodDescriptor {
  /// Flags describing the method.
  MethodDescriptorFlags Flags;

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

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

函数的真实调用地址应该是在Mach-O文件中的偏移量加上ASLR,然后偏移 TargetMethodDescriptor中 flag 的4个字节,加上Impl中的偏移量,最后再减去Mach-O 的虚拟基地址。

// 应用程序的基地址 + Mach-O里的方法地址 + Flags的4字节 + offset
0x0000000100b38000 + B894 + 4 + FFFFFB88C = 0x1100B3F124
//减去虚拟地址0x100000000
0x1100B3F124 - 0x100000000 = 0x100B3F124

验证计算结果:0x0000000100b3f124, 至此验证成功。

结构体的方法调用

把class改为struct,进入汇编调试: 看到是直接拿到函数的地址直接调用,包括init方法。 Swift是一门静态语言,许多东西在运行的时候就可以确定了,拿到函数的地址进行调用就叫做方法的静态派发。

extension和继承的方法调用

简化一下代码:

class YFClass {
    func classMethod() {
        print("classMethod")
    }
}

class YFSubClass: YFClass {
    override func classMethod() {
        super.classMethod()
        print("override classMethod")
    }
    
    func subClassMethod() {
        print("subClassMethod")
    }
}

extension YFClass {
    func classExtension() {
        print("classExtension")
    }
}

struct YFStruct{
    func structMethod() {
        print("structMethod")
    }
}

extension YFStruct {
    func structExtension() {
        print("structExtension")
    }
}

let struct1 = YFStruct()
struct1.structExtension()

let class1 = YFClass()
class1.classExtension()

let subClass = YFSubClass()
subClass.classMethod()
subClass.classExtension()

extension方法调用: extension方法调用 类的VTable在编译时就确定了,extension的时机是不确定的。内存是连续的,如果从末尾插入会造成其他内存移动的开销。何况这种扩展子类也可能调用,不使用静态派发还得都存方法调用。所以extension都是静态派发

对于继承,子类先后调用父类方法,父类扩展: 子类调用父类方法

方法调度方式总结: 值类型和extension是静态派发,类和继承是函数表派发。但是总有例外,有些关键字会影响函数派发。

哪些关键字会影响函数派发方式

  • final: 添加了 final 关键字的函数无法被重写,使用静态派发,不会在 vtable 中出现,且 对 objc 运行时不可见。
  • dynamic: 函数均可添加 dynamic 关键字,为非objc类和值类型的函数赋予动态性,但派发 方式还是函数表派发。
  • @objc: 该关键字可以将Swift函数暴露给Objc运行时,依旧是函数表派发。
  • @objc + dynamic: 消息派发的方式

函数内联

函数内联是一种编译器优化技术,它通过使用方法的内容替换直接调用该方法(将函数调用展开成函数体),从而优化性能。 通过在函数前添加关键字来控制:

  • @inline(__always): 始终内联函数。
  • @inline(never): 永远不会内联函数。

xcode可以设置是否开启编译器优化(Release默认开启)。打开项目的Build Setting,搜索optimization可以找到: 函数自动内联设置 即便是开启了优化,递归调用、动态派发的函数也不会被内联。函数体过长的不会被自动内联。