iOS 底層 - 手把手帶你探索OC方法的本質

前言

  • 以前逆向部分的文章基礎知識和所需工具已經講述的差很少了 , 後續準備好實戰項目以及彙編和越獄部份內容繼續更新, 敬請關注 .git

  • 目前準備更新一個底層系列文章 , 從 dyld 加載可執行文件到入口 main 函數 , 到類 , 分類 , 協議等等的加載爲主線 . 一步步探索底層原理 .github

  • 本篇文章從方法的本質開始講述 , 前面少幾篇文章 , 後續補上 , 而後會準備一個目錄 .objective-c

前導知識

Runtime

說到任何關於 OC 本質的東西 , 咱們不得不提一下 Runtime 這個東西 .算法

官方文檔緩存

這裏只是簡單瞭解一下 Runtime , 爲咱們探索方法本質提供一些幫助 , 後續更新詳細的 Runtime 機制和具體使用 . 安全

Runtime 簡單介紹

◈      Objective-C 擴展了 C 語言,並加入了面向對象特性和 Smalltalk 式的消息傳遞機制。而這個擴展的核心是一個用 C 和 編譯語言 寫的 Runtime 庫。它是 Objective-C 面向對象和動態機制的基石和根本。bash

◈      Objective-C 是一個動態語言,這意味着它不只須要一個編譯器,也須要一個運行時系統來動態得建立類和對象、進行消息傳遞和轉發。架構

◈       理解 Objective-CRuntime 機制能夠幫咱們更好的瞭解這個語言,適當的時候還能對語言進行擴展,從系統層面解決項目中的一些設計或技術問題。app

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 , 找到就會返回調用 , 找不到則進入消息查找和消息轉發慢速流程 .

相關文章
相關標籤/搜索