iOS objc_msgSend 彙編分析
概述
Objective-C 裡每個物件都會指向一個類,每個類都會有一個方法列表,方法列表裡的每個方法都是由 selector、函式指標imp 和 metadata 組成的。objc_msgSend的工作就是傳入物件和selector,查詢相應方法的函式指標,然後跳到函式指標所指向的位置。
objc_msgSend是用匯編寫的原因有兩個:
- 在C語言中不可能通過寫一個函式來保留未知的引數並且跳轉到一個任意的函式指標。
- objc_msgSend 的呼叫頻次最高,幾乎所有方法的呼叫都要經過它,因此要夠快。
訊息傳送的程式碼可以被分為兩部分:objc_msgSend中有一個快速路徑,是用匯編寫的,還有一個慢速的路徑,是用C實現的。彙編部分主要實現的是在快取中查詢方法,並且如果找到的話就跳轉過去的一個過程。如果在快取中沒有找到方法的實現,就會呼叫C的程式碼來處理後續的事情。
分析objc_msgSend的流程:
- 獲取傳入的物件的類
- 獲取這個類的方法快取
- 通過傳入的selector,在快取中查詢方法
- 如果快取中沒有,呼叫C程式碼
- 跳到這個方法的IMP
根據上述流程分析objc_msgSen的彙編。
現在,蘋果公司已經開源了 Objective-C 的執行時程式碼。你可以在蘋果公司的開源網站,找到 objc_msgSend 的原始碼。
指令分析
ARM64架構下有31個通用暫存器,每個都是64位寬的。他們被標記為x0~x30。同樣也有可能使用w0到w30來訪問暫存器的低32位。暫存器x0~x7被用於函式入參的前8個引數。這就表示objc_msgSend收到的self引數是儲存在x0中,selector _cmd引數在x1裡。
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
複製程式碼
判斷儲存在p0中的self是否為空。如果小於0,跳轉到 LNilOrTagged, 執行tagged_pointers情況,如果等於0,跳轉到 LReturnZero ,執行傳送訊息給nil的情況。(在ARM64上 通過設定指標的高位來指明是tagged pointer。(x86-64上是設定低位)。如果高位被設定了1,且被作為一個帶符號的整型解析的時候,那麼值就是負數。一般情況下self是正常的,不會進入這些分支。)
ldr p13,[x0] // p13 = isa
複製程式碼
載入x0所指向的記憶體,實則是載入self的isa指標。一個物件的第一個指標就是isa指標。p13暫存器儲存了isa。
GetClassFromIsa_p16 p13 // p16 = class
複製程式碼
執行巨集 GetClassFromIsa_p16
.macro GetClassFromIsa_p16 /* src */
#if SUPPORT_INDEXED_ISA
// Indexed isa
mov p16,$0 // optimistically set dst = src
tbz p16,#ISA_INDEX_IS_NPI_BIT,1f // done if not non-pointer isa
// isa in p16 is indexed
adrp x10,_objc_indexed_classes@PAGE
add x10,x10,_objc_indexed_classes@PAGEOFF
ubfx p16,p16,#ISA_INDEX_SHIFT,#ISA_INDEX_BITS // extract index
ldr p16,[x10,UXTP #PTRSHIFT] // load class from array
1:
#elif __LP64__
// 64-bit packed isa
and p16,$0,#ISA_MASK
#else
// 32-bit raw isa
mov p16,$0
#endif
.endmacro
複製程式碼
判斷 SUPPORT_INDEXED_ISA ,表示isa_t中存放的資訊是Class的地址,還是一個索引(根據索引可以在類資訊表中查詢該類的結構地址)。iOS裝置SUPPORT_INDEXED_ISA 為0,然後判斷是否為64位,執行
and p16,#ISA_MASK
複製程式碼
ARM64可以使用非指標的isa。通常isa指標指向的是物件的類,但是非指標的isa利用了備用的bit位,填充了一些其他的資訊。這條彙編指令執行了一個邏輯與運算,掩蓋掉了所有額外的位,把實際的指向類的指標儲存在p16暫存器中。
.macro CacheLookup
// p1 = SEL,p16 = isa
ldp p10,p11,[x16,#CACHE] // p10 = buckets,p11 = occupied|mask
#if !__LP64__
and w11,w11,0xffff // p11 = mask
#endif
and w12,w1,w11 // x12 = _cmd & mask
add p12,p10,p12,LSL #(1+PTRSHIFT)
// p12 = buckets + ((_cmd & mask) << (1+PTRSHIFT))
ldp p17,p9,[x12] // {imp,sel} = *bucket
1: cmp p9,p1 // if (bucket->sel != _cmd)
b.ne 2f // scan more
CacheHit $0 // call or return imp
2: // not hit: p12 = not-hit bucket
CheckMiss $0 // miss if bucket->sel == 0
cmp p12,p10 // wrap if bucket == buckets
b.eq 3f
ldp p17,[x12,#-BUCKET_SIZE]! // {imp,sel} = *--bucket
b 1b // loop
3: // wrap: p12 = first bucket,w11 = mask
add p12,UXTW #(1+PTRSHIFT)
// p12 = buckets + (mask << 1+PTRSHIFT)
// Clone scanning loop to miss instead of hang when cache is corrupt.
// The slow path may detect any corruption and halt later.
ldp p17,sel} = *--bucket
b 1b // loop
3: // double wrap
JumpMiss $0
.endmacro
複製程式碼
分析上面的巨集
#define CACHE (2 * __SIZEOF_POINTER__)
ldp p10,p11 = occupied|mask
複製程式碼
從x16暫存器編譯16個位元組,取到的值儲存到p10和p11中。方法快取的結構如下:
typedef uint32_t mask_t;
struct cache_t {
struct bucket_t *_buckets;
mask_t _mask;
mask_t _occupied;
}
複製程式碼
p10儲存了buckets的值,p11的高32位儲存了_occupied,低32位儲存了_mask。
_occupied指定了雜湊表中包含了多少條目,在objc_msgSend中不起什麼作用。_mask很重要:它描述了雜湊表的尺寸,方便用於與運算的掩碼。它的值總是一個2的冪減一,用二進位制的方法描述看起來就像是000000001111111,末尾是可變數量的1。通過這個值可以知道selector的查詢索引,並在查詢表的時候包裹著結尾。
and w12,w11 // x12 = _cmd & mask
複製程式碼
x1中包含_cmd,所以w1包含了_cmd的低32位。w11包含了上面提到的_mask。這條指令將這兩個值做與運算並將結果放到w12中。結果相當於是計算_cmd & mask,但是避免了開銷很大的模運算。這一步計算出了傳入的selector的起始雜湊表的索引。
add p12,LSL #(1+PTRSHIFT)
// p12 = buckets + ((_cmd & mask) << (1+PTRSHIFT))
複製程式碼
這條指令通過索引表的指標向左位移,再加上buckets,得到第一個查詢的bucket的地址,也就是imp。
ldp p17,sel} = *bucket
複製程式碼
把x12儲存的bucket的地址,每個bucket包含一個selector和imp。p17包含了當前的imp,p9包含了當前的selector。
1: cmp p9,p1 // if (bucket->sel != _cmd)
b.ne 2f // scan more
CacheHit $0 // call or return imp
2: // not hit: p12 = not-hit bucket
CheckMiss $0 // miss if bucket->sel == 0
複製程式碼
對p1中的cmd和p9中的selector比較。如果相等,CacheHit將imp返回。接下去就是執行目標方法的程式碼了,objc_msgSend的快速路徑到此已經結束了。所有引數暫存器不會受到幹擾,原封不動的傳給目標方法,就好像直接呼叫了目標方法一樣。如果不相等,跳轉到2f位置,處理不相等的邏輯,執行CheckMiss。
來看CheckMiss的巨集定義
.macro CheckMiss
// miss if bucket->sel == 0
.if $0 == GETIMP
cbz p9,LGetImpMiss
.elseif $0 == NORMAL
cbz p9,__objc_msgSend_uncached
.elseif $0 == LOOKUP
cbz p9,__objc_msgLookup_uncached
.else
.abort oops
.endif
.endmacro
複製程式碼
由於傳入的是nomal模式,會進入__objc_msgSend_uncached
cbz p9,__objc_msgSend_uncached
複製程式碼
p9包含了從bucket載入的selector,這條指令是p9和0作比較,如果等於0跳轉到__objc_msgSend_uncached,這就說明這是個空的bucket,意味著目標方法不在快取中,這時候會進入C語言方法__objc_msgSend_uncached,執行詳細的查詢流程。如果不為0就說明bucket不是空,只是沒有找到,則繼續查詢。
cmp p12,p10 // wrap if bucket == buckets
b.eq 3f
複製程式碼
p12中儲存的是當前的bucket地址,p10中儲存的是buckets雜湊表的首地址,比較如果匹配,跳轉到3f處。 如果不匹配會繼續執行
ldp p17,sel} = *--bucket
b 1b // loop
複製程式碼
再一次從快取的bucket中載入。這次他從偏移量為BUCKET_SIZE的地方載入當前快取bucket的地址。地址引用末尾的感嘆號是一個有趣的特性。這指定一個暫存器進行回寫,意思就是暫存器會更新為計算後的值。這條指令有效的執行了x12 -= 16來載入新的bucket,並使x12指向這個新的bucket。
現在已經載入了一個新的bucket,所以接下去的執行就要回到之前的檢查當前bucket是否匹配的程式碼。這條指令代表回到1b,使用新的值再執行一次所有程式碼。如果仍然沒有找到匹配的bucket,這些程式碼會持續執行,直到找到匹配的,或者空的bucket,或者命中表的開頭。
3f處匹配的邏輯指令:
3: // wrap: p12 = first bucket,sel} = *bucket
複製程式碼
x12中包含了當前的bucket指標,w11表示的是mask,表的大小。將mask左移1+PTRSHIFT位,加上buckets的首地址。得到的結果指向表的末尾。
把得到的新的bucket儲存到p17,p9。
cmp p9,p1 // if (bucket->sel != _cmd)
b.ne 2f // scan more
CacheHit $0 // call or return imp
複製程式碼
這段程式碼還是去檢測bucket是否匹配,並且跳轉返回imp。我們可以看到有兩次123的流程,第二次123就是為了防止兩種情況:
第一種,多執行緒呼叫的時候給的一次容錯機會。
第二種是 為了在遇到記憶體被破壞或者無效物件時,防止陷入無限迴圈而榨乾效能。舉個例子,堆損壞能夠在快取中塞滿非0的資料,或者設定快取的掩碼為0,快取不命中就會一直迴圈執行快取掃描。額外的檢查可以停止迴圈,將問題轉變為崩潰日誌。
參考資料: