忽然間想記錄一下64位的彙編...這裏的話主要就是記錄一下和32位彙編的一些比較大的差別,主要就是寄存器和函數調用這兩方面,指令什麼的話我覺得就遇到查就可以了,有時間的話整理吧。
首先先來看寄存器,如下圖
對於上圖而言,白色背景部分表示兼容x86的寄存器,支持所有的模式,而對於灰色部分而言,表示擴展的寄存器,只支持64位模式。
先來看通用寄存器,可以發現擴展了8個通用的寄存器,也就是R8-R15,並且由原先的32字節擴展爲64個字節,總共16個寄存器
EAX -> RAX
EBX -> RBX
ECX -> RCX
EDX -> RDX
EBP -> RBP
ESI -> RSI
EDI -> RDI
ESP -> RSP
增加以下8個寄存器
R8 - R15
可以發現,intel也算是醒悟了,因爲之前的Rax,Rbx...這些寄存器的名字真的太難記了,這些擴展的寄存器的名字就簡化成R8到R15。
下面再看對於EIP擴展爲RIP,EFLAGS擴展爲RFLAGS。浮點棧寄存器沒有變化,多媒體寄存器多個8個寄存器。OK,主要重心放在上面的通用寄存器的擴展就好了。
下面再來看在64位模式下對通用寄存器的高低位訪問
可以通過上圖看到,對於通用寄存器分別在8位,16位,32位和64位下其訪問的名稱,這裏主要重點提一下R8-R15的寄存器,分別在尾部加入B,W,D來表示高低位
B - byte //1字節
W - word //2字節
D - dword //4字節
舉個例子,當使用R9D的時候,也就是訪問該寄存器的低4個字節。
對於上圖而言,還有一個比較細節的地方,也就是最上面的部分,有這樣幾行說明
zero-extend for 32-bit operands //32位操作數使用了零擴展
not modified for 16-bit operands
not modified for 8-bit operands
也就是說如果操作數是8位或者16位,其高位不修改,當使用32位時則會零擴展,也就是高4字節默認會被影響修改爲0
mov eax,0 //零擴展,rax=0
mov rax,0
也就是說對於上面的兩行彙編代碼,其結果是一樣的。而在8位或者16位中僅僅只對其低位進行修改,不影響高位。
OK,對於任何程序的入門都是寫一個Hello World,那麼下面就寫一個彈窗版的Hello World!,新建hello.asm文件
extern MessageBoxA:proc
extern ExitProcess:proc
includelib user32.lib
includelib kernel32.lib
.data
body db 'Hello World!',0
capt db 'x64',0
.code
start proc
call MessageBoxA
call ExitProcess
start endp
end
上面就是大體的框架結構,和32位彙編差不多,這裏需要注意的是這裏無32位中的inc頭文件,需要自己聲明函數的實現,還有就是也無法使用.if,invoke等僞指令。
下面,我們使用vs只帶的編譯工具進行編譯,可以進入vs自帶的環境命令窗口進行編譯鏈接,下面的bat腳本,只需修改自己的vs安裝目錄即可
call "D:\Microsoft_Visual_Studio_2015\VC\vcvarsall.bat" amd64
ml64 /c hello.asm
link /subsystem:windows /entry:start hello.obj
pause
編譯鏈接成功後,上面的程序是不能跑的,因爲都還沒有傳遞參數,那麼如何傳遞參數呢,這裏就涉及到函數的調用約定了,對於x64而言,使用的是四寄存器fastcall調用約定
RCX - 參數一
RDX - 參數二
R8 - 參數三
R9 - 參數四
剩餘的參數依次入棧
返回值使用RAX寄存器
熟悉32位的fastcall的調用約定這裏應該是比較好理解的,也就是函數的前四個參數使用寄存器傳遞參數。
好了,那麼來修改一下對應的代碼
start proc
xor r9d,r9d ;參數四
lea r8,capt ;參數三
lea rdx,body ;參數二
xor ecx,ecx ;參數一
call MessageBoxA
xor ecx,ecx
call ExitProcess
start endp
注意,對於64位的程序中,其和地址相關的類型佔用的都是8字節的,而其餘的基本數據類型不變(也就是int還是佔用4字節),而像上面的程序,其參數一是一個HWND的類型,本質上其實是一個指針的類型,但是上面操作的卻是ecx,按理應該操作Rcx纔對,其實這裏用到了上面說的對於32位進行零擴展,所以相當於操作的是Rcx清零。
重新編譯運行上面的程序,會發現還是跑不起來,爲什麼呢?這裏就涉及到了參數的預留空間的問題了,參數預留空間簡單來說就是在函數的調用前先申請0x20字節的空間(4個參數共32字節),那麼爲什麼需要預留空間呢?
首先,我們來假設如果函數內部,其寄存器如果不夠用了,那麼一定會需要將寄存器中的參數保存到堆棧中,也就是如下
sub rsp,20h ;開闢空間用於保存參數
mov [rsp+0],rcx
mov [rsp+8],rdx
mov [rsp+10h],r8
mov [rsp+18h],r9
;.... 函數內部邏輯
add rsp,20h
那麼我們來假設一下多次調用了MessageBox呢?
call MessageBoxA //sub rsp ... add...
call MessageBoxA //sub rsp ... add...
call MessageBoxA //sub rsp ... add...
可以發現,每一個函數內部都需要進行開闢0x20字節的空間然後進行存放數據,那麼這個重複的操作如果在函數調用前就開闢好了,那麼這也算是一種優化了。
sub rsp,20h ;預留空間,目的可以重複利用此空間
call MessageBoxA
call MessageBoxA
call MessageBoxA
;...
add rsp,20h
;MessageBox函數內部保存參數
mov [rsp+8],rcx
mov [rsp+10h],rdx
mov [rsp+18h],r8
mov [rsp+20h],r9
可以發現,這樣子就算多次發生了函數調用,那麼也只需在剛開始的時候申請一次空間即可,而當函數內部發現寄存器不夠用時,直接將寄存器中的值保存到預留空間就行了。相對於自己開闢空間保存寄存器,這裏rcx保存的位置是[rsp+8],因爲[rsp+0]的位置的返回地址。
需要注意的是,當有函數調用時,必須要提供這個預留空間(就算沒有參數傳遞),否則不遵守這個規則,程序只能是蹦嘍,因爲函數調用者不提供這個預留空間,當函數內部有使用時很容易就蹦,沒使用就又會一切正常。
下面修改繼續修改程序,重新編譯運行
start proc
sub rsp,20h
xor r9d,r9d ;參數一
lea r8,capt ;參數二
lea rdx,body ;參數三
xor ecx,ecx ;參數四
call MessageBoxA
xor ecx,ecx
call ExitProcess
start endp
這裏因爲最後都調用退出程序了,所以函數最後釋不釋放其實問題都不大了,其餘情況要注意堆棧空間的平衡。
不過很遺憾的是上面的程序還是跑不起來,因爲還需要遵守一個規則
當調用子函數時,堆棧指針RSP必須在16字節邊界對齊上
爲什麼有這麼一個規定,我們可以先來看看上面的程序使用x64跑起來會出現什麼樣的異常錯誤
可以發現上面的一條多媒體指令,xmm寄存器佔用的是16字節。那麼64位CPU規定,把一個n字節的數據放到棧裏面,棧地址必須模n,而現在16字節的話意味着需要模16,如何判斷一個地址是模16呢,其實就看最低位是不是0就可以了,如上圖地址
22F048 -> 22F040地址是模16的
所以上面的地址不符合要求就C05了。那麼如何能做到通用呢?那麼就都模16就可以了,畢竟地址符合模16,那麼其肯定符合模4,8。
那麼如何保證其堆棧指針的RSP是在16字節的對齊上呢?其實只需在擡棧的時候,其擡棧的空間的模8的就可以了,也就是說每進入一個函數,其RSP肯定是在8字節的對齊上,然後因爲擡一個模8的空間,那麼最終RSP就是在16的對齊上了。
可以確保進入一個函數時其開始地址一定模8麼?這裏是一定的,如果不這麼遵守,那麼當遇到多媒體指令的時候就很容易蹦了。
你可以這麼理解,在調用main函數操作系統之前,我們假設堆棧指針在16字節邊界上對齊
然後操作系統調用main時,調用指令(call)在堆棧推送一個8字節的返回地址,此時會其地址就不是16的倍數了,所以進入main函數剛開始其地址是模8的,爲了符合最終的標準,我們需要再減去一個模8的堆棧空間。
最後我們修改一下最終的程序
extern MessageBoxA:proc
extern ExitProcess:proc
includelib user32.lib
includelib kernel32.lib
.data
body db 'Hello World!',0
capt db 'x64',0
.code
start proc
sub rsp,8 ;將堆棧指令對齊到偶數16字節邊界
sub rsp,20h
xor r9d,r9d ;參數一
lea r8,capt ;參數二
lea rdx,body ;參數三
xor ecx,ecx ;參數四
call MessageBoxA
xor ecx,ecx
call ExitProcess
start endp
end
OK,下面重新編譯運行,就可以發現程序正常的運行了。
下面再寫一個如果函數內部有2個局部參數,然後需要傳遞6個參數的例子,主要再來研究一下其堆棧模型,一般堆棧的函數調用清楚後,一般x64的彙編程序也能大概的看看了。
首先我們先來計算一下需要擡高多少堆棧空間,一般在64位程序都開頭都是先計算出最大所需的空間(sub ...),然後下面進行操作。
預留空間 0x20字節
調用參數空間 0x10字節 //預留空間4個參數,如果某函數最多需要傳遞6個參數,那麼還需開闢2個參數的空間
局部參數空間 0x10字節 //兩個參數
-------------------------
0x40 字節 -對齊-> 0x48字節
首先對於調用參數的空間,這裏還需要說明一下,就是你只需要統計這個函數內那個函數調用的參數最多的即可,也就是最大的滿足空間要求了,其餘的函數調用肯定滿足了,如上,假設6個參數的函數已經是最多了,那麼就拿6個參數來計算。
總共所需的大小是0x40字節,不過爲了模16對齊,所以這裏需要擡高0x48個字節。這裏還需注意的是,如果局部參數就一個,那麼總共就是0x38大小,此時也就不需要對齊了。
簡單起見,下面寫一個求和的函數調用例子
extern ExitProcess:proc
includelib kernel32.lib
.code
myAdd proc ;該函數未涉及到函數調用,可以不申請預留空間
mov [rsp+8],rcx ;這裏只是爲了演示其預留空間的作用,實際該函數不需要將寄存器值存放到堆棧,因爲寄存器夠用
mov [rsp+10h],rdx
mov [rsp+18h],r8
mov [rsp+20h],r9
;累計求和
xor eax,eax
add rax,[rsp+8] ;分別對參數一到六求和
add rax,[rsp+10h]
add rax,[rsp+18h]
add rax,[rsp+20h]
add rax,[rsp+28h]
add rax,[rsp+30h]
ret
myAdd endp
start proc
sub rsp,48h
mov qword ptr [rsp+30h],5 ;局部參數一
mov qword ptr [rsp+38h],6 ;局部參數二
;下面開始傳遞參數調用
mov ecx,1 ;下面分別爲參數一到四
mov edx,2
mov r8,3
mov r9,4
mov rax,qword ptr [rsp+30h] ;獲取局部參數一
mov qword ptr [rsp+20h],rax ;參數五,注意這裏不能直接使用push
mov rax,qword ptr [rsp+38h]
mov qword ptr [rsp+28h],rax ;參數六
call myAdd
xor ecx,ecx
call ExitProcess
start endp
end
這裏主要講一下對於參數五和參數六,這裏不能使用push來壓棧,因爲這裏使用push來壓棧,那麼其參數數值就會在預留空間的上面,而我們需要的是在預留空間的下面,所以此時只能自己手動進行mov。
好了,函數調用的內容差不多就這些了,最後再總結一下函數調用的幾個約定,也就是編碼時一定要遵守以下的規則。
1.傳遞給函數的前四個參數放在RCX,RDX,R8和R9寄存器中
2.調用者的職責是在運行時分配至少32字節的預留空間,以便調用的函數可以選擇在此區域保存寄存器參數
3.當調用子函數時,堆棧指針RSP必須在16字節邊界對齊上