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

Jeffcky發表於2020-05-19

前言

上一節內容我們對在32位作業系統下堆疊幀進行了詳細的分析,本節我們繼續來看看在64位作業系統下對於過程呼叫在處理機制上是否會有所不同呢?

堆疊幀

我們給出如下示例程式碼方便對照彙編程式碼看,和上一節有所不同的是函式呼叫多了幾個引數。

#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也會是如此嗎?如下:

//將【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指令如下:

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。

相關文章