讀懂操作系統(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),該計數器指向要執行的下一條指令。棧幀中局部變量爲負偏移量,參數爲正偏移量。

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