1. 程式人生 > >為什麼要使用堆疊? sp和fp的解釋

為什麼要使用堆疊? sp和fp的解釋

 為什麼要使用堆疊?
    一個過程呼叫可以象跳轉(jump)命令那樣改變程式的控制流程, 但是與跳轉不同的是, 當工作完成時,函式把控制權返回給呼叫之後的語句或指令。 這種高階抽象實現起來要靠堆疊的幫助。
    堆疊也用於給函式中使用的區域性變數動態分配空間, 同樣給函式傳遞引數和函式返回值也要用到堆疊。

堆疊區詳解
    堆疊是一塊儲存資料的連續記憶體。 一個名為堆疊指標(SP)的暫存器指向堆疊的頂部。堆疊的底部在一個固定的地址。 堆疊的大小在執行時由核心動態地調整。

    堆疊由邏輯堆疊幀組成。當呼叫函式時邏輯堆疊幀被壓入棧中, 當函式返回時邏輯堆疊幀被從棧中彈出。 堆疊幀包括函式的引數, 函式地區域性變數, 以及恢復前一個堆疊幀所需要的資料, 其中包括在函式呼叫時指令指標(IP)的值。

    堆疊既可以向下增長(向記憶體低地址)也可以向上增長, 這依賴於具體的實現。在我們的例子中, 堆疊是向下增長的。堆疊指標(SP)也是依賴於具體實現的。它可以指向堆疊的最後地址,或者指向堆疊之後的下一個空閒可用地址。 在我們的討論當中, SP指向堆疊的最後地址。

    除了堆疊指標(SP指向堆疊頂部的的低地址)之外, 為了使用方便還有指向幀內固定地址的指標叫做幀指標(FP)。
有些文章把它叫做區域性基指標(LB-local base pointer)。從理論上來說, 區域性變數可以用SP加偏移量來引用。 然而, 當有字被壓棧和出棧後, 這些偏移量就變了。 儘管在某些情況下編譯器能夠跟蹤棧中的字操作, 由此可以修正偏移量, 但是在某些情況下不能。而且在所有情況下, 要引入可觀的管理開銷。 而且在有些機器上, 比如Intel處理器, 由SP加偏移量訪問一個變數需要多條指令才能實現。

    因此, 許多編譯器使用第二個暫存器, FP, 對於區域性變數和函式引數都可以引用, 因為它們到FP的距離不會受到PUSH和POP操作的影響。 在Intel CPU中, BP(EBP)用於這個目的。 在Motorola CPU中, 除了A7(堆疊指標SP)之外的任何地址暫存器都可以做FP。考慮到我們堆疊的增長方向, 從FP的位置開始計算, 函式引數的偏移量是正值, 而區域性變數的偏移量是負值。

    當一個例程被呼叫時所必須做的第一件事是儲存前一個FP(這樣當例程退出時就可以恢復)。 然後它把SP複製到FP, 建立新的FP, 把SP向前移動為區域性變數保留空間。 這稱為例程的序幕(prolog)工作。當例程退出時, 堆疊必須被清除乾淨, 這稱為例程的收尾(epilog)工作。
Intel的ENTER和LEAVE指令, Motorola的LINK和UNLINK指令, 都可以用於有效地序幕和收尾工作。
這裡利用了一個簡單的例子來做堆疊溢位示例。首先描述了該例子編
譯後的記憶體分配情況,然後修改這個例子,使它成為一個典型的溢位程
序。分析溢位時的堆疊情況。

------------------------------------------------------------------

一個簡單的堆疊例子
example1.c:
------------------------------------------------------------------
void function(int a, int b, int c) {
  char buffer1[5];
  char buffer2[10];
}

void main() {
  function(1,2,3);
}
------------------------------------------------------------------
    使用gcc的-S選項編譯, 以產生彙編程式碼輸出:
      $ gcc -S -o example1.s example1.c

    通過檢視組合語言輸出, 我們看到對function()的呼叫被翻譯成:
        pushl $3
        pushl $2
        pushl $1
        call function
 
    以從後往前的順序將function的三個引數壓入棧中, 然後呼叫function()。 指令call會把指令指標(IP)也壓入棧中。 我們把這被儲存的IP稱為返回地址(RET)。 在函式中所做的第一件事情是例程的序幕工作:
        pushl ëp
        movl %esp,ëp
        subl $20,%esp

    將幀指標EBP壓入棧中。 然後把當前的SP複製到EBP, 使其成為新的幀指標。 我們把這個被儲存的FP叫做SFP。 接下來將SP的值減小, 為區域性變數保留空間。

    記憶體只能以字為單位定址。 一個字是4個位元組, 32位。 因此5位元組的緩衝區會佔用8個位元組(2個字)的記憶體空間, 而10個位元組的緩衝區會佔用12個位元組(3個字)的記憶體空間。 這就是為什麼SP要減掉20的原因。 這樣我們就可以想象function()被呼叫時堆疊的模樣(每個空格代表一個位元組):
記憶體低地址                                            記憶體高地址
          buffer2      buffer1  sfp  ret  a    b    c
<------  [            ][        ][    ][    ][    ][    ][    ]
堆疊頂部                                                堆疊底部

製造緩衝區溢位
    現在試著修改我們第一個例子, 讓它可以覆蓋返回地址, 而且使它可以執行任意程式碼。堆疊中在buffer1[]之前的是SFP, SFP之前是返回地址。 ret從buffer1[]的結尾算起是4個位元組。應該記住的是buffer1[]實際上是2個字即8個位元組長。 因此返回地址從buffer1[]的開頭算起是12個位元組。 我們會使用這種方法修改返回地址, 跳過函式呼叫後面的賦值語句'x=1;', 為了做到這一點我們把返回地址加上8個位元組。 程式碼看起來是這樣的:
example3。c:
--------------------------------------------------------------------
void function(int a, int b, int c) {
  char buffer1[5];
  char buffer2[10];
  int *ret;

  ret = buffer1 + 12;
  (*ret) += 8;
}

void main() {
  int x;

  x = 0;
  function(1,2,3);
  x = 1;
  printf("%d\n",x);
}
-------------------------------------------------------------------
    我們把buffer1[]的地址加上12, 所得的新地址是返回地址儲存的地方。 我們想跳過賦值語句而直接執行printf呼叫。

如何知道應該給返回地址加8個位元組呢? 我們先前使用過一個試驗值(比如1), 編譯該程式, 祭出工具gdb:

-----------------------------------------------------------------
[aleph1]$ gdb example3
GDB is free software and you are welcome to distribute copies of it
under certain conditions; type "show copying" to see the conditions。
There is absolutely no warranty for GDB; type "show warranty" for details。
GDB 4。15 (i586-unknown-linux), Copyright 1995 Free Software Foundation, Inc...
(no debugging symbols found)...
(gdb) disassemble main
Dump of assembler code for function main:
0x8000490 :      pushl  ëp
0x8000491 :    movl  %esp,ëp
0x8000493 :    subl  $0x4,%esp
0x8000496 :    movl  $0x0,0xfffffffc(ëp)
0x800049d :    pushl  $0x3
0x800049f :    pushl  $0x2
0x80004a1 :    pushl  $0x1
0x80004a3 :    call  0x8000470
0x80004a8 :    addl  $0xc,%esp
0x80004ab :    movl  $0x1,0xfffffffc(ëp)
0x80004b2 :    movl  0xfffffffc(ëp),êx
0x80004b5 :    pushl  êx
0x80004b6 :    pushl  $0x80004f8
0x80004bb :    call  0x8000378
0x80004c0 :    addl  $0x8,%esp
0x80004c3 :    movl  ëp,%esp
0x80004c5 :    popl  ëp
0x80004c6 :    ret
0x80004c7 :    nop
------------------------------------------------------------------

    我們看到當呼叫function()時, RET會是0x8004a8, 我們希望跳過在0x80004ab的賦值指令。 下一個想要執行的指令在0x8004b2。 簡單的計算告訴我們兩個指令的距離為8位元組。