剖析 ARM 64 架構中的 objc_msgSend

SwiftGG翻譯組發表於2018-08-06

原文連結:swift.gg/2018/08/06/…
作者:Mike Ash
譯者:BigNerdCoding
校對:pmst,shanks
定稿:CMB

很高興,我又回來了。在剛剛過去的 WWDC 期間,我在 CocoaConf Next Door 做個一個關於剖析 ARM64 上 objc_msgSend 執行流程的發言。現在我將整理後的內容重新發布到 Friday Q&A 上。

概述

每個 Objective-C 物件都會指向一個類,而每個類又包含一個方法列表。每個方法則由選擇器(selector)、函式指標和一些後設資料(metadata)構成。objc_msgSend 職責就是接收物件(object)和選擇器(selector),根據選擇器名稱找到對應方法的函式指標並跳轉執行該函式。

查詢過程相對來說還是比較複雜的。若某個方法在當前類中未找到,就需要沿著繼承鏈繼續在父類中查詢。如果在父類中也未查詢到的話,則會觸發 runtime 機制中的訊息轉發機制。任何物件在接收到第一條訊息後都會觸發類方法 +initialize

因為每次方法呼叫都會觸發上述流程,所以在常見場景下的查詢速度必須非常快。顯然這與複雜的操作過程之間存在一定衝突。

為了解決這對矛盾提高查詢速度,Objective-C 採用了方法快取策略。每個類都會使用雜湊表將其方法按照 Selector - IMPs(函式指標) 鍵值對關係快取起來。這樣在查詢方法時,runtime 首先會直接去雜湊表中查詢。如果雜湊表中不存在的話則轉而執行原有複雜、緩慢的處理流程,並將最終結果快取起來已備下次使用。

objc_msgSend 用匯編語言進行實現,具體理由有兩個:首先純 C 語言無法實現這麼一個函式:接收不定個數且未知型別的引數作為入參跳轉至任意函式指標(即呼叫實現);其次,執行速度對 objc_msgSend 來說非常重要,組合語言能最大化提升該項指標。

當然,使用匯編語言實現整個複雜的訊息處理過程是不現實的,而且也沒這種必要。因為有些流程一旦觸發程式都會變慢,無論採用何種語言層面的實現。整個訊息處理流程程式碼可以分為兩個部分:通過彙編程式碼實現的快速路徑部分(fast path) ,C 語言實現的慢路徑流程(slow path)。其中彙編程式碼對應快取表中查詢方法部分並且未命中時跳轉 C 程式碼來進行下一步處理。

因此,objc_msgSend 程式碼處理流程大致如下:

  1. 獲取訊息物件所對應的類資訊
  2. 獲取類所對應的方法快取
  3. 在方法快取中查詢 selector 物件的函式實現
  4. 如果查詢失敗則呼叫 C 程式碼進行下一步處理
  5. 跳轉到 IMP 所指的函式實現

下面開始分析其具體實現。

執行過程的指令

objc_msgSend 在不同情形下執行路徑不盡相同。對於向 nil 傳送訊息,標記指標(tagged pointers),雜湊表衝突會相應特殊程式碼中進行處理。下面我將通過最常見也是最簡單的情形來解釋 objc_msgSend 的執行,即處理 non-nil、non-tagged 訊息並且雜湊表也能命中該方法。我會在該過程中標記出那些需要注意的處理路徑岔路口,然後回過頭來進行詳細講解。

我將列出單條或一組指令,然後在下面緊接相關解釋內容。

每條指令前面都會有一個地址偏移量,可以將其看作一個指示跳轉位置的標記量。

ARM64 架構中包含 31 個 64 位整型暫存器,對應符號表示為 x0 - x30 。每個暫存器的低 32 位也可以通過 w0 到 w30 進行訪問,就像它也是一個單獨的暫存器。其中 x0 到 x7 被用來儲存函式呼叫時的前 8 個引數。這意味著 objc_msgSend 函式中的 self 引數儲存在 x0 而 _cmd 儲存在 x1 。

起始指令如下:

0x0000 cmp     x0, #0x0
0x0004 b.le    0x6c  	 
複製程式碼

該段指令是將 self 與 0 進行有符號比較,如果 self 不大於 0 的話則會進行跳轉處理。等於 0 其實就相當於 nil 物件,也就是說此時會呼叫向 nil 傳送訊息情形下對應的特定程式碼。另外,該指令也被用於標記指標(tagged pointers)的處理。ARM64 通過設定最高位為 1 來標記 Tagged Pointers(x86-64 則是最低位),此時對應有符號數比為負。對於普通指標來說,上述處理分支都會不被觸發。

0x0008 ldr    x13, [x0] 
複製程式碼

該指令將 x0 中所表示的 self 的 isa 地址載入到 x13 暫存器中。

0x000c and    x16, x13, #0xffffffff8 
複製程式碼

因為 ARM64 架構下能夠使用 non-pointer isas 技術,所以與之前相比 isa 欄位不僅可以包含指向 Class 的資訊,它還能利用多餘位元位儲存其它有效資訊(例如,引用計數)。這裡通過 AND 邏輯運算去除低位的冗餘資訊得到最終的 Class 的地址並將其存入 x13 暫存器中。

0x0010 ldp    x10, x11, [x16, #0x10]
複製程式碼

這是整個 objc_msgSend 處理流程中我最喜歡的指令。該指令會將 Class 中的方法快取雜湊表載入到 x10 和 x11 兩個暫存器中。ldp 指令會將有效的記憶體資訊載入到該指令的前兩個暫存器中,而第三個引數則對應該資訊的記憶體地址。在該例中快取雜湊表地址為 x16 暫存器中地址偏移 16 後所處位置。快取物件資料結構類似於:

typedef uint32_t mask_t;

struct cache_t {
    struct bucket_t *_buckets;
    mask_t _mask;
    mask_t _occupied;
}
複製程式碼

在上述 ldp 指令中,x10 中儲存了 _buckets 值,而 x11 暫存器的高 32 位儲存的是 _occupied 低 32 位則儲存了 _mask

_occupied 表示雜湊表中的元素的數量,在 objc_msgSend 處理過程中沒有太大的作用。而 _mask 則相對重要:它將雜湊表大小描述為了一個便於進行與操作的掩碼。_mask 值為 2^n - 1 ,換句話說它的二進位制表示將以一組 1 作為結尾,形如 000000001111111 。該值為查詢 selector 的雜湊表索引以及標記表尾的必要條件。

0x0014 and    w12, w1, w11
複製程式碼

該指令用於計算 _cmd 所傳遞過來的 selector 在雜湊表中的起始位置。因為 _cmd 儲存在 x1 暫存器中,所以 w1 暫存器則包含了 _cmd 的低 32 位資訊。而 w11 暫存器儲存了上面提到的 _mask 資訊。通過 AND 指令我們將這兩個暫存器中數值與操作結果儲存到 w12 暫存器中。計算結果相當於 _cmd % table_size ,但是它卻避免了模操作的昂貴開銷。

0x0018 add    x12, x10, x12, lsl #4
複製程式碼

僅僅得到索引是不夠,為了從表中載入資料,我們需要得到最終的實際地址。而這正是該指令的目的。因為雜湊表的 bucket 都是 16 個位元位,所以這裡先對 x12 暫存器中的索引值左移 4 位也就是乘以 16 ,然後再將其與表首地址相加後的確切 bucket 地址資訊儲存到 x12 中。

0x001c ldp    x9, x17, [x12]
複製程式碼

再一次通過 ldp 指令,將上一步儲存在 x12 暫存器中 bucket 對應的資訊載入到 x9 和 x17 暫存器中。因為 bucket 由 selector 和 IMP 兩部分構成,所以 x9 對應儲存了 selector 資訊而 x17 則儲存了 IMP 資訊。

0x0020 cmp    x9, x1
0x0024 b.ne   0x2c
複製程式碼

該段指令會將 x9 暫存器中的內容和 x1 中的 _cmd 進行對比,如果它們不等則意味著 bucket 中不包含我們所操作的 selector ,並且在此時跳轉到 0x2c 處執行對應的未匹配處理。如果相同的話則表示命中,繼續執行下一條指令。

0x0028 br    x17
複製程式碼

該指令為無條件跳轉到 x17 暫存器所指位置,也就是跳轉到 IMP 所指處執行具體實現程式碼。此時 objc_msgSend 處理流程中最快的路徑已經結束。其餘引數所做暫存器都沒有被干擾,目標方法會接受傳入的全部引數,一切行如直接呼叫目標函式。

在最理想的情形下,objc_msgSend 處理流程最快可以在 3 納秒內執行完畢。

在介紹完理想的最快情形後,接下來我們需要關注其餘幾種情形。首先,我們來看下當方法未快取時的處理。

0x002c cbz    x9, __objc_msgSend_uncached
複製程式碼

前面提到 x9 暫存器包含了載入後的 selector 資訊。將暫存器中的資訊與零進行比較,如果等於 0 的話就跳轉到 __objc_msgSend_uncached 程式碼處。因為等於 0 就意味著 bucket 為空也就是說方法查詢失敗,selector 對應的方法沒有被快取到雜湊表中。此時我們需要呼叫 C 語言程式碼進行更為複雜的處理,也就是 __objc_msgSend_uncached 。如果僅僅只是方法不匹配且 bucket 不為空的話,則需要繼續進行方法查詢。

0x0030 cmp    x12, x10
0x0034 b.eq   0x40
複製程式碼

該指令將 x12 暫存器中的當前 bucket 地址與 x10 暫存器中的雜湊表首地址進行比較。如果兩者內容匹配上了,則我們從雜湊表的末尾進行反向查詢。雖然我還沒弄明白此時為什麼沒有采用常見的正向遍歷查詢,但是有理由認為可能這樣速度更快。

0x40 表示匹配後跳轉目的地址。如果兩者不匹配則繼續執行下面的指令。

0x0038 ldp    x9, x17, [x12, #-0x10]!
複製程式碼

再一次程式碼通過 ldp 指令載入快取資訊,只不過地址為距當前 bucket 偏移 -0x10 所指位置。該指令中的 !符號表示暫存器回寫操作,也就是說會使用計算後的結果更新 x12 暫存器。將其用數學方式表示就是:x12 -= 16,將 x12 中表示的地址前移 16 個單位。

0x003c b      0x20
複製程式碼

載入新的 bucket 資訊後,程式碼重新跳轉到 0x20 處迴圈查詢過程,直到出現下列情形:找到匹配項,bucket 為空,再次回到了雜湊表的起始處。

0x0040 add    x12, x12, w11, uxtw #4
複製程式碼

當查詢到匹配想後會觸發該指令。此時 x12 暫存器為最新的 bucket 地址,而 w11 儲存了包含雜湊表大小的掩碼值。該指令將 w11 左移 4 位後將兩個值進行疊加得到雜湊表尾地址,並將結果儲存到 x12 暫存器中,然後接著恢復查詢操作。

0x0044 ldp    x9, x17, [x12]
複製程式碼

該指令為載入新 bucket 資訊到 x9,x17 暫存器中。

0x0048 cmp    x9, x1
0x004c b.ne   0x54
0x0050 br     x17
複製程式碼

該段指令與前面的 0x0020 處的功能一致,只要暫存器內容匹配上了就跳轉到對應 IMP 位置執行程式碼。

0x0054 cbz    x9, __objc_msgSend_uncached
複製程式碼

同樣的,若不匹配則執行與前面 0x002c 一樣的處理流程。

0x0058 cmp    x12, x10
0x005c b.eq   0x68
複製程式碼

該指令與 0x0030 處一致,只不過如果此時 x12 暫存器內容依舊是雜湊表首地址的話程式會跳轉到 0x68 處進行處理。

0x0068 b      __objc_msgSend_uncached
複製程式碼

這種情況一般不太容易發生,因為它會導致雜湊表持續膨脹。此時雜湊表的查詢效率會下降而去潛在雜湊碰撞的可能性會變高。

至於原因,原始碼中的註釋是這些寫的:

Clone scanning loop to miss instead of hang when cache is corrupt. The slow path may detect any corruption and halt later. 當快取損壞時,需要跳出上面的迴圈查詢流程而不是進入掛起狀態。 轉而執行慢速路徑流程去檢測任何可能的損壞並終止程式碼執行。

我懷疑這種情況很常見,但很顯然蘋果公司的員工已經看到記憶體損壞會讓雜湊表充滿無效內容所以在此處跳轉到 C 程式碼中進行錯誤診斷。

此項檢查的存在應該將對未損壞的快取的影響降低到最小。去除該檢查,原來的迴圈處理流程可以被重用,這會節省一點指令快取空間。 無論如何,該處理程式器並不是常見的情況。 只會在雜湊表的開始位置查詢到所需的選擇子或者發生了雜湊碰撞時才會被呼叫。

0x0060 ldp    x9, x17, [x12, #-0x10]!
0x0064 b      0x48
複製程式碼

該段指令與之前功能一致,載入新 bucket 資訊到 x9,x17 暫存器中。更新 x12 中的地址,並跳轉到 0x48 處重複查詢流程。

objc_msgSend 的主要處理流程到此告一段落,剩下 Tagged Pointer 和 nil 兩個特殊情形的處理。

標記指標的處理

我們回到第一組彙編指令的跳轉處來講解標記指標(Tagged Pointer)的處理。

0x006c b.eq    0xa4
複製程式碼

當引數 self 不大於 0 時,該指令就會被觸發。其中小於 0 對應標記指標,而等於零則對應 nil 。這兩種情形有各自的處理流程,所以第一步就是要區分出到底是哪種情形。若為 nil 情形則跳轉到 0xa4 處進行處理,否則繼續執行。

在繼續講解之前,先簡單討論下標記指標工作原理。 標記指標支援多個類。其中高 4 位(在 ARM64 上)指明瞭“物件”的類資訊,本質上就是 Tagged Pointer 的 isa 。當然 4 個位元位不足以容納一個類指標,實際上這些資訊都被存在了一張特殊表中。我們可以以高 4 位的值為索引去表中查詢真正的類資訊。

這還不是全部,標記指標(至少在 ARM64 上)支援擴充類。當高 4 位全為 1 時,緊接著的 8 個位元位將被用作擴充類表中的索引值。 這樣在執行時支援更多的標記指標類,不過代價就是能儲存的有效資訊會變少。

下面繼續指令的執行。

0x0070 mov    x10, #-0x1000000000000000
複製程式碼

該指令將一個整形值(高 4 位為 1 ,其餘全為 0)寫入 x10 暫存器中。這將用作下一步提取 self 標記位的掩碼。

0x0074 cmp    x0, x10
0x0078 b.hs   0x90
複製程式碼

這一步時檢查擴充標記指標內容。如果 self 大於或者等於 x10 中的值,則意味這 self 的高 4 位也全部為 1 。此時程式碼會跳轉到 0x90 處理擴充類部分的內容,否則就繼續執行下面的指令去主標記指標表中的查詢類資訊。

0x007c adrp   x10, _objc_debug_taggedpointer_classes@PAGE
0x0080 add    x10, x10, _objc_debug_taggedpointer_classes@PAGEOFF
複製程式碼

該段指令主要就是載入 _objc_debug_taggedpointer_classes@PAGE 所指的主標記指標表地址。因為 ARM64 上的指標是 64 位寬,而指令只有 32 位寬,所以需要採用類 RISC 標準技術通過兩個指令來載入符號地址。

x86 架構則不存在該問題,因為它採用可變長度指令集。它可以通過一個 10 位元組長的指令處理上面的問題:2 個位元組用來區分具體指令和暫存器,剩下 8 個位元組用來儲存指標地址。

而在定長指令集機器上,我們只能通過一組命令加以應對。例如,上例就是通過兩條指令實現 64 位指標地址的載入操作。adrp 指令載入高 32 位資訊然後再通過 add 指令將其與低 32 位進行求和。

0x0084 lsr    x11, x0, #60
複製程式碼

因為索引值儲存在 x0 的高 4 位中,所以該指令將 x0 進行右移 60 位取出對應的索引值(取值範圍為 0-15)並儲存到 x11 中。

0x0088 ldr    x16, [x10, x11, lsl #3]
複製程式碼

根據索引值獲取標記指標的類資訊並儲存到 x16 中。

0x008c b      0x10
複製程式碼

獲得類資訊後程式會無條件跳回 0x10 處,並複用主分支中的程式碼進行方法查詢處理。

0x0090 adrp   x10, _objc_debug_taggedpointer_ext_classes@PAGE
0x0094 add    x10, x10, _objc_debug_taggedpointer_ext_classes@PAGEOFF
複製程式碼

該段指令與前面載入主標記指標表功能一樣,只不過此時它用於處理前面提到的擴充表分支。

0x0098 ubfx   x11, x0, #52, #8
複製程式碼

該指令只要是取出 self 中從第 52 位開始的 8 位資訊作為擴充表的索引值,並將其儲存到 x11 中。

0x009c ldr    x16, [x10, x11, lsl #3]
複製程式碼

再一次,我們將獲得的類資訊載入到 x16 中。

0x00a0 b      0x10
複製程式碼

最後,我們同樣跳回到 0x10 處。

接下來,我們來看 nil 情形的處理過程。

nil 的處理

作為最後一個特殊情況,下面就是 nil 情形下被執行的所有指令。

0x00a4 mov    x1, #0x0
0x00a8 movi   d0, #0000000000000000
0x00ac movi   d1, #0000000000000000
0x00b0 movi   d2, #0000000000000000
0x00b4 movi   d3, #0000000000000000
0x00b8 ret
複製程式碼

nil 情形的處理與其他情形完全不同,它不會進行類查詢和方法派發,而僅僅返回 0 給呼叫者。

該段指令最麻煩的事情是 objc_msgSend 不知道具體的返回值型別。是整型值、浮點值、亦或者是什麼都不返回。

幸運的是,所有用於設定返回值的暫存器都能被安全覆寫,即使此次呼叫過程不會使用到。整型返回值被儲存在 x0 和 x1 中,而浮點值則儲存在向量暫存器 v0 - v3 中。同時使用多個暫存器可以返回一個小型結構體型別返回值。

在處理 nil 情形時,上訴指令會將 x1 以及 v0 - v3 中的值全部清空並設定為 0。其中 d0 - d3 分別對應向量暫存器 v0 - v3 的後半部分,通過將其設定為 0 清除了後半部分然後在通過 movi 清除所有的暫存器內容。清空返回值暫存器後,控制權將重新回到呼叫方。

如果返回值為比較大的結構體,那麼暫存器可能就變的不夠用了。此時就需要呼叫者做出一些配合。呼叫者會在一開始為該結構體分配一塊記憶體,然後將其地址提前寫入到 x8 暫存器中。在設定返回值的時候,直接往該地址中寫資料即可。 因為該記憶體大小對 objc_msgSend 是透明的,因此不能對其進行清空操作。取而代之的操作就是在呼叫 objc_msgSend 之前編譯器會將其設定為 0 。

以上就是 nil 情形的處理,objc_msgSend 流程到此也宣告結束。

總結

深入框架底層還是很有趣的,而 objc_msgSend 就像一件藝術品,值得細細玩味。

今天的內容到此結束,下次再會為大家帶來一些更好的內容。Friday Q&A 很多內容都是由讀者驅動而來,所以歡迎大家在下面積極發言。

彙編指令校對者注

  1. #0x0:“#”修飾的數字表示立即數,可簡單理解為數值,而非地址:
  2. b :跳轉指令,b.le 指比較結果小於等於的時候跳轉至某記憶體地址;
  3. ldr :從記憶體中讀取資料到暫存器;
  4. and:arm 的 and 指令,需要3個運算元,例如 AND R0,R0,#3 是將 R0 暫存器的值與數字3(0x0000003)邏輯與,將結果儲存為 R0 暫存器
  5. addADD[con][S] Rd,Rn,operand,將 operand 資料與 Rn 的值相加,結果儲存到 Rd 暫存器;
  6. lsl: 邏輯左移指令,可以結合 add 指令一起使用,如ADDS R0,R1,R2,LSL#2,將 R2 暫存器左移 2 位,接著 R1 和 R2 值相加,將結果儲存到 R0 中;
  7. cbz:c對應compare,b就是上面的跳轉,z對應0 zero,因此這條命令當比較結果為零(Zero)就跳轉至之後的指令;
  8. UXTW: 32 位的邏輯左移指令,更多請見[llvm] r205861;
  9. LSR: 邏輯右移;
  10. UBFXUBFX{cond} Rd, Rn, #lsb, #width 從一個暫存器中提取位域,cond —可選,條件碼 ;Rd — 目標暫存器 ;Rn — 源暫存器 ;lsb —位域的最低有效位的位置,範圍是 0 - 31; width — 位域的寬度,範圍是1到 32-lsb

相關文章