1. 程式人生 > >讀懂作業系統(x86)之堆疊幀(過程呼叫)

讀懂作業系統(x86)之堆疊幀(過程呼叫)

前言

為進行基礎回爐,接下來一段時間我將持續更新彙編和作業系統相關知識,希望通過遮蔽底層細節能讓大家明白每節所闡述內容。當我們寫下如下C程式碼時背後究竟發生了什麼呢?

#include <stdio.h>
int main()
{
    int a = 2, b = 3;
    int func(int a, int b);
    int c = func(a, b);
    printf("%d\n%d\n%d\n",a, b, c);
}

int func(int a, int b)
{
    int c = 20;
    return a + b + c;
}

接下來我們gcc編譯器通過如下命令

gcc -S fileName.c

將其轉換為如下AT&T語法的彙編程式碼(看不懂的童鞋可自行忽略,接下來我會遮蔽細節,從頭開始分析如下彙編程式碼的本質)

_main:
LFB13:
 .cfi_startproc
 pushl %ebp
 movl %esp, %ebp
 andl $-16, %esp
 subl $32, %esp
 call ___main
 movl $2, 28(%esp)
 movl $3, 24(%esp)
 movl 24(%esp), %eax
 movl %eax, 4(%esp)
 movl 28(%esp), %eax
 movl %eax, (%esp)
 call _func
 movl %eax, 20(%esp)
 movl 20(%esp), %eax
 movl %eax, 12(%esp)
 movl 24(%esp), %eax
 movl %eax, 8(%esp)
 movl 28(%esp), %eax
 movl %eax, 4(%esp)
 movl $LC0, (%esp)
 call _printf
 movl $0, %eax
 leave
 .cfi_restore 5
 .cfi_def_cfa 4, 4
 ret
 .cfi_endproc
LFE13:
 .globl _func
 .def _func; .scl 2; .type 32; .endef
_func:
LFB14:
 .cfi_startproc
 pushl %ebp
 movl %esp, %ebp
 subl $16, %esp
 movl $20, -4(%ebp)
 movl 8(%ebp), %edx
 movl 12(%ebp), %eax
 addl %eax, %edx
 movl -4(%ebp), %eax
 addl %edx, %eax
 leave
 .cfi_restore 5
 .cfi_def_cfa 4, 4
 ret
 .cfi_endproc
LFE14:
 .ident "GCC: (MinGW.org GCC Build-20200227-1) 9.2.0"
 .def _printf; .scl 2; .type 32; .endef

CPU提供了基於棧的資料結構,當我們利用push和pop指令時說明會將暫存器上某一塊地址作為棧來使用,但是當我們執行push或者pop指令時怎麼知道哪一個單元是棧頂呢?此時將涉及到兩個暫存器,段暫存器SS和暫存器SP,棧頂的段地址存放在SS中,而偏移地址存放在SP中,通過SS:SP即(段地址/基礎地址 + 偏移地址 = 實體地址),因為堆疊是向下增長,所以當我們進行比如push ax(運算元和結果資料的累加器)即將ax壓入棧時,會進行如下兩步操作:(1)SP = SP - 2,SS:SP指向當前棧頂前面的單元,以當前棧頂前面的單元作為新的棧頂(畫外音:SP就是堆疊指標)(2)將ax中的內容送入SS:SP指向的記憶體單元處,SS:SP指向新棧頂。

那麼CPU提供基於堆疊的資料結構可以用來做什麼呢?堆疊的主要用途在於過程呼叫,一個堆疊將由一個或多個堆疊幀組成,每個堆疊幀(也稱作活動記錄)對應於對尚未以返回終止的函式或過程的呼叫,堆疊幀本質就是函式或者方法。我們知道對於函式或者方法有引數、區域性變數、返回值。所以對於堆疊幀由函式引數、指向前一個堆疊幀的反向指標、區域性變數組成。有了上述基礎知識鋪墊,接下來我們來分析在主函式中對函式呼叫如何利用匯編程式碼實現

int c = func(a, b);

int func(int a, int b)
{
    int c = 20;
    return a + b + c;
}

引數

當呼叫func時,我們需要通過push指令將引數壓入堆疊,此時在堆疊中入棧順序如下

push b
push a
call func

當每個引數被推到堆疊上時,由於堆疊會向下生長,所以將堆疊指標暫存器減4個位元組(在32位模式下),並將該引數複製到堆疊指標暫存器所指向的儲存位置。注意:指令會隱式將返回地址壓入堆疊。

棧幀

接下來進入被呼叫函式即進入棧幀,如果我們想要訪問引數,可以像如下訪問(注意:sp為早期處理器堆疊指標,如下esp為intel x86堆疊指標,只是名稱不同而已)

[esp + 0]   - return address
[esp + 4]   - parameter 'a'
[esp + 8]   - parameter 'b'

然後我們開始為區域性變數c分配空間,但是如果我們還是利用esp來指向函式區域性變數將會出現問題,因為esp作為堆疊指標,若在其過程中執行push(推送)或者pop(彈出)操作時,esp堆疊指標將會發生變化,此時將導致esp無法真正引用其中任何變數即通過esp表示的區域性變數的偏移地址不再有效,偏移量由編譯器所計算並在指令中為其硬編碼,所以在執行程式期間很難對其進行更改。

 

為了解決這個問題,我們引入幀指標暫存器(bp),當被呼叫函式或方法開始執行時,我們將其設定為堆疊幀的地址,如果程式碼將區域性變數稱為相對於幀指標的偏移量而不是相對於堆疊指標的偏移量,則程式可以使用堆疊指標而不會使對自動變數的訪問複雜化,然後,我們將堆疊幀中的某些內容稱為offset($ fp)而不是offset($ sp)。

 

上述幀指標暫存器從嚴格意義上來說稱作為堆疊基指標暫存器(bp:base pointer),我們希望將堆疊基指標暫存器設定為當前幀,而不是先前的函式,因此,我們將舊的儲存在堆疊上(這將修改堆疊上引數的偏移量),然後將當前的堆疊指標暫存器複製到堆疊基指標暫存器。

push ebp        ; 儲存之前的堆疊基指標暫存器
mov  ebp, esp   ; ebp = esp

區域性變數

區域性變數存在堆疊中,所以接下來我們通過esp為區域性變數分配記憶體單元空間,如下:

sub esp, bytes ; bytes為區域性變數所需的位元組大小

如上意思則是,sub為單詞(subtraction)相減縮寫,堆疊向下增長(根據處理器不同可能方向有所不同,但通常是向下增長比如x86-64),若區域性變數為3個(int)即雙字,則位元組大小為12,則堆疊指幀向上減去12即esp-12(注:這種說法不是很準確,涉及到具體細節,可暫且這樣理解)。 如上所述最終將完成堆疊幀呼叫,最終我們將所有內容放在一起,則是如下這般

[ebp + 12]  - parameter 'b'
[ebp + 8]   - parameter 'a'
[ebp + 4]   - return address
[ebp + 0]   - saved stackbase-pointer register

當呼叫函式或方法完畢後,對堆疊幀必須進行清理即進行記憶體釋放和恢復先前堆疊幀指標暫存器繼續往下執行,如下:

mov esp, ebp   ; 釋放區域性變數記憶體空間
pop ebp        ; 恢復先前的堆疊幀指標暫存器

如上只是從整體上去對堆疊幀呼叫的大概說明,我們來看看區域性變數和引數基於ebp的偏移量是為正值還是負值

void func()
{
  int a, b, c;
  a = 1;
  b = 2;
  c = 3;
}

執行:
push ebp
mov ebp, esp

高地址
|
|<--------------  ebp = esp 
|

低地址


執行:
sub esp, 12

高地址
|
|<--------------  ebp
|
|<--------------  esp
|

低地址

執行:
mov [ebp-4], 1
mov [ebp-8], 2
mov [ebp-12], 3

高地址


| <--------------  ebp
|1
|2
|3
| <--------------- esp
低地址

如上所述在進入函式後,舊的ebp值將被壓入堆疊,並將ebp設定為esp的值,然後esp遞減(因為堆疊在記憶體中向下增長),以便為函式的區域性變數和臨時變數分配空間。從那一刻起,在函式執行期間,函式的引數位於堆疊上,因為它們在函式呼叫之前被壓入,所以與ebp的偏移量為正值,而區域性變數位於與ebp的偏移量為負值的位置,因為它們是在函式輸入之後分配在堆疊上(如上圖分析)。到這裡我們將開始所寫的函式最終在堆疊中的記憶體位置是怎樣的呢?圖解如下:

最後我們將上述通過AT&T語法轉換的彙編程式碼轉換為intel語法彙編程式碼可能會更好理解一點

gcc -S -masm=intel 1.c

二者只不過是對應指令所使用符號有所不同而已,比如運算元為立即數時,AT&T語法將新增$符號,而intel語法不會,對上述函式呼叫進行詳細解釋,如下

//主函式棧幀    
_main:
LFB13:
    push    ebp
    mov    ebp, esp
    and    esp, -16
    sub    esp, 32
    call    ___main
    
    //將立即數2寫入【esp+28】
    mov    DWORD PTR [esp+28], 2
    
    //將立即數3寫入【esp+24】
    mov    DWORD PTR [esp+24], 3
    
    //將【esp+24】值寫入暫存器eax
    mov    eax, DWORD PTR [esp+24]
    
    //將暫存器eax中的值(即3)寫入【esp+4】
    mov    DWORD PTR [esp+4], eax
    
    //將[esp+28]值寫入eax暫存器
    mov    eax, DWORD PTR [esp+28]
    
    //將暫存器eax中的值(即2)寫入【esp+0】
    mov    DWORD PTR [esp], eax
    
    //呼叫_func函式,此時將返回地址壓入棧
    call    _func
    
    //將eax暫存器的值結果(即25)寫入【esp+20】
    mov    DWORD PTR [esp+20], eax
    
    //將【esp+20】值寫入eax暫存器
    mov    eax, DWORD PTR [esp+20]
    
    //將暫存器eax中的值寫入【esp+12】 = 25
    mov    DWORD PTR [esp+12], eax
    
    //將【esp+24】值寫入eax暫存器
    mov    eax, DWORD PTR [esp+24]
    
    //將暫存器eax中的值寫入【esp+8】 = 3
    mov    DWORD PTR [esp+8], eax
    
    //將【esp+28】值寫入eax暫存器
    mov    eax, DWORD PTR [esp+28]
    
    //將暫存器eax中的值寫入【esp+4】 = 2
    mov    DWORD PTR [esp+4], eax
    
    mov    DWORD PTR [esp], OFFSET FLAT:LC0
    
    call    _printf
    
    mov    eax, 0
    leave
    ret
    
//被呼叫函式(_func)棧幀    
_func:
LFB14:
    push    ebp
    mov    ebp, esp
    
    //為函式區域性變數分配16個位元組空間
    sub    esp, 16
    
    //將立即數寫入偏移棧幀4位的地址上
    mov    DWORD PTR [ebp-4], 20
    
    //將偏移棧幀8位上的地址值(即2)寫入edx暫存器
    mov    edx, DWORD PTR [ebp+8]
    
    //將偏移棧幀12位上的地址值(即3)寫入eax暫存器
    mov    eax, DWORD PTR [ebp+12]
    
    //將eax暫存器中的值和edx暫存器中的值相加即(a+b) = 5
    add    edx, eax
    
    //將偏移棧幀地址4位上的地址值(即20)寫入暫存器eax
    mov    eax, DWORD PTR [ebp-4]
    
    //將eax暫存器值和edx暫存器儲存的值相加即(20+c) = 25
    add    eax, edx
    
    //相當於執行(move esp,ebp; pop ebp;)有效清除堆疊幀空間
    leave
    
    //相當於執行(pop ip),從堆疊中彈出返回地址,並將控制權返回到該位置
    ret

上述對彙編程式碼的詳細解釋可能對零基礎的彙編童鞋理解起來還是有很大困難,接下來我將再一次通過圖解方式一步步給大家做出明確的解釋,通過對堆疊幀的學習我們能夠知道函式或方法呼叫的具體細節以及高階語言中值型別複製的原理,它的本質是什麼呢?接下來我們一起來看看。(注:英特爾架構上的堆疊從高記憶體增長到低記憶體,因此堆疊的頂部(最新內容)位於低記憶體地址中)。

 

 

在主函式棧幀如圖所示,首先分配區域性變數記憶體空間,然後儲存主函式的堆疊幀,最後將2和3分別壓入棧,接下來進入呼叫函式,如下圖所示

然後開始呼叫函式,當執行call指令時會將返回地址壓入棧以便執行棧幀上的ret指令時進行返回,將當前堆疊針移動到堆疊針,定義了堆疊幀的開始,從此刻開始進行函式呼叫內部,如下圖

首先我們儲存先前的ebp值,並將堆疊幀指標設定為堆疊的頂部(堆疊指標的當前位置),然後我們通過從堆疊指標中減去16個位元組來增加堆疊為區域性變數分配空間,在此堆疊框架中,包含該函式的本地資料、幀指標ebp的負偏移量(棧的頂部,到較低的記憶體中)r表示本地變數、ebp的正偏移量將使我們能夠讀取傳入的引數,接下來則是將區域性變數c設定為20,完成後,通過leave指令將堆疊指標設定為幀指標的值(ebp),並彈出儲存的幀指標值,有效地釋放堆疊幀記憶體空間,此時,堆疊指標指向函式返回地址,執行ret指令時彈出堆疊,並將控制轉移到call指令壓入棧的返回地址,繼續往下執行。

堆疊幀解惑

通過如上圖解對比彙編程式碼分析可以為我們解惑兩大問題,我們看到將運算元為立即數的a = 2和 b = 3入棧【esp+28】和【esp+24】的地址上,如下:

//將立即數2寫入【esp+28】
mov    DWORD PTR [esp+28], 2

//將立即數3寫入【esp+24】
mov    DWORD PTR [esp+24], 3

但是我們會發現接下來會將2和3將通過暫存器eax分別寫入到棧為【esp+4】和【esp+0】的地址上,但是最終獲取變數a和b的值依然是對應地址【esp+28】和【esp+24】,這就是高階語言中值型別的原理即深度複製(副本):通過暫存器傳遞(比如eax)將值副本儲存到堆疊幀上其他記憶體單元地址,引數值即從該記憶體單元獲取。

//將【esp+24】值寫入暫存器eax
mov    eax, DWORD PTR [esp+24]

//將暫存器eax中的值(即3)寫入【esp+4】
mov    DWORD PTR [esp+4], eax

//將[esp+28]值寫入eax暫存器
mov    eax, DWORD PTR [esp+28]

//將暫存器eax中的值(即2)寫入【esp+0】
mov    DWORD PTR [esp], eax

呼叫完函式後:

//將【esp+24】值寫入eax暫存器
mov    eax, DWORD PTR [esp+24]

//將暫存器eax中的值寫入【esp+8】 = 3
mov    DWORD PTR [esp+8], eax

//將【esp+28】值寫入eax暫存器
mov    eax, DWORD PTR [esp+28]

//將暫存器eax中的值寫入【esp+4】 = 2
mov    DWORD PTR [esp+4], eax

將變數a和b複製到棧【esp+0】和【esp+4】地址上,就是將其作為函式或方法的呼叫引數,即使進行修改操作也不會修改原有變數的值,但是我們會發現在函式中當獲取變數a和b的值是通過【ebp+8】和【ebp+12】來獲取

//將偏移棧幀8位上的地址值(即2)寫入edx暫存器
mov    edx, DWORD PTR [ebp+8]

//將偏移棧幀12位上的地址值(即3)寫入eax暫存器
mov    eax, DWORD PTR [ebp+12]

若是看到上述彙編程式碼時存在看不懂的情況,結合圖解3將一目瞭然,引數通過基於當前堆疊幀的偏移位移來獲取,因為在呼叫函式時也將返回地址和函式的ebp壓入棧,最終將堆疊針指向當前函式的ebp,所以相對於當前函式的堆疊幀而言,變數a和b的地址自然而然就變成了【ebp+8】和【ebp+12】。

總結

經典的書籍針對棧頂的定義實際上是指堆疊所佔記憶體區域中的最低地址,和我們自然習慣有所不同,有些文章若是指向堆疊記憶體高地址,這種說法是錯誤的。存在幀指標暫存器(ebp)存在的主要原因在於堆疊指標(sp)的值會發生變化,但是這只是歷史遺留問題針對早期的處理器而言,現如今處理器對於sp有些已具備offset(相對定址)屬性,所以對於幀指標暫存器是可選的,不過利用bp在跟蹤和除錯函式的引數和區域性變數更加方便。一個呼叫堆疊由1個或多個堆疊幀組成,每個堆疊幀對應於對尚未以返回終止的函式或過程的呼叫。要使用棧幀,執行緒保留兩個指標,一個稱為堆疊指標(SP),另一個稱為幀指標(FP)。SP始終指向堆疊的頂部,而FP始終指向幀的頂部。此外,該執行緒還維護一個程式計數器(PC),該計數器指向要執行的下一條指令。棧幀中區域性變數為負偏移量,引數為正偏移