譯者前言
總是看到有人說用匯編實現objc_msgSend
是為了速度快,當然這個不可否認。但是難道沒有別的原因?於是就看到了這篇文章,遂翻譯之!=。=
我自己的理解就是,用匯編實現,是為了應對不同的“Calling convention”,把函式呼叫前的棧和暫存器的引數、狀態設定,交給編譯器去處理。
先看看原文吧。
原作者: Ari Grant
原文連結: Why objc_msgSend Must be Written in Assembly
開始
對於Objective-C來說,呼叫一個物件例項的方法,也叫作向這個物件例項“傳送訊息”,而每條“訊息”,在編譯階段都會轉變為一次對objc_msgSend
函式的呼叫,呼叫的引數不僅有原本訊息的所有引數,還有訊息的接收者receiver
和對應的方法selector
。舉個例子,下面的語句:
1 |
[receiver message:foo beforeDate:bar]; |
將會被編譯成:
1 |
objc_msgSend(receiver, @selector(message:beforeDate:), foo, bar); |
對於objc_msgSend
函式的實現原理,前人已經做了大量的探索。所以,本文將會把重點放在objc_msgSend
的一個之前沒有太受到關注的點上,那就是:
objc_msgSend
是不可能用Objective-C、C或者C++實現的。
THE RETURN TYPE – 返回型別
先看看如下兩行程式碼:
1 2 |
NSUInteger n = [array count]; id obj = [array objectAtIndex:6]; |
直觀上看,將會被編譯成
1 2 |
NSUInteger n = objc_msgSend(array, @selector(count)); id obj = objc_msgSend(array, @selector(objectAtIndex:), 6); |
但是實際上這是不可能的,因為沒有函式可以同時滿足這兩個呼叫。而且它的返回值也不能同時是NSUInteger
和id
。
而且,上面的程式碼也是無法編譯通過的。那麼,加上型別轉換怎麼樣?
1 2 |
NSUInteger n = (NSUInteger (*)(id, SEL))objc_msgSend(array, @selector(count)); id obj = (id (*)(id, SEL, NSUInteger))objc_msgSend(array, @selector(objectAtIndex:), 6); |
這下可以編譯通過了,雖然看起來不直觀。。。
objc_msgSend
是一個Public的函式,在裡宣告,如果你想直接呼叫它,就必須按照上面的格式加上強制型別轉換,要不然是無法編譯通過的。但是
objc_msgSend
到底是如何實現,來支援各種返回型別的?本文後面會講到。
THE IMP – 方法對應的函式指標
objc_msgSend
函式的本質很簡單,傳入一個接受者物件例項receiver
和方法名selector
,它就會按照以下步驟執行:(譯者注:只是最粗略的步驟=。=)
- 獲取
receiver
得類Class - 在Class的方法列表method table裡面查詢對應
selector
的方法實現 - 找到的話就呼叫,返回
- 找不到就在其父類中找,重複前面的步驟(直到沒有父類為止)
整個流程很簡單,沿著繼承鏈,向上找到方法selector
對應的函式指標即可,也就是IMP
。同時,在每層Class中都有快取,加快後續的方法查詢。但是,這也只是objc_msgSend
的實現細節,所以,接著往下看。
THE ARG TYPES AND COUNT – 引數型別和數量
簡單來說,當objc_msgSend
找到對應的函式指標後,只要用傳入的引數呼叫這個函式即可。剩下來的就是找到一種方法,可以呼叫任意引數型別、數量的任意函式。
引數的數量很容易計算。然後我們可以把所有的引數都放入varargs
,然後呼叫函式時傳入即可。但是這樣的話,每個Objective-C的方法都必須在其prologue
(譯者注:函式執行具體的“任務”前,所做的準備環節)裡面把所有的引數從varargs
裡面提取出來。
這種把引數打包到varargs
裡面然後又取出來的辦法顯然是非常糟糕的,同時也是不必要的。
在C語言中,呼叫一個函式會被編譯成對應的組合語言指令,首先是設定引數(把引數放到暫存器、棧上),然後用如jump
或者call
的指令,跳到具體的函式程式碼地址處。如果我們想支援任意型別的函式型別,我們就必須寫一個switch
語句,把所有的引數組合情況都包含起來,這樣才能正確的為任何形式的函式設定引數(譯者注:即按照某種“規範”、“約定”,把引數依次存放到“約定”的暫存器、棧上),這顯然是沒有擴充套件性的,更是不可能的。
UNWINDING THE CALL – 拆解呼叫
objc_msgSend
的解決辦法,主要依據的是:當objc_msgSend被呼叫時,所有的引數已經被設定好了。
換一種方式來說,就是:在objc_msgSend開始執行時,棧幀(stack frame)的狀態、資料,和各個暫存器的組合形式、資料,跟呼叫具體的函式指標(IMP)時所需的狀態、資料,是完全一致的!
如下這行程式碼:
1 |
id obj = objc_msgSend(array, @selector(objectAtIndex:), 6); |
在呼叫objc_msgSend
時,需要設定三個引數,分別是被呼叫方receiver
、方法名selector
和最後一個整型引數6。這和具體的方法函式IMP的引數順序、型別是完全一致的,也就是說,呼叫objc_msgSend
前,設定的棧、暫存器的狀態、資料正是呼叫具體的方法函式時需要的狀態!
所以,當objc_msgSend
找到要呼叫的函式實現IMP後,只需要把所有的對棧、暫存器的操作“倒”回到objc_msgSend
執行開始的狀態(類似於函式執行完成return返回前,做的“收尾處理”工作一樣,即epilogue),直接jump/call
到IMP函式指標對應的地址,執行指令即可,因為所有的引數已經被設定好了。
同時,當selector
對應的IMP執行完成後,返回值也被正確的設定好了(在x86平臺上,返回值被設定到了指定的暫存器eax/rax
裡,在arm上,則是r0
暫存器),所以,我們也不必擔心前文提到的不同型別的返回值問題了。
WRAP UP – 總結
把上面提到的所有解釋綜合起來,就是:在C語言裡面呼叫函式,必須在編譯時就知道呼叫的“狀態”;而這些“狀態”在執行時是無法得出或正確處理的,所以必須往底層走,用匯編處理。(譯者注:這裡不知道咋翻譯好=。=,原文是:calling a function in C requires the signature to be known for each call-site at compile-time;doing so at run-time is not possible and so one must drop down into assembly and party there instead.)
UPDATE – 後續
有人指出objc_msgSend
有可能是用GCC的擴充套件方法__builtin_apply_args
,__builtin_apply
,和__builtin_return
實現的。這也正指出了一個事實,就是這些builtins方法是非常有必要的,因為單靠語言本身無法實現這些功能。實現objc_msgSend
所需要的技巧,也正是實現這些builtins方法所需要的技巧。本文的目的並不是非要將什麼是真正的C、什麼不是真正的C分個清楚,只是為了指出objc_msgSend
特殊罷了。
譯者總結
開頭也說了,我的理解是:用匯編實現,是為了應對不同的“Calling convention”,把函式呼叫前的棧和暫存器的引數、狀態設定,交給編譯器去處理。
嗯,以後不要再說用匯編實現只是為了快了=。=