讀懂操做系統(x64)之堆棧幀(過程調用)

前言

上一節內容咱們對在32位操做系統下堆棧幀進行了詳細的分析,本節咱們繼續來看看在64位操做系統下對於過程調用在處理機制上是否會有所不一樣呢?windows

堆棧幀

咱們給出以下示例代碼方便對照彙編代碼看,和上一節有所不一樣的是函數調用多了幾個參數。安全

#include <stdio.h>
int main()
{
    int a = 1,b = 2, c = 3, d = 4, e = 5,f = 6, g = 7,h = 8;
    int func(int a, int b,int c,int d,int e,int f ,int g,int h);
    func(a,b,c,d,e,f,g,h);
}

int func(int a, int b,int c,int d,int e,int f ,int g,int h)
{
    int i = 30;
    return a + b + c + d + e + f + g + h + i;
}

接下來咱們將上述代碼轉換爲intel語法彙編代碼,以下:數據結構

gcc -S -masm=intel -m64 1.c

x86僅提供8個通用寄存器(eax,ebx,ecx,edx,ebp,esp,esi,edi),而x64將它們擴展到64位(前綴爲「r」而不是「e」),並添加了另外8個(r8,r9,r10,r11,r12,r13,r14,r15)。因爲x86的某些寄存器具備特殊的隱含含義,而且並未真正用做通用寄存器(最著名的是ebp和esp),所以有效的增長甚至更大。根據《深刻理解計算機系統》這本書介紹,函數的前6個整數或指針參數在寄存器中傳遞,第一個放在rdi中,第二個放在rsi中,第三個放在rdx中,而後是rcx,r8和r9寄存器中,僅第7個參數及後續參數在堆棧上傳遞(以下圖所示)框架

關於以上代碼就不一一進行圖解了,這裏我用一張圖解進行最終解釋,以下:函數

由如上可知,前6個參數經過寄存器傳遞,而最後最後2個參數也就是g和h經過堆棧傳遞,可是除此和x86區別以外,還有個酒紅色的區域,該空間不得由信號或中斷處理程序修改,所以,函數可使用此區域存儲跨函數調用不須要的臨時數據。尤爲是,子函數能夠在整個堆棧框架中使用此區域,而不是在序言和結語中調整堆棧指針,該區域稱爲紅色區域(簡而言之,保留該區域是一種優化)。好比在上述函數中調用子函數並將對應參數傳遞到子函數中去,此時會將子函數中的局部變量存儲在該保留區域,這樣一來就無需經過rsp減去堆棧地址爲局部變量分配空間,從而達到優化目的。以上對於x86-64的堆棧幀調用約定遵循AMD64 ABI(Application Binary Interface:應用程序二進制接口),可是針對Windows x64位(ABI)定義了x86-64軟件調用約定,稱爲fastcall。接下來咱們結合基於Windows x64彙編代碼講講和上述區別在哪裏?咱們知道首先爲主函數分配一個堆棧幀,而後將對應參數壓入棧,如上述a~h參數,對應彙編代碼以下:優化

push    rbp
mov    rbp, rsp

sub    rsp, 96

call    __main

//將當即數1寫入【rbp-4】
mov    DWORD PTR -4[rbp], 1

//將當即數2寫入【rbp-8】
mov    DWORD PTR -8[rbp], 2

//將當即數3寫入【rbp-12】
mov    DWORD PTR -12[rbp], 3

//將當即數4寫入【rbp-16】
mov    DWORD PTR -16[rbp], 4

//將當即數5寫入【rbp-20】
mov    DWORD PTR -20[rbp], 5

//將當即數6寫入【rbp-24】
mov    DWORD PTR -24[rbp], 6

//將當即數7寫入【rbp-28】
mov    DWORD PTR -28[rbp], 7

//將當即數8寫入【rbp-32】
mov    DWORD PTR -32[rbp], 8

咱們知道接下來會調用函數,並將a~h參數進行傳入,因此此時會將上述8個參數經過寄存器傳遞多對應堆棧上,這是x86操做系統上的作法,在windows x64也會是如此嗎?以下:spa

//將【rbp-16】值(即4)寫入寄存器r9d
mov    r9d, DWORD PTR -16[rbp]

//將【rbp-12】值(即3)寫入寄存器r8d
mov    r8d, DWORD PTR -12[rbp]

//將【rbp-8】值(即2)寫入寄存器edx
mov    edx, DWORD PTR -8[rbp]

//將【rbp-4】值(即1)寫入寄存器eax
mov    eax, DWORD PTR -4[rbp]

在windows x64上會將前4個參數存入對應寄存器(雖然將其編譯成x64彙編代碼,但爲兼容x86,因此將數據存入的是32位的寄存器,只不過針對堆棧指針寄存器【rsp】和堆棧幀寄存器【rbp】使用的是x64,同時windows x64會將edi和esi進行保留,因此最終參數順序對應上述表edx、ecx、r8d、r9d,可是咱們會發現表中根本就沒有eax寄存器,請繼續往下看),而剩餘的參數則放到堆棧上,以下:操作系統

//將【rbp-32】值寫入寄存器ecx
mov    ecx, DWORD PTR -32[rbp]    
//將寄存器ecx中的值(即8)寫入【rsp+56】
mov    DWORD PTR 56[rsp], ecx

//將【rbp-28】值寫入寄存器ecx
mov    ecx, DWORD PTR -28[rbp]        
//將寄存器ecx中的值(即7)寫入【rsp+48】
mov    DWORD PTR 48[rsp], ecx

//將【rbp-24】值寫入寄存器ecx
mov    ecx, DWORD PTR -24[rbp]    
//將寄存器ecx中的值(即6)寫入【rsp+40】
mov    DWORD PTR 40[rsp], ecx

//將【rbp-20】值寫入寄存器ecx
mov    ecx, DWORD PTR -20[rbp]    
//將寄存器ecx中的值(即5)寫入【rsp+32】
mov    DWORD PTR 32[rsp], ecx

此時理應進入函數調用,由於上述將當即數1存入的是eax寄存器,因此這裏會將eax寄存器的數據傳送到ecx(我有點疑惑,對照上述表的話,在windows x64會將esi和edi寄存器保留,第一個參數對應的寄存器應是edx,可是這裏倒是ecx寄存器,不明白edx和ecx寄存器存儲參數的順序爲什麼顛倒了,如有明白的童鞋,還望指點一二),以下:指針

//將寄存器eax的數據【rbp-4】送入寄存器ecx
mov    ecx, eax

接下來開始調用函數,首先將返回地址壓入棧,經過call指令以下:code

call    func    

進入函數堆棧幀,首先設置當前函數堆棧幀,接下來則是分配局部變量空間,而後將局部變量入棧,並獲取寄存器和堆棧上存儲的數據進行計算,整個邏輯以下:

push    rbp
mov    rbp, rsp

sub    rsp, 16

//將寄存器ecx中的值(即1)寫入【rbp+16】
mov    DWORD PTR 16[rbp], ecx

//將寄存器edx中的值(即2)寫入【rbp+24】
mov    DWORD PTR 24[rbp], edx

//將寄存器edx中的值(即3)寫入【rbp+32】
mov    DWORD PTR 32[rbp], r8d

//將寄存器edx中的值(即4)寫入【rbp+40】
mov    DWORD PTR 40[rbp], r9d

//將當即數寫入【rbp-4】
mov    DWORD PTR -4[rbp], 30

//將【rbp+16】值(即)寫入寄存器edx
mov    edx, DWORD PTR 16[rbp]

//將【rbp+24】值(即2)寫入寄存器edx
mov    eax, DWORD PTR 24[rbp]

//edx寄存器存儲結果爲3
add    edx, eax

//將【rbp+32】值(即3)寫入寄存器eax
mov    eax, DWORD PTR 32[rbp]

//edx寄存器存儲結果爲6
add    edx, eax

//將【rbp+40】值(即4)寫入寄存器edx
mov    eax, DWORD PTR 40[rbp]

//edx寄存器存儲結果爲10
add    edx, eax

//將【rbp+48】值(即5)寫入寄存器edx
mov    eax, DWORD PTR 48[rbp]

//edx寄存器存儲結果爲15
add    edx, eax

//將【rbp+56】值(即6)寫入寄存器edx
mov    eax, DWORD PTR 56[rbp]

//edx寄存器存儲結果爲21
add    edx, eax

//將【rbp+64】值(即7)寫入寄存器edx
mov    eax, DWORD PTR 64[rbp]

//edx寄存器存儲結果爲28
add    edx, eax

//將【rbp+72】值(即8)寫入寄存器edx
mov    eax, DWORD PTR 72[rbp]

//edx寄存器存儲結果爲36
add    edx, eax

mov    eax, DWORD PTR -4[rbp]

//eax寄存器存儲結果爲66
add    eax, edx

計算完畢後,則是釋放局部變量內存空間,並返回(注:釋放局部變量內存空間和x86有所不一樣),以下:

//清理堆棧幀,釋放局部變量空間
add    rsp, 16

//彈出當前堆棧幀
pop    rbp

//彈出返回地址
ret

到這裏關於函數堆棧幀已經執行完畢,這裏稍微注意下,咱們在主函數中調用函數時並未將結果返回,因此在彙編代碼中會將已存儲結果的寄存器數據置爲0,而後一樣也是釋放主函數局部變量內存空間,以下:

//將eax寄存器中已存儲的數據置爲0
mov    eax, 0

add    rsp, 96
pop    rbp

ret

這裏呢,我再一次將整個彙編代碼邏輯經過圖方式來進行詳細解釋,以下:

 

如上爲調用函數以前主函數堆棧幀,此時前4個參數在對應寄存器上,而剩餘4個參數則是在堆棧上,接下來進入調用函數堆棧幀,以下:

堆棧幀解惑

大多數數據結構將按照其天然對齊方式對齊,這意味着,若是數據結構須要與特定邊界對齊,則編譯器將根據須要插入填充(加速cpu訪問,以空間換時間),針對x64調用約定雖然windows x64有所區別,可是都必須知足相同的堆棧對齊策略,也就是說棧必須與16字節邊界徹底對齊,若是內存地址能夠被16整除,或者最後一位爲0(用十六進制表示),換言之經過rsp分配的堆棧必須是16的倍數,好比上述主函數的96個字節,函數調用的16個字節(經查資料,gcc上的32位也是16個字節邊界對齊),仔細觀察上述圖發現,當咱們調用函數時(即call指令),此時會將8個字節的返回地址壓入棧,這實際上是windows x64中的作法,所以,在分配堆棧空間時,全部函數調用必須將堆棧調整爲16n + 8形式,因此針對堆棧幀的偏移都爲8。

 

在釋放堆棧幀上內存空間時,咱們發現是直接經過堆棧針rsp加上在分配時減去的字節數(好比主函數的add rsp,96),在x64處理器模式下,如上述極少狀況下會經過rsp來調整參數而是經過rbp來進行偏移,同時x64會分配足夠大的堆棧空間來調用最大目標函數(按參數方式使用),而x86模式下,esp的值會隨着添加和從堆棧中清除參數而發生變化。

總結

x64處理器模式下須要知足16個字節邊界對齊策略,它和x86處理器模式主要有兩大區別,一個是x64處理器模式下的參數可經過寄存器來傳遞參數(這是一大優化,將參數壓入堆棧必將致使內存訪問),而x86處理器模式下的參數都是存儲在堆棧上,另一個是x64直接使用堆棧針來釋放內存空間(即rsp),而x86使用堆棧幀釋放空間(即ebp)。AMD x64 ABI和Windows x64 ABI也有幾點區別,好比參數傳遞方式,AMD x64是前6個參數經過寄存器傳遞,而剩餘參數放在堆棧上,而Windows x64則是前4個參數經過寄存器傳遞,而剩餘參數放在堆棧上,AMD x64留有紅色的暫存區域,而Windows x64認爲該區域是不安全的,因此不存在,同時Windows x64在調用函數時會將8個字節的返回地址壓入棧,因此對於參數的訪問則需再移動8個字節以知足16個字節邊界對齊調用約定,理論上不論是x86仍是x64都應該有調用方清理堆棧應而不是被調用方,可是Windows x64模式則是被調用方清理堆棧,還有其餘好比對浮點數的存儲和處理等等。x64體系結構起源於AMD,被稱爲AMD64,後來由Intel實施,被稱之爲IA-32e,而後是EM64T,最後是Intel64。它也被稱爲x86-64,這兩個版本之間有些不兼容,可是大多數代碼在兩個版本上均可以正常工做,咱們更多的稱之爲x64或x86-64。

相關文章
相關標籤/搜索