程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> VC >> 關於VC++ >> ATL布幔之下的秘密(4)

ATL布幔之下的秘密(4)

編輯:關於VC++

介紹

到現在為止,我們還沒有討論過任何有關匯編語言的東西。但是 如果我們真的要了解ATL底層內幕的話,就不能回避這一話題,因為ATL使用了一 些底層的技術以及一些內聯匯編語言來使它更小巧快速。在這裡,我假設讀者已 經擁有了匯編語言的基礎知識,所以我只會集中於我的主題,而不會再另外寫一 份匯編語言的教程。如果你尚未足夠了解匯編語言,那麼我建議你看一看Matt Pietrek於1998年2月發表在Microsoft System Journal的文章《Under The Hood 》,這篇文章會給予你關於匯編語言足夠的信息的。

現在就要開始我們 的旅行了,那麼先以這個簡單的程序作為熱身吧:

程序 55.

void fun(int, int) {
}
int main() {
 fun (5, 10);
 return 0;
}

現在在命令行模式下,使用命令行 編譯器cl.exe來編譯它。在編譯的時候,使用-FAs開關,例如,如果程序的名字 是prog55的話:

Cl -FAs prog55.cpp

這就會生成一個帶有相同文 件名,擴展名為.asm的文件,這個文件中包含有以下程序的匯編語言代碼。現在 看看生成的輸出文件,讓我們首先來討論函數的調用吧。調用函數的匯編代碼是 類似這個樣子:

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

在我們進行詳細地講解之前,請看以下函數的最後一條語句,也就是ret 0。為 什麼是0?或者可以是別的非0數嗎?正如我們所見,我們向函數傳遞的所有參數 事實上都被壓入了堆棧。在你或者編譯器向堆棧中壓入數據的時候,會對寄存器 有什麼影響嗎?請看以下這個簡單的程序來觀察這一行為吧。我使用了printf而 不是cout,這是為了避免cout的開銷。

程序59.

#include <cstdio>
int g_iTemp;
int main() {
 fun(5, 10); // 譯注:這裡的fun,應該是上文中的void fun(int, int)
 _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在本文中都被作者籠統地稱為“Stack Pointer”,事實上ESP應稱作“堆棧指針[Stack Pointer]寄存器 ”,它指示堆棧的棧頂便宜地址;EBP應稱作“基址指針[Base Pointer]寄存器”,它用來作為基地址並和偏移量組合使用來訪問堆棧中 的信息。)

這樣,編譯器首先保存前一個堆棧幀的地址,然後使用ESP的 值創建新的堆棧幀。函數返回之前,先前的堆棧幀就恢復了。

現在來看 看堆棧幀中都有什麼。在EBP的高地址一邊存放所有參數,EBP的低地址一邊則存 放所有的局部變量。

函數的返回地址保存在EBP中,前一個堆棧幀的地址 保存在EBP + 4。現在看看下面的例子,它擁有兩個參數和三個局部變量。

程序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和EBP + 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。我們知道參數的地址 ,並且在程序中我們改變了參數的值。因而,在我們將兩個變量相加時,新的變 量值15和14就被加起來了。

VC中函數擁有naked屬性。如果你將任何函數 指定為naked,那麼它就不會為該函數產生prolog代碼和epilog代碼。那麼什麼 是prolog代碼和epilog代碼呢?prolog是一個英文詞匯,意思是“Opening (開始的)”,當然它也是一種用於AI的程序設計語言的名稱 ——但是在這裡,這門語言和編譯器產生的prolog代碼沒有任何關系 。prolog代碼是編譯器自動產生的,它會被插入到函數的開始處來設置堆棧幀。 你可以看看程序61產生的匯編語言代碼,在函數的開頭處,編譯器會自動插入以 下的代碼來設置堆棧幀。

 

push ebp
mov  ebp, esp
sub  esp, 12 ; 0000000cH

這段代碼就稱作prolog代碼。同樣,插 入在函數末尾的代碼就稱作epilog代碼。在程序61中,編譯器生成的epilog代碼 為:

mov  esp, ebp
pop  ebp
ret  0

現在來看看 帶有naked屬性的函數。

程序64.

extern "C" void _declspec(naked) fun() {
 _asm ret
}
int main() {
 fun();
 return 0;
}

編譯器生成的fun函數代碼是類似 於這個樣子:

_asm ret

這就意味著在這個函數中沒有prolog代碼 和epilog代碼。事實上,naked函數有一些規則,也就是你不能在naked函數中定 義自動變量。因為如果你這麼做的話,編譯器就需要為你產生代碼,而naked函 數中編譯器是不會產生任何代碼的。其實,你還需要自己編寫ret語句,否則程 序就會崩潰。你甚至不能在naked函數中編寫return語句。為什麼呢?因為當你 從函數中返回一些東西的時候,編譯器就會把它的值放在eax寄存器之中。所以 這就意味著編譯器會為你的return語句產生代碼。讓我們通過下面的簡單程序來 弄懂函數返回值的工作過程吧。

程序64.

#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的值復制 了一份。

現在來編寫我們的naked函數,這個函數沒有prolog代碼和 epilog代碼,它返回了兩個變量的和。

程序65.

#include <cstdio>
extern "C" int _declspec(naked) sum(int a, int b) {
 // prolog代碼
 _asm push ebp
 _asm mov ebp, esp
 // 用於相加變量和返回的代碼
 _asm mov eax, dword ptr [ebp + 8]
 _asm add eax, dword ptr [ebp + 12]

 // epilog代碼
 _asm pop ebp
 _asm ret
}
int main() {
 int iRetVal;
 sum(3, 7);
 _asm mov iRetVal, eax
 printf("%d\n", iRetVal);
 return 0;
}

程序的輸出為10,也就是兩個參數3和7的和。

這一屬 性被用於ATLBASE.H中來實現_QIThunk結構的成員。這個結構被用於在定義了 _ATL_DEBUG_INTERFACES的情況下調試ATL程序的引用計數。

我希望在下 一篇文章中能夠探究一些ATL的其它秘密。

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved