讀懂作業系統(x64)之堆疊幀(過程呼叫)
前言
上一節內容我們對在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-6