原文鏈接: http://draveness.me/method-struct/
關注倉庫,及時獲得更新:iOS-Source-Code-Analyze
因為 ObjC 的 runtime 只能在 Mac OS 下才能編譯,所以文章中的代碼都是在 Mac OS,也就是
x86_64
架構下運行的,對于在 arm64 中運行的代碼會特別說明。
在上一篇分析 isa
的文章從 NSObject 的初始化了解 isa中曾經說到過實例方法被調用時,會通過其持有 isa
指針尋找對應的類,然后在其中的 class_data_bits_t
中查找對應的方法,在這一篇文章中會介紹方法在 ObjC 中是如何存儲方法的。
這篇文章的首先會根據 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
當前類的實例需要 rawisa
-
執行 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 <Foundation/Foundation.h>
@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 <Foundation/Foundation.h>
#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 <no value available>
name = 0x0000000100000f7a "XXObject"
baseMethodList = 0x00000001000010c8
baseProtocols = 0x0000000000000000
ivars = 0x0000000000000000
weakIvarLayout = 0x0000000000000000 <no value available>
baseProperties = 0x0000000000000000
}
現在我們獲取了類經過編譯器處理后的只讀屬性 class_ro_t
:
(class_ro_t) $4 = {
flags = 128
instanceStart = 8
instanceSize = 8
reserved = 0
ivarLayout = 0x0000000000000000 <no value available>
name = 0x0000000100000f7a "XXObject"
baseMethodList = 0x00000001000010c8
baseProtocols = 0x0000000000000000
ivars = 0x0000000000000000
weakIvarLayout = 0x0000000000000000 <no value available>
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<method_t, method_list_t> = {
= {
list = 0x0000000000000000
arrayAndFlag = 0
}
}
}
properties = {
list_array_tt<property_t, property_list_t> = {
= {
list = 0x0000000000000000
arrayAndFlag = 0
}
}
}
protocols = {
list_array_tt<unsigned long, protocol_list_t> = {
= {
list = 0x0000000000000000
arrayAndFlag = 0
}
}
}
firstSubclass = nil
nextSiblingClass = nil
demangledName = 0x0000000000000000 <no value available>
}
(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 <no value available>
name = 0x0000000100000f7a "XXObject"
baseMethodList = 0x00000001000010c8
baseProtocols = 0x0000000000000000
ivars = 0x0000000000000000
weakIvarLayout = 0x0000000000000000 <no value available>
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
,對于方法的添加會在之后的文章中分析。
參考資料
關注倉庫,及時獲得更新:iOS-Source-Code-Analyze
轉載請注明 Blog: Draveness
Follow: @Draveness·Github