2012年11月17日 星期六

C語言函式呼叫慣例 ( cdecl ) (完) Part2

從上一篇可知道,stdcall 為 Win32 API 標準呼叫慣例,而且它的堆疊平衡
是由 callee (被呼叫者) 自己負責,而不是 caller (呼叫者),參數推入堆疊的次序是
右至左,很顯而易見的是 stdcall 會產生體積較小的程式碼,
因為你的主程式不用自己平衡堆疊

來看看另外一種重要的呼叫慣例 __cdecl

要說 stdcall 與 cdecl 哪一個重要,其實都很重要,沒有先後,
像 Linux系統下就通通全部都是 cdecl,這種特性本身也體現了 Linux
追求作為一個 "很純 C" 極致的作業系統,假如用 Linux 的話,基本上不太用
煩惱呼叫慣例的問題。
cdecl 呼叫慣例也存在很久了,正如其名,"C 呼叫慣例"
它的參數傳遞規則也是很簡單,與 stdcall 相同,由右至左,它的函式
編譯後的真名也很簡單,看一下例子就懂

        假如有一條函式  int __cdecl MyAdd(int, int)  則
        真名為 _MyAdd,沒錯,就是原來名稱前面加底線。
        __cdecl 這個 keyword 可以省略,因為一般 C語言環境
        通通預設都是這種呼叫慣例,除非修改環境設定。

可是 cdecl 與 stdcall 除了真名不同外,有一點也不同,這是最重要的特性,
這種呼叫慣例的堆疊平衡是由 caller 處理,callee 是不處理堆疊平衡的問題。

看程式碼就懂

假如呼叫了 使用 cdecl 慣例的 MyAdd,產生的組合語言會變成


push    2      ;推入立即數 2
push    1      ;推入立即數 1
call    _MyAdd ;推入返回位址,在呼叫 _MyAdd
add     esp,8  ;由caller自己平衡堆疊頂指標 esp
mov     dword ptr [ebp-4],eax ; eax 在傳給區域變數

可以看到 因為有兩個參數,所以 caller 平衡時要將 esp 在加 8。

在來看看 參數傳入 MyAdd 後,MyAdd 怎麼接收它們

int MyAdd(int a,int b)
   ... 省略 Prologue 跟 stdcall 一模一樣
=========================================
return a + b;
   mov eax,dword ptr [ebp+8]
   add eax,dword ptr [ebp+0Ch]
=========================================
   ... 省略部分 Epilogue 跟 stdcall 一模一樣   
   ret ; 沒有特別指定堆疊平衡數值,
       ; 所以最後只從堆疊彈出返回位址,然後 esp<==esp+4
       ; 根據返回位址跳躍。


因為堆疊平衡由caller 負責,所以就可以實現不定參數個數的函式,例如 printf,
這類函式只能使用 cdecl。

簡單的說,就是把 ret n 拆開成兩個部分

 ret n      ; 本來在 callee 內

 變成

 ret        ; 現在只有 ret 在 callee 內
 add esp, n ; 將 esp 加 n 的動作由 caller 負責

在知道原理之後,也許會有個疑問,那要怎麼自己寫這類不定參數個數的函式呢
其實解了上面的原理就很簡單了,顯然要先取得 不定參數個數 倒底有多少個參數
其次就是每個參數實際存放在 堆疊裡面的位址。

因為我們知道不管是 stdcall 或 cdecl,剛進去函式內部時,當執行完Prologue時,
[esp] 必然指向 ebp 而前一個堆疊元素就是 返回位址 [esp+4] 而
所以 [esp + 8] 就是 函式的第一個參數值,用內插組語可以很輕易取得
但是在 C 語言中,這類函式最好至少要給出第一個參數,
後面使用不定參數會比較方便,例如

int MyAdd(int first, ... ); // 至少要給出第一個參數

這是為了 C 語言實作不定參數上能夠較容易實現,因為只要有第一項參數
就可以用 C 語言的 "address of" operator 取得第一項參數的位址,而 VC 中
也使用了 Macro 方便取得每一個參數值,否則得要自己操作 [ebp+n]取得不定參數。

一個可變參數的加法函式

int MyAdd(int first, ...)
{
   int sum = 0, i = first;
   va_list marker;
   
   va_start( marker, first ); /*Initialize variable arguments.*/
   while( i != -1 )
   {
     sum += i;
i = va_arg( marker, int);
   }
   va_end(marker); /*Reset variable arguments.*/
   return sum;
}


假如是 int MyAdd( ... )這種形式,那你得要自己處理頭位址問題

int MyAdd3( ... )
{
   int *first;
   int sum = 0, i;
   va_list marker;
   
   __asm{
push eax        ; save eax
// mov  eax,   ebp ; 因為參數頭位址必然是 ebp+8
// add  eax,   8   ; eax = ebp + 8
   // 取得參數頭位址,在加上偏移值,可以用 lea 指令合併
   lea  eax, [ebp+8]; lea 只取 ebp+8 而不會取 [ebp+8]
mov  first, eax ; first = eax
pop  eax        ; restore eax
   }   

   va_start( marker, *first ); /*Initialize variable arguments.*/
   i = *first; // 我們研究一下 指標變數的原始面貌

   /* mov         eax,dword ptr [ebp-4]; 其實 first:=[ebp-4] 
      mov         ecx,dword ptr [eax] ; 內容本身是位址,[eax]才是內容
      mov         dword ptr [ebp-0Ch],ecx; i:=[ebp-0ch]
   */
   while( i != -1 )
   {
sum += i;
i = va_arg( marker, int);
   }
   va_end(marker); /*Reset variable arguments.*/
   return sum;
}
指標變數用組合語言觀點看,其實就是雙指標了,32-bit 整數位址
作為 stack [ebp-4] 的內容,取出內容後在指一次,取得實際內容。
在內插組合語言內先取得第一個參數位址放入 first,在利用C語言的指標運算
*first 取得內容,才把第一個數值傳遞給 變數 i。

讓我們看看這些 va_ 開頭的 Macro

#define _INTSIZEOF(n)   ( (sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1) )

#define va_start(ap,v)       ( ap = (va_list)&v + _INTSIZEOF(v) )
#define va_arg(ap,t)          ( *(t *)( (ap +=_INTSIZEOF(t)) - _INTSIZEOF(t)) )
#define va_end(ap)           ( ap = (va_list)0 )

很明顯,va_start 的目的就是要取得參數陣列的頭位址,為了要保證指標指向的大小為
byte,因此進行了強迫型態轉換,其中 va_list 只是 char* 的化身,加 _INTSIZEOF(v),
就可以指向變參數陣列的頭位址,_INTSIZEOF 巨集很有趣,這種運算會剛剛好使得
記憶體對齊指定型態的邊界,這裡要對齊的邊界型態是 int,為何需要如此?假如你有
看懂翻譯成組合語言的表達,應該也發現了 push 在 32-bit 系統是一次 4-byte在推入,所以
一定要保證剛剛好一次存取一整個堆疊元素 4-byte 大小,因為有些型態翻譯為組合語言
是會變成兩次 push 來表達,假如第一個參數型態變成 double 那組合語言會變成 push 兩次
4-byte 的整數值,邊界沒對齊的話,整個堆疊區就崩潰了。

至於 va_arg(apt, t) 就沒甚麼神奇了,你在組合語言內可以用 [ebp+n] 不斷偏移取得所有參數值
在 C 語言也是用 += 運算子 不斷偏移資料,Macro 參數 t 是指 type,指標偏移要根據資料
型態的實際大小來移動,在這裡是 int,最後 va_end 只是用完後,指標歸零而已。

那麼在不定參數中,假如並非所以型態都是 int,你也許可能會有一些型態要視為 double
這樣的話,得自己在函式中,利用指標重新解讀資料的意義。

例如 你可能已經 取得所有變參數陣列的資料,並且儲存在一個 Array 中,類似於

int Buf[] = {0x00000000,0x3ff00000};

這兩個 int 可能在你的編碼中,真正的意義是 double,因此
你需要用指標重新解讀資料的意義。

double*pf64Buf = (double*)(Buf);

當你用 printf 印出*pf64Buf的內容,就會發現原來這就是浮點數1.0



後記:怎麼講個 cdecl 會不小心講到一些指標的知識,因為它們是一連貫的知識
只有當你這些東西完全了解,你才會知道甚麼叫 cdecl,尤其是變參數的特性。
fastcall 還沒開講 不過其實它不是很重要,只是推入的參數有幾個放在暫存器
用來加快參數傳遞速度而已,Part 3在來講。

還有,Du 快把QT文教學PO上來 \/. (我要一次上手文)


3 則留言:

  1. 你們好酷喔!可以這樣三個人共筆。^^

    回覆刪除
  2. 哈哈 謝謝你 不過我不太會建構網頁
    所以就從最簡單的開始了

    回覆刪除
  3. while( i != -1 )
    {
    sum += i;
    i = va_arg( marker, int);
    }
    請問這里while 迴圈跳脫的條件為什麼是 i == -1 呢?

    回覆刪除