前言
- 着重介绍了Swift的方法调度。通过汇编调试,Mach-O文件解析来验证方法调度的内存地址。
- 异变方法。
- 函数派发方式。
- 函数内联。
异变方法-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 x8和0x1000e3570 <+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: 是一张包含很多内容的表。内容包括区域的位置、符号表、动态符号表 等。
| Name | Value |
|---|---|
| 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 | 代码签名 |
- 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 中。
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) 这个区找到:
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方法调用:
类的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可以找到:
即便是开启了优化,递归调用、动态派发的函数也不会被内联。函数体过长的不会被自动内联。