Swift底层进阶--004:内存分区 & 方法调度

内存分区
内存五大区
  • 内存分区按地址从高到低排列: 栈区->堆区->全局静态区->常量区-> 代码区
  • 栈区的地址比堆区的地址大很多
  • 栈区从高地址往低地址分配空间,堆区全局静态区常量区代码区都是从低地址往高地址分配空间
  • 栈区堆区边界碰撞,就会出现开发中的溢出。
栈区

栈区
Stack栈区

  • 从高地址往低地址分配空间,向下延伸,是连续的内存空间
  • 栈区存放局部变量、函数调用上下文,由系统自动管理,使用完由系统回收
堆区

堆区
Heap堆区

  • 从低地址往高地址分配空间,向上延伸,堆空间是不连续的,结构类似链表
  • 通过newmalloc在堆区分配内存空间,由开发者手动管理,使用完手动释放
全局静态区

使用c语言测试

全局静态区
abc都在全局静态区

  • 从低地址往高地址分配空间
  • 已初始化的全局变量,存储在__DATA.__data
  • 未初始化的全局变量,存储在__DATA.__common
  • 未初始化比已初始化的全局变量地址更高

swiftc的差异

Swift和C的差异
main.swift中定义变量age1和常量age2

  • age1可以正常获取地址并打印,它存储在__DATA.__common
  • age2由于是不可变,不允许使用withUnsafePointer获取地址

使用断点查看汇编代码寻找age2的地址

汇编代码
通过首地址+偏移地址,找到 age2地址并打印,它同样存储在__DATA.__common

常量区

使用c语言测试

常量区
ab都在常量区

  • 从低地址往高地址分配空间
  • 常量存储在__DATA.__data

查看硬编码的字符串存放位置

char *p="Zang";

上述代码中的字符串"Zang"存储在哪里?

硬编码的字符串存放位置
通过查看Mach-O文件,"Zang"存储在__TEXT.__cstring段,内存分区中的常量区

代码区

代码区
代码段__TEXT.__text:里面存放了要执行的汇编代码。每一个swift文件都会经过编译,然后汇编形成.o文件(目标文件),最终.o文件会合成为一个文件,当前代码会按照链接顺序依次在.o文件里排列好,放在.o文件的__TEXT.__text段。

使用static const修饰的变量

使用c语言测试

使用static const修饰的变量

  • a处于全局区,存储在__DATA.__data
  • b处于常量区,存储在__DATA.__data
  • c提示找不到地址,因为使用static const修饰的变量,Mach-O没有记录。c实际只是一个别名,没有独立内存空间
方法调度
静态调度

值类型的函数调用方式是静态调度。
例如结构体中的⽅法调度就是静态调度,通过地址直接调用。在编译、链接完成之后,当前的函数地址就已经确定,存放在代码段__TEXT.__text,结构体内并不存储函数地址。

struct LGTeacher{
    func test() {
        print("test")
    }
}

var t=LGTeacher()
t.test();

通过断点查看汇编代码:

函数地址
函数地址在编译、链接后已经确定,通过callq指令的跳转,直接地址调用。

打开Mach-O文件:

Mach-O
函数地址存储在代码段__TEXT.__text,而结构体内并不存储函数地址。

函数地址后面的符号,又是如何存储的?

符号

打开Mach-O文件,来到Symbol Table
Symbol Table
符号存储在Symbol Table符号表里面
Symbol Table:符号表,里面存储的是符号位于String Table字符串表的偏移地址
命名重整:包含工程名类名函数名参数参数类型等信息

Symbol Table虽然是符号表,但里面并不直接存储符号。
打开Mach-O文件,来到String Table

String Table
符号字符串实际存储在String Table字符串表里面
String Table:字符串表,里面存储了所有变量名和函数名,它们都以字符串形式进行存储。符号字符串也在其内
通过首地址+偏移地址可以找到相应符号

Dynamic Symbol Table:动态库函数位于符号表的偏移信息

Dynamic Symbol Table

通过命令操作符号表
  • 查看符号表:nmMach-O路径】

    查看符号表

  • 搜索符号:nmMach-O路径】| grep【地址】

    搜索符号

  • 还原符号名称:xcrun swift-demangle【符号】

    还原符号名称

还原符号表

Release模式编译项目,Mach-O中的符号表只保留不能确定地址的符号。同时在可执行文件目录下,多出一个.dSYM文件。因为静态链接的函数,实际上是不需要符号的。一旦编译完成,其地址确定后,当前符号表会删除当前函数对应的符号。这样可以减小Mach-O文件的大小。

  • 可执行文件目录下,多出一个.dSYM文件
    执行文件目录
  • Release模式编译后的Mach-O文件,符号表中的符号少了很多,只保留不能确定地址的符号
    Release模式编译后的Mach-O文件
什么是不能确定地址的符号?

打开Mach-O文件,来到Lazy Symbol

Lazy Symbol
Lazy Symbol:懒加载符号表,里面存储不能确定地址的符号。它们是在运行时才能确定,即函数第一次调用时。

例如print函数,通过dyld_stub_bind确定地址,很遗憾我在Xcode Version 12.3版本中没有找到

print

函数的命名重整规则

c语言:_函数名

c语言
原函数cFunc,重整后函数符号:_cFunc。简单的在函数名前面加_。所以c语言不允许函数重载,因为重整规则过于简单,函数重载在编译后根本无法区分。

oc-[类名 函数名]

oc
原函数ocFunc,重整后函数符号:-[ocTest ocFunc]。对于oc来说,同样不支持函数重载。

swift:包含工程名类名函数名参数名参数类型等信息

swift
原函数func test(abc : Int),重整后函数符号:_$s4demo4test3abcySi_tF
原函数func test(abc : String),重整后函数符号:_$s4demo4test3abcySS_tF
swift支持函数重载,它的命名重整规则也比coc复杂得多,包含工程名类名函数名参数名参数类型等信息,目的是确保函数符号的唯一性。

ASLR

ASLR:随机地址偏移(address space layout randomizes
每次APP启动,都会随机生成一个地址偏移值。造成编译后Mach-O文件中的地址与App运行时的地址产生偏差。

test方法上设置断点,使用真机运行,可以看到运行时test函数地址:0x100ab2cf8

运行时函数地址

打开Mach-O文件,来到Symbol Table,搜索test,可以看到编译时test函数地址:0x0100006CF8

编译时函数地址
可以看到test函数地址,在运行时和编译时有明显的差异

公式:

  • ASLR随机偏移值 = 运行时基地址 - 编译时基地址
  • 运行时函数地址 = 编译时函数地址 + ASLR随机偏移值

首先找到App运行时基地址,使用image list打印镜像文件的地址。第一个镜像文件地址就是App运行时的基地址:0x100aac000

运行时基地址

再打开Mach-O文件,通过Load Comands->LC_SEGMENT_64(__TEXT)->VM Address,找到App编译时的基地址:0x100000000

编译时的基地址

通过刚才的公式进行验证:
ASLR随机偏移值:0x100aac000 - 0x100000000 = 0x000aac000
运行时函数地址:0x0100006CF8 + 0x000aac000 = 0x100ab2cf8

通过公式进行验证

通过公式计算出的结果,和断点里输出的运行时函数地址完全一致

动态调度

结构体中的⽅法都是静态调度,而类中的方法通过V-table函数表进行调度,是动态调度。

V-table在SIL文件中的格式:

//声明sil vtable关键字
decl ::= sil-vtable
//sil vtable中包含的关键字、标识(当前的类名)、所有方法
sil-vtable ::= 'sil_vtable' identifier '{' sil-vtable-entry* '}'
//方法中包含了声明以及函数名称
sil-vtable-entry ::= sil-decl-ref ':' sil-linkage? sil-function-na me

通过⼀个简单的源⽂件进行演示:

class LGTeacher{
    func test1() {}
    func test2() {}
    func test3() {}
    @objc deinit{}
    init() {}
}

将上述代码生成SIL文件:swiftc -emit-sil main.swift | xcrun swift-demangle

LGTeacher函数表

  • 首先sil_vtable是关键字,后面LGTeacher表明当前是LGTeacher Class的函数表
  • 其次就是当前⽅法声明对应着⽅法名称
  • 函数表本质可以理解为数组,声明在Class内部的方法在不加任何关键字修饰的过程中,会连续存放在我们当前的地址空间中

我们可以通过断点,查看汇编代码进行验证:

汇编验证
很明显test1test2test3这三个函数,是连续存放在当前的地址空间中

ARM64汇编指令

  • blr:带返回的跳转指令,跳转到指令后边跟随寄存器中保存的地址
  • mov:将某一寄存器的值复制到另一寄存器(只能用于寄存器与起存起或者寄存器与常量之间传值,不能用于内存地址)
    mov x1, x0将寄存器x0的值复制到寄存器x1
  • ldr:将内存中的值读取到寄存器中
    ldr x0, [x1, x2]将寄存器x1和寄存器x2相加作为地址,取该内存地址的值翻入寄存器x0
  • str:将寄存器中的值写入到内存中
    str x0, [x0, x8]将寄存器x0的值保存到内存[x0 + x8]
  • bl:跳转到某地址

我们还可以通过源码进行验证,搜索initClassVTable,设置断点并调试:

源码验证
initClassVTable的核心代码,通过for循环,从i等于0截止到VTableSize的大小。循环过程中,先通过offset+i偏移,再调用getMethod(i)得到对应的method,将其存入偏移后的内存中。从上述代码可以看出,函数是连续存放在当前的地址空间中。

extension中声明的函数,是通过V-table进行调度吗?
class LGTeacher {
    func test1() {}
    func test2() {}
    func test3() {}
    @objc deinit{}
    init() {}
}

extension LGTeacher{
    func test4() {}
}

通过断点,查看汇编代码进行验证:

extension中的函数调用
extension中的函数,并不是通过V-table函数表进行调度,而是直接地址调用

子类继承父类,函数表会变成什么样?

class LGTeacher {
    func test1() {}
    func test2() {}
    func test3() {}
    @objc deinit{}
    init() {}
}

class LGChild : LGTeacher {
    override func test2() {}
    func test5() {}
}

extension LGTeacher{
    func test4() {}
}

将上述代码生成SIL文件:swiftc -emit-sil main.swift | xcrun swift-demangle

LGChild函数表

  • sil_vtable LGChild中,由子类声明的函数,被追加到父类函数下面。
  • 被子类重写的父类函数,位置不变,但被记录为子类函数。
  • 未被子类重写的父类函数,位置不变,依旧记录为父类函数。
  • extension中的函数,并不是通过V-table函数表进行调度,也不能被子类重写,只能被子类调用。

extension中的函数,不通过V-table函数表调度而是直接地址调用,其原因在于编译时无法将extension中的函数插入到该类函数表的正确位置。

例如子类将父类的函数表继承后,如果存在子类声明的函数,会继续在连续地址中插入,也就是刚才看到的子类声明的函数被追加到父类函数的下面。而声明extension在代码中的位置无法确定,很有可能在子类编译后才被读取到。这时子类中并没有指针记录来区分哪些函数属于子类、哪些函数属于父类,故此extension中的函数无法正确插入到指定位置。这也是extension中的函数不能被子类重写,只能被子类调用的原因。

final

使用final修饰的方法,并不是通过V-table函数表进行调度,而是直接地址调用。不能被子类重写,只能被子类调用。

class LGTeacher {
    final func test1() {}
    func test2() {}
    func test3() {}
    @objc deinit{}
    init() {}
}

将上述代码生成SIL文件:swiftc -emit-sil main.swift | xcrun swift-demangle

LGTeacher函数表
final修饰的test1方法,在函数表里不见了。修饰后的test1方法不再通过V-table进⾏调度,变成直接地址调用。

我们可以通过断点,查看汇编代码进行验证:

汇编代码验证
final修饰的test1方法是直接地址调用。test2test3方法首地址+偏移,是通过V-table函数表进行调度。

@objc

使用@objc修饰可以将swift方法暴露给oc使用。

class LGTeacher {
    @objc func test1() {}
    func test2() {}
    func test3() {}
    @objc deinit{}
    init() {}
}

将上述代码生成SIL文件:swiftc -emit-sil main.swift | xcrun swift-demangle

LGTeacher函数表
函数表没有发生任何变化,被@objc修饰的test1方法,依然通过V-table函数表进行调度。

@objc修饰的方法,虽然调度方式没有改变,但方法的声明变成了两个。

方法的声明
分别出现了swifttest1方法和octest1方法,而octest1方法内部调用的还是swifttest1方法。

演示一下oc如何访问swift的方法:

class LGTeacher : NSObject {
    @objc func test1() {}
    func test2() {}
    func test3() {}
    @objc deinit{}
    override init() {}
}

方法只通过@objc修饰方法,oc并不能访问到,还要将Class继承NSObject

main.swift里写入上述代码,编译后找到桥接文件

找到桥接文件

打开桥接文件,可以看到被@objc修饰的方法和属性都生成了oc代码

demo-Swift.h

ocTest.m中导入头文件,可以直接使用swift的类和方法

ocTest.m
dynamic

使用dynamic修饰的方法具有动态特性,可动态修改。调度方式没有改变,依然通过V-table函数表进行调度。

  • 使用dynamic修饰方法,如果Class继承NSObject,可以使用method-swizzling
  • swift中的方法交换:使用dynamic修饰方法,使用@_dynamicReplacement交换方法

演示一下swift中的方法交换:

class LGTeacher {
    dynamic func test1() {
        print("test1")
    }
}

extension LGTeacher{
    @_dynamicReplacement(for:test1)
    func test2() {
        print("test2")
    }
}

var t = LGTeacher()
t.test1()

//输出以下内容:
//test2

方法未使用dynamic修饰,使用@_dynamicReplacement交换方法时,编译报错

未使用`dynamic`修饰方法

方法不存在,使用@_dynamicReplacement交换方法时,编译报错

方法不存在
@objc + dynamic

使用@objc + dynamic修饰方法,会改变方法的调度方式。

class LGTeacher {
    @objc dynamic func test1() {}
    func test2() {}
    func test3() {}
    @objc deinit{}
    init() {}
}

我们可以通过断点,查看汇编代码进行验证:

汇编代码验证
test1方法的调用方式,变为消息调度,使用objc_msgSend动态消息转发

总结:
  • 值类型的函数调用方式是静态调度
  • 引用类型通过V-table函数表进行调度,是动态调度
  • extension中的函数调用方式是静态调度
  • final修饰的函数调用方式是静态调度
  • @objc修饰的函数通过V-table函数表进行调度,是动态调度
  • dynamic修饰的函数通过V-table函数表进行调度,是动态调度
  • @objc + dynamic修饰的函数调用方式是消息调度,使用objc_msgSend动态消息转发

你可能感兴趣的:(Swift底层进阶--004:内存分区 & 方法调度)