組合語言低階特性卻又能帶有一定程度的移植性而被創造的語言。
那呼叫慣例倒底是甚麼 ? 呼叫慣例就是指程式語言內的函式被編譯器編譯後,函式採用的
函式符號名稱與函式參數傳遞的方式,這是程式語言最重要的屬性,不管
用甚麼硬體平台,先了解該平台上函式傳遞的方式才是學好程式語言的基本步驟。
呼叫慣例為何如此重要呢? 以下是一個經典的例子
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
解說 __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 這兩個部分,
需要對這些平常程式開發者不會觸碰的部分有所了解,才能自行操控函式參數
傳入的方法,這很重要,寫系統程式常常就需要系統創造者自己規定函式的傳遞規則。
{
// 我們現在已經知道函式進後目前 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 就會直接針對
這種呼叫的參數傳遞解說,不再逐一地細部分析了。
幫你剪一段當頭 避免網頁瀏覽時太長
回覆刪除ㄎㄎ 偷偷發文被發現 不過 _MyAdd@8 內部原理還沒講完
回覆刪除作為 callee (被呼叫者) 是怎麼在從堆疊拿取參數
還得 繼續論說 才行阿~~~
以後會偷發幾篇 指標 跟 QT 的文章~~~
學習了, 內容很棒
回覆刪除