64位彙編入門

忽然間想記錄一下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字節邊界對齊上

 

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