本文授權轉載,作者:左書祺(關注倉庫,及時獲得更新:iOS-Source-Code-Analyze)
因為 ObjC 的 runtime 只能在 Mac OS 下才能編譯,所以文章中的代碼都是在 Mac OS,也就是 x86_64 架構下運行的,對於在 arm64 中運行的代碼會特別說明。
在上一篇分析 isa 的文章《從 NSObject 的初始化了解 isa》中曾經說到過實例方法被調用時,會通過其持有 isa 指針尋找對應的類,然後在其中的 class_data_bits_t 中查找對應的方法,在這一篇文章中會介紹方法在 Objective-C 中是如何存儲方法的。
這篇文章的首先會根據 ObjC 源代碼來分析方法在內存中的存儲結構,然後在 lldb 調試器中一步一步驗證分析的正確性。
方法在內存中的位置
先來了解一下 ObjC 中類的結構圖:
isa 是指向元類的指針,不了解元類的可以看:Classes and Metaclasses
super_class 指向當前類的父類
cache 用於緩存指針和 vtable,加速方法的調用
bits 就是存儲類的方法、屬性、遵循的協議等信息的地方
class_data_bits_t 結構體
這一小結會分析類結構體中的 class_data_bits_t bits。
下面就是 ObjC 中 class_data_bits_t 的結構體,其中只含有一個 64 位的 bits 用於存儲與類有關的信息:
在 objc_class 結構體中的注釋寫到 class_data_bits_t 相當於 class_rw_t 指針加上 rr/alloc 的標志。
class_data_bits_t bits;// class_rw_t * plus custom rr/alloc flags
它為我們提供了便捷方法用於返回其中的 class_rw_t * 指針:
class_rw_t* data() { return (class_rw_t *)(bits & FAST_DATA_MASK); }
將 bits 與 FAST_DATA_MASK 進行位運算,只取其中的 [3, 47] 位轉換成 class_rw_t * 返回。
在 x86_64 架構上,Mac OS 只使用了其中的 47 位來為對象分配地址。而且由於地址要按字節在內存中按字節對齊,所以掩碼的後三位都是 0。
因為 class_rw_t * 指針只存於第 [3, 47] 位,所以可以使用最後三位來存儲關於當前類的其他信息:
#define FAST_IS_SWIFT (1UL<<0) #define FAST_HAS_DEFAULT_RR (1UL<<1) #define FAST_REQUIRES_RAW_ISA (1UL<<2) #define FAST_DATA_MASK 0x00007ffffffffff8UL
isSwift():FAST_IS_SWIFT 用於判斷 Swift 類
hasDefaultRR():FAST_HAS_DEFAULT_RR 當前類或者父類含有默認的 retain/release/autorelease/retainCount/_tryRetain/_isDeallocating/retainWeakReference/allowsWeakReference 方法
requiresRawIsa():FAST_REQUIRES_RAW_ISA 當前類的實例需要 raw isa
執行 class_data_bits_t 結構體中的 data() 方法或者調用 objc_class 中的 data() 方法會返回同一個 class_rw_t * 指針,因為 objc_class 中的方法只是對 class_data_bits_t 中對應方法的封裝。
// objc_class 中的 data() 方法 class_data_bits_t bits; class_rw_t *data() { return bits.data(); } // class_data_bits_t 中的 data() 方法 uintptr_t bits; class_rw_t* data() { return (class_rw_t *)(bits & FAST_DATA_MASK); }
class_rw_t 和 class_ro_t
ObjC 類中的屬性、方法還有遵循的協議等信息都保存在 class_rw_t 中:
struct class_rw_t { uint32_t flags; uint32_t version; const class_ro_t *ro; method_array_t methods; property_array_t properties; protocol_array_t protocols; Class firstSubclass; Class nextSiblingClass; };
其中還有一個指向常量的指針 ro,其中存儲了當前類在編譯期就已經確定的屬性、方法以及遵循的協議。
struct class_ro_t { uint32_t flags; uint32_t instanceStart; uint32_t instanceSize; uint32_t reserved; const uint8_t * ivarLayout; const char * name; method_list_t * baseMethodList; protocol_list_t * baseProtocols; const ivar_list_t * ivars; const uint8_t * weakIvarLayout; property_list_t *baseProperties; };
在編譯期間類的結構中的 class_data_bits_t *data 指向的是一個 class_ro_t * 指針:
然後在加載 ObjC 運行時的過程中在 realizeClass 方法中:
從 class_data_bits_t 調用 data 方法,將結果從 class_rw_t 強制轉換為 class_ro_t 指針
初始化一個 class_rw_t 結構體
設置結構體 ro 的值以及 flag
最後設置正確的 data。
const class_ro_t *ro = (const class_ro_t *)cls->data(); class_rw_t *rw = (class_rw_t *)calloc(sizeof(class_rw_t), 1); rw->ro = ro; rw->flags = RW_REALIZED|RW_REALIZING; cls->setData(rw);
下圖是 realizeClass 方法執行過後的類所占用內存的布局,你可以與上面調用方法前的內存布局對比以下,看有哪些更改:
但是,在這段代碼運行之後 class_rw_t 中的方法,屬性以及協議列表均為空。這時需要 realizeClass 調用 methodizeClass 方法來將類自己實現的方法(包括分類)、屬性和遵循的協議加載到 methods、 properties 和 protocols 列表中。
XXObject
下面,我們將分析一個類 XXObject 在運行時初始化過程中內存的更改,這是 XXObject 的接口與實現:
// XXObject.h 文件 #import @interface XXObject : NSObject - (void)hello; @end // XXObject.m 文件 #import "XXObject.h" @implementation XXObject - (void)hello { NSLog(@"Hello"); } @end
這段代碼是運行在 Mac OS X 10.11.3 (x86_64)版本中,而不是運行在 iPhone 模擬器或者真機上的,如果你在 iPhone 或者真機上運行,可能有一定差別。
這是主程序的代碼:
#import #import "XXObject.h" int main(int argc, const char * argv[]) { @autoreleasepool { Class cls = [XXObject class]; NSLog(@"%p", cls); } return 0; }
編譯後內存中類的結構
因為類在內存中的位置是編譯期就確定的,先運行一次代碼獲取 XXObject 在內存中的地址。
0x100001168
接下來,在整個 ObjC 運行時初始化之前,也就是 _objc_init 方法中加入一個斷點:
然後在 lldb 中輸入以下命令:
(lldb) p (objc_class *)0x100001168 (objc_class *) $0 = 0x0000000100001168 (lldb) p (class_data_bits_t *)0x100001188 (class_data_bits_t *) $1 = 0x0000000100001188 (lldb) p $1->data() warning: could not load any Objective-C class information. This will significantly reduce the quality of type information available. (class_rw_t *) $2 = 0x00000001000010e8 (lldb) p (class_ro_t *)$2 // 將 class_rw_t 強制轉化為 class_ro_t (class_ro_t *) $3 = 0x00000001000010e8 (lldb) p *$3 (class_ro_t) $4 = { flags = 128 instanceStart = 8 instanceSize = 8 reserved = 0 ivarLayout = 0x0000000000000000 name = 0x0000000100000f7a "XXObject" baseMethodList = 0x00000001000010c8 baseProtocols = 0x0000000000000000 ivars = 0x0000000000000000 weakIvarLayout = 0x0000000000000000 baseProperties = 0x0000000000000000 }
現在我們獲取了類經過編譯器處理後的只讀屬性 class_ro_t:
(class_ro_t) $4 = { flags = 128 instanceStart = 8 instanceSize = 8 reserved = 0 ivarLayout = 0x0000000000000000 name = 0x0000000100000f7a "XXObject" baseMethodList = 0x00000001000010c8 baseProtocols = 0x0000000000000000 ivars = 0x0000000000000000 weakIvarLayout = 0x0000000000000000 baseProperties = 0x0000000000000000 }
可以看到這裡面只有 baseMethodList 和 name 是有值的,其它的 ivarLayout、 baseProtocols、 ivars、weakIvarLayout 和 baseProperties 都指向了空指針,因為類中沒有實例變量,協議以及屬性。所以這裡的結構體符合我們的預期。
通過下面的命令查看 baseMethodList 中的內容:
(lldb) p $4.baseMethodList (method_list_t *) $5 = 0x00000001000010c8 (lldb) p $5->get(0) (method_t) $6 = { name = "hello" types = 0x0000000100000fa4 "v16@0:8" imp = 0x0000000100000e90 (method`-[XXObject hello] at XXObject.m:13) } (lldb) p $5->get(1) Assertion failed: (i < count), function get, file /Users/apple/Desktop/objc-runtime/runtime/objc-runtime-new.h, line 110. error: Execution was interrupted, reason: signal SIGABRT. The process has been returned to the state before expression evaluation. (lldb)
使用 $5->get(0) 時,成功獲取到了 -[XXObject hello] 方法的結構體 method_t。而嘗試獲取下一個方法時,斷言提示我們當前類只有一個方法。
realizeClass
這篇文章中不會對 realizeClass 進行詳細的分析,該方法的主要作用是對類進行第一次初始化,其中包括:
分配可讀寫數據空間
返回真正的類結構
static Class realizeClass(Class cls)
上面就是這個方法的簽名,我們需要在這個方法中打一個條件斷點,來判斷當前類是否為 XXObject:
這裡直接判斷兩個指針是否相等,而不使用 [NSStringFromClass(cls) isEqualToString:@"XXObject"] 是因為在這個時間點,這些方法都不能調用,在 ObjC 中沒有這些方法,所以只能通過判斷類指針是否相等的方式來確認當前類是 XXObject。
直接與指針比較是因為類在內存中的位置是編譯期確定的,只要代碼不改變,類在內存中的位置就會不變(已經說過很多遍了)。
這個斷點就設置在這裡,因為 XXObject 是一個正常的類,所以會走 else 分支分配可寫的類數據。
運行代碼時,因為每次都會判斷當前類指針是不是指向的 XXObject,所以會等一會才會進入斷點。
在這時打印類結構體中的 data 的值,發現其中的布局依舊是這樣的:
在運行完這段代碼之後:
我們再來打印類的結構:
(lldb) p (objc_class *)cls // 打印類指針 (objc_class *) $262 = 0x0000000100001168 (lldb) p (class_data_bits_t *)0x0000000100001188 // 在類指針上加 32 的 offset 打印 class_data_bits_t 指針 (class_data_bits_t *) $263 = 0x0000000100001188 (lldb) p *$263 // 訪問 class_data_bits_t 指針的內容 (class_data_bits_t) $264 = (bits = 4302315312) (lldb) p $264.data() // 獲取 class_rw_t (class_rw_t *) $265 = 0x0000000100701f30 (lldb) p *$265 // 訪問 class_rw_t 指針的內容,發現它的 ro 已經設置好了 (class_rw_t) $266 = { flags = 2148007936 version = 0 ro = 0x00000001000010e8 methods = { list_array_tt = { = { list = 0x0000000000000000 arrayAndFlag = 0 } } } properties = { list_array_tt = { = { list = 0x0000000000000000 arrayAndFlag = 0 } } } protocols = { list_array_tt = { = { list = 0x0000000000000000 arrayAndFlag = 0 } } } firstSubclass = nil nextSiblingClass = nil demangledName = 0x0000000000000000 } (lldb) p $266.ro // 獲取 class_ro_t 指針 (const class_ro_t *) $267 = 0x00000001000010e8 (lldb) p *$267 // 訪問 class_ro_t 指針的內容 (const class_ro_t) $268 = { flags = 128 instanceStart = 8 instanceSize = 8 reserved = 0 ivarLayout = 0x0000000000000000 name = 0x0000000100000f7a "XXObject" baseMethodList = 0x00000001000010c8 baseProtocols = 0x0000000000000000 ivars = 0x0000000000000000 weakIvarLayout = 0x0000000000000000 baseProperties = 0x0000000000000000 } (lldb) p $268.baseMethodList // 獲取基本方法列表 (method_list_t *const) $269 = 0x00000001000010c8 (lldb) p $269->get(0) // 訪問第一個方法 (method_t) $270 = { name = "hello" types = 0x0000000100000fa4 "v16@0:8" imp = 0x0000000100000e90 (method`-[XXObject hello] at XXObject.m:13) } (lldb) p $269->get(1) // 嘗試訪問第二個方法,越界 error: Execution was interrupted, reason: signal SIGABRT. The process has been returned to the state before expression evaluation. Assertion failed: (i < count), function get, file /Users/apple/Desktop/objc-runtime/runtime/objc-runtime-new.h, line 110. (lldb)
最後一個操作實在是截取不到了
const class_ro_t *ro = (const class_ro_t *)cls->data(); class_rw_t *rw = (class_rw_t *)calloc(sizeof(class_rw_t), 1); rw->ro = ro; rw->flags = RW_REALIZED|RW_REALIZING; cls->setData(rw);
在上述的代碼運行之後,類的只讀指針 class_ro_t 以及可讀寫指針 class_rw_t 都被正確的設置了。但是到這裡,其 class_rw_t 部分的方法等成員都指針均為空,這些會在 methodizeClass 中進行設置:
在這裡調用了 method_array_t 的 attachLists 方法,將 baseMethods 中的方法添加到 methods 數組之後。我們訪問 methods 才會獲取當前類的實例方法。
方法的結構
說了這麼多,到現在我們可以簡單看一下方法的結構,與類和對象一樣,方法在內存中也是一個結構體。
struct method_t { SEL name; const char *types; IMP imp; };
其中包含方法名,類型還有方法的實現指針IMP:
上面的 -[XXObject hello] 方法的結構體是這樣的:
name = "hello" types = 0x0000000100000fa4 "v16@0:8" imp = 0x0000000100000e90 (method`-[XXObject hello] at XXObject.m:13
方法的名字在這裡沒有什麼好說的。其中,方法的類型是一個非常奇怪的字符串 "v16@0:8" 這在 ObjC 中叫做類型編碼(Type Encoding),你可以看這篇官方文檔了解與類型編碼相關的信息。
對於方法的實現,lldb 為我們標注了方法在文件中實現的位置。
小結
在分析方法在內存中的位置時,筆者最開始一直在嘗試尋找只讀結構體 class_ro_t 中的 baseMethods 第一次設置的位置(了解類的方法是如何被加載的)。嘗試從 methodizeClass 方法一直向上找,直到 _obj_init 方法也沒有找到設置只讀區域的 baseMethods 的方法。
而且在 runtime 初始化之後,realizeClass 之前,從 class_data_bits_t 結構體中獲取的 class_rw_t 一直都是錯誤的,這個問題在最開始非常讓我困惑,直到後來在 realizeClass 中發現原來在這時並不是 class_rw_t 結構體,而是class_ro_t,才明白錯誤的原因。
後來突然想到類的一些方法、屬性和協議實在編譯期決定的(baseMethods 等成員以及類在內存中的位置都是編譯期決定的),才感覺到豁然開朗。
類在內存中的位置是在編譯期間決定的,在之後修改代碼,也不會改變內存中的位置。
類的方法、屬性以及協議在編譯期間存放到了“錯誤”的位置,直到 realizeClass 執行之後,才放到了 class_rw_t 指向的只讀區域 class_ro_t,這樣我們即可以在運行時為 class_rw_t 添加方法,也不會影響類的只讀結構。
在 class_ro_t 中的屬性在運行期間就不能改變了,再添加方法時,會修改 class_rw_t 中的 methods 列表,而不是 class_ro_t 中的 baseMethods,對於方法的添加會在之後的文章中分析。
參考資料
Classes and Metaclasses
Tagged Pointer
類型編碼
Type Encodings