前言:這一系列博客翻譯自 The Code Project 上的文章,作者是Zeeshan amjad。
題目:ATL Under the Hood - Part 4
原文鏈接:http://www.codeproject.com/Articles/2387/ATL-Under-the-Hood-Part-4
介紹
目前爲止我們還沒有討論彙編語言。但如果想知道ATL的內幕,這是不可避免的。因爲ATL用了一些底層的技術和一些內聯的彙編語言來提速和減少代碼量。我假設讀者已經具備了基本的彙編語言基礎以便將重點集中在我的主題上,而不是去講解彙編語言本身。如果你不瞭解彙編,強烈建議你看看Matt Pietrek在1998年2月的微軟系統日誌的文章 ”Under The Hood”,其中講解了大量的彙編語言。
首先看下面這個簡單的程序:
程序 55
void fun(int, int) { } int main() { fun(5, 10); return 0; }
現在,我們用命令行編譯器cl.exe編譯該程序,編譯時添加-FAs選項。比如,如果這個程序的名字是prog55,按照下面的方式編譯:
Cl -FAs prog55.cpp
這條語句將產生於源文件同名的.asm文件,裏面包含了如下的彙編代碼。我們首先看看函數調用。調用這個fun函數的彙編代碼如下:
push 10 ; 0000000aH push 5 call ?fun@@YAXHH@Z ; fun
首先將函數參數自右向左壓入棧,然後再調用函數。但是函數名不一樣了,這是因爲c++編譯器會修飾函數以便執行函數重載。下面是個函數重載的例子:
程序 56
void fun(int, int) { } void fun(int, int, int) { } int main() { fun(5, 10); fun(5, 10, 15); return 0; } 這種情況下,在調用兩個函數時,其彙編代碼分別如下: push 10 ; 0000000aH push 5 call ?fun@@YAXHH@Z ; fun push 15 ; 0000000fH push 10 ; 0000000aH push 5 call ?fun@@YAXHHH@Z ; fun
看看函數名,我們用了相同的名字,但編譯器會自行修飾以便進行重載。
如果不想讓編譯器修飾你的函數名,在定義函數時,添加 extern“C”標識。看看下面的例子:
程序 57
extern "C" void fun(int, int) { } int main() { fun(5, 10); return 0; }
彙編代碼如下:
push 10 ; 0000000aH push 5 call _fun
這意味着你將利用C鏈接,不可以重載你的函數了。看看下面的例子:
程序58
extern "C" void fun(int, int) { } extern "C" void fun(int, int, int) { } int main() { fun(5, 10); return 0; }
上述代碼將報出編譯錯誤,因爲C語言不支持函數重載。你用了相同的函數名且不讓編譯器修飾,將導致使用C鏈接而非C++鏈接。
現在看看編譯器對空函數產生了什麼代碼。下面是編譯器產生的代碼:
push ebp mov ebp, esp pop ebp ret 0
當你或者編譯器向棧中推入值時,對寄存器有什麼影響呢?看看下面簡單的例子。
程序 59
#include <cstdio> int g_iTemp; int main() { fun(5, 10); _asm mov g_iTemp, esp printf("Before push %d\n", g_iTemp); _asm push eax _asm mov g_iTemp, esp printf("After push %d\n", g_iTemp); _asm pop eax return 0; }
程序輸出結果爲:
Before push 1244980 After push 1244976
這個程序顯示了在推入棧前後,寄存器ESP的值的變化。結果顯示,當你向棧中推入值時,ESP的值向下增長。
問題是,當我們傳遞給函數參數後,誰來恢復棧指針呢?是函數本身還是其調用者呢?實際上兩者皆有可能。這也是標準調用和C調用的區別所在。看看如下語句,注意函數調用後面的那條:
push 10 ; 0000000aH push 5 call _fun add esp, 8
我們有兩個參數傳遞給函數,所以在推入堆棧後,棧指針就會減8,在這個程序中,調用者負責設置棧指針。這就叫做C調用。在這種調用規則下,無可辯駁,你可以傳遞變量,因爲調用者知道你傳遞了幾個參數,所以在調用結束後知道如何設置棧指針。
然而,如果選擇了標準調用,那被調用者將有責任清除棧(也就是恢復棧指針)。在這種情況下,你當然不可以傳遞任何參數,因爲沒辦法知道有幾個參數傳給了函數,所以調用函數時,需要適當的設置棧指針。
通過如下例子看看標準調用的行爲:
程序 60
extern "C" void _stdcall fun(int, int) { } int main() { fun(5, 10); return 0; }
看看函數調用:
push 10 ; 0000000aH push 5 call _fun@8
函數名後的@顯示,這是標準調用,8顯示了推入棧的字節數,我們可以通過這個數除4得到傳給函數的參數個數。
空函數彙編代碼如下:
push ebp mov ebp, esp pop ebp ret 8
這個函數同個“ret 8”這條語句,在退出之前,自己恢復了棧指針。
現在我們來研究一下編譯器爲我們產生的代碼。在標準調用中,編譯器插入這些代碼來構建棧框架,以便我們能獲取參數以及函數的局部變量。棧框架是專爲函數保留的一片內存區域,用來存儲函數參數,局部變量以及返回地址等。棧框架經常在調用新函數的時候創建,函數返回時銷燬。在8086體系結構中,EBP寄存器用來存儲棧結構的地址,有時又叫棧指針。
所以,編譯器首先保存前一個函數棧構架的地址,再利用當前的ESP值創建一個新的棧構架。在函數調用返回之前,先前的棧構架將處於受保護狀態。
現在看看什麼事棧構架。棧構架在EBP的+ve一側存儲了所有的參數,在-ve的一側存儲了所有的局部變量。
所以,函數的返回地址保存在EBP中,前一個棧結構保存在EBP+4中。看看下面函數調用,其中有2個參數,3個局部變量:
程序 61
extern "C" void fun(int a, int b) { int x = a; int y = b; int z = x + y; return; } int main() { fun(5, 10); return 0; }
看看編譯器產生的代碼:
push ebp mov ebp, esp sub esp, 12 ; 0000000cH ; int x = a; mov eax, DWORD PTR _a$[ebp] mov DWORD PTR _x$[ebp], eax ; int y = b; mov ecx, DWORD PTR _b$[ebp] mov DWORD PTR _y$[ebp], ecx ; int z = x + y; mov edx, DWORD PTR _x$[ebp] add edx, DWORD PTR _y$[ebp] mov DWORD PTR _z$[ebp], edx mov esp, ebp pop ebp ret 0
_x,_y是在函數定義之上定義的:
_a$ = 8 _b$ = 12 _x$ = -4 _y$ = -8 _z$ = -12
意味着你可以這樣讀取這些值:
; int x = a; mov eax, DWORD PTR [ebp + 8] mov DWORD PTR [ebp - 4], eax ; int y = b; mov ecx, DWORD PTR [ebp + 12] mov DWORD PTR [ebp - 8], ecx ; int z = x + y; mov edx, DWORD PTR [ebp - 4] add edx, DWORD PTR [ebp - 8] mov DWORD PTR [ebp - 12], edx
這就意味着,函數參數a和b分別爲EBP + 8
andEBP + 12
. 而x,y,z的值分別保存在EBP - 4
,EBP - 8
,
EBP – 12
。
有了這些知識,我們看看如下例子:
程序 62
#include <cstdio> extern "C" int fun(int a, int b) { return a + b; } int main() { printf("%d\n", fun(4, 5)); return 0; }
這個程序結果可以預測爲輸出“9”,下面我們稍作修改:
程序 63
#include <cstdio> extern "C" int fun(int a, int b) { _asm mov dword ptr[ebp+12], 15 _asm mov dword ptr[ebp+8], 14 return a + b; } int main() { printf("%d\n", fun(4, 5)); return 0; }
這個程序輸出結果爲“29”。我們知道函數參數的地址,所以我們通過其地址修改了參數的值。所以,我們將a與b相加時,將會是15與14相加。
Vc對於函數調用設置了裸屬性。如果設這了裸屬性,它將不產生任何prolog和 epilog代碼。那什麼是prolog和 epilog代碼呢?prolog代碼意思是“opening”,這在AI中也是編程語言的名字。但這裏的名字與那個沒有任何關聯,prolog代碼由編譯器產生。這些代碼是編譯器自動插入到函數調用最開始的用來設置棧構架的。看看陳程序61中產生的彙編代碼。在函數最開始,編譯器自動插入瞭如下代碼來設置棧構架:
push ebp mov ebp, esp sub esp, 12 ; 0000000cH
這就叫做prolog代碼。同樣的方式,在函數調用結束前插入的代碼,叫做Epilog代碼。上述同樣程序中的Epilog代碼如下:
mov esp, ebp pop ebp ret 0
作爲對比,我們看看裸屬性的函數調用示例: attribute
程序 64
extern "C" void _declspec(naked) fun() { _asm ret } int main() { fun(); return 0; }
編譯器產生的函數相關代碼如下:
_asm ret
也就是在該函數中沒有prolog和epilog代碼產生。實際上對於裸屬性函數,有一些規則,比如不可以在裸函數內部申明自動變量,因爲局部變量要求編譯器產生特定代碼,而裸函數卻又要求編譯器不產生任何代碼。實際上,你必須寫返回值,以便函數不會崩潰。你甚至不能在裸函數中寫返回語句,因爲當你想返回某些值時,編譯器將其放入寄存器eax中,所以意味着編譯器必須爲你的返回語句產生代碼。我們看看下面的例子。
程序 65
#include <cstdio> extern "C" int sum(int a, int b) { return a + b; } int main() { int iRetVal; sum(3, 7); _asm mov iRetVal, eax printf("%d\n", iRetVal); return 0; }
輸出結果是10,我們沒有直接用函數的返回值,而是在調用函數後複製了eax寄存器內部的值。
現在,我們在裸函數內部自己添加prolog和epilog,用來返回兩個參數的和:
Program 66
#include <cstdio> extern "C" int _declspec(naked) sum(int a, int b) { // prolog code _asm push ebp _asm mov ebp, esp // code for add two variables and return _asm mov eax, dword ptr [ebp + 8] _asm add eax, dword ptr [ebp + 12] // epilog code _asm pop ebp _asm ret } int main() { int iRetVal; sum(3, 7); _asm mov iRetVal, eax printf("%d\n", iRetVal); return 0; }
程序輸出結果爲“10”。
這個屬性在ATLBASE.H文件中用來實現_QIThunk結構的成員。在_ATL_DEBUG_INTERFACES
宏定義的前提下,這個結構用來調試
ATL
函數的引用計數。