2013年3月5日 星期二

C語言函式呼叫慣例 ( fastcall ) (完) Part3

Part2 講到關於重要的 cdecl 呼叫慣例分析後,應該已經可以掌握
或是已經記憶在腦袋裡面了,stdcall 與 cdecl 其實長得差不多,除了
函式編譯後的符號名稱不同 與 cdecl 要自己平衡堆疊外,參數傳遞方式
是一樣,由右至左推入堆疊,使得在函式內部取用參數時,剛剛好是從
param 1 開始拿取,另外一個重點在於假如使用編譯器標準處理 Prologue 與
Epilogue Code,那執行完 Prologue Code 時,Stack 的內容一定是

Stack : param n, param n-1, ... , param 1, return address, ebp <== [TOS]

所以 [esp - 8] 必然是函式第一參數值,這些應該要牢記。


這兩種都是很標準的參數傳遞的方式,它們都動用到 堆疊
可是存取堆疊的效率稍差,因為需要將資料傳輸至外部記憶體 (這樣就會有較長的延遲)
白話講就是插在主機板上那些 SDRAM,它們的優點就是容量大 (相對於 CPU上的 Cache)
其實傳輸也不能說慢,記憶體傳輸當然還是比硬碟快許多 (相對於硬碟容量是小多了)。


吾人使用的還是古代設備,nForce 4 晶片組,使用DDR400,這種已經絕跡
的硬體,上圖還是可以看到連 這種古老的 DDR400 模擬出的 Ramdisk,跑出
的讀取速度即便是現在 新款SSD 也還達不到這個水準,可見不能說慢。

那麼本文要談的第三種呼叫慣例 __fastcall 為
什麼會稱為 "快速呼叫" 呢?

那是因為將函式參數傳遞到外部記憶體存放,終究還是會因為線路較長,
具有較長時間的延遲,所以另外一種記憶體是存放在 CPU 內部的記憶體
稱為 Cache (快取),CPU內的暫存器本身都是快取組成,快取記憶體不同
於DRAM,這種特別的記憶體是直接由邏輯電路構成的靜態記憶體(SRAM),
擁有超短的延遲時間,沒有像DRAM有電容需要定期更新,沒有複雜的記憶體參數
SRAM 本質就是一堆 D型正反器(相當於 1-bit 記憶體) 把需要的記憶體容量堆起來,
D型正反器只有 上緣觸發 時,輸入端才能設定輸出端的值,否則輸出端會維持不變
但是快取記憶體的成本高,容量小,價格昂貴,一般只用在 CPU 內部,或是獨立的
32K SRAM 晶片。目前已知最快 SRAM 的 一個隨機週期 是 0.9ns

所以 __fastcall 的想法就是讓參數傳遞都在 CPU 內部
搬移,這樣就省掉了跟外部 DRAM 傳遞的延遲時間

讓我們看看 fastcall 怎麼傳遞,假如有一條 fastcall 呼叫慣例的 MyAdd 函式

int __fastcall MyAdd(int a, int b, int c); // 看看它被呼叫時的組語

被呼叫時使用的組合語言:


int c = MyAdd(1, 2, 3);
push  3     ; 參數一樣由右開始推入推疊
mov   edx,2 ; 不一樣的出現了,param 2 直接被傳入 edx
mov   ecx,1 ; 然後 param 1 直接被傳入 ecx
call  @MyAdd@12
mov   dword ptr [ebp-4],eax ; 返回值放在 eax 就不用在解釋了



其實 fastcall 基本上就是針對 stdcall 作改良,將 參數1 放在 ecx參數2 放在 edx
第三個參數開始一樣就是放在堆疊裡面,編譯後的符號名稱也類似 stdcall ,只不過
名稱前面多加了一個 @ 而已,至於函式本身譯成組合語言的樣子,你有真的了解
stdcall 慣例的話,其實應該就會知道原理是一樣的形式,只是堆疊從第三個參數開始
規則全部相同,所以就不仔細分析全部翻譯後的面貌,要關心的重點是執行完
Prologue Code 堆疊儲存的樣子,排列如下

Stack: param3, return address, ebp <== [TOS]=[ebp]

假如還有印象前面講過的分析,直接存取堆疊會用 ebp 加上偏移,所以 Prologue Code 裡面
會有一段 mov ebp, esp也就是說 fastcall 裡面的 [ebp - 8] 指的是 第三個參數值
參數1 在 ecx,參數2 在 edx,直接用 mov 指令從這兩個暫存器複製過來就好了
至於堆疊平衡的問題也很簡單,跟 stdcall 一樣,函式自己平衡

這樣的設計很實用,程式碼少的時候效益看不太出來,當程式碼龐大的時候
資料能夠盡可能的設計到只在 CPU 內部移動,那程式速度將會有明顯提升。

這邊要特別提醒
參數1 在 ecx,參數2 在 edx,這是 VC++ 編譯器的規格
假如你用 C++ Builder,這個牌子的 fastcall 會是
參數1 在 eax,參數2 在 edx,參數3 在 ecx,與微軟規定的不同


現在 stdcall cdecl fastcall 都解釋完畢了,stdcall 中還詳細解釋了
Prologue 與 Epilogue 的原理,然後示範了 naked 形式的應用


另外,假如沒有特別說明,你應該只能將這三個part的知識認為
只有在 32-bit 系統下才成立 (MSDN有關於不同系統下呼叫慣例的介紹),
怎麼說呢?64-bit 的 Windows 下所用的 fastcall 可就跟上面講的完全不同
這邊只稍微簡介一下 64-bit 下的 fastcall,首先你得要了解近年來新的
x86-64 架構而且M$ 為了簡化呼叫慣例,在 64-bit 乾脆通通改成用 fastcall
你就可以知道呼叫慣例是程式語言最重要的屬性,否則 M$ 不會先開這一刀,
統一呼叫慣例,程式語言間在互相引用函式符號才不會溝通不良,出現一堆
符號找不到的連結錯誤或參數傳遞規則不同引發錯誤,相信不少人都碰過。

64-bit 下的 fastcall 很好記,有一個口訣 CD89 與 MM03
甚麼意思?假如有一條 64-bit 的 MyAdd 是

int MyAdd(int a,int b,int c,int d);
參數 a 直接放在 rcx ; x86-64 下 cx 前面會加上 r
參數 b 直接放在 rdx ; x86-64 下 dx 前面會加上 r
參數 c 直接放在 r8  ; x86-64 新的通用暫存器 r8
參數 d 直接放在 r9  ; x86-64 新的通用暫存器 r9


float MyAdd(float a,float b,float c,float d);
參數 a 直接放在 xmm0 ; 這種巨大的暫存器一次可以塞入128-bit(16-byte)
參數 b 直接放在 xmm1 ; xmm 開頭的暫存器是給向量指令 SSE 指令用
參數 c 直接放在 xmm2 ;
參數 d 直接放在 xmm3 ; 



float MyAdd(int a,double b,int c,int d);
參數 a 直接放在 rcx ;
參數 b 直接放在 xmm1
參數 c 直接放在 r8  
參數 d 直接放在 r9  


那超過四個參數怎麼辦,一樣塞進堆疊裡面。
至於怎麼操作 SSE指令 當然不是一個小篇幅可以解釋所以不講,只是
初步介紹一下 64-bit 下的 fastcall 呼叫慣例長甚麼模樣。
從呼叫慣例的改進,可以看到程式語言本身連參數傳遞的方式也一直在進步
很明顯 M$ 也想讓參數傳遞更快,對付 浮點數 甚至連 xmm 暫存器都搬來用
直接存放浮點數,接收後直接操作 x87 或 SSE 作浮點運算,在 32-bit 系統下
比較沒有效率,double 與 float 還是會被翻譯為 對應的整數 push 到堆疊中
其中 double 還會變成兩個 4-byte 整數 被 push 兩次,關於 x86-64 參數傳遞就講到這裡。

另外 要注意 假如你寫程式 副檔名是 .cpp 的話
記得請加上 extern "C" 這個 keyword,才能測試這三個 part 講的內容,範例為
// Prefix 加上 extern "C" 表示當 C 函式處理
extern "C" int MyAdd(int a, int b){ ... } 

相信有仔細看懂這三個 Part,對於函式底層運作的原理會大有進步
尤其是一些有大量 組合語言 跟 C 語言混合的大型系統程式,
像 作業系統 與 模擬器,沒有這些具備函式傳遞原理的功底絕對不行。

最後總結一下,所謂呼叫慣例應該包含三件事情:


  • Parameter Passing              ; 參數怎麼傳遞
  • Return Values                  ; 回傳值怎麼放
  • Caller/Callee Saved Registers  ; 誰來平衡堆疊指標與保存暫存器 

  • 一種呼叫慣例就是由這三件事情組成,前面兩個 part 也講了不少。

    至於 thiscall 沒有講,因為這屬於 C++ 中函式呼叫的範疇
    這並不是 C 語言函式呼叫的範疇,有機會講到 C++ 函式呼叫慣例在來分析
    因為 C++ 函式呼叫慣例更複雜,幸運的是參數傳遞的規則一樣,即是

    C++ 函式 stdcall   的 參數傳遞規則 與 C 函式相同
    C++ 函式 cdecl     的 參數傳遞規則 與 C 函式相同
    C++ 函式 fastcall  的 參數傳遞規則 與 C 函式相同

    C++ 函式呼叫慣例複雜的地方在於它們的 符號命名規則,這是為了支援多型
    C++ 函式在命名時會多出 裝飾名稱 (Decoration name),有空另闢專欄講述。

    假如有空的話 ... zzz 

    沒有留言:

    張貼留言