64位汇编入门

Posted 嘻嘻兮

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了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字节边界对齐上

 

以上是关于64位汇编入门的主要内容,如果未能解决你的问题,请参考以下文章

Windows X64汇编入门

如何在vs2017中进行64位汇编的配置

优化系列汇编优化技术:ARM架构64位(AARCH64)汇编优化及demo

优化系列汇编优化技术:X86架构汇编优化及demo

汇编语言 第三章

需要解释 64 位汇编指令与 32 位相反