CMM編譯器和C編譯器過程呼叫實現的比較

鴨脖發表於2013-01-04

之前寫了一個cmm的直譯器,並且自己設計實現了函式呼叫。當時設計的時候並沒有看c編譯器是如何實現的,不過今天看了深入理解計算機系統之後,發現我的設計方法和c的還真的特別像,現在就簡單總結一下,因為裡面涉及了很多的彙編知識,所以不懂彙編的童鞋最好先看一下彙編:


首先我想說的是一個最重要的思想,那就是相對定址的思想,如果沒有這個方法,那麼編寫過程呼叫幾乎是不可能的。因為計算機指令是以變數的地址作為運算元的,那麼也就意味著你在生成程式碼的時候要事先把變數的地址都給算出來。但是過程呼叫有時候是不確定的,比如遞迴,你不確定它要呼叫多少次自身才會return,那麼自然而然所產生的區域性比變數的個數也是不確定的,那麼很顯然我們不能在運算元中使用絕對地址。那麼做法就是設定一個基址暫存器,在c中就是ebp,它記錄的是該過程呼叫開始處棧的基地址。有了這個東西,我們只需要確定過程呼叫程式碼中各個變數的相對過程呼叫開始處的地址,那麼不管我們呼叫多少次這個過程的程式碼塊,堆疊會往上成塊增長,而且在過程中的相對地址不會發生改變。


那麼下面我簡單說一下我的設計:

我在每次發生過程呼叫的時候,要把老的ebp儲存在新的棧幀的底部,然後利用回填的技術,將返回地址也儲存在棧幀的底部,這樣每發生一次過程呼叫,棧幀的底部必定是這兩個值。然後新的棧幀指標便是老的ebp的堆疊位置,當然我們更新ebp的時候是通過ADD方法,不斷累加上去的,即將ebp暫存器中的值加上目前相對位置的最大偏移量。

然後再壓入的便是過程的引數了。我直接壓入的是各個值,也就是說所有的過程呼叫都是傳值方式。這也是我設計的侷限性吧。把所有的引數壓入棧中之後,在之後如果在過程中使用它們的時候,便可以檢視符號表中有關函式的引數的資訊,查出它們的引數順序,然後通過計算轉換成它們的相對地址。之後如果有區域性變數宣告的時候,便在該函式的棧幀中繼續分配空間進行儲存。

當遇到return語句的時候,便是進行返回了。返回之前,需要恢復ebp,但是這之前需要先把返回地址儲存到臨時變數區中,否則改變了ebp之後便取不到它的值了,而且還需要將返回值載入到eax中,以便為返回之後的賦值。

我的設計確實可以實現過程呼叫,包括遞迴。下面我們看看C編譯器的設計,我是參考書以及通過檢視反彙編程式碼來感悟的:

首先我們先看一下網上有關過程呼叫的活動記錄的圖示吧:


很顯然,從圖中可以看出c編譯器和我的設計在堆疊儲存結構上是不同的,而且最大的不同是,在發生過程呼叫的時候,它是選擇將被調函式的引數和返回地址儲存在呼叫者的棧幀中的。由於棧幀是從高地址向低地址增長的,這就造成被調過程在訪問引數值的時候,需要將相對地址設定為正值,因為此時ebp已經更新了。而且,C中使用了esp暫存器,用來儲存當前棧頂的絕對地址(按照我的想法是這樣的)。為了更深入地理解這個過程呼叫的過程,我們來看下面的彙編程式碼。

呼叫者:main{

int start = 9;

int stride = start + 2;

char* tmp = extract_message1(start,stride);

}

被調者:char * extract_message1(int start, int stride) {.......return "cao";}

那麼我們通過反彙編器來看看在發生過程呼叫的前後都發生了什麼吧:(與過程呼叫無關的彙編程式碼都已經刪去)

首先在過程呼叫的外部:

00A5183C  mov         eax,dword ptr [stride]  
00A5183F  push        eax  
00A51840  mov         ecx,dword ptr [start]  
00A51843  push        ecx  
00A51844  call        extract_message1 (0A51122h)  
00A51849  add         esp,8  
00A5184C  mov         dword ptr [tmp],eax 

那麼從這個彙編程式碼中其實已經很清楚了。首先是從右自左地壓入函式的引數。然後通過call指令發生函式呼叫並且跳轉到函式進行執行。返回之後會繼續執行下面的指令,注意這個add指令,其實它的意圖很明顯,相當於釋放引數的空間。但是要注意,其實在add之後我們緊接著還是可以訪問他們的。最後是執行賦值語句,將eax中的返回值移到變數tmp的儲存空間內。


那麼在call指令中都發生了什麼呢?

看下面的彙編程式碼:

00A515A0  push        ebp  
00A515A1  mov         ebp,esp  
00A515A3  sub         esp,0F0h  
00A515A9  push        ebx  
00A515AA  push        esi  
00A515AB  push        edi  
00A515AC  lea         edi,[ebp-0F0h]  
00A515B2  mov         ecx,3Ch  
00A515B7  mov         eax,0CCCCCCCCh  


上面的程式碼就是在call指令開始執行之後所發生的。其實call指令做了兩部分工作,第一部分是將返回地址壓入到棧中,第二部分就是跳轉到過程開始處進行執行。兩部分工作都和pc暫存器相關。那麼接下來就是過程建立的準備工作。從彙編程式碼中可以看出。首先是儲存ebp,然後設定新的ebp,新的值就是當前esp的值。注意我是在ebp中直接更新的,也就是絕對地址是在ebp中不斷更新的,而c中明顯可以看出,它的絕對值的更新是通過esp的,更新ebp是藉助於esp中的絕對地址的。更新過之後,再分配一段空間(我不太清楚是幹嘛的,不過這個時候esp發生改變了,說明棧頂又上移了),然後是儲存被呼叫者儲存暫存器。

這裡簡單說一下呼叫者儲存暫存器和被呼叫者儲存暫存器。呼叫者儲存的意思就是呼叫者在呼叫過程之前都需要把這些暫存器push到自己的棧幀中。也就是說它不確定子過程是否要使用這些個暫存器,所以為了保險都全都push了吧。而被呼叫者儲存暫存器則是呼叫者不用儲存,而在呼叫的過程中如果使用了這些個暫存器的話,那麼就需要push到子過程的棧幀中,再進行覆蓋。在子過程返回之後,需要再次恢復這些值。如果子過程不使用這些暫存器的話,那麼就不必進行棧幀讀寫。所以這種方式可以潛在地減少棧幀讀寫的次數。因此C編譯器採用的後一種方式。按照慣例,在Ia32機器中,呼叫者儲存暫存器有eax,ecx,edx,而被呼叫者儲存暫存器有ebx,esi,edi.所以上面的那三句push語句你應該明白了吧。最後的三條指令我想大概做的是初始化工作吧。暫且不去管它。

那麼當遇到return語句之後,且看下面的彙編:

00A5164E  pop         edi  
00A5164F  pop         esi  
00A51650  pop         ebx  
00A51651  mov         esp,ebp  
00A51653  pop         ebp  
00A51654  ret  

可以看出,在恢復被呼叫者儲存暫存器之後,計算機還恢復了ebp的值,pop ebp的效果就是將old ebp儲存到了ebp暫存器中。最後的ret語句,則是將返回地址pop掉,並且將pc的值設定到那即可。


從上面可以看出,其實我設計的思想和c的還是一樣的,都是相對,跳轉,棧幀。所以從這點來看,至少我的設計是正確的。

























相關文章