iOS 底层 - 手把手带你探索OC方法的本质

3,209 阅读7分钟

前言

  • 之前逆向部分的文章基础知识和所需工具已经讲述的差不多了 , 后续准备好实战项目以及汇编和越狱部分内容继续更新, 敬请关注 .

  • 目前准备更新一个底层系列文章 , 从 dyld 加载可执行文件到入口 main 函数 , 到类 , 分类 , 协议等等的加载为主线 . 一步步探索底层原理 .

  • 本篇文章从方法的本质开始讲述 , 前面少几篇文章 , 后续补上 , 然后会准备一个目录 .

前导知识

Runtime

说到任何关于 OC 本质的东西 , 我们不得不提一下 Runtime 这个东西 .

官方文档

这里只是简单了解一下 Runtime , 为我们探索方法本质提供一些帮助 , 后续更新详细的 Runtime 机制和具体使用 .

Runtime 简单介绍

◈      Objective-C 扩展了 C 语言,并加入了面向对象特性和 Smalltalk 式的消息传递机制。而这个扩展的核心是一个用 C 和 编译语言 写的 Runtime 库。它是 Objective-C 面向对象和动态机制的基石和根本。

◈      Objective-C 是一个动态语言,这意味着它不仅需要一个编译器,也需要一个运行时系统来动态得创建类和对象、进行消息传递和转发。

◈       理解 Objective-CRuntime 机制可以帮我们更好的了解这个语言,适当的时候还能对语言进行扩展,从系统层面解决项目中的一些设计或技术问题。

Runtime 版本

Runtime 其实有两个版本: 'modern''legacy'。我们现在用的 Objective-C 2.0 采用的是现行 ( Modern ) 版的 Runtime 系统,只能运行在 iOSmacOS 10.5 之后的 64 位程序中。而 macOS 较老的 32 位程序仍采用 Objective-C 1 中的(早期) ( Legacy ) 版本的 Runtime 系统。

这两个版本最大的区别在于 当你更改一个类的实例变量的布局时,在早期版本中你需要重新编译它的子类,而现行版就不需要

Runtime 基本是用 C 和汇编写的。你可以在 这里 下到苹果维护的开源代码。AppleGNU 各自维护一个开源的 runtime 版本,这两个版本之间都在努力的保持一致。

Runtime API

文档地址

建议多多阅读 .

Runtime 用处

Runtime 对于我们普通开发者来说主要是根据其动态的机制 , 来实现各种各样的需求 / 效果 . 简单列举一下 :

  • 关联对象 ( Objective-C Associated Objects ) 给分类增加属性
  • 方法交换 ( Method Swizzling ) 方法添加和替换和 KVO 实现
  • 消息转发 ( 热更新 ) 解决Bug ( JSPatch )
  • 实现 NSCoding 的自动归档和自动解档
  • 实现字典和模型的自动转换 ( MJExtension )
  • ...

实际上根据 Runtime 的机制和其提供的 API , 我们可以自由的运用 从而生成不同的功能 .

简单总结

  • C 语言中,将代码转换为可执行程序,一般要经历三个步骤,即编译、链接、运行。在链接的时候,对象的类型、方法的实现就已经确定好了。

  • 而在 Objective-C 中 , 由于 LLVM 将一些在编译和链接过程中的工作,放到了运行阶段。也就是说,就算是一个编译好的 .ipa 包,在程序没运行的时候,也不知道调用一个方法会发生什么。这也为后来大行其道的「热修复」提供了可能 。

这样的设计使 Objective-C 变得灵活,甚至可以让我们在程序运行的时候,去动态修改一个方法的实现。

由此引出我们今天方法本质的探索 -- 消息发送机制

实例对象 - 类对象 - 元类对象

关于这三种对象之前有篇文章里面有较为详细的讲述 , 本篇就不多赘述了 , 本系列文章中会继续更新 类 / 对象的本质 . OC类对象/实例对象/元类解析

方法的本质

探索

说了这么多 , 下面我们去除上帝视角 , 来从零开始一步步探索 OC 方法的完整流程 .

案例

新建一个 Command Line 项目 , 代码如下:

// main.m
#import <Foundation/Foundation.h>

@interface LBObject : NSObject
- (void)eat;
@end

@implementation LBObject
- (void)eat{
    NSLog(@"eat");
}
@end

void run(){
    NSLog(@"%s",__func__);
}

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        LBObject * obj = [LBObject alloc];
        [obj eat]; // OC 方法
        
        run();  // C 函数
    }
    return 0;
}

编译期 - clang

clang -rewrite-objc main.m -o main.cpp

由于 LLVM 本身就是内置的 clang , 因此通过该命令我们即可查看编译后,运行前源码转换成了 C 之后的样子 .

打开 main.cpp , 直接拉到最底下 main 函数实现 .

C 函数与 OC 方法

上图中我们很清楚的看到 , run 函数在编译期就确定了函数调用 以及实现 . 而 OC 方法被编译成调用objc_msgSend 函数. 这也就是我们在 Runtime 所提到的 消息发送机制 .

LLVM + Runtime 使用这种做法以此来实现动态的可能 .

因此得出结论 :

OC 方法的本质就是调用 objc_msgSend 等函数 .

为什么说 '等函数' , 因为调用类方法 / 父类方法 都会有不同 . 例如 : objc_msgSendSuper , objc_msgSend_stret 等等 .

objc_msgSend

通过编译后代码我们看到 objc_msgSend 函数有两个参数 id , SEL . id 显然就是操作哪个对象 . 而通过SELimp 的机制 , 以此实现了动态调用方法的本质 . 我们称这种机制为 消息发送 .

不同方法调用

调用对象实例方法
LBObject *obj = [LBObject alloc];
[obj eat];
// 实例方法调用底层编译
// 方法的本质: 消息 : 消息接受者 消息编号 ....参数 (消息体)
objc_msgSend(obj, sel_registerName("eat"));
调用类方法
objc_msgSend(objc_getClass("LBObject"), sel_registerName("eat"));
调用父类实例方法
struct objc_super lbSuper;
lbSuper.receiver = obj;
lbSuper.super_class = [LBSuper class];
//  __OBJC2__ 只需  receiver 和 super_class 即可
objc_msgSendSuper(&lbSuper, @selector(sayHello));
调用父类类方法
struct objc_super myClassSuper;
myClassSuper.receiver = [obj class];
myClassSuper.super_class = class_getSuperclass(object_getClass([obj class]));// 元类
objc_msgSendSuper(&myClassSuper, sel_registerName("test_classFunc"));
小提示

使用 objc_msgSend 函数要把校验关闭 , 否则编译就报错了.

objc_msgSend源码分析

重头戏终于来了 .

  • 打开 objc4 源码 . 可编译objc4 源码 , 密码 r5v6 .

  • 搜索 objc_msgSend , 直接来到 objc-msg-arm64.sENTRY _objc_msgSend 中.

在汇编里面,函数的入口格式是 ENTRY + 函数名 , 结束是 END_ENTRY , 我们这里以 arm64 架构为例

汇编源码

objc_msgSend 是使用汇编来写的 , 为什么呢 ? 个人感觉由于以下原因 :

  • 限制
    • C 语言作为静态语言 , 不可能通过一个函数来实现未知参数个数,类型并且跳转到另一个任意的函数指针的需求 .
  • 效率
    • 高级代码终究需要转换成汇编语言来从而能够被识别 .
  • 安全
    • 逆向中我们提到过, 为了防止系统函数被 hook , 我们经常使用汇编来调用方法和实现函数.

源码如下 :

ENTRY _objc_msgSend
	UNWIND _objc_msgSend, NoFrame

	cmp	p0, #0			// nil check and tagged pointer check
#if SUPPORT_TAGGED_POINTERS
	b.le	LNilOrTagged		//  (MSB tagged pointer looks negative)
#else
	b.eq	LReturnZero
#endif
    // person - isa - 类
	ldr	p13, [x0]		// p13 = isa
	GetClassFromIsa_p16 p13		// p16 = class
LGetIsaDone:
	CacheLookup NORMAL		// calls imp or objc_msgSend_uncached

#if SUPPORT_TAGGED_POINTERS
LNilOrTagged:
	b.eq	LReturnZero		// nil check
	
	//...

代码太长我就不粘全部了 , 大家自己在源码中看 .

流程分析

整个汇编过程具体代码就不带着分析了 , 后续继续更新逆向时会讲汇编部分 , 到时候会好好分析一下寄存器和汇编指令.

简单总结一下 _objc_msgSend 整个汇编代码过程如下:

  • 1️⃣ : 在 objc_msgSend 中分为两部分 ,第一部分是汇编写的查找缓存的流程 . 直到 bl __class_lookupMethodAndLoadCache3 时 , 转到 C 函数继续执行 后续的 lookUpImpOrForward 流程.

  • 2️⃣ : 获取对象真实的isa , 非 taggedpointerisa 是一个联合体 , 使用位域来存储各种信息 , 这个后续笔者会详细讲述 .

  • 3️⃣ : 来到 CacheLookup过程 , 通过指针偏移找到 cache_t,处理 bucket 以及内存哈希表处理 , 通过 sel 哈希算法之后的 key 找到 imp , 找到则返回 , 找不到 JumpMiss .

  • 4️⃣ : 继续来到 __objc_msgSend_uncached -> MethodTableLookup

  • 5️⃣ : 调用 bl __class_lookupMethodAndLoadCache3, 来到慢速查找流程 .

后续就是 C 函数实现的消息查找以及转发流程 , 由于篇幅问题 , 下篇文章继续讲述完整流程 .

IMP _class_lookupMethodAndLoadCache3(id obj, SEL sel, Class cls)
{        
    return lookUpImpOrForward(cls, sel, obj, 
                              YES/*initialize*/, NO/*cache*/, YES/*resolver*/);
}

总结 :

OC 方法的本质如下 :

在编译期由 LLVM 将方法调用编译成调用 objc_msgSend 等函数 , 然后在汇编代码执行缓存查找 sel 对应的 imp , 找到就会返回调用 , 找不到则进入消息查找和消息转发慢速流程 .