上篇博客咱們講解了計算機彙編語言是如何實現循環結構的。本篇博客咱們將介紹彙編語言中過程的實現方式。java
過程在高級語言中也稱爲函數,方法。一個過程的調用包括將數據(以過程參數和返回值的形式)和控制從代碼的一部分傳遞到另外一部分。此外,它還必須在進入時爲過程的局部變量分配空間,並在退出時釋放空間。大多數機器,包括咱們一直講的 IA32,只提供轉移控制到過程和從過程當中轉移出控制這種簡單指令。數據傳遞和局部變量的分配釋放都是經過操縱程序棧來實現。數組
合理的構建方法並調用,能大大增長代碼的複用性,也能使代碼結構更加清晰,接下來咱們就來詳細的介紹。函數
IA32 程序用程序棧來支持過程調用。機器用棧來傳遞過程參數、存儲返回信息、保存寄存器用於之後恢復,以及本地存儲。而爲單個過程分配的那部分棧稱爲幀棧(stack frame)。佈局
幀棧能夠認爲是程序棧的一段,它有兩個端點,一個標識着起始地址,一個標識着結束地址,而這兩個地址,則分別存儲在固定的寄存器當中,即起始地址存在%ebp寄存器當中,結束地址存在%esp寄存器當中。也就是說寄存器 %ebp 爲幀指針,寄存器 %esp 爲棧指針。優化
當程序執行時,棧指針能夠移動,所以大多數信息的訪問都是相對於幀指針的。指針
這個圖基本上已經包括了程序棧的構成,它由一系列棧幀構成,這些棧幀每個都對應一個過程,並且每個幀指針+4的位置都存儲着函數的返回地址,每個幀指針指向的存儲器位置當中都備份着調用者的幀指針。各位須要知道的是,每個棧幀都創建在調用者的下方(也就是地址遞減的方向),當被調用者執行完畢時,這一段棧幀會被釋放。還有一點很重要的是,%ebp和%esp的值指示着棧幀的兩端,而棧指針會在運行時移動,因此大部分時候,在訪問存儲器的時候會基於幀指針訪問,由於在一直移動的棧指針沒法根據偏移量準確的定位一個存儲器位置。blog
還有一點比較重要的內容,就是棧幀當中內存的分配和釋放。因爲棧幀是向地址遞減的方向延伸,所以若是咱們將棧指針減去必定的值,就至關於給棧幀分配了必定空間的內存。這個理解起來很簡單,由於在棧指針向下移動之後(也就是變小了),幀指針和棧指針中間的區域會變長,這就是給棧幀分配了更多的內存。相反,若是將棧指針加上必定的值,也就是向上移動,那麼就至關於壓縮了棧幀的長度,也就是說內存被釋放了。須要注意的是,上面的一切內容,都基於一個前提,那就是幀指針在過程調用當中是不會移動的。遞歸
過程的實現主要就是在於數據如何在調用者和被調用者之間傳遞,以及在被調用者當中局部變量內存的分配以及釋放。內存
而過程實現當中,參數傳遞以及局部變量內存的分配和釋放都是經過以上介紹的棧幀來實現的,大部分狀況下,咱們認爲過程調用當中作了如下幾個操做。資源
①、備份原來的幀指針,調整當前的幀指針到棧指針的位置,這個過程就是咱們常常看到的以下兩句彙編代碼作的事情。
pushl %ebp movl %esp, %ebp
②、創建起來的棧幀就是爲被調用者準備的,當被調用者使用棧幀時,須要給臨時變量分配預留內存,這一步通常是通過下面這樣的彙編代碼處理的。
subl $16,%esp
③、備份被調用者保存的寄存器當中的值,若是有值的話,備份的方式就是壓入棧頂。所以會採用以下的彙編代碼處理。
pushl %ebx
④、使用創建好的棧幀,好比讀取和寫入,通常使用mov,push以及pop指令等等。
⑤、恢復被調用者寄存器當中的值,這一過程實際上是從棧幀中將備份的值再恢復到寄存器,不過此時這些值可能已經不在棧頂了。所以在恢復時,大多數會使用pop指令,但也並不是必定如此。
⑥、釋放被調用者的棧幀,釋放就意味着將棧指針加大,而具體的作法通常是直接將棧指針指向幀指針,所以會採用相似下面的彙編代碼處理(也多是addl)。
movl %ebp,%esp
⑦、恢復調用者的棧幀,恢復其實就是調整棧幀兩端,使得當前棧幀的區域又回到了原始的位置。由於棧指針已經在第六步調整好了,所以此時只須要將備份的原幀指針彈出到%ebp便可。相似的彙編代碼以下。
popl %ebp
⑧、彈出返回地址,跳出當前過程,繼續執行調用者的代碼。此時會將棧頂的返回地址彈出到PC,而後程序將按照彈出的返回地址繼續執行。這個過程通常使用ret指令完成。
過程的實現大概就是以上八個步驟組成的,不過這些步驟並不都是必須的(大部分時候,開啓編譯器的優化會優化掉不少步驟),並且第6和第7步有時會使用leave指令代替。下面會詳細講解這些步驟。
下圖是支持過程調用和返回的指令:
①、call指令:call 指令有一個目標,即指明被調用過程起始的指令地址。直接調用的目標能夠是一個標號,間接調用的目標是 * 後面跟一個操做符。它一共作兩件事,第一件是將返回地址(也就是call指令執行時PC的值)壓入棧頂,第二件是將程序跳轉到當前調用的方法的起始地址。第一件事是爲了爲過程的返回作準備,而第二件事則是真正的指令跳轉。
②、leave指令:它也是一共作兩件事,第一件是將棧指針指向幀指針,第二件是彈出備份的原幀指針到%ebp。第一件事是爲了釋放當前棧幀,第二件事是爲了恢復調用者的棧幀。
③、ret指令:它一樣也是作兩件事,第一件是將棧頂的返回地址彈出到PC,第二件事則是按照PC此時指示的指令地址繼續執行程序。這兩件事其實也能夠認爲是一件事,由於第二件事是系統本身保證的,系統老是按照PC的指令地址執行程序。
能夠看出,除了call指令以外,leave和ret指令都與上面8個步驟有些不可分割的關係。call指令沒有在8個步驟當中體現,是由於它發生在進入過程以前,所以在第1步發生的時候,call指令每每已經被執行了,而且已經爲ret指令準備好了返回地址。
程序寄存器組是惟一可以被全部過程共享的資源。雖然在給定時刻只能有一個過程是活動的,可是咱們必須保證當一個過程(調用者)調用另外一個過程(被調用者)時,被調用者不會覆蓋某個調用者稍後會使用的寄存器的值。爲此必須採用一組統一的寄存器使用慣例,全部的過程都必須遵照,包括程序庫的過程。
假如沒有這些規矩,好比在調用一個過程時,不管是調用者仍是被調用者,均可能更新寄存器的值。假設調用者在%edx中存了一個整數值100,而被調用者也使用這個寄存器,並更新成了1000,因而悲劇就發生了。當過程調用完畢返回後,調用者再使用%edx的時候,值已經從100變成了1000,這幾乎必將致使程序會錯誤的執行下去。因此便有以下規矩:
在 IA32 中,寄存器%eax,%edx和%ecx被劃分爲調用者保存寄存器。當過程 P 調用 Q 時,Q能夠覆蓋這些寄存器,而不會破壞 P 所需的數據。
寄存器%ebx,%esi和%edi被劃分爲被調用者保存寄存器。這裏 Q 必須在覆蓋這些寄存器的值以前,先把他們保存到棧中,並在返回前恢復它們,由於 P(或某個更高層次的過程)可能會在從此的計算中須要這些值。上面所說的過程實現的8個步驟中第三步即是如此。
考慮以下代碼:
int P(int x) { int y = x*x; int z = Q(y); return y+z; }
過程 P 在調用 Q 以前會先計算 y 的值,並且它必須保證 y 的值在 Q 返回後是可用的。這裏有兩種方法實現:
①、能夠在調用 Q 以前,將 y 的值保存在本身的幀棧中;當 Q 返回時,過程 P 就能夠從棧中取出y 的值。換句話說就是調用者 P 本身保存這個值。
②、能夠將 y 保存在被調用者保存寄存器中。若是 Q ,或者其它 Q 調用的程序想使用這個寄存器,它必須將這個寄存器的值保存在幀棧中,並在返回前恢復該值。換句話說就是被調用者保存這個值。當 Q 返回到 P 時,y 的值會在被調用者保存寄存器中,或者是由於寄存器根本就沒有改變,或者是由於它被保存並恢復了。
這兩種方法在 IA32 中是都採用的。
考慮以下代碼 function.c
#include <stdio.h> int add(int a,int b){ register int c = a + b; return c; } int main(){ int a = 100; int b = 101; int c = add(a,b); return c; }
相信上面的代碼沒有什麼難度,在 main過程當中調用 add過程。咱們經過以下指令編譯成彙編代碼:
gcc -O0 -S function.c
爲了完整的展示那8個步驟,所以給變量c加了register關鍵字修飾,這將會將c送入寄存器,從而更改被調用者保存寄存器,就會致使步驟3的發生。如下是main函數以及add函數各自的棧幀狀況:
上面的彙編代碼是咱們沒有使用優化級別編譯出來的,因此完整的呈現了前面所講的8個步驟。這裏咱們須要注意兩點:
①、add函數會將返回結果存入%eax(前提是返回值可使用整數來表示),在main函數中,call指令以後,默認將%eax做爲返回結果來使用。
②、全部函數(包括main函數)都必須有第1步和第六、七、8步,這是必須的4步。咱們的棧指針和幀指針有固定的大小關係,即棧指針永遠小於等於幀指針,當兩者相等時,當前棧幀被認爲沒有分配內存空間。
前面咱們講的都是一個過程能調用其它的過程,可是其實一個過程也能調用本身自己的,也就是遞歸調用。由於每一個調用在棧中都有它本身的私人空間,多個未完成調用的局部變量不會互相影響,此外,棧的原則也提供了適當的策略,當過程被調用時分佈局部存儲空間,當過程執行完畢返回時釋放存儲空間。
下面是一段求 n 的階乘的遞歸調用代碼:
int rfact(int n){ int result; if(n<=1){ result = 1; }else{ result = n * rfact(n-1); } return result; }
咱們仍是用 -O0 -S 來編譯獲得彙編代碼:
上面的彙編代碼,當用參數 n 來調用時,首先代碼 2~5 行會建立一個幀棧,其中包含 %ebp 的舊值、保存的被調用者保存的寄存器 %ebx 的值,以及當遞歸調用自身的時候保存參數的四個字節。
以下圖所示,它用寄存器 %ebx 來保存過程參數 n 的值(第 6 行代碼)。它將寄存器 %ebx 中的返回值設置爲 1,預期 n<=1 的狀況,它就會跳轉到完成代碼。
對於遞歸的狀況,計算 n-1,將這個值存儲在棧上,而後調用函數自身(第10~12行),在代碼的完成部分,咱們能夠假設:
①、寄存器%eax保存這(n-1)!的值
②、被調用保存寄存器%ebx保存着參數n
所以將這兩個值相乘(第 13 行)獲得該函數的返回值。對於終止條件和遞歸調用,代碼都會繼續到完成部分(第15~17行),恢復棧和被調用者保存寄存器,而後在返回。
因此咱們看到遞歸調用一個函數自己與調用其它函數是同樣的。棧規則提供了一種機制,每次函數調用都有它本身的私有狀態信息(保存的返回值、棧指針和被調用者保存寄存器的值)存儲。若是須要,它還能夠提供局部變量的存儲。分配和釋放的棧規則很天然的就與函數調用——返回的順序匹配。
本章對於函數的彙編實現作了詳細的講解,主要是棧規則的機制,幫咱們解決了數據如何在調用者和被調用者之間傳遞,以及在被調用者當中局部變量內存的分配以及釋放。那麼下篇博客咱們將介紹數組的分配和訪問,咱們知道好比Java語言中的集合不少都是在數組的基礎上實現的。弄懂下一章的內容後,你會對定長數組與不定長數組(集合)有更深入的瞭解。