從源代碼看 ObjC 中消息的發送

由於貴乎不支持 Markdown,本篇文章在知乎上重新排版,可能會有樣式的錯誤

原文鏈接:從源代碼看 ObjC 中消息的發送

關注倉庫,及時獲得更新:GitHub - Draveness/iOS-Source-Code-Analyze: 關於 iOS 開源項目源代碼解析的文章

寫在前面

因為 ObjC 的 runtime 只能在 Mac OS 下才能編譯,所以文章中的代碼都是在 Mac OS,也就是 x86_64 架構下運行的,對於在 arm64 中運行的代碼會特別說明

如果你點開這篇文章,相信你對 Objective-C 比較熟悉,並且有多年使用 Objective-C 編程的經驗,這篇文章會假設你知道:

  1. 在 Objective-C 中的「方法調用」其實應該叫做消息傳遞

  2. `[receiver message]` 會被翻譯為 `objc_msgSend(receiver, @selector(message))`

  3. 在消息的響應鏈中**可能**會調用 `- resolveInstanceMethod:` `- forwardInvocation:` 等方法

  4. 關於選擇子 SEL 的知識

如果對於上述的知識不夠了解,可以看一下這篇文章 Objective C Runtime,但是其中關於 `objc_class` 的結構體的代碼已經過時了,不過不影響閱讀以及理解。

5. 方法在內存中存儲的位置,深入解析 ObjC 中方法的結構

文章中不會刻意區別方法和函數、消息傳遞和方法調用之間的區別。

6. 能翻牆(會有一個 Youtube 的鏈接)

概述

關於 Objective-C 中的消息傳遞的文章真的是太多了,而這篇文章又與其它文章有什麼不同呢?

由於這個系列的文章都是對 Objective-C 源代碼的分析,所以會從 Objective-C 源代碼中分析併合理地推測一些關於消息傳遞的問題

關於 @selector() 你需要知道的

因為在 Objective-C 中,所有的消息傳遞中的「消息「都會被轉換成一個 `selector` 作為 `objc_msgSend` 函數的參數:

[object hello] -> objc_msgSend(object, @selector(hello))n

這裡面使用 `@selector(hello)` 生成的選擇子 SEL 是這一節中關注的重點。

我們需要預先解決的問題是:使用 `@selector(hello)` 生成的選擇子,是否會因為類的不同而不同?各位讀者可以自己思考一下。

先放出結論:使用 `@selector()` 生成的選擇子不會因為類的不同而改變,其內存地址在編譯期間就已經確定了。也就是說向不同的類發送相同的消息時,其生成的選擇子是完全相同的

XXObject *xx = [[XXObject alloc] init]nYYObject *yy = [[YYObject alloc] init]nobjc_msgSend(xx, @selector(hello))nobjc_msgSend(yy, @selector(hello))n

接下來,我們開始驗證這一結論的正確性,這是程序主要包含的代碼:

// XXObject.hn#import <Foundation/Foundation.h>nn@interface XXObject : NSObjectnn- (void)hello;nn@endnn// XXObject.mn#import "XXObject.h"nn@implementation XXObjectnn- (void)hello {n NSLog(@"Hello");n}nn@endn// main.mn#import <Foundation/Foundation.h>n#import "XXObject.h"nnint main(int argc, const char * argv[]) {n @autoreleasepool {n XXObject *object = [[XXObject alloc] init];n [object hello];n }n return 0;n}n

在主函數任意位置打一個斷點, 比如 `-> [object hello];` 這裡,然後在 lldb 中輸入:

這裡面我們列印了兩個選擇子的地址` @selector(hello)` 以及 `@selector(undefined_hello_method)`,需要注意的是:

`@selector(hello)` 是在編譯期間就聲明的選擇子,而後者在編譯期間並不存在,`undefined_hello_method` 選擇子由於是在運行時生成的,所以內存地址明顯比 `hello` 大很多

如果我們修改程序的代碼:

在這裡,由於我們在代碼中顯示地寫出了 `@selector(undefined_hello_method)`,所以在 lldb 中再次列印這個 `sel` 內存地址跟之前相比有了很大的改變。

更重要的是,我沒有通過指針的操作來獲取 `hello` 選擇子的內存地址,而只是通過 `@selector(hello)` 就可以返回一個選擇子。

從上面的這些現象,可以推斷出選擇子有以下的特性:

  1. Objective-C 為我們維護了一個巨大的選擇子表

  2. 在使用 `@selector()` 時會從這個選擇子表中根據選擇子的名字查找對應的 `SEL`。如果沒有找到,則會生成一個 `SEL` 並添加到表中

  3. 在編譯期間會掃描全部的頭文件和實現文件將其中的方法以及使用 `@selector()` 生成的選擇子加入到選擇子表中

在運行時初始化之前,列印 `hello` 選擇子的的內存地址:

message.h 文件

Objective-C 中 `objc_msgSend` 的實現並沒有開源,它只存在於 `message.h` 這個頭文件中。

/** n * @note When it encounters a method call, the compiler generates a call to one of then * functions c objc_msgSend, c objc_msgSend_stret, c objc_msgSendSuper, or c objc_msgSendSuper_stret.n * Messages sent to an object』s superclass (using the c super keyword) are sent using c objc_msgSendSuper; n * other messages are sent using c objc_msgSend. Methods that have data structures as return valuesn * are sent using c objc_msgSendSuper_stret and c objc_msgSend_stret.n */nOBJC_EXPORT id objc_msgSend(id self, SEL op, ...)n

在這個頭文件的注釋中對消息發送的一系列方法解釋得非常清楚:

當編譯器遇到一個方法調用時,它會將方法的調用翻譯成以下函數中的一個 `objc_msgSend`、`objc_msgSend_stret`、`objc_msgSendSuper` 和 `objc_msgSendSuper_stret`。

發送給對象的父類的消息會使用 `objc_msgSendSuper`

有數據結構作為返回值的方法會使用 `objc_msgSendSuper_stret` 或 `objc_msgSend_stret`

其它的消息都是使用 `objc_msgSend` 發送的

在這篇文章中,我們只會對消息發送的過程進行分析,而不會對上述消息發送方法的區別進行分析,默認都使用 `objc_msgSend` 函數。

objc_msgSend 調用棧

這一小節會以向 `XXObject` 的實例發送 `hello` 消息為例,在 Xcode 中觀察整個消息發送的過程中調用棧的變化,再來看一下程序的代碼:

// XXObject.hn#import <Foundation/Foundation.h>nn@interface XXObject : NSObjectnn- (void)hello;nn@endnn// XXObject.mn#import "XXObject.h"nn@implementation XXObjectnn- (void)hello {n NSLog(@"Hello");n}nn@endn// main.mn#import <Foundation/Foundation.h>n#import "XXObject.h"nnint main(int argc, const char * argv[]) {n @autoreleasepool {n XXObject *object = [[XXObject alloc] init];n [object hello];n }n return 0;n}n

在調用 `hello` 方法的這一行打一個斷點,當我們嘗試進入(Step in)這個方法只會直接跳入這個方法的實現,而不會進入 `objc_msgSend`:

因為 `objc_msgSend` 是一個私有方法,我們沒有辦法進入它的實現,但是,我們卻可以在 `objc_msgSend` 的調用棧中「截下」這個函數調用的過程。

調用 `objc_msgSend` 時,傳入了 `self` 以及 `SEL` 參數。

既然要執行對應的方法,肯定要尋找選擇子對應的實現。

在 `objc-runtime-new.mm` 文件中有一個函數 `lookUpImpOrForward`,這個函數的作用就是查找方法的實現,於是運行程序,在運行到 `hello` 這一行時,激活 `lookUpImpOrForward` 函數中的斷點。

調用棧演示

由於轉成 gif 實在是太大了,筆者試著用各種方法生成動圖,然而效果也不是很理想,只能貼一個 Youtube 的視頻鏈接,不過對於能夠翻牆的開發者們,應該也不是什麼問題吧(手動微笑)

如果跟著視頻看這個方法的調用棧有些混亂的話,也是正常的。在下一個節中會對其調用棧進行詳細的分析。

解析 objc_msgSend

對 `objc_msgSend` 解析總共分兩個步驟,我們會向 `XXObject` 的實例發送兩次 `hello` 消息,分別模擬無緩存和有緩存兩種情況下的調用棧。

無緩存

在 `-> [object hello]` 這裡增加一個斷點,**當程序運行到這一行時**,再向 `lookUpImpOrForward` 函數的第一行添加斷點,確保是捕獲 `@selector(hello)` 的調用棧,而不是調用其它選擇子的調用棧。

由圖中的變數區域可以了解,傳入的選擇子為 `"hello"`,對應的類是 `XXObject`。所以我們可以確信這就是當調用 `hello` 方法時執行的函數。在 Xcode 左側能看到方法的調用棧:

0 lookUpImpOrForwardn1 _class_lookupMethodAndLoadCache3n2 objc_msgSendn3 mainn4 startn

調用棧在這裡告訴我們: `lookUpImpOrForward` 並不是 `objc_msgSend` 直接調用的,而是通過 `_class_lookupMethodAndLoadCache3` 方法:

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

這是一個僅提供給派發器(dispatcher)用於方法查找的函數,其它的代碼都應該使用 `lookUpImpOrNil()`(不會進行方法轉發)。`_class_lookupMethodAndLoadCache3` 會傳入 `cache = NO` 避免在**沒有加鎖**的時候對緩存進行查找,因為派發器已經做過這件事情了。

實現的查找 lookUpImpOrForward

由於實現的查找方法 `lookUpImpOrForward` 涉及很多函數的調用,所以我們將它分成以下幾個部分來分析:

  1. 無鎖的緩存查找

  2. 如果類沒有實現(isRealized)或者初始化(isInitialized),實現或者初始化類

  3. 加鎖

  4. 緩存以及當前類中方法的查找

  5. 嘗試查找父類的緩存以及方法列表

  6. 沒有找到實現,嘗試方法解析器

  7. 進行消息轉發

  8. 解鎖、返回實現

無鎖的緩存查找

下面是在沒有加鎖的時候對緩存進行查找,提高緩存使用的性能:

runtimeLock.assertUnlocked();nn// Optimistic cache lookupnif (cache) {n imp = cache_getImp(cls, sel);n if (imp) return imp;n}n

不過因為 `_class_lookupMethodAndLoadCache3` 傳入的 `cache = NO`,所以這裡會直接跳過 if 中代碼的執行,在 `objc_msgSend` 中已經使用彙編代碼查找過了。

類的實現和初始化

Objective-C 運行時 初始化的過程中會對其中的類進行第一次初始化也就是執行 `realizeClass` 方法,為類分配可讀寫結構體 `class_rw_t` 的空間,並返回正確的類結構體。

而 `_class_initialize` 方法會調用類的 `initialize` 方法,我會在之後的文章中對類的初始化進行分析。

if (!cls->isRealized()) {n rwlock_writer_t lock(runtimeLock);n realizeClass(cls);n}nnif (initialize && !cls->isInitialized()) {n _class_initialize (_class_getNonMetaClass(cls, inst));n}n

加鎖

加鎖這一部分只有一行簡單的代碼,其主要目的保證方法查找以及緩存填充(cache-fill)的原子性,保證在運行以下代碼時不會有新方法添加導致緩存被沖洗(flush)

runtimeLock.read();n

在當前類中查找實現

實現很簡單,先調用了 `cache_getImp` 從某個類的 `cache` 屬性中獲取選擇子對應的實現:

imp = cache_getImp(cls, sel);nif (imp) goto done;n

不過 `cache_getImp` 的實現目測是不開源的,同時也是彙編寫的,在我們嘗試 step in 的時候進入了如下的彙編代碼。

它會進入一個 `CacheLookup` 的標籤,獲取實現,使用彙編的原因還是因為要加速整個實現查找的過程,其原理推測是在類的 `cache` 中尋找對應的實現,只是做了一些性能上的優化。

如果查找到實現,就會跳轉到 `done` 標籤,因為我們在這個小結中的假設是無緩存的(第一次調用 `hello` 方法),所以會進入下面的代碼塊,從類的方法列表中尋找方法的實現:

meth = getMethodNoSuper_nolock(cls, sel);nif (meth) {n log_and_fill_cache(cls, meth->imp, sel, inst, cls);n imp = meth->imp;n goto done;n}n

調用 `getMethodNoSuper_nolock` 方法查找對應的方法的結構體指針 `method_t`:

static method_t *getMethodNoSuper_nolock(Class cls, SEL sel) {n for (auto mlists = cls->data()->methods.beginLists(), n end = cls->data()->methods.endLists(); n mlists != end;n ++mlists)n {n method_t *m = search_method_list(*mlists, sel);n if (m) return m;n }nn return nil;n}n

因為類中數據的方法列表 `methods` 是一個二維數組 `method_array_t`,寫一個 `for` 循環遍歷整個方法列表,而這個 `search_method_list` 的實現也特別簡單:

static method_t *search_method_list(const method_list_t *mlist, SEL sel)n{n int methodListIsFixedUp = mlist->isFixedUp();n int methodListHasExpectedSize = mlist->entsize() == sizeof(method_t);n n if (__builtin_expect(methodListIsFixedUp && methodListHasExpectedSize, 1)) {n return findMethodInSortedMethodList(sel, mlist);n } else {n for (auto& meth : *mlist) {n if (meth.name == sel) return &meth;n }n }nn return nil;n}n

`findMethodInSortedMethodList` 方法對有序方法列表進行線性探測,返回方法結構體 `method_t`。

如果在這裡找到了方法的實現,將它加入類的緩存中,這個操作最後是由 `cache_fill_nolock` 方法來完成的:

static void cache_fill_nolock(Class cls, SEL sel, IMP imp, id receiver)n{n if (!cls->isInitialized()) return;n if (cache_getImp(cls, sel)) return;nn cache_t *cache = getCache(cls);n cache_key_t key = getKey(sel);nn mask_t newOccupied = cache->occupied() + 1;n mask_t capacity = cache->capacity();n if (cache->isConstantEmptyCache()) {n cache->reallocate(capacity, capacity ?: INIT_CACHE_SIZE);n } else if (newOccupied <= capacity / 4 * 3) {nn } else {n cache->expand();n }nn bucket_t *bucket = cache->find(key, receiver);n if (bucket->key() == 0) cache->incrementOccupied();n bucket->set(key, imp);n}n

如果緩存中的內容大於容量的 `3/4` 就會擴充緩存,使緩存的大小翻倍。

在緩存翻倍的過程中,當前類全部的緩存都會被清空,Objective-C 出於性能的考慮不會將原有緩存的 `bucket_t` 拷貝到新初始化的內存中。

找到第一個空的 `bucket_t`,以 `(SEL, IMP)` 的形式填充進去。

在父類中尋找實現

這一部分與上面的實現基本上是一樣的,只是多了一個循環用來判斷根類:

1. 查找緩存

2. 搜索方法列表

curClass = cls;nwhile ((curClass = curClass->superclass)) {n imp = cache_getImp(curClass, sel);n if (imp) {n if (imp != (IMP)_objc_msgForward_impcache) {n log_and_fill_cache(cls, imp, sel, inst, curClass);n goto done;n } else {n break;n }n }nn meth = getMethodNoSuper_nolock(curClass, sel);n if (meth) {n log_and_fill_cache(cls, meth->imp, sel, inst, curClass);n imp = meth->imp;n goto done;n }n}n

與當前類尋找實現的區別是:在父類中尋找到的 `_objc_msgForward_impcache` 實現會交給當前類來處理。

方法決議

選擇子在當前類和父類中都沒有找到實現,就進入了方法決議(method resolve)的過程:

if (resolver && !triedResolver) {n _class_resolveMethod(cls, sel, inst);n triedResolver = YES;n goto retry;n}n

這部分代碼調用 `_class_resolveMethod` 來解析沒有找到實現的方法。

void _class_resolveMethod(Class cls, SEL sel, id inst)n{n if (! cls->isMetaClass()) {n _class_resolveInstanceMethod(cls, sel, inst);n } n else {n _class_resolveClassMethod(cls, sel, inst);n if (!lookUpImpOrNil(cls, sel, inst, n NO/*initialize*/, YES/*cache*/, NO/*resolver*/)) n {n _class_resolveInstanceMethod(cls, sel, inst);n }n }n}n

根據當前的類是不是元類(Hamster Emporium: [objc explain]: Classes and metaclasses在 `_class_resolveInstanceMethod` 和 `_class_resolveClassMethod` 中選擇一個進行調用。

static void _class_resolveInstanceMethod(Class cls, SEL sel, id inst) {n if (! lookUpImpOrNil(cls->ISA(), SEL_resolveInstanceMethod, cls, n NO/*initialize*/, YES/*cache*/, NO/*resolver*/)) {n // 沒有找到 resolveInstanceMethod: 方法,直接返回。n return;n }nn BOOL (*msg)(Class, SEL, SEL) = (__typeof__(msg))objc_msgSend;n bool resolved = msg(cls, SEL_resolveInstanceMethod, sel);nn // 緩存結果,以防止下次在調用 resolveInstanceMethod: 方法影響性能。n IMP imp = lookUpImpOrNil(cls, sel, inst, n NO/*initialize*/, YES/*cache*/, NO/*resolver*/);n}n

這兩個方法的實現其實就是判斷當前類是否實現了 `resolveInstanceMethod:` 或者 `resolveClassMethod:` 方法,然後用 `objc_msgSend` 執行上述方法,並傳入需要決議的選擇子。

關於 `resolveInstanceMethod` 之後可能會寫一篇文章專門介紹,不過關於這個方法的文章也確實不少,在 Google 上搜索會有很多的文章。

在執行了 `resolveInstanceMethod:` 之後,會跳轉到 retry 標籤,重新執行查找方法實現的流程,只不過不會再調用 `resolveInstanceMethod:` 方法了(將 `triedResolver` 標記為 `YES`)。

消息轉發

在緩存、當前類、父類以及 `resolveInstanceMethod:` 都沒有解決實現查找的問題時,Objective-C 還為我們提供了最後一次翻身的機會,進行方法轉發:

imp = (IMP)_objc_msgForward_impcache;ncache_fill(cls, sel, imp, inst);n

返回實現 `_objc_msgForward_impcache`,然後加入緩存。

====

這樣就結束了整個方法第一次的調用過程,緩存沒有命中,但是在當前類的方法列表中找到了 `hello` 方法的實現,調用了該方法。

緩存命中

如果使用對應的選擇子時,緩存命中了,那麼情況就大不相同了,我們修改主程序中的代碼

int main(int argc, const char * argv[]) {n @autoreleasepool {n XXObject *object = [[XXObject alloc] init];n [object hello];n [object hello];n }n return 0;n}n

然後在第二次調用 `hello` 方法時,加一個斷點:

`objc_msgSend` 並沒有走 `lookupImpOrForward` 這個方法,而是直接結束,列印了另一個 `hello` 字元串。

我們如何確定 `objc_msgSend` 的實現到底是什麼呢?其實我們沒有辦法來確認

它的實現,因為這個函數的實現使用彙編寫的,並且實現是不開源的。

不過,我們需要確定它是否真的訪問了類中的緩存來加速實現尋找的過程。

好,現在重新運行程序至第二個 `hello` 方法調用之前:

列印緩存中 bucket 的內容:

(lldb) p (objc_class *)[XXObject class]n(objc_class *) $0 = 0x0000000100001230n(lldb) p (cache_t *)0x0000000100001240n(cache_t *) $1 = 0x0000000100001240n(lldb) p *$1n(cache_t) $2 = {n _buckets = 0x0000000100604bd0n _mask = 3n _occupied = 2n}n(lldb) p $2.capacity()n(mask_t) $3 = 4n(lldb) p $2.buckets()[0]n(bucket_t) $4 = {n _key = 0n _imp = 0x0000000000000000n}n(lldb) p $2.buckets()[1]n(bucket_t) $5 = {n _key = 0n _imp = 0x0000000000000000n}n(lldb) p $2.buckets()[2]n(bucket_t) $6 = {n _key = 4294971294n _imp = 0x0000000100000e60 (debug-objc`-[XXObject hello] at XXObject.m:17)n}n(lldb) p $2.buckets()[3]n(bucket_t) $7 = {n _key = 4300169955n _imp = 0x00000001000622e0 (libobjc.A.dylib`-[NSObject init] at NSObject.mm:2216)n}n

在這個緩存中只有對 `hello` 和 `init` 方法實現的緩存,我們要將其中 `hello` 的緩存清空:

(lldb) expr $2.buckets()[2] = $2.buckets()[1]n(bucket_t) $8 = {n _key = 0n _imp = 0x0000000000000000n}n

這樣 `XXObject` 中就不存在 `hello` 方法對應實現的緩存了。然後繼續運行程序:

雖然第二次調用 `hello` 方法,但是因為我們清除了 `hello` 的緩存,所以,會再次進入 `lookupImpOrForward` 方法。

下面會換一種方法驗證猜測:在 hello 調用之前添加緩存

添加一個新的實現 `cached_imp`:

#import <Foundation/Foundation.h>n#import <objc/runtime.h>n#import "XXObject.h"nnint main(int argc, const char * argv[]) {n @autoreleasepool {n __unused IMP cached_imp = imp_implementationWithBlock(^() {n NSLog(@"Cached Hello");n });n XXObject *object = [[XXObject alloc] init];n [object hello];n [object hello];n }n return 0;n}n

我們將以 `@selector(hello), cached_imp` 為鍵值對,將其添加到類結構體的緩存中,這裡的實現 `cached_imp` 有一些區別,它會列印 `@"Cached Hello"` 而不是 `@"Hello"` 字元串:

在第一個 `hello` 方法調用之前將實現加入緩存:

然後繼續運行代碼:

可以看到,我們雖然沒有改變 `hello` 方法的實現,但是在 objc_msgSend 的消息發送鏈路中,使用錯誤的緩存實現 `cached_imp` 攔截了實現的查找,列印出了 `Cached Hello`。

由此可以推定,`objc_msgSend` 在實現中確實檢查了緩存。如果沒有緩存會調用 `lookupImpOrForward` 進行方法查找。

為了提高消息傳遞的效率,ObjC 對 `objc_msgSend` 以及 `cache_getImp` 使用了彙編語言來編寫。

如果你想了解有關 `objc_msgSend` 方法的彙編實現的信息,可以看這篇文章 mikeash.com: Friday Q&A 2012-11-16: Lets Build objc_msgSend

小結

這篇文章與其說是講 ObjC 中的消息發送的過程,不如說是講方法的實現是如何查找的。

Objective-C 中實現查找的路徑還是比較符合直覺的:

1. 緩存命中

2. 查找當前類的緩存及方法

3. 查找父類的緩存及方法

3. 方法決議

4. 消息轉發

文章中關於方法調用棧的視頻最開始是用 gif 做的,不過由於 gif 時間較長,試了很多的 gif 轉換器,都沒有得到一個較好的質量和合適的大小,所以最後選擇用一個 Youtube 的視頻。

參考資料

  • 深入解析 ObjC 中方法的結構

  • Objective C Runtime

  • mikeash.com: Friday Q&A 2012-11-16: Lets Build objc_msgSend

GitHub - Draveness/iOS-Source-Code-Analyze: 關於 iOS 開源項目源代碼解析的文章Blog: Dravenessgithub: Draveness · Github

推薦閱讀:

開源!iOS 應用安全分析工具 Passionfruit
如何評價在GitHub 上 3000 fork庫的代碼水平?
如何正確地使用ios與mac的照片流?
這次 XcodeGhost 事件影響有多大?App Store 審核是否有漏洞?有沒有不可推卸的責任?
【獨家觀點】在新iPad Pro登場不久之際,我們如何評價iPad系列的「生產力」? #火箭親測

TAG:iOS | Objective-C | iOS开发 |