13

从Mach-O角度谈谈Swift和OC的存储差异

 3 years ago
source link: https://mp.weixin.qq.com/s?__biz=MzI1NDc5MzIxMw%3D%3D&%3Bmid=2247491058&%3Bidx=1&%3Bsn=3b1d7e68ff51df58046d1e0d0aee1039
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

导读

本文从二进制的角度初步介绍了Swift与OC的差异性,包括Swift在可执行文件中函数表的存储结构、函数的存储结构等(目前只列出基本结构,泛型等结构描述会陆续补充)。为了方便阅读理解,文末附有Demo地址。OC版本的二进制解析工具已经开源,针对Swift的二进制解析工具目前正在开发中,近期即将发布,敬请关注WBBlades~

背景

经过数年的更新,Swift的ABI终于稳定了。由此引来的就是各大厂对Swift引入的争相尝试。为此58同城APP在集团内发起了引入Swift语言的协同项目— 混天项目 。混天项目从混编架构、工具链、基础组件、UI组件等多方面着手,旨在提高Swift引入后的开发效率。本文是混天项目工具链组阶段性研究成果。

动态调用

在正文开始之前,我们先来看个与主题无关的例子。

class MyClass {
var p:Int = 0
init() {
print("init")
}
func helloSwift() -> Int {
print("helloSwift")
return 100
}
func helloSwift1() -> Int {
print("helloSwift1")
return 100
}
func helloSwift2() -> Int {
print("helloSwift2")
return 100
}
}

在运行时,我们能否动态调用上面这个类的函数呢?如果换成OC语言,我相信绝大多数iOSer 都知道如何动态调用。
以下面代码为例:

/**
假设MyClass由OC实现
*/
@interface MyClass : NSObject


@property(nonatomic,assign)int p;


@end


@implementation MyClass


- (instancetype)init{
if (self = [super init]) {
NSLog(@"init");
}
return self;
}
- (int)helloSwift{
NSLog(@"helloSwift");
return 100;
}
- (int)helloSwift1{
NSLog(@"helloSwift1");
return 100;
}
- (int)helloSwift2{
NSLog(@"helloSwift2");
return 100;
}


@end


/**
那么通过runtime可以获取到任意的方法IMP
*/
Class class = NSClassFromString(@"MyClass");
unsigned int count = 0;
Method *list = class_copyMethodList(class,&count);
for (int i = 0; i < count; i++) {
Method method = list[i];
NSLog(@"- [%@ %@]",class,NSStringFromSelector(method_getName(method)));
}
NSLog(@"%@ count = %u",class,count);


//模拟通过IMP调用更直观
Method method = class_getInstanceMethod(class, @selector(helloSwift));
IMP imp = method_getImplementation(method);
imp();
打印结果如下
2020-11-19 17:16:08.885763+0800 SwiftToolDemo[45037:17709798] - [MyClass init]
2020-11-19 17:16:08.886219+0800 SwiftToolDemo[45037:17709798] - [MyClass helloSwift]
2020-11-19 17:16:08.886389+0800 SwiftToolDemo[45037:17709798] - [MyClass helloSwift1]
2020-11-19 17:16:08.886537+0800 SwiftToolDemo[45037:17709798] - [MyClass helloSwift2]
2020-11-19 17:16:08.886680+0800 SwiftToolDemo[45037:17709798] - [MyClass p]
2020-11-19 17:16:08.886823+0800 SwiftToolDemo[45037:17709798] - [MyClass setP:]
2020-11-19 17:16:08.886932+0800 SwiftToolDemo[45037:17709798] MyClass count = 6
2020-11-19 17:16:08.887166+0800 SwiftToolDemo[45037:17709798] helloSwift

但是换成Swift,可能会难倒一部分同学。首先我们来观察下,MyClass没有继承自任何类,它是一个纯Swift类。我们通过runtime获取到类,但是无法获取到相关的函数信息。

 Class class = NSClassFromString(@"SwiftDynamicRun.MyClass");
unsigned int count = 0;
Method *list = class_copyMethodList(class,&count);
for (int i = 0; i < count; i++) {
Method method = list[i];
NSLog(@"- [%@ %@]",class,NSStringFromSelector(method_getName(method)));
}


打印结果如下:
2020-11-11 16:08:30.714057+0800 SwiftDynamic[71869:13232511] SwiftDynamic.MyClass count = 0

OC的存储

为什么OC能够在运行时找到类和方法呢?归根到底还是由于Mach-O文件存储了类和函数的信息。在Mach-O中,所有的类都存储到__objc_classlist这个section中。

FFnauqV.png!mobile

通过 __objc_classlist中的地址,我们能找到每个类的详细信息。本文以arm64架构为例,在找到0x11820文件偏移后,我们很容易通过结构体结构套取到类的信息。

struct class64
{
unsigned long long isa;
unsigned long long superClass;
unsigned long long cache;
unsigned long long vtable;
unsigned long long data;
};

在本文中,可能有同学对地址和偏移的换算存在困惑。例如8字节中存储的是0x1000011820,为什么我们要去寻找0x11820的文件偏移。在Mach-O需要先判断0x1000011820位于哪个segment中,在Load Commands里会记录每个segment的起始虚拟地址及size。

if (address >= segmentCommand.vmaddr && address <= segmentCommand.vmaddr + segmentCommand.vmsize) {
return address - (segmentCommand.vmaddr - segmentCommand.fileoff);
}

在本文中,为了不影响阅读,可以将虚拟地址 - 0x100000000当做文件偏移。
因此class64结构体的isa就位于0x11820的连续8字节。data就位于0x11820随后的第5个8字节。

ii6vqiE.png!mobile

上文中struct class64 中的data指向了class64Info结构体的地址。根据class64Info结构体我们很容易能找到类名和类的实例方法列表。并且通过方法列表的IMP找到每个函数的起始地址。

NJ3E7re.png!mobile

上文的简单演示下OC类信息的遍历过程,即如何找到每个类的每个方法及首条指令地址。除了实例方法外还有类方法、分类中的方法等等,详细的过程和代码可以参考58开源的WBBlades(https://github.com/wuba/WBBlades),代码中有详细的过程,在此不再赘述。

Swift

不论是OC类还是Swift类,都会被存储到__objc_classlist中。Swift类完整的保留了OC的存储结构。也就是说上文中的MyClass也是按照OC的查找方式也是能找到对应的结构的。

BryYJjy.png!mobile

虽然Swift完整保留了struct class64和struct class64Info的数据结构,但是MyClass并没有将方法列表保存到struct class64Info中。那么在这里就会有2个问题

  • 为什么Swift类要保留OC的类结构?

  • MyClass的方法存在哪里?

Swift类要保留OC的类结构是为了兼容OC,部分Swift类继承自OC,并且需要向OC暴露接口,不可避免地需要借用OC的消息转发机制。

那么MyClass的方法存储在哪里呢?参考Swift5.0的Runtime机制浅析的总结(https://www.jianshu.com/p/158574ab8809),可能一部分方法在编译优化时被内联化。假设先不考虑内联这种场景,如何找到每个MyClass的函数表呢?
Swift除了兼容了OC的存储结构外,还具备自己的存储结构,通过MachOView能看到Mach-O文件中存储了很多以swift5命名的section(以swift5示例)。

zMRryan.png!mobile

这些section中,__swift5_types中存储的是Class、Struct、Enum的地址。具体每个section存储Swift的哪些数据,在Swift metadata (https://knight.sc/reverse%20engineering/2019/07/17/swift-metadata.html) 一文中有较为详细的描述。

如果此时你打开MachOView,查看__swift5_types的二进制数据后你会发现它与OC的存储有很大的不同。在OC中,存储地址通常都是8字节的直接存储对应的地址。但是types不是8字节地址,而是4字节,并且所存储的数据明显不是直接地址,而是相对地址。那么如何得出MyClass的地址呢?当前文件偏移 + 随后4字节中存储的value即可得到地址。

2QvM7zv.png!mobile

为什么Swift要采用这种方式来存储数据呢?猜测是为了节省包大小,按照OC的存储习惯存储一个地址需要8字节,而在这里4字节就够了。

经过计算后可发现,MyClass的偏移位于__TEXT,__const中。无论是按 Scott Knight(https://knight.sc/)整理好的结构:

type ClassDescriptor struct {
Flags uint32
Parent int32
Name int32
AccessFunction int32
FieldDescriptor int32
SuperclassType int32
MetadataNegativeSizeInWords uint32
MetadataPositiveSizeInWords uint32
NumImmediateMembers uint32
NumFields uint32
}

还是按HandyJSON(https://github.com/alibaba/HandyJSON)整理的结构:

struct _ClassContextDescriptor: _ContextDescriptorProtocol {
var flags: Int32
var parent: Int32
var mangledNameOffset: Int32
var fieldTypesAccessor: Int32
var reflectionFieldDescriptor: Int32
var superClsRef: Int32
var metadataNegativeSizeInWords: Int32
var metadataPositiveSizeInWords: Int32
var numImmediateMembers: Int32
var numberOfFields: Int32
var fieldOffsetVector: Int32
}

都无法得知Swift函数表的存储位置。不过从2者之间的差异可以推测,ClassContextDescriptor的结构可能双方都没有罗列完全。之所以会这样猜想,是因为在通过MachOView查看二进制时,因为好奇计算了下MyClass的ClassDescriptor后续几个字节的地址,发现确实是指向了汇编代码。

U73Qrm.png!mobile

那到底是不是ClassDescriptor这个结构体还有其他的内容呢?这个只能从源码中寻找答案了。
首先查看了 ClassContextDescriptorBuilder 的layout方法,这里似乎能看到我们想要的信息——VTable。

class ClassContextDescriptorBuilder
//重写了addLayoutInfo
void layout() {
super::layout();
addVTable();
addOverrideTable();
addObjCResilientClassStubInfo();
maybeAddCanonicalMetadataPrespecializations();
}
}

ClassContextDescriptorBuilder的结构并不是在一个类中完全确定的,而是通过继承关系逐渐 添加丰富完成的。

例如,最开始的2个4字节内容是由基类ContextDescriptorBuilderBase确定的,而TypeContextDescriptorBuilderBase又不断的向自己的结构中丰富完善信息。最后,ClassContextDescriptorBuilder除了通过重写layout()方法和addLayoutInfo()方法外,还在此基础上添加了Vtable(其他信息暂不关注)。

ClassContextDescriptorBuilder //重写父类addLayoutInfo方法,从而添加SuperclassType 、MetadataNegativeSizeInWords、MetadataPositiveSizeInWords、NumImmediateMembers 、NumFields、FieldOffsetVectorOffset、VTable、OverrideTable等
^
|
TypeContextDescriptorBuilderBase // 添加Name、AccessFunction、FieldDescriptor、NumFields、FieldOffsetVectorOffset
^
|
ContextDescriptorBuilderBase //添加Flag 、Parent
class TypeContextDescriptorBuilderBase
void layout() {
asImpl().computeIdentity();


super::layout();
asImpl().addName();
asImpl().addAccessFunction();
asImpl().addReflectionFieldDescriptor();
asImpl().addLayoutInfo();
asImpl().addGenericSignature();
asImpl().maybeAddResilientSuperclass();
asImpl().maybeAddMetadataInitialization();
}


void addLayoutInfo() {
auto properties = getType()->getStoredProperties();


// uint32_t NumFields;
B.addInt32(properties.size());


// uint32_t FieldOffsetVectorOffset;
B.addInt32(FieldVectorOffset / IGM.getPointerSize());
}
}
 class ContextDescriptorBuilderBase {
void layout() {
asImpl().addFlags();
asImpl().addParent();
}
}

那么Vtable是如何添加的呢?按Mach-O的存储习惯,大概率是先约定单个函数的存储长度,再告诉我们函数个数;

 void addVTable() {
...
B.addInt32(VTableEntries.size());
for (auto fn : VTableEntries)
emitMethodDescriptor(fn);
}

在addVTable函数中可以看出,在依次存储函数前,先通过4字节存储函数表的大小。

IZrQniV.png!mobile

从上文中的代码描述来看,在某些情况下是不存在VTable的,那么怎么才能知道是否存在VTable呢?如果不存在VTable的情况下,按照存在VTable的结构去解析,会造成错乱。
按照Mach-O的习惯,一般Kind、Flag这样的字节都会有一定的标示性,能够通过一个或几个字节告诉我们后续内容的类别情况。
经过整理,Flag的详细说明如下:

 -------------------------------------------------------------------------------------------------
| TypeFlag(16bit) | version(8bit) | generic(1bit) | unique(1bit) | unknown (1bit) | Kind(5bit) |
-------------------------------------------------------------------------------------------------

先来看2个枚举:

// Kinds of context descriptor.
enum class ContextDescriptorKind : uint8_t {
/// This context descriptor represents a module.
Module = 0,


/// This context descriptor represents an extension.
Extension = 1,


/// This context descriptor represents an anonymous possibly-generic context
/// such as a function body.
Anonymous = 2,


/// This context descriptor represents a protocol context.
Protocol = 3,


/// This context descriptor represents an opaque type alias.
OpaqueType = 4,


/// First kind that represents a type of any sort.
Type_First = 16,


/// This context descriptor represents a class.
Class = Type_First,


/// This context descriptor represents a struct.
Struct = Type_First + 1,


/// This context descriptor represents an enum.
Enum = Type_First + 2,


/// Last kind that represents a type of any sort.
Type_Last = 31,
};sVTable = 15, };
/// Flags for nominal type context descriptors. These values are used as the
/// kindSpecificFlags of the ContextDescriptorFlags for the type.
class TypeContextDescriptorFlags : public FlagSet<uint16_t> {
enum {
// All of these values are bit offsets or widths.
// Generic flags build upwards from 0.
// Type-specific flags build downwards from 15.


/// Whether there's something unusual about how the metadata is
/// initialized.
///
/// Meaningful for all type-descriptor kinds.
MetadataInitialization = 0,
MetadataInitialization_width = 2,


/// Set if the type has extended import information.
///
/// If true, a sequence of strings follow the null terminator in the
/// descriptor, terminated by an empty string (i.e. by two null
/// terminators in a row). See TypeImportInfo for the details of
/// these strings and the order in which they appear.
///
/// Meaningful for all type-descriptor kinds.
HasImportInfo = 2,


/// Set if the type descriptor has a pointer to a list of canonical
/// prespecializations.
HasCanonicalMetadataPrespecializations = 3,


// Type-specific flags:


/// The kind of reference that this class makes to its resilient superclass
/// descriptor. A TypeReferenceKind.
///
/// Only meaningful for class descriptors.
Class_ResilientSuperclassReferenceKind = 9,
Class_ResilientSuperclassReferenceKind_width = 3,


/// Whether the immediate class members in this metadata are allocated
/// at negative offsets. For now, we don't use this.
Class_AreImmediateMembersNegative = 12,


/// Set if the context descriptor is for a class with resilient ancestry.
///
/// Only meaningful for class descriptors.
Class_HasResilientSuperclass = 13,


/// Set if the context descriptor includes metadata for dynamically
/// installing method overrides at metadata instantiation time.
Class_HasOverrideTable = 14,


/// Set if the context descriptor includes metadata for dynamically
/// constructing a class's vtables at metadata instantiation time.
///
/// Only meaningful for class descriptors.
Class_HasVTable = 15,
};

Flag比较有用的低5位和高16位。低5位可以代表32类型,中间位用来表示version、是否唯一、泛型等,暂不关心。其中:

  • 低5位标识当前描述的类型,是 Class | Struct | Enum | Protocol 等等。

  • 高16位用于标识是否有 Class_HasVTable | Class_HasOverrideTable | Class_HasResilientSuperclass 等等。

以MyClass的Falg = 0x80000050为例。低5位为0x50 = 1 0 0 0 0 。其十进制为16,在ContextDescriptorKind中,16标识Class。高16位为0x8000 = 1 0 0 0 0 0 0 0 0 0 0 0 0 0, 在TypeContextDescriptorFlags中,第16位为1标识Class_HasVTable。因此0x80000050的意思为具有VTable的类。

如何实现动态调用

感兴趣的可以下载Demo( https://github.com/pilaf-king/SwiftMachODemo ,在运行时大家可能会有疑问,为什么输出的函数数量与实际写的函数不一致。因为除了自己写的函数外,还有额外自动生成的函数,也被加入到VTable中。

函数的Flag解释如下,感兴趣的可以关注下
/**
------------------------------------------------------------------------------------
| ExtraDiscriminator(16bit) | .. | isDynamic(1bit) | isInstance(1bit) | Kind(4bit) |
------------------------------------------------------------------------------------


enum class Kind {
Method,
Init,
Getter,
Setter,
ModifyCoroutine,
ReadCoroutine,
};
*/

另外,overrideTable在Demo中没有实现,但是结构和存储位置在代码做了注释标记,感兴趣的可以自己解析下。

//OverrideTable结构如下,紧随VTable后4字节为OverrideTable数量,再其后为此结构数组
struct SwiftOverrideMethod {
struct SwiftClassType *OverrideClass;
struct SwiftMethod *OverrideMethod;
struct SwiftMethod *Method;
};

总结

本文从动态调用开始引入思考,逐渐探索Swift的二进制存储。Swift的函数存储具有很大的局限性,例如:我们只能知道函数的类型及Index,通过Index和类型确定哪个函数,一旦函数发生变化那么VTable的位置就发生了变化。本文并不是推广动态调用,仅仅是从动态调用这个场景将大家吸引到Mach-O的解析过程中。Swift作为一门很先进的语言,有太多的特性值得我们去探索。笔者也只是刚接触Swift,难免带着OC的思维去揣摩和探索Swift,如有疏漏之处,敬请指正。

作者简介:

邓竹立: 用户价值增长中心-iOS技术部 开发工程师

参考文献:

https://knight.sc/reverse%20engineering/2019/07/17/swift-metadata.html

https://juejin.im/post/6844903783449755655

https://github.com/apple/swift/blob/5c59babfebb1603c8fd95bf04e1a33c8e074c7ca/lib/IRGen/GenMeta.cpp

https://www.jianshu.com/p/158574ab8809

https://github.com/neil-wu/SwiftDump/blob/master/README_zh.md

推荐阅读:

基于Flink构建实时数仓实践

58同城无侵入改造业务库为Dynamic Feature工程的探索和实践

福利环节

为了鼓励优质内容传播,【58技术】公众号近期会持续推出不定期活动奖励。

  1. 评论区互动留言,即可参与此次活动

  2. 留言转发集赞,点赞量前三名(点赞数需大于10)可获得定制版新年代码台历一本

  3. 活动时间:截至2021年1月17日

BFFBvaY.jpg!mobile

Nb2iMvQ.jpg!mobile


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK