剖析 ARM 64 架構中的 objc_msgSend

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

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

概述

每一個 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 代碼處理流程大體以下:oop

  1. 獲取消息對象所對應的類信息
  2. 獲取類所對應的方法緩存
  3. 在方法緩存中查詢 selector 對象的函數實現
  4. 若是查詢失敗則調用 C 代碼進行下一步處理
  5. 跳轉到 IMP 所指的函數實現

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

執行過程的指令

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
相關文章
相關標籤/搜索