C調用匯編

 1.80X86 32位彙編基礎以及寄存器設定

 2.棧幀與C函數調用

 3.函數調用的彙編級解釋以及棧圖

 4.stdcall和cdcel

------------------------------------------------------

1. 80X86 32位CPU的編程模型(programming model)

80X86有16個通用寄存器register。從某種程度上來說介紹80X86的CPU編程模型,就是介紹這16個寄存器。另外,介紹的是32位彙編,請把80X86的16位彙編先忘記掉,這麼短的文章不可能介紹完彙編,而是給出彙編最基本的東西和最簡單的抽象。

eax ebx ecx edx

這4個寄存器是通用寄存器。用來暫存數據的地方。

esi edi(extension source index, extension destination index.)

它們也可以用來暫存數據。更一般的是伴隨串指令使用。

esp ebp(extension stack pointer, extension base pointer)

棧指針寄存器和基址指針寄存器。關於棧和過程調用,最重要的寄存器就是這兩個了!

eip(extension instruction pointer, or program count)

指令指針寄存器!這就是“順序存儲控制”的核心!又稱程序計數器!

eflag

 標誌寄存器。算術、邏輯及相關指令運算會影響該標誌寄存器中的位。這個寄存器很重要也很麻煩。

以上!就是32位彙編(又稱平坦地址模式彙編)會使用到的所有寄存器,一共十個,都是32位的。啊不是說十六個寄存器嗎?

對,還有6個寄存器,分別名爲:

 cs ;代碼段寄存器code segment

 ds ;數據段寄存器data segment

 ss ;棧寄存器stack segment

 es fs gs; 附加段寄存器

這6個寄存器都是16位寄存器。即使是現今的80686 32位系統中,它們仍然是16位的。這些段寄存器在8086中用來對內存地址進行段指定。有8086 16位彙編知識的同學都知道怎麼回事,...還是解釋一下吧,8086是16位CPU,而地址線是20位。20根地址線表明能尋址的空間是2^20也就是1M(1024 * 1024).16位不夠表達1M的地址空間,因此由“段*16+偏移”得到內存地址值。

但是在32位系統中,這些段寄存器已經不怎麼使用了。總之32位彙編不需要關注這些寄存器,因爲32位系統CPU和各寄存器是32位,地址線也是32位,一個32位值足夠表達32位尋址空間。...實際上這些段寄存器在32位系統中是同一個值,用來指向某個索引表,但這是本文不需要在此關注的東西。

以上,16個寄存器介紹完畢!接下來介紹簡潔的編程模型抽象!

由於是簡單而本質的抽象,因此我們不考慮分頁機制、MMU(memory management unit)之類的。正是如此,它們本來對於我們就是透明的。

 
所以內存就被考慮爲一個從編號(地址)0開始、以編號(地址)0xffff ffff結束的字節序列。每一個字節都被順序地編號。編號就是字節的地址。
 在32位FLAT模式彙編中,本來就是如此。

在程序加載入內存後,程序的指令和數據都按某種方式存放在內存裏面。要訪問和執行他們,只需要知道他們的地址就可以了。
 
最重要的東西登場,它就是eip,指令指針寄存器,或稱程序計數器。eip中的值程序員無法修改(嗯,可是彙編程序員呢?彙編程序員也無法修改它的值嗎?廢話,彙編程序員也是程序員啊!),它的值就是下一條即將執行的指令的地址。就是說eip永遠指向下一條指令。
 
然後就是esp,它指向棧的棧頂。當向棧壓入數據或從棧彈出數據時,esp的值不斷變化,但無論如何變化,它都指向棧頂。
 
最後就是ebp,它用來把棧中的某個地址作爲基址(基本地址,這樣理解就是了),它用來標識棧中的某個固定位置,因此可以通過它訪問這個固定位置附近的數據。
 
80X86的棧是向下增長的。也就是說,當向棧壓入4個字節的數據時,esp = esp - 4; 當從棧中彈出4個字節時,esp = esp + 4。
 
以上!多麼幸福的事情啊,32位彙編只需要在意這3個寄存器就可以了!(標誌寄存器也挺重要的啊!但是跟本文要陳述的東西沒太大關係,略)。

 

解釋下上圖代表什麼意思...純粹照顧完全的新手。
 首先,那一排格子代表內存空間中的一小段,每個格子代表4個字節。右邊的十六位數值代表方格的地址。格子中間的“...”代表格子的內容。
 圖中地址是從下往上增長的。
 esp永遠指向棧頂。一開始它指向地址爲0x0063 fff4的字節。然後向棧壓入4個字節。
 對80X86來說,指令就是push ...;
 數據壓入後,esp指向0x0063 fff0。這是新的棧頂。
 
彈出數據跟上面的過程相反。esp中的值會增加。

關於80X86 32位CPU彙編模型就講上面這些了。之所以講這麼少,因爲這就是最基本的和最本質的內容,講多了反而把重點搞沒了。
 總結就是記住3個寄存器。eip, esp, ebp。記住他們的意義就可以了。

------------------------------------------------------

2.棧幀與C函數調用

關於計算機,最重要的三個抽象是什麼?答案是虛擬地址空間、進程、文件。
 
一個進程就是一個運行中的程序,或者被加載到內存中的程序。現代操作系統使進程看上去獨佔了所有的系統資源,但實際上系統中運行着多個進程。
 
所以從一個進程的視角看去,它獨佔了系統中的所有內存資源和CPU資源。對於32位系統虛擬地址空間被抽象爲編號0~0xffff ffff的字節序列,它是平坦的,線性的,被系統抽象了的,所以叫它平坦地址或線性地址、虛擬地址。

 

對於Linux來說,保留高1G爲系統使用。0-3G空間被應用程序也就是進程獨佔。
 
對於一個被加載了的程序也就是進程,其在內存中的分佈爲:

 棧
 共享內存段
 自由存儲區(堆)
 BSS段
 數據段
 只讀數據段
代碼段

棧向下增長。
 
每一個函數調用,都是一個棧幀。
 以下代碼:
 int add(int x, int y)
 {
    int z;
    z = x + y;
    return z;
 } int main(int argc, char* argv[])
 {
   add(3, 5);
   return 0;
 }


   那麼main函數是一個棧幀,add是一個棧幀。

 當程序運行時,main函數棧幀先被建立,這個棧幀在高地址。然後調用add函數。此時add函數棧幀被建立,在低地址。當程序執行流進入add函數時,add函數內的局部變量在add函數棧幀中被建立。然後add返回。當add函數返回,此時add函數棧幀被銷燬,同時add函數內的局部變量也被銷燬。所以,C編程原則告訴我們:永遠不要返回一個指向局部對象的指針。也就是說如下代碼是錯誤的:
 
int* getNumber(void)
{
   int a = 3;
   return &a;
}

 那麼運行時的棧是什麼樣子的呢?它是一個隨着運行,不斷增長(進入新的函數調用)和縮短(函數返回)的動態影像。
 
OK,關於C棧幀就說到這裏,完畢。
------------------------------------------------------------------

3.函數調用的彙編級解釋以及棧圖

先來一段彙編代碼。很簡單,有註釋。
請注意。不同的彙編編譯器使用不同的文法。MASM、NASM、gcc後端彙編編譯器,它們的文法幾乎完全不一樣。尤其是gcc後端,他妹的那文法那個汗。
這裏使用的是MASM.學習彙編的話用MASM還是NASM都沒關係,學了之後用什麼都一樣,因爲那只是文法方面的東西。指令助記符一般也不會有太多改變。如果真的寫彙編代碼的話,我想我傾向於使用NASM.


彙編語句分爲指令(instruction)、指示性語句(directive)、和宏(macro).
只有指令是真正的機器代碼。指示性語句是編譯器處理的東西。宏是一堆指令性語句或指示性語句。
以下代碼使用MASM。
.386                        ;386系統
.MODEL FLAT                 ;32位平坦地址模式
 
Exit PROTO NEAR32 stdcall, dwPara:DWORD ;退出函數原型
                                        ;Exit是函數名,dwPara是函數參數
 
.STACK 4096                 ;保留4096字節棧空間
 
.DATA                       ;數據段,定義全局變量
   number1 DWORD 11111111h  ;定義變量number1,大小4字節
   number2 DWORD 22222222h  ;定義變量number2, 大小4字節
 
.CODE                 ;程序代碼

Init PROTO NEAR32     ;定義函數Init
 
   mov number1, 0     ;假設該指令地址爲0x0040 0000
   mov number2, 0
   ret                ;函數Init返回
 
Init ENDP             ;函數Init結束
 
_start:               ;相當於main函數
   call Init          ;調用函數Init,此指令地址爲0x0040 000f
   ......             ;該處指令地址爲0x0040 0014

   INVOKE Exit, 0     ;調用Exit退出
 
PUBLIC _start         ;公開入口點
 
END                   ;程序結束
這裏說明一下。程序一旦加載,所有的指令、全局變量都被載入內存並有了確切的內存地址(程序加載前,或者說程序沒有運行時,只是硬盤上的一個可執行文件對吧。程序運行前有一個系統加載動作,這個加載由操作系統完成)。這個我的另一篇BLOG《程序員的基本概念》裏面略提過。清楚加載細節的是操作系統開發者,同時涉及到編譯器和鏈接器。要更明白這個問題請參照《Linker and Loader》。
 
 
那麼程序加載。棧初始化了。數據區域在內存中開闢出來了,全局變量被給予確切地址(這裏是虛擬地址,因爲這是一個進程,它的地址只管在虛擬地址空間中給就可以了,虛擬地址到物理地址的映射由操作系統和MMU完成)。代碼段(也就是要執行的指令)也被放入內存中並給予確切地址。eip指向代碼段的開始,並開始執行程序...
這裏說明一下。程序一旦加載,所有的指令、全局變量都被載入內存並有了確切的內存地址(程序加載前,或者說程序沒有運行時,只是硬盤上的一個可執行文件對吧。程序運行前有一個系統加載動作,這個加載由操作系統完成)。這個我的另一篇BLOG《程序員的基本概念》裏面略提過。清楚加載細節的是操作系統開發者,同時涉及到編譯器和鏈接器。要更明白這個問題請參照《Linker and Loader》。
 
那麼程序加載。棧初始化了。數據區域在內存中開闢出來了,全局變量被給予確切地址(這裏是虛擬地址,因爲這是一個進程,它的地址只管在虛擬地址空間中給就可以了,虛擬地址到物理地址的映射由操作系統和MMU完成)。代碼段(也就是要執行的指令)也被放入內存中並給予確切地址。eip指向代碼段的開始,並開始執行程序...

所以eip只管指向某個內存地址,這個內存地址存儲着程序員編寫的指令,然後CPU把指令取出來執行就是了。所以計算機叫做“順序存儲控制機”。對不起我囉嗦了。
 
好的。我們假設了,在程序加載後,esp被初始化爲0x0063 00f8,並假設了mov number1, 0這個指令的地址在0x0040 0000,根據這個假設的地址和每個指令碼的長度(這些指令都放在代碼段,而且一個一個指令就是挨着放的),推斷出call指令的地址是0x0040 000f,call指令的下一條指令的地址是0x0040 0014(因爲這個call指令的長度佔用5個字節,0x0040 000f + 5 = 0x0040 0014)。這裏不算我對指令長度的計算錯誤,總之假設我的地址計算是正確的。

 OK開始了。程序已經加載。那麼開始程序執行。eip首先指向call指令,因爲_start開始那裏就是call指令。嗯,eip就是一個32位寄存器,這個寄存器裏面的值永遠是即將執行的指令的內存地址,這時eip裏面的值是0x0040 000f。

call指令執行!該指令首先將下一條指令的地址壓入棧,也就是說,call指令的第一個動作是將0x0040 0014(call指令的下一條指令地址)壓入棧。esp此時變化,其值變爲0x0063 00f4。爲什麼?因爲esp被初始化爲0x0063 00f8,一個地址4個字節入棧之後,esp = esp - 4。然後call指令轉去調用Init過程代碼。eip變化爲0x0040 0000,爲什麼?因爲Init過程的第一個指令地址就是0x0040 0000.這個過程是由CPU自動完成的,也就是說,call指令,讓CPU自動完成這一系列動作。

然後Init過程執行到ret指令。
ret指令幹什麼?它將棧內數據彈出,並用該數據填充eip。棧內數據是什麼?就是0x0040 0014,它就是call指令的下一條指令的地址!同時esp = esp + 4.也就是說,ret指令執行後,eip值變爲0x0040 0014, esp的值變回0x0063 00f8.這個過程由CPU自動完成。ret指令讓CPU自動完成這一系列動作。

整理:執行call,call指令首先將下一條指令地址入棧,然後跑去執行過程代碼;過程代碼中執行ret,ret首先從棧中將下一條指令地址彈回eip,這樣程序就開始執行call指令後的指令。一句話:eip始終指向下一條指令地址。

以上!就是彙編函數調用和返回的過程。就是一個call和一個ret。eip在這個執行過程中通過棧來保存。


接下來,讓我們開始考察C語言的過程調用和返回,也就是C語言函數的參數壓棧和參數訪問過程。

 

圖中每個格子是一個字節,圖中畫的內存地址是向上增長,棧是向下增長的。左邊是caller(調用者)棧,右邊是callee(被調用者)棧(是同一個棧,分別是壓參前、call指令執行後的狀態。caller和callee的視圖)。

首先,esp是棧頂,直接從caller棧頂看起。也就是,在調用前,esp指向某個內存地址。
在調用函數前將參數壓入棧中。

push var1
push var2
這兩行代碼使 esp - 8. 然後壓參完畢,圖中即爲壓參完畢esp.
然後調用函數:

call add

 嗯,之前複習call指令時說什麼了?call指令執行時,首先將返回地址壓入棧。
也就是將“add esp, 8”這條指令的地址壓入棧。如左圖所示。

然後call指令執行過程調用,eip指向add函數內第一條指令的地址:

push ebp ;將ebp保存到棧中,同時esp - 4(說過了80X86的棧是向低地址方向增長的).


 此時ebp原值被保存入棧中。參看右圖,藍色部分是ebp原值。
然後:

mov ebp, esp


 此時以ebp爲基準的棧建立了。此時ebp和esp都指向棧頂(ebp原值被棧保存起來了哦)。
爲什麼要這麼做?
因爲esp是隨時變動的,只要有壓棧和出棧的操作,esp的值就隨着壓棧和出棧的操作變化(隨着push和pop操作變化,甚或,程序員直接改動esp的值)。
而ebp卻不會隨着push和pop操作變化。程序員在callee中不會修改ebp的值,而是使用ebp作爲基準訪問參數。

那麼接下來就很好理解了,第二個參數的地址是ebp + 8, 第一個參數的地址是ebp + 12.所以

mov eax, [ebp + 8] ;複製第二個參數值(var2)到eax
mov eax, [ebp + 12] ;加上第一個參數值(var1)

 就不難理解了。

在過程把實現代碼處理完畢的最後,pop ebp將ebp原值從棧中彈出恢復。
然後ret返回指令將返回地址彈出並賦給eip(請注意,返回地址彈出後,esp + 4, 這時esp正好指向調用者壓參完畢的位置),...
回到調用者的地方並繼續執行。
 
那麼調用處(caller)的

add esp, 8 ;從棧移除參數

是幹什麼用的?註釋已經說得很清楚了。
調用者將var1和var2壓到棧中,由於調用者的壓棧,esp被往下移動了8;那麼這個esp的原始位置也就是caller的棧頂應該在過程調用後恢復,add esp, 8就是恢復esp的。
 
ok。基本上就是如此了!

 
對於C語言的過程調用,比如,在main函數裏面調用add

 int main(int argc, char* argv[])
 {
    ...
    add(x, y);
    ...
 }


 實際上,這裏add(x, y)(調用者處)被編譯器編譯成如下彙編代碼:

 push y
 push x
 call add
 add esp, 8
以上,這就是C過程調用的彙編解釋。


接下來給出一般過程的入口代碼和出口代碼。


不難猜測,所有的過程(被調用函數)都有一樣的入口代碼和出口代碼。

所有的C函數,在被編譯器編譯成彙編代碼之後,
函數開始的幾行彙編代碼總是這樣的,所以我們稱這它爲入口代碼(entry code):

push ebp      ;保存基址
mov ebp, esp  ;建立ebp偏移基準
sub esp, n    ;n個字節的局部變量參數
push ...      ;保存過程中會用到的通用寄存器
...
pushf         ;保存標識寄存器,也就是保存標誌位

而結尾的幾行總是這樣的,所以稱其爲出口代碼(exit code):

popf          ;恢復標識寄存器
pop ...       ;恢復寄存器
...
mov esp, ebp  ;恢復callee esp
pop ebp       ;恢復ebp
ret           ;返回
------------------------------------------------------------------

4. stdcall和cdcel

既然已經瞭解了上述內容,那麼調用慣例就很容易理解了。
cdcel和stdcall是約定俗成的調用慣例,它們的區別在於由誰來恢復esp。

cdcel是由調用者恢復esp的調用慣例,
也就是說

push var1
push var2
call add
add esp, 8

這是cdcel調用慣例

而stdcall則是由callee恢復esp的調用慣例
stdcall會在callee裏面將ret這樣寫:

ret 8

意思是返回的同時esp + 8.

這兩種調用慣例,stdcall的好處是不用每次都在調用過程後寫add esp, 8這樣就減小了代碼量,減小了目標文件的體積。
而stdcall的缺陷更明顯,那就是callee有時候無法推斷參數的個數和長度,這樣的話esp只能由調用者恢復(比如變參數函數,這種函數callee是無法推斷參數個數的,也就無法知道應該在ret後面加多少偏移量)。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章