2012年11月14日 星期三

C語言函式呼叫慣例 ( stdcall ) (完) Part1

除了組合語言之外,C 語言是當今最重要的系統級程式語言,學通 C 語言有助於快速切入所有程式技術,吾人觀點認為 C 語言不算高階語言,比較接近中階語言,是為了同時保有
組合語言低階特性卻又能帶有一定程度的移植性而被創造的語言。

呼叫慣例倒底是甚麼 ? 呼叫慣例就是指程式語言內的函式被編譯器編譯後,函式採用的
函式符號名稱函式參數傳遞的方式,這是程式語言最重要的屬性,不管
用甚麼硬體平台,先了解該平台上函式傳遞的方式才是學好程式語言的基本步驟。

呼叫慣例為何如此重要呢? 以下是一個經典的例子

Windows系統上驅動程式的進入點是   _DriverEntry@8
可是開發者在撰寫驅動程式的時候 此進入點函式的原型如下:

NTSTATUS DriverEntry(
  _In_  struct _DRIVER_OBJECT *DriverObject,
  _In_  PUNICODE_STRING RegistryPath
)

系統在裝載驅動程式的時候只看得懂 _DriverEntry@8 ,根本就看不懂 DriverEntry

所以,預設呼叫慣例 __cdecl 必須在編譯環境中更改呼叫慣例為 __stdcall
這樣編譯出來的函式符號名稱就會是 _DriverEntry@8,所以有時候函式符號
名稱又會被稱為函式的真名,後面會解釋 __stdcall 是怎麼命名與傳遞參數。

在x86硬體環境中,一共有五種呼叫慣例
  • __stdcall
  • __cdecl
  • __fastcall
  • thiscall
  • naked call
C 語言用最多的慣例就是 __stdcall 與 __cdecl 這兩類也是最重要,本篇只先
解說 __stdcall 傳遞方式,因為幾乎所有 Windows API 都是採用這種呼叫慣例。

__stdcall 可以說是最有名的傳遞方式,這是因為被 Windows API 採用,幾乎
全部系統 API 全部都是採用 __stdcall 呼叫慣例,所以我們常常會見到兩個頻繁
出現的詞彙,WINAPI 或 APIENTRY,它們通通都是 __stdcall 的化身而已,即是

typedef __stdcall WINAPI;
typedef WINAPI APIENTRY;

這是因為 Visual Studio 環境預設是採用 __cdecl,所以要特別指定呼叫慣例
沒有指定的話,編譯器會採用環境預設呼叫慣例來處理函式名稱的編碼。

__stdcall 呼叫慣例規則很簡單,假設你有一個函式如下

int __stdcall MyAdd(int, int); // 編譯後真名是 _MyAdd@8

那經過編譯後,MyAdd的真名就會變成 _MyAdd@8,@8 是因為 int 占用 4-byte
那參數有兩個 int,所以一共佔用8-byte。
所以剛剛前面 _DriverEntry@8 的範例就是這個原因,它的@8是因為參數兩個都是指標型,32-bit 環境指標就是一個 32-bit 整數位址,兩個指標參數一共佔用8-byte,所以編碼後會帶有@8,所以這邊就可以看到C語言有最簡單的參數傳遞特性,通通都是傳值就對了,連指標也不例外,只是傳進去的值是位址,這就是為什麼結構一般都是傳遞位址的原因,不管結構本身 size 在大,反正只有傳遞位址給函式。參數傳遞的部分,由右到左推入推疊,看下面範例 :

int c = MyAdd(1, 2);  // 看看這 Expression 變成組語的樣子

native assembly language : 假設 變數 c 使用 [ebp] 表達

push 2                   ; 立即數 2 推入堆疊
push 1                   ; 立即數 1 推入堆疊
                             ; Stack: [esp]   [esp-4]             [esp-8]<== top of stack
                             ;              2           1      返回的程式位址(下一行)
call   _MyAdd@8  ; 呼叫前會在推入下一行程式位址,然後呼叫 _MyAdd@8
mov [ebp], eax      ; 因為 __stdcall 回傳值會放在 eax,複製到[ebp]

從上面還可以發現,所謂的區域變數其實編譯器就是利用
[ebp+offset] 來表達,也就是說區域變數是 thread stack-based variable
這邊要稍微了解一下 x86 架構下 stack 怎麼運作,stack 在 x86 CPU 中使用 esp
暫存器表示 top of stack (TOS),所以當你使用組合語言指令 push 時,esp 會自動推進
然後在把數值推入 stack,數值可以是立即數(imm)來自暫存器(reg),要特別注意 x86 架構
很詭異,它的堆疊是倒頭而放,就是說 假如目前 TOS 是 esp,當你執行了 push 指令時
新的 TOS 會是 esp <== esp - 4,是遞減式的堆疊型式,這個跟 CPU 特性有關,因為
暫存器 ecx 常被用來記錄迴圈次數,某些指令會根據 ecx 的值重複執行,直到 ecx 為零
所以要連續操作 stack 用倒頭而放的方式反而簡單,至於 pop 就是動作顛倒而已,先取出
堆疊內容值,在修改 esp <== esp + 4,Offset 4 是因為 32-bit 架構一次操作 4-byte。

用虛擬操作可以總結為
push val  ; esp <== esp - 4 and then [esp] <== val
pop   val ;  [esp]<== val and then esp <== esp + 4

來看看 MyAdd 翻譯為組合語言變甚麼模樣
了解立即數 1 與 2 怎麼透過 參數 a,b 傳遞進去函式內
其實沒甚麼神奇的東西 函式被翻譯成組合語言時,主要就包含三個部分
用集合表達法可以寫為
A Function = {Prologue Code, Your Code, Epilogue Code}
Prologue Code 是函式的開場,是編譯器負責,
Epilogue Code 是函式的閉幕,是編譯器負責,這兩者基本上不用理它們。
要關心的重點是 Your Code,就是實際撰寫的C程式被翻譯為組語的地方。
這邊既然講解原理,了解 Prologue Code還是很重要,因為這牽涉到函式
區域堆疊初始化保存目前ebp,Epilogue Code就是利用保存的ebp作堆疊回復與平衡
讓我們一步一步看看下面程式。

這邊要先講一個觀念,通常操作 stack,不會直接操作 esp,而是把 esp 用 mov
指令複製給 ebp,才利用 ebp 操作堆疊,所以第一步一定是利用堆疊保存目前 ebp。

int __stdcall MyAdd(int a,int b)
01. push     ebp              ;很明顯,原因如上所述,目前 TOS (esp) 指向內容是 ebp
02. mov      ebp,esp          ;很明顯,原因如上所述,[ebp]=[esp]=ebp
03. sub      esp,40h          ;TOS偏移到 -40h 處 (為什麼,其後講)
04. push     ebx              ;儲存目前 ebx
05. push     esi              ;儲存目前 esi
06. push     edi              ;儲存目前 edi
07. lea      edi,[ebp-40h]    ;詭異,為什麼 edi 的值要是 ebp-40 (其後講)
08. mov      ecx,10h          ;顯然,等一下有迴圈指令要重複跑 16 次
09. mov      eax,0CCCCCCCCh   ;待會 eax 會被 stos 指令使用
10. rep stos dword ptr [edi]  ;[edi] <== eax and then edi <== edi+4 (stos 跑 16 次)
==========上半部為 Prologue============
  return a + b;  11,12行 就是我們的 Your Code 被翻譯為組語的形式
11. mov      eax,dword ptr [ebp+8]   ; [ebp+8]   代表參數 a
12. add      eax,dword ptr [ebp+0Ch] ; [epb+0ch] 代表參數 b
==========下半部為 Epilogue============
13. pop      edi     ;恢復原來的 edi
14. pop      esi     ;復原來的 esi
15. pop      ebx     ;復原來的 ebx,所以TOS又回到 -40 偏移處
16. mov      esp,ebp ;把現在的 ebp 複製給 esp
17. pop      ebp     ;回復原本ebp,然後 esp <== esp+4
18. ret      8       ;因為上一行的 pop 指令,使得目前 esp 內容剛剛好指向
                     ;返回位址,當 ret 執行時,TOS內容彈出,取得返回位址,而且
                     ;像 pop 一樣,會自動作 esp <== esp+4,然後 8 代表要平衡
                     ;的堆疊指標是 8,即是 esp <== esp+8,使得進入此函式的 esp 值
                     ;與退出函式時的 esp 值會完全一樣,表示平衡
來討論一下剛剛還沒解釋的部分,就是
03行所講 TOS 偏移到 -40h 處 與 07、08、09、10行的程式碼,這個排列很巧妙,
事實上只要把Prologue跑完後的堆疊內容列出來即可理解,執行完Prologue後
堆疊內容如下:

0012FE80  ????????      0012FF30=edi
0012FE88  00000001=esi  7FFD4000=ebx <= 保存原本暫存器值用的堆疊區塊
====================================
0012FE90  CCCCCCCC      CCCCCCCC  
0012FE98  CCCCCCCC      CCCCCCCC  
0012FEA0  CCCCCCCC      CCCCCCCC  
0012FEA8  CCCCCCCC      CCCCCCCC  
0012FEB0  CCCCCCCC      CCCCCCCC  
0012FEB8  CCCCCCCC      CCCCCCCC  
0012FEC0  CCCCCCCC      CCCCCCCC  
0012FEC8  CCCCCCCC      CCCCCCCC
====================================  
0012FED0  0012FF30      00401071=returning address  
0012FED8  00000001      00000002

因為頭位址都是 0012FE??,看末兩位即可。
發現了沒有,剛剛 TOS 至偏移 -40h 處 (程式碼03行),會使得 D0h-40h=90h
所以當 push指令執行的時候(程式碼04行),esp 會先減 4 那就是Offset Address = 8C
然後儲存目前 ebx esi edi,使用的 TOS 為 8C,程式碼02行預留了伏筆,使得 esp=ebp
因為剛剛 esp 已經被用過了,所以TOS值已經改變了,可是我們需要沒有改變過的值,
ebp裡面剛好也有一份,再拿來用一次,ebp-40h 剛剛好在次得到 偏移值 90h
並且將值複製到 edi,從這個分界開始,利用指令 stos 執行 16 次,往下填入 eax
至 偏移值 CC ,這樣就把整個函式的區域堆疊初始化,很巧妙在地方在於
CC 的下一個位址 D0 不就是剛剛函式參數區使用的堆疊區塊的 TOS
也就是說 mov ebp, esp 乍看之下只有一條指令,其實隱藏兩種用途
也就是說 其實這些Prologue程式碼只是在規劃堆疊區塊的使用與劃分,而且
這樣跑完後,剛剛好很巧妙的被切成三塊,還剛剛好都連續的銜接在一起,只是
在程式上不是很值觀就是了,經過分析後其實沒甚麼神奇的地方。 

所以可以總結為 Prologue 程式碼在抽象概念上會將 stack記憶體區塊 規劃為

低位址
      函式保存暫存器 堆疊區
     =====================
      函式區域變數   堆疊區
     =====================
      函式傳入參數   堆疊區
高位址

這也就是為什麼在除錯時,常常會看到未初始化函式區域變數都是
0xCCCCCCCC,就是這個道理,應該是說 被初始化為 0xCCCCCCCC

有了前面那些外星知識後,就可以知道甚麼是 naked call
簡單的說,就是函式沒有編譯器預設塞入的 Prologue 與 Epilogue 程式碼
甚麼意思? 就是說 當你有特殊目的自行創造函式參數傳遞的方法時,你得
手動處理你自己設計的 Prologue 與 Epilogue,通常都是為了呼叫效率

馬上看一個例子就懂,像上面編譯器其實產生不少 垃圾碼,
假如我們自己用 naked call 寫可以精簡不少。

__declspec(naked) int __stdcall MyAdd(int a,int b)
{

  // 我們現在已經知道函式進後目前 TOS 指向 返回位址(retadr)
  __asm{ ;自己處理 Prologue
  // Current Stack: b a retadr<==[TOS]
  }
  //=============================================
  __asm{ // 避開編譯器,直接 pop 出 a 與 b 參數自己處理
     pop ecx     ; get return address and save to ecx
     pop eax     ; eax = a;
     pop edx     ; edx = b;
     add eax, edx; 因為在 C語言中 接收返回值使用 eax
     push ecx    ; restore return address
  }
  //=============================================
  __asm{ ; 自己處理 Epilogue
     ret ; 當 ret 作 pop 時取得返回位址然後 esp<==esp+4,剛好平衡,
         ; 就不用在處理堆疊平衡數值了,可以想像成 ret 0
  }

}

裸函式還是得要搭配呼叫慣例使用,像上面使用 stdcall 慣例的裸函式
caller 還是會照例依 stdcall 慣例,參數由右至左推入推疊
另外要注意裸函式內,要自己注意平衡堆疊指標與返回。

可以看到,在沒有違反 stdcall 慣例下,整個程式碼簡化不少,
竟然連prologue都不見了,我自己用另外一種型式從堆疊接收參數,
證明了同樣 stdcall 慣例,Prologue 與  Epilogue 並不唯一,不過
因為我直接把 a 與 b 利用 pop 保存到 eax 與 edx 中處理,將帶有返回位址
的 ecx 推入推疊,這樣堆疊內就只有返回位址,所以 ret 即可,堆疊依然平衡
所以 naked 讓我們可以在 現有的慣例上,去創造自己的參數傳遞方式,
精簡程式碼,避開編譯器產生那些樣板式的程式碼。

要自己處理"裸露"出來的 Prologue 與 Epilogue 這兩個部分,
需要對這些平常程式開發者不會觸碰的部分有所了解,才能自行操控函式參數
傳入的方法,這很重要,寫系統程式常常就需要系統創造者自己規定函式的傳遞規則。

呼~~~ 終於講完 stdcall 傳遞方式了。

許多背景知識都已經在講解 stdcall 時也順便講解,後面 cdecl 就會直接針對
這種呼叫的參數傳遞解說,不再逐一地細部分析了。

題外話: 感謝  真是罩  神秘學弟提供寫作平台

3 則留言:

  1. 幫你剪一段當頭 避免網頁瀏覽時太長

    回覆刪除
  2. ㄎㄎ 偷偷發文被發現 不過 _MyAdd@8 內部原理還沒講完
    作為 callee (被呼叫者) 是怎麼在從堆疊拿取參數
    還得 繼續論說 才行阿~~~

    以後會偷發幾篇 指標 跟 QT 的文章~~~

    回覆刪除