【淺談】棧幀的創建與銷燬

【淺談】棧幀的創建與銷燬

什麼是棧幀?

  簡單點說,C語言中,每個棧幀對應着一個未運行完的函數。棧幀中保存了該函數的返回地址和局部變量。棧幀也叫過程活動記錄,是編譯器用來實現過程/函數調用的一種數據結構。
從邏輯上講,棧幀就是一個函數執行的環境:函數參數、函數的局部變量、函數執行完後返回到哪裏等等。
  從簡單的文字我們無法想象和理解棧幀到底是一個什麼樣的存在,下面我通過一系列的圖片和文字帶大家領略一下函數的棧幀,希望對大家有一定的幫助。

爲什麼要認識棧幀?

  我們通過上面棧幀概念簡單的描述知道了棧幀是一種過程活動的記錄,它記錄了函數在未結束前在內存中的一系列行爲,包括函數參數,函數的局部參數的創建,傳遞,函數在內存中空間的邊界,函數執行完後返回的地址等等數據。而他們的創建與銷燬都是在內存中進行的,我們在學習C語言的過程中僅僅會了簡單的編程是不夠的,通過認識棧幀,認識函數在內存中的創建與銷燬,能讓我們更加清晰深入的認識函數的執行過程。對我們以後的編程水平會有大幅度的提高。

棧幀的創建與銷燬

  接下來,我會通過VC6.0編譯器32位環境下逐步調試一段函數來讓大家對棧幀有一個深入的認識。這裏我之所以用VC6.0編譯器對代碼進行調試,原因是VC6.0作爲一個早期的編譯器版本,它在編譯代碼時步驟更加簡潔明瞭,更能直觀的反應棧幀的銷燬和創建。將要調試代碼如下:

```int Add(int x, int y)
   {
    int z = 0;
    z = x + y;
    return z;   
   }
   int main()
   {
        int a = 10;
        int b = 20;
        int ret = 0;
        ret = Add(a,     b);
        printf("%d\n", ret)
        return 0;
   }
```

  這裏我放上我調試的代碼,也希望大家下去後也能積極調試,畢竟代碼只有自己上手寫過操作過才能更加理解。
  如果有小夥伴在調試過程中遇到彙編語言下,某些值和我的不一樣,不必驚慌,要知道這很正常,在不同版本或不同編譯器或不同環境下編譯時,同樣的某些代碼產生的結果都可能會有所不同,一來,可能是編譯器自己的原因,二來可能是編譯器版本的原因,新的版本可能會和老舊的版本在內存的創建,命令上有一些差異。三來,也有系統位數不同的差異,在64位環境下,int型變量佔8個字節,而32位環境下,int型變量則只佔4個字節。這其中的差異希望大家在心理上有一個預期,在如果出現異常情況時,希望大家能夠積極的上網搜索答案,或者來到我的博客下留言,我看到後都會積極幫助的。

main函數在內存中的開闢

在查看main函數在內存中的創建前,我需要先給大家說明一個概念:
  大家在編輯好代碼後,按逐語句調試按鈕(F10)開始調試,這時在上邊菜單欄的調試窗口(DEBUG)打開調用堆棧(Call Stack)這一選項:
           這裏寫圖片描述
  在調用堆棧(Call Stack)中我們可以看到下面的畫面,這裏第一行箭頭指向的是我們創建的main函數,學習過C語言的小夥伴都知道,程序始終是從main函數開始執行的,而這裏的調用關係是從下往上,也就是說main函數被下面這個mainCRTStartup()函數調用。
      這裏寫圖片描述
  這裏我們另外提一句,main函數雖然是程序的開始,但不代表它就是調用關係的最上級,它依然會被其他函數調用,我把這幾個函數列舉出來,希望大家也能夠了解以一二:

  //main()函數被下面這個函數調用
  //__tmainCRTStartup
 //而__tmainCRTStartup()  函數又被下面這個函數調用
 //mainCRTStartup

  這裏我們可以不用過於深入的瞭解mainCRTStartup()的作用,但main函數被它調用,我們就有了理解這段代碼的彙編代碼的引子。
  我們在main函數代碼塊開頭區域處右擊鼠標,在彈出的菜單欄內選擇轉到反彙編(Go To Disassembly),如下圖窗口:
          這裏寫圖片描述
  會彈出一個如下窗口,我們需要將窗口調整到如下圖中main函數的位置,從這裏開始觀看棧幀的創建:
          這裏寫圖片描述
  在觀看之前,我還需要給大家普及一些簡單的知識,大家可能看到在黃色箭頭所指向的這一行末尾的ebp這個詞,以及下一行末尾的esp。以及下面幾行末尾提到的ebx,esi,edi,ecx,eax。它們都是我們電腦中的寄存器,寄存器是中央處理器內的組成部分。寄存器是有限存貯容量的高速存貯部件,它們可用來暫存指令、數據和地址。這裏我們對寄存器的相關知識不作展開,大家只需要知道寄存器是被用來存放數據,指令,地址的臨時儲存工具。它與CPU組合,可以形成高效的工作方式。

  通過剛纔的調用堆棧,我們看到在main函數之前,還有一個mainCRTStartup函數,既然這個函數被使用,那麼它在內存中也必然會有一塊自己的空間,而esp,ebp這兩個寄存器就是用存放這塊空間的邊界地址(棧頂與棧底地址)的,換言之,這兩個寄存器也是用來維護這一塊空間的兩個寄存器。事實上,每一個函數開闢的空間都是由這兩個函數來維護的。如下圖:
     這裏寫圖片描述
  而棧空間的數據存放方式是從棧頂壓入棧中,取出方式相應的也是由棧頂依次將數據取出。這種數據的讀寫方式我們稱之爲壓棧,出棧。
  在mainCRTStartup這個函數中它又調用了main函數,相應的,也應該給main函數開闢一塊內存空間。
  我們回到彙編窗口,看彙編語句的前三句話:
          這裏寫圖片描述
  這裏的push(壓棧)將ebp寄存器壓入棧頂,其實就是將ebp裏存放的棧底的地址壓入棧頂的一塊空間。後面又使用mov(賦值)將esp寄存器的地址賦值給ebp寄存器,通過下圖我們可以看到,這時ebp與esp在內存中同時指向mainCRTStartup函數的棧頂空間:
  這裏寫圖片描述 這裏寫圖片描述
  執行第三條彙編語句前,esp寄存器爲了維護這塊空間的邊界,指向了剛剛壓入棧頂的ebp區域的頂部(如上圖),第三條語句執行之後,esp寄存器中的地址被sub(減去)十六進制4Ch。我們要知道,棧空間中,棧頂到棧底的地址是由低到高的。也就是說esp這時指向[原地址 - 4Ch]處(如下圖所示),在內存窗口(Memory)中我們也能看到如下紅框中的一塊內存空間(其中窗口第一行地址反應的是esp,ebp的地址,這裏的用ebp的地址減去esp的地址,剛好是4Ch):
這裏寫圖片描述 這裏寫圖片描述
  沒錯,這塊空間就是爲main函數開闢的內存空間了。我們看接下來的幾條彙編語句,將ebx,esi,edi寄存器push(壓棧)壓入棧頂(這裏的ebx以及esi寄存器的作用並不影響我們理解棧幀,所喲我們不需要知道它被壓棧的作用),並使用lea(加載有效地址 loading effective address)將有效地址[ebp - 4Ch]傳遞給edi寄存器(edi寄存器就擁有了main函數低地址邊界的地址)。這裏我們先不對這條彙編語句的作用做出解釋,大家只需要記住這條語句的功能,後面我們會做解釋。esp,ebp依然維護函數空間的上下邊界,形象示意圖如下:
這裏寫圖片描述這裏寫圖片描述
  接下來的三條語句將13h與0cccccccch分別賦值給ecx與eax寄存器,並使用rep stos命令(重複拷貝)將edi所存放地址向高地址的13h(十進制的19)長度的空間重複拷貝0cccccccch這個值。也就是將上面圖中紅框所框中的地址全部賦值爲0cccccccch(通過這條彙編語句,有的同學應該能夠理解ecx以及eax寄存器的作用了吧,它倆的作用是將相關命令參數存放傳遞的作用,同時這裏的dword是double word的意思,也就是雙字,在C語言中是4個字節的意思)。效果如下:
這裏寫圖片描述
  上面的指令就是在爲main函數開闢空間,可以說,對main函數開闢空間是非常重要的前提,沒有main函數的空間,一切程序都無法執行,相信小夥伴們也都認可這點吧,那麼現在讀到這裏,相信很多小夥伴都對棧幀有了一定的認識,那麼接下來我們將要看到變量的創建以及函數參數的傳遞。
  首先我們看接下來的三條定義語句,第一句彙編代碼mov(賦值)將0Ah(也就是十進制的10)賦值給[ebp - 4]地址。也就是將0Ah放在棧底向低地址 - 4字節間的空間裏。同樣的,後面兩條語句也將14h以及0賦值給[ebp - 8]和[ebp - 0Ch]這兩個地址所代表的空間裏。形象示意圖如下:
這裏寫圖片描述這裏寫圖片描述
  哈哈,函數變量的賦值在彙編語言與內存中的表達是不是不過如此,大家別急,我們再回到下一句彙編語句,Add函數的調用,讓我們看一下它在內存中是怎麼實現的。這可是重頭戲,大家擦亮眼睛哦!
  下面我們看接下來的語句,mov(賦值)命令將[ebp - 8]地址中的內容賦值給eax,並用push(壓棧)將eax壓入棧頂,(這裏小夥伴們想一想,是不是相當於將b的值壓入了棧頂?)緊接着後面兩句語句,同樣將[ebp - 4]地址中的內容賦值給ecx,並將ecx壓入棧頂。我們從形象示意圖和內存可以同時觀察到這一現象:
  這裏寫圖片描述
  哎?有細心的小夥伴是不是猛然明白過來,對,這是在給Add函數傳參,那這裏被壓入棧頂的兩個寄存器就相當於a,b的一份臨時拷貝。接下來我們看下一句彙編語句,Call(聲明函數返回地址)(這裏插一句,我們都知道,不管是變量還是函數都在內存中存放,但因爲內存的分配使得他們的地址並不連貫,所以爲了程序執行的流暢,這裏需要聲明被調用函數執行完成後返回上一級函數的地址,如果大家還不明白的話可以看一下我畫的示意圖)將返回地址存放在棧頂,這裏我們要觀察這個現象時,只要記住Call指令下一條指令的地址(即被調函數結束後返回地址),在內存中就可以觀察到:
  注意:在Call語句處調試時需要按F11來進入被調函數Add。
這裏寫圖片描述這裏寫圖片描述
  逐語句F11跳轉後彙編代碼如下,jmp(跳轉)命令將程序執行跳轉到函數內部,這時我們再按F11進入函數:
  這裏寫圖片描述
  相信大家對下面的語句都不陌生,這些語句時用來創建Add函數空間的語句。爲Add函數開闢一塊空間供它使用,事實上每一個函數的調用都需要進行這麼一系列的相似的語句。這裏我們不詳細再將它說明了,它的創建以及隨後的局部變量z的創建初始化我們直接給出相應的示意圖:
  這裏寫圖片描述這裏寫圖片描述
  隨後的z=x+y的彙編語句,而我們可以在彙編語句中清晰的看到,其中並沒有創建變量的語句,那麼它的x,y是怎麼創建的呢?
  我們看一看z=x+y的彙編語句,mov(賦值)將[ebp + 8]地址的內容賦值給eax,add(加法)將[ebp + 0Ch]地址的內容加給eax。然後mov(賦值)將eax內容賦值給[ebp - 4]地址的內容。那麼有的小夥伴可能疑問,這裏的[ebp + 8],[ebp + 0Ch],[ebp - 4]到底是什麼呢?我們不妨看一看剛纔的示意圖以及相應的內存:
這裏寫圖片描述
  怎麼樣?是不是有點印象了呢?再配合上面的示意圖看一看,[ebp - 4]是Call指令存放的地址,而[ebp + 8]是剛纔傳進來的參數a=10,[ebp + 0Ch]是參數b=20。現在大家明白了吧,函數內部並沒有直接創建一個參數x,y。而是調用了傳參過來的寄存器中的值。而且參數的傳遞還有一個特點,就是按你傳遞參數時的順序來讀取參數。
  到了這裏,z=x+y就執行完了,接下來改返回函數的值了,讓我們看看最後返回函數時,彙編語句又是如何反應的:
  這裏寫圖片描述
  在返回z的值時,這裏mov(賦值)將z的值賦值到了eax中。我們通過這裏可以看出來,函數返回值的傳遞實質上是通過寄存器傳遞的。
  隨後,函數結束,我們看到三個pop(出棧)指令,將edi, esi,ebx寄存器退出棧頂。並用mov(賦值)命令將ebp寄存器中的地址賦值給esp(這裏的意思,大家可以揣摩一下,ebp,esp是維護空間邊界的兩個寄存器,當他倆地址相遇時,代表這片空間消失),相當於將爲Add函數開闢的空間銷燬。
  之後再pop(出棧)退出現在存放在棧頂的ebp寄存器返回ebp寄存器中。這個語句的具體意思是將原本儲存在Add函數棧底的main函數ebp的地址返回給現在的ebp,這樣ebp雖然和esp相遇,但隨後ebp依然回到了main函數的棧底,是不是很厲害很自然?
  然後執行返回語句ret(返回)返回存放在當前棧頂的地址中的地址值,我們的程序又自然的回到了剛纔Call(聲明返回地址)指令的下一條指令處:
  這裏寫圖片描述
  到這裏,我們將Add函數的調用和銷燬過程看的一清二楚。但這個程序還沒有結束,我們接着往下看(按F11逐語句調試):
  這裏寫圖片描述
  我們重新回到main函數中,這裏看到add(加法)給esp加8,是不是相當於將剛纔傳遞的兩個參數銷燬掉了呢?
  隨後將eax寄存器中存放的值mov(賦值)給[ebp - 0Ch]地址所存的內容,吶,看看上面這張圖,[ebp - 0Ch]地址處不就是ret嘛!
  隨後的彙編語句開始調用printf函數,我們就不做深入討論了,想必大家對函數的創建和銷燬有了一定的認識了吧?(是不是不僅認識了棧幀,而且學了不少彙編指令?)
  這裏最後提一句,我們在函數創建的這塊空間通常叫做運行時堆棧或者叫函數棧幀。這就是棧幀的創建和銷燬的全部內容了。

全文完,感謝瀏覽

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