欧美在线专区-欧美在线伊人-欧美在线一区二区三区欧美-欧美在线一区二区三区-pornodoxxx中国妞-pornodoldoo欧美另类

position>home>Spotlights

C語言/C++ 堆棧工作機制

[導讀]我們經常會討論這樣的語言問題:什么時候數據存儲在堆棧 (Stack) 中,什么時候數據存儲在堆 (Heap) 中。堆棧我們知道,工作局部變量是機制存儲在堆棧中的;debug 時,查看堆棧可以知道函數的語言調用順序;函數調用時傳遞參數,事實上是堆棧把參數壓入堆棧,聽起來,工作堆棧象一個大雜燴。機制那么,語言堆棧 (Stack) 到底是堆棧如何工作的呢?本文將詳解 C/C++ 堆棧的工作機制。

來源:https://segmentfault.com/a/1190000038292644

C語言/C++ 堆棧工作機制


前言


我們經常會討論這樣的工作問題:什么時候數據存儲在堆棧 (Stack) 中,什么時候數據存儲在堆 (Heap) 中。機制我們知道,語言局部變量是堆棧存儲在堆棧中的;debug 時,查看堆棧可以知道函數的工作調用順序;函數調用時傳遞參數,事實上是把參數壓入堆棧,聽起來,堆棧象一個大雜燴。那么,堆棧 (Stack) 到底是如何工作的呢?本文將詳解 C/C++ 堆棧的工作機制。閱讀時請注意以下幾點:


1)本文討論的編譯環境是 Visual C/C++,由于高級語言的堆棧工作機制大致相同,因此對其他編譯環境或高級語言如 C# 也有意義。


2)本文討論的堆棧,是指程序為每個線程分配的默認堆棧,用以支持程序的運行,而不是指程序員為了實現算法而自己定義的堆棧。


3)? 本文討論的平臺為 intel x86。


4)本文的主要部分將盡量避免涉及到匯編的知識,在本文最后可選章節,給出前面章節的反編譯代碼和注釋。


5)結構化異常處理也是通過堆棧來實現的(當你使用 try…catch 語句時,使用的就是? c++ 對 windows 結構化異常處理的擴展),但是關于結構化異常處理的主題太復雜了,本文將不會涉及到。


從一些基本的知識和概念開始


1) 程序的堆棧是由處理器直接支持的。在 intel x86 的系統中,堆棧在內存中是從高地址向低地址擴展(這和自定義的堆棧從低地址向高地址擴展不同),如下圖所示:

?因此,棧頂地址是不斷減小的,越后入棧的數據,所處的地址也就越低。


2) 在 32 位系統中,堆棧每個數據單元的大小為 4 字節。小于等于 4 字節的數據,比如字節、字、雙字和布爾型,在堆棧中都是占 4 個字節的;大于 4 字節的數據在堆棧中占4字節整數倍的空間。


3) 和堆棧的操作相關的兩個寄存器是 EBP 寄存器和 ESP 寄存器的,本文中,你只需要把 EBP 和 ESP 理解成 2 個指針就可以了。ESP 寄存器總是指向堆棧的棧頂,執行 PUSH 命令向堆棧壓入數據時,ESP減4,然后把數據拷貝到ESP指向的地址;執行POP 命令時,首先把 ESP 指向的數據拷貝到內存地址/寄存器中,然后 ESP 加 4。EBP 寄存器是用于訪問堆棧中的數據的,它指向堆棧中間的某個位置(具體位置后文會具體講解),函數的參數地址比 EBP 的值高,而函數的局部變量地址比 EBP 的值低,因此參數或局部變量總是通過 EBP 加減一定的偏移地址來訪問的,比如,要訪問函數的第一個參數為 EBP+8。


4) 堆棧中到底存儲了什么數據?包括了:函數的參數,函數的局部變量,寄存器的值(用以恢復寄存器),函數的返回地址以及用于結構化異常處理的數據(當函數中有 try…catch 語句時才有,本文不討論)。這些數據是按照一定的順序組織在一起的, 我們稱之為一個堆棧幀(Stack Frame)。一個堆棧幀對應一次函數的調用。在函數開始時,對應的堆棧幀已經完整地建立了(所有的局部變量在函數幀建立時就已經分配好空間了,而不是隨著函數的執行而不斷創建和銷毀的);在函數退出時,整個函數幀將被銷毀。


5) 在文中,我們把函數的調用者稱為 caller(調用者),被調用的函數稱為callee(被調用者)。之所以引入這個概念,是因為一個函數幀的建立和清理,有些工作是由 Caller 完成的,有些則是由 Callee 完成的。


開始討論堆棧是如何工作的


我們來討論堆棧的工作機制。堆棧是用來支持函數的調用和執行的,因此,我們下面將通過一組函數調用的例子來講解,看下面的代碼:

    int foo1(int m, int n){ int p=m*n;????return?p;}int?foo(int?a,?int?b){ ????int?c=a+1;????????int?d=b+1;????????int?e=foo1(c,d);????????return?e;}int?main(){ ????int?result=foo(3,4);????return?0;}

    這段代碼本身并沒有實際的意義,我們只是用它來跟蹤堆棧。下面的章節我們來跟蹤堆棧的建立,堆棧的使用和堆棧的銷毀。


    堆棧的建立


    我們從main函數執行的第一行代碼,即 int result=foo(3,4); 開始跟蹤。這時 main 以及之前的函數對應的堆棧幀已經存在在堆棧中了,如下圖所示:

    圖1

    參數入棧?


    當 foo 函數被調用,首先,caller(此時caller為main函數)把 foo 函數的兩個參數:a=3,b=4 壓入堆棧。參數入棧的順序是由函數的調用約定 (Calling Convention) 決定的,我們將在后面一個專門的章節來講解調用約定。一般來說,參數都是從右往左入棧的,因此,b=4 先壓入堆棧,a=3 后壓入,如圖:

    圖2


    返回地址入棧


    我們知道,當函數結束時,代碼要返回到上一層函數繼續執行,那么,函數如何知道該返回到哪個函數的什么位置執行呢?函數被調用時,會自動把下一條指令的地址壓入堆棧,函數結束時,從堆棧讀取這個地址,就可以跳轉到該指令執行了。如果當前"call foo"指令的地址是 0x00171482 ,由于 call 指令占 5 個字節,那么下一個指令的地址為 0x00171487,0x00171487 將被壓入堆棧:

    圖3

    代碼跳轉到被調用函數執行


    返回地址入棧后,代碼跳轉到被調用函數 foo 中執行。到目前為止,堆棧幀的前一部分,是由 caller 構建的;而在此之后,堆棧幀的其他部分是由 callee 來構建。


    EBP指針入棧

    ????

    在 foo 函數中,首先將 EBP 寄存器的值壓入堆棧。因為此時 EBP 寄存器的值還是用于 main 函數的,用來訪問 main 函數的參數和局部變量的,因此需要將它暫存在堆棧中,在 foo 函數退出時恢復。同時,給 EBP 賦于新值。


    1)將 EBP 壓入堆棧


    2)把 ESP 的值賦給 EBP

    圖4

    ????

    這樣一來,我們很容易發現當前EBP寄存器指向的堆棧地址就是 EBP 先前值的地址,你還會發現發現,EBP+4 的地址就是函數返回值的地址,EBP+8 就是函數的第一個參數的地址(第一個參數地址并不一定是 EBP+8,后文中將講到)。因此,通過 EBP 很容易查找函數是被誰調用的或者訪問函數的參數(或局部變量)。


    為局部變量分配地址

    接著,foo 函數將為局部變量分配地址。程序并不是將局部變量一個個壓入堆棧的,而是將 ESP 減去某個值,直接為所有的局部變量分配空間,比如在 foo 函數中有 ESP=ESP-0x00E4,(根據燭秋兄在其他編譯環境上的測試,也可能使用 push 命令分配地址,本質上并沒有差別,特此說明)如圖所示:

    圖5

    ?????

    奇怪的是,在 debug 模式下,編譯器為局部變量分配的空間遠遠大于實際所需,而且局部變量之間的地址不是連續的(據我觀察,總是間隔 8 個字節)如下圖所示:

    ?

    圖6

    ????

    我還不知道編譯器為什么這么設計,或許是為了在堆棧中插入調試數據,不過這無礙我們今天的討論。


    通用寄存器入棧

    ?????

    最后,將函數中使用到的通用寄存器入棧,暫存起來,以便函數結束時恢復。在 foo 函數中用到的通用寄存器是 EBX,ESI,EDI,將它們壓入堆棧,如圖所示:

    圖7

    ???

    至此,一個完整的堆棧幀建立起來了。


    堆棧特性分析

    ??

    上一節中,一個完整的堆棧幀已經建立起來,現在函數可以開始正式執行代碼了。本節我們對堆棧的特性進行分析,有助于了解函數與堆棧幀的依賴關系。


    1)一個完整的堆棧幀建立起來后,在函數執行的整個生命周期中,它的結構和大小都是保持不變的;不論函數在什么時候被誰調用,它對應的堆棧幀的結構也是一定的。


    2)在 A 函數中調用B函數,對應的,是在A函數對應的堆棧幀“下方”建立 B 函數的堆棧幀。例如在 foo 函數中調用 foo1 函數,foo1 函數的堆棧幀將在 foo 函數的堆棧幀下方建立。如下圖所示:

    圖8?

    ?

    3)函數用 EBP 寄存器來訪問參數和局部變量。我們知道,參數的地址總是比 EBP 的值高,而局部變量的地址總是比 EBP 的值低。而在特定的堆棧幀中,每個參數或局部變量相對于 EBP 的地址偏移總是固定的。因此函數對參數和局部變量的的訪問是通過 EBP 加上某個偏移量來訪問的。比如,在 foo 函數中,EBP+8 為第一個參數的地址,EBP-8 為第一個局部變量的地址。


    4)如果仔細思考,我們很容易發現 EBP 寄存器還有一個非常重要的特性,請看下圖中:

    圖9

    ???

    我們發現,EBP 寄存器總是指向先前的 EBP,而先前的 EBP 又指向先前的先前的 EBP,這樣就在堆棧中形成了一個鏈表!這個特性有什么用呢,我們知道 EBP+4 地址存儲了函數的返回地址,通過該地址我們可以知道當前函數的上一級函數(通過在符號文件中查找距該函數返回地址最近的函數地址,該函數即當前函數的上一級函數),以此類推,我們就可以知道當前線程整個的函數調用順序。事實上,調試器正是這么做的,這也就是為什么調試時我們查看函數調用順序時總是說“查看堆棧”了。


    返回值是如何傳遞的


    堆棧幀建立起后,函數的代碼真正地開始執行,它會操作堆棧中的參數,操作堆棧中的局部變量,甚至在堆(Heap)上創建對象,balabala….,終于函數完成了它的工作,有些函數需要將結果返回給它的上一層函數,這是怎么做的呢?

    ????

    首先,caller 和 callee 在這個問題上要有一個“約定”,由于 caller 是不知道 callee 內部是如何執行的,因此 caller 需要從 callee 的函數聲明就可以知道應該從什么地方取得返回值。同樣的,callee 不能隨便把返回值放在某個寄存器或者內存中而指望Caller 能夠正確地獲得的,它應該根據函數的聲明,按照“約定”把返回值放在正確的”地方“。下面我們來講解這個“約定”:?


    1)首先,如果返回值等于 4 字節,函數將把返回值賦予EAX寄存器,通過 EAX 寄存器返回。例如返回值是字節、字、雙字、布爾型、指針等類型,都通過 EAX 寄存器返回。


    2)如果返回值等于 8 字節,函數將把返回值賦予 EAX 和 EDX 寄存器,通過 EAX 和 EDX 寄存器返回,EDX 存儲高位 4 字節,EAX存儲低位 4 字節。例如返回值類型為 __int64 或者 8 字節的結構體通過 EAX 和 EDX 返回。


    3)? 如果返回值為 double 或 float 型,函數將把返回值賦予浮點寄存器,通過浮點寄存器返回。


    4)如果返回值是一個大于 8 字節的數據,將如何傳遞返回值呢?這是一個比較麻煩的問題,我們將詳細講解:


    我們修改 foo 函數的定義如下并將它的代碼做適當的修改:

      MyStruct foo(`int a, int b)`{ ...}

      MyStruct定義為:

        struct MyStruct{ int value1;__int64 value2;bool value3;};

        ?這時,在調用 foo 函數時參數的入棧過程會有所不同,如下圖所示:

        圖10

        ????

        caller 會在壓入最左邊的參數后,再壓入一個指針,我們姑且叫它ReturnValuePointer,ReturnValuePointer 指向 caller 局部變量區的一塊未命名的地址,這塊地址將用來存儲 callee 的返回值。函數返回時,callee 把返回值拷貝到ReturnValuePointer 指向的地址中,然后把 ReturnValuePointer 的地址賦予 EAX 寄存器。函數返回后,caller 通過 EAX 寄存器找到 ReturnValuePointer,然后通過ReturnValuePointer 找到返回值,最后,caller 把返回值拷貝到負責接收的局部變量上(如果接收返回值的話)。

        ????

        你或許會有這樣的疑問,函數返回后,對應的堆棧幀已經被銷毀,而ReturnValuePointer 是在該堆棧幀中,不也應該被銷毀了嗎?對的,堆棧幀是被銷毀了,但是程序不會自動清理其中的值,因此 ReturnValuePointer 中的值還是有效的。

        堆棧幀的銷毀

        ???

        當函數將返回值賦予某些寄存器或者拷貝到堆棧的某個地方后,函數開始清理堆棧幀,準備退出。堆棧幀的清理順序和堆棧建立的順序剛好相反:(堆棧幀的銷毀過程就不一一畫圖說明了)


        ??? 1)如果有對象存儲在堆棧幀中,對象的析構函數會被函數調用。


        ??? 2)從堆棧中彈出先前的通用寄存器的值,恢復通用寄存器。


        ??? 3)ESP 加上某個值,回收局部變量的地址空間(加上的值和堆棧幀建立時分配給局部變量的地址大小相同)。


        ??? 4)從堆棧中彈出先前的 EBP 寄存器的值,恢復 EBP 寄存器。


        ??? 5)從堆棧中彈出函數的返回地址,準備跳轉到函數的返回地址處繼續執行。


        ??? 6)ESP 加上某個值,回收所有的參數地址。


        前面 1-5 條都是由 callee 完成的。而第 6 條,參數地址的回收,是由 caller 或者callee 完成是由函數使用的調用約定(calling convention )來決定的。下面的小節我們就來講解函數的調用約定。


        函數的調用約定(calling convention)


        函數的調用約定 (calling convention) 指的是進入函數時,函數的參數是以什么順序壓入堆棧的,函數退出時,又是由誰(Caller還是Callee)來清理堆棧中的參數。有 2 個辦法可以指定函數使用的調用約定:


        1)在函數定義時加上修飾符來指定,如

          void __thiscall mymethod();{ ...}


          2)在 VS 工程設置中為工程中定義的所有的函數指定默認的調用約定:在工程的主菜單打開 Project|Project Property|Configuration Properties|C/C++|Advanced|Calling Convention,選擇調用約定(注意:這種做法對類成員函數無效)。


          常用的調用約定有以下3種:


          1)__cdecl。這是 VC 編譯器默認的調用約定。其規則是:參數從右向左壓入堆棧,函數退出時由 caller 清理堆棧中的參數。這種調用約定的特點是支持可變數量的參數,比如 printf 方法。由于 callee 不知道caller到底將多少參數壓入堆棧,因此callee 就沒有辦法自己清理堆棧,所以只有函數退出之后,由 caller 清理堆棧,因為 caller 總是知道自己傳入了多少參數。


          2)__stdcall。所有的 Windows API 都使用 __stdcall。其規則是:參數從右向左壓入堆棧,函數退出時由 callee 自己清理堆棧中的參數。由于參數是由 callee 自己清理的,所以 __stdcall 不支持可變數量的參數。


          3)?__thiscall。類成員函數默認使用的調用約定。其規則是:參數從右向左壓入堆棧,x86 構架下 this 指針通過 ECX 寄存器傳遞,函數退出時由 callee 清理堆棧中的參數,x86構架下this指針通過ECX寄存器傳遞。同樣不支持可變數量的參數。如果顯式地把類成員函數聲明為使用__cdecl或者__stdcall,那么,將采用__cdecl或者__stdcall的規則來壓棧和出棧,而this指針將作為函數的第一個參數最后壓入堆棧,而不是使用ECX寄存器來傳遞了。


          反編譯代碼的跟蹤(不熟悉匯編可跳過)


          以下代碼為和 foo 函數對應的堆棧幀建立相關的代碼的反編譯代碼,我將逐行給出注釋,可對照前文中對堆棧的描述:


          main 函數中 int result=foo(3,4); 的反匯編:

            008A147E push 4 //b=4 壓入堆棧008A1480 push 3 //a=3 壓入堆棧,到達圖2的狀態008A1482 call foo (8A10F5h) //函數返回值入棧,轉入foo中執行,到達圖3的狀態008A1487 add esp,8 //foo返回,由于采用__cdecl,由Caller清理參數008A148A mov dword ptr [result],eax //返回值保存在EAX中,把EAX賦予result變量

            下面是 foo 函數代碼正式執行前和執行后的反匯編代碼

              008A13F0 push ebp //把ebp壓入堆棧008A13F1 mov ebp,esp //ebp指向先前的ebp,到達圖4的狀態008A13F3 sub esp,0E4h //為局部變量分配0E4字節的空間,到達圖5的狀態008A13F9 push ebx //壓入EBX008A13FA push esi //壓入ESI008A13FB push edi //壓入EDI,到達圖7的狀態008A13FC lea edi,[ebp-0E4h] //以下4行把局部變量區初始化為每個字節都等于cch008A1402 mov ecx,39h008A1407 mov eax,0CCCCCCCCh008A140C rep stos dword ptr es:[edi]...... //省略代碼執行N行......008A1436 pop edi //恢復EDI008A1437 pop esi //恢復ESI008A1438 pop ebx //恢復EBX008A1439 add esp,0E4h //回收局部變量地址空間008A143F cmp ebp,esp //以下3行為Runtime Checking,檢查ESP和EBP是否一致008A1441 call @ILT+330(__RTC_CheckEsp) (8A114Fh)008A1446 mov esp,ebp008A1448 pop ebp //恢復EBP008A1449 ret //彈出函數返回地址,跳轉到函數返回地址執行 //(__cdecl調用約定,Callee未清理參數)
              參考

              Debug Tutorial Part 2: The Stack


              Intel匯編語言程序設計(第四版)?第8章

              - EOF -

              免責聲明:本文內容由21ic獲得授權后發布,版權歸原作者所有,本平臺僅提供信息存儲服務。文章僅代表作者個人觀點,不代表本平臺立場,如有問題,請聯系我們,謝謝!

              Popular articles

              主站蜘蛛池模板: 2021日韩麻豆| 日韩免费一区二区三区| 美女不遮不挡的免费视频裸体| 午夜爽视频| 男人桶女人爽羞羞漫画| 天天操夜夜操天天操| 女人说疼男人就越往里| 亚洲春黄在线观看| 日本精品ova樱花动漫| 国产呦系列免费| 蜜桃97爱成人| 久久4k岛国高清一区二区| 美女让男人捅爽| 在线播放国产不卡免费视频| 黄色一级片日本| avtt亚洲天堂| 久久中文网中文字幕| 国产精品亚洲片夜色在线 | 一区二区三区中文字幕| 国产欧美va欧美va香蕉在| 波多野结衣不卡| 青娱乐国产在线视频| 好爽好黄的视频| 国产成人精品怡红院在线观看| 国产欧美va欧美va香蕉在线| 成人午夜影院| 国产91精品久久久久久久 | 篠田优被公侵犯电影| 午夜精品在线免费观看| 7777精品久久久大香线蕉| 岛国片在线观看| 啊v在线视频| 亚洲色国产欧美日韩| 女主调教贱女m视频| 自拍欧美亚洲| 精品久久洲久久久久护士免费| 两个小姨子韩国| 久久久久久久久久久久久久久 | 天天摸天天摸色综合舒服网| 欧美日一级片| jux434被公每天侵犯的我|