彙編中的函數調用與遞歸

棧幀的結構

 

  假若咱們要想搞清楚過程的實現,就必須先知道棧幀的結構是如何構成的。棧幀其實能夠認爲是程序棧的一段,而程序棧又是存儲器的一段,所以棧幀說到底仍是存儲器的一段。那麼既然是一段,確定有兩個端點,這個不須要LZ再普及了吧。web

  這兩個端點其實就是兩個地址,一個標識着起始地址,一個標識着結束地址,而這兩個地址,則分別存儲在固定的寄存器當中,即起始地址存在%ebp寄存器當中,結束地址存在%esp寄存器當中。至於爲何要存在這兩個寄存器當中,就像程序的下一條指令地址爲何存在PC當中同樣,是毫無心義的問題,就是這樣規定的,沒有爲何。函數

  起始地址和結束地址還有另外的名字,起始地址一般稱爲幀指針,結束地址一般稱爲棧指針(也就是棧頂的地址)。所以,咱們就把過程的存儲器內存使用區域稱爲棧幀。這下咱們就瞭解了棧幀的來歷以及它們的命名習慣和存儲慣例,接下來是LZ畫的一幅圖,它揭示了棧幀在存儲器當中的位置。優化

  這個圖基本上已經包括了程序棧的構成,它由一系列棧幀構成,這些棧幀每個都對應一個過程,並且每個幀指針+4的位置都存儲着函數的返回地址,每個幀指針指向的存儲器位置當中都備份着調用者的幀指針。各位須要知道的是,每個棧幀都創建在調用者的下方(也就是地址遞減的方向),當被調用者執行完畢時,這一段棧幀會被釋放。還有一點很重要的是,%ebp和%esp的值指示着棧幀的兩端,而棧指針會在運行時移動,因此大部分時候,在訪問存儲器的時候會基於幀指針訪問,由於在一直移動的棧指針沒法根據偏移量準確的定位一個存儲器位置。this

  還有一點比較重要的內容,就是棧幀當中內存的分配和釋放。因爲棧幀是向地址遞減的方向延伸,所以若是咱們將棧指針減去必定的值,就至關於給棧幀分配了必定空間的內存。這個理解起來很簡單,由於在棧指針向下移動之後(也就是變小了),幀指針和棧指針中間的區域會變長,這就是給棧幀分配了更多的內存。相反,若是將棧指針加上必定的值,也就是向上移動,那麼就至關於壓縮了棧幀的長度,也就是說內存被釋放了。須要注意的是,上面的一切內容,都基於一個前提,那就是幀指針在過程調用當中是不會移動的。spa

 

過程的實現

 

  過程雖然很好,但想要實現過程,仍是存在必定難度的,儘管如今看來它並不困難。它實現的難度主要就在於數據如何在調用者和被調用者之間傳遞,以及在被調用者當中局部變量內存的分配以及釋放。指針

  不過天大的難題都難不倒那羣計算機界的大神們,他們找出了一種方式,能夠簡單並有效的處理過程實現當中的難題。這一切彷佛看起來十分偶然,但其實也是必然的。世間的不少規律都是客觀存在的,只是它在等着咱們去發現而已。code

  總的來講,過程實現當中,參數傳遞以及局部變量內存的分配和釋放都是經過以上介紹的棧幀來實現的,大部分狀況下,咱們認爲過程調用當中作了如下幾個操做。orm

  一、備份原來的幀指針,調整當前的幀指針到棧指針的位置,這個過程就是咱們常常看到的以下兩句彙編代碼作的事情。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、leave、ret

 

  因爲過程調用當中會常常見到幾個新的指令,所以在這裏,LZ先給你們介紹一下這三個指令。它們三個都是過程實現當中很是重要的角色,這三個指令很相似,由於它們都是一個指令作了兩件事,這裏LZ就依次介紹一下它們各自都作了什麼事。

  call指令:它一共作兩件事,第一件是將返回地址(也就是call指令執行時PC的值)壓入棧頂,第二件是將程序跳轉到當前調用的方法的起始地址。第一件事是爲了爲過程的返回作準備,而第二件事則是真正的指令跳轉。

  leave指令:它也是一共作兩件事,第一件是將棧指針指向幀指針,第二件是彈出備份的原幀指針到%ebp。第一件事是爲了釋放當前棧幀,第二件事是爲了恢復調用者的棧幀。

  ret指令:它一樣也是作兩件事,第一件是將棧頂的返回地址彈出到PC,第二件事則是按照PC此時指示的指令地址繼續執行程序。這兩件事其實也能夠認爲是一件事,由於第二件事是系統本身保證的,系統老是按照PC的指令地址執行程序。

  能夠看出,除了call指令以外,leave和ret指令都與上面8個步驟有些不可分割的關係。call指令沒有在8個步驟當中體現,是由於它發生在進入過程以前,所以在第1步發生的時候,call指令每每已經被執行了,而且已經爲ret指令準備好了返回地址。

 

寄存器使用的規矩

 

  寄存器一共就8個,所以在數目上來講的話,使用起來確定是捉襟見肘的。在這種狀況下,就確定須要必定的規矩去約束程序如何使用,不然要是一羣人翻同一我的的牌子,那到底伺候誰纔是呢。其實咱們在以前已經或多或少的接觸到了寄存器的規矩,好比%eax通常用於存儲過程的返回值,%ebp保存幀指針,%esp保存棧指針。這裏要介紹的,是另一個規矩,而這個規矩是與過程實現相關的。

  試想一下,在調用一個過程時,不管是調用者仍是被調用者,均可能更新寄存器的值。假設調用者在%edx中存了一個整數值100,而被調用者也使用這個寄存器,並更新成了1000,因而悲劇就發生了。當過程調用完畢返回後,調用者再使用%edx的時候,值已經從100變成了1000,這幾乎必將致使程序會錯誤的執行下去。

  爲了不上面這種狀況發生,就須要在調用者和被調用者之間作一個協調。因而便有了這樣的規矩,它的描述以下,咱們假設這裏在過程P中調用了過程Q,P是調用者,Q是被調用者。

  %eax、%edx、%ecx:這三個寄存器被稱爲調用者保存寄存器。意思就是說,這三個寄存器由調用者P來保存,而對於Q來講,Q能夠隨便使用,用完了就不用再管了。

  %ebx、%esi、%edi:這三個寄存器被稱爲被調用者保存寄存器。一樣的,這裏是指這三個寄存器由被調用者Q來保存,換句話說,Q可使用這三個寄存器,可是若是裏面有P的變量值,Q必須保證使用完之後將這三個寄存器恢復到原來的值,這裏的備份,其實就是上面那8個步驟中第3個步驟作的事情。

 

一個過程示例

 

  上面已經作好了充足的準備,接下來咱們就要探索真理了,咱們隨便寫一個調用過程的例子,LZ寫了如下的代碼來作這個十分重要的例子,咱們稱它爲function.c。

複製代碼
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; }
複製代碼

  這裏LZ爲了完整的展示那8個步驟,所以給變量c加了register關鍵字修飾,這將會將c送入寄存器,從而更改被調用者保存寄存器,就會致使步驟3的發生。接下來咱們就使用參數-S來編譯這段代碼,而後使用cat來看看這段代碼的彙編形式。如下是main函數以及add函數各自的棧幀狀況,LZ已經詳細標記了它們屬於哪一個步驟。

  因爲咱們沒有使用編譯優化,所以彙編代碼會多出不少,這也爲了完整的詮釋咱們的步驟。能夠看到,圖中包含了完整的8個步驟,可是不管是main函數仍是add函數,它們單獨來說,都沒有完整的8個步驟,這實際上是大多數的狀況。大部分時候,一個函數不會徹底包含上述的8個步驟。LZ這裏再也不一一拆分各個步驟,各位猿友能夠嚴格按照各個指令的做用,本身畫圖理解一下這個過程,答案自會浮現。

  LZ這裏只說幾點各位須要注意的地方,首先第一點是,add函數會將返回結果存入%eax(前提是返回值可使用整數來表示),在main函數中,call指令以後,默認將%eax做爲返回結果來使用。第二點是,全部函數(包括main函數)都必須有第1步和第六、七、8步,這是必須的4步。最後一點是,咱們的棧指針和幀指針有固定的大小關係,即棧指針永遠小於等於幀指針,當兩者相等時,當前棧幀被認爲沒有分配內存空間。

  還有一點十分有趣的事情,注意main函數當中100和101的傳遞過程,是先進入存儲器,而後再進去寄存器,而後再進去存儲器,準備做爲add函數的參數。這一來一回產生了四次寄存器與存儲器之間的數據傳輸,假若咱們加上-O1參數去編譯這個程序,編譯器將產生以下的彙編代碼。

  能夠看到,整個main函數的指令數驟降,100和101將直接進入存儲器,準備做爲add函數的參數。可見編譯器的優化當中至少會有一項,就是減小數據的來回傳輸,增長效率。不過這一點其實與過程的實現沒有什麼關係,只是讓之前可能不知道的猿友看一下,編譯器其實會將咱們的程序作很大的改動。

  

遞歸過程調用

 

  書中對遞歸調用還進行了說明,這是爲了讓咱們相信,棧幀的創建和銷燬慣例,能夠保證遞歸過程的正常運行。其實若是各位猿友願意一點一點的,將上面main函數和add函數的彙編代碼搞清楚,那麼遞歸調用其實也能夠很輕鬆的搞定。由於指令就這麼多了,只要嚴格按照-S編譯出的彙編指令,一步一步的推算寄存器和存儲器的狀態,那麼遞歸調用的實現也會自動浮現。

  LZ這裏準備給各位猿友詮釋一下遞歸的過程,各位猿友能夠對照着上面的示例看一下,如下是一段簡單的求n的階乘的代碼。

複製代碼
int rfact(int n){ int result; if(n<=1){ result = 1; }else{ result = n * rfact(n-1); } return result; }
複製代碼

  接下來咱們編譯一下這段代碼,使用-O1優化,咱們能夠獲得以下的彙編代碼。

  LZ在圖中詳細標註了各個步驟所作的事情,其實嚴格按照各個指令的做用分析,很輕鬆的就能夠分析出圖中的解釋部分(即註釋)。難點就在於,棧幀的變化是如何的,LZ這裏就給各位演示一下棧幀的變化過程,若是各位已經把前面的那個main函數和add函數搞定了,那麼能夠在這裏驗證一下本身的理解是否正確。

  須要特殊說明的是,以上每個棧幀(大括號括起來的),最上面(也就是地址遞增方向)的都是幀指針位置,最下面的都是棧指針位置。然而寄存器中只有%ebp和%esp保存棧幀指針,所以同一時間只能保存一對。當進展到第三層的時候,已經有了三個棧幀(原則上來說必定是多於3個),寄存器固然是存不下的,所以就須要在存儲器當中備份一下,以後再恢復。因而就出現了每一個棧幀的幀指針指向的存儲器位置,都會備份着外層方法(也就是調用者)的幀指針。

  當方法遞歸到n=1結束時,棧幀會自下向上依次收回,棧幀指針(也就是%ebp和%esp當中的值)都會依次向上移動,直到程序結束。也就是說,上面的三幅圖,若是倒過來,就是遞歸方法依次結束時棧幀的狀態。

  由此就能夠看出,過程中棧幀創建以及完成的慣例,能夠保證遞歸調用的正常運行,包括循環調用。不得不說,這羣計算機界的大神們實在是太牛了,儘管當棧幀出現之後,看起來也並不複雜,但難點就在於無中生有的發現或者說某種意義上的創造。

 

做者:zuoxiaolong(左瀟龍)

出處:博客園左瀟龍的技術博客--http://www.cnblogs.com/zuoxiaolong

相關文章
相關標籤/搜索