Win64 栈帧的性能和注意事项
在前文《masm64栈帧结构的详解》中,从Masm64的角度介绍了Win64栈帧结构及构建方法,本文介绍这种栈帧结构的性能和编程注意事项。
在Win64中,API函数采用新的调用约定,也即新的栈帧平衡机制,本人称其为"静态栈帧"。这种栈帧带来了很大变化,它改善了函数调用的性能,并且有更多的灵活性。对于使用汇编语言的编程人员来说,必须熟知这些变化,以适应新的技术环境。
1. 近距离使用push/pop指令
在Win32中,push/pop指令常用在函数体的首尾,以保存和恢复寄存器,但往往因为push/pop指令没有正确配对,造成程序崩溃。而采用静态栈帧,就消除了这类错误,提高了代码的安全性。
但这并不是说push/pop指令不能使用,其实还可以使用,但必须"近距离使用"。所谓近距离使用,就是用于变量的复制,而不是用于寄存器的保护。
如将变量val1的值复制到val2中,这就是近距离使用的意思了:
push val1
pop val2
以下这种情况是不可以的,因为不符合栈空间16字节对齐的要求,在调用子函数(即Test1)时直接出错。
push rbx
invoke Test1,101,102,103,104
pop rbx
以下这种情况也是不可以的,虽然符合栈空间16字节对齐的要求,可以调用子函数(即Test1),但寄存器RAX和RBX的值并没有被保护,这是因为在调用子函数时,原压入栈中的2个寄存器值被函数参数替换。
push rax
push rbx
invoke Test1,101,102,103,104
pop rbx ;rbx=101(被替换为第1个参数值)
pop rax ;rax=102(被替换为第2个参数值)
2. 函数参数传递的优化和注意事项
在Win32中使用push指令将函数参数压入栈中,但即要求被调用者负责释放,这种"不公平"的约定增加了系统级的错误机会。而在Win64中,在函数的首部一次性构建了所需的全部栈空间,谁构建栈空间就由谁来负责释放,责任明确。并且使用mov指令将函数参数置入栈中,避免了因使用push指令而引起的函数间的栈平衡问题。
另一方面,mov指令的执行速度大约比push指令快1倍,而且mov指令置入栈中的函数参数不会被子函数清除,可以重复利用,并且还约定前4个数参分别由RCX、RDX、R8、R9四个寄存器来传递,这进一步提高了函数调用的速度。一个程序是由大量的函数块构成的,每个函数又有很多参数需要传递,所以对于提高程序运行速度应该是有利的。
这里要注意的是,使用RCX、RDX、R8、R9四个寄存器用作参数时,一定要注意"对号入座",否则就出错了。例:
invoke Test,rcx,rdx,r8,r9,arg5,arg6 ;这是正确的
invoke Test,rdx,r8,arg3,arg4,rcx,r9 ;这是错误的
另外要注意的是,在Masm64中如果将RAX用作参数传递,必须是前4个参数之一,因为Masm64在处理后面参数(第5个参数开始)要使用寄存器RAX,这样RAX的值就被改变了。例:
invoke Test,rcx,rdx,r8,rax,arg5,arg6 ;这是正确的
invoke Test,rcx,rdx,r8,r9,arg5,rax ;这是错误的(在处理arg5后,RAX值被改变)
3. 函数参数的"双向传递"效果
在Win32中,函数参数是不能双向传递的,除非你使用变量指针作为参数,来显式地接收函数运算的结果。而在Win64中,函数参数具有双向传递的效果,如函数TestA调用函数TestB,如果在TestB函数中改变了参数的值,则能在TestA函数中得到该变化后的值,这相当前逆传给调用者,这对于需要返回多个运算结构是很有用的。例:
以下例子中,TestA调用TestB计算最小与最大值,再分别使用两种方法调用TestC和TestD计算中位数。例子本身没有什么价值,只是演示函数参数双向传递的技巧。
;===============================
; 计算中位数---使用默认栈帧
;===============================
TestD proc min:QWORD,max:QWORD
mov rax,max
inc rax
sub rax,min
shr rax,1
ret
TestD endp
;===============================
; 计算中位数---使用自定义栈帧
;===============================
NOSTACKFRAME
TestC proc min:QWORD,max:QWORD
ENTER 0,0
mov rax,max
inc rax
sub rax,min
shr rax,1
leave
ret
TestC endp
STACKFRAME
;==================================================
; 求数列的最大和最小值---逆向返回计算结果
; 返回:
; RAX=数值个数
; a1=最小值
; a2=最大值
;==================================================
TestB proc a1:QWORD,a2:QWORD,a3:QWORD,a4:QWORD,a5:QWORD,a6:QWORD
xor rax,rax
mov rdx,-1
mov rcx,6
lea r8,a1
ss_lp1:
cmp rax,[r8]
jnbe @F
mov rax,[r8]
@@:
cmp rdx,[r8]
jbe @F
mov rdx,[r8]
@@:
add r8,8
dec rcx
jnz ss_lp1
;---返回结果值---
mov a1,rdx ;返回最小值
mov a2,rax ;返回最大值
mov rax,6
ret
TestB endp
;====================================
; 参数双向传递例---主函数
;===================================
TestA proc
LOCAL ss_a1:QWORD
LOCAL ss_a2:QWORD
LOCAL ss_a3:QWORD
LOCAL ss_a4:QWORD
LOCAL ss_a5:QWORD
LOCAL ss_a6:QWORD
LOCAL ss_min:QWORD
LOCAL ss_max:QWORD
LOCAL ss_mid1:QWORD
LOCAL ss_mid2:QWORD
;---初始化---
mov ss_a1,3
mov ss_a2,1
mov ss_a3,5
mov ss_a4,4
mov ss_a5,6
mov ss_a6,2
;---调用TestB计算最小与最大值---
invoke TestB,ss_a1,ss_a2,ss_a3,ss_a4,ss_a5,ss_a6
;---获取返回值(注意:参数ss_a1 - ss_a6的值并没有改变)---
mov rax,[rsp]
mov ss_min,rax
mov rax,[rsp+8]
mov ss_max,rax
;--------------------------------------------------
; 调用TestC函数计算中位数
; 因为TestC使用自定义栈帧,不会改变当前rsp中的参数,
; 所以用call直接调用
;--------------------------------------------------
call TestC
mov ss_mid1,rax
;--------------------------------------------------
; 调用TestD函数计算中位数
; 因为TestD使用默认栈帧,会改变当前rsp中的参数,
; 所以用invoke调用。
;--------------------------------------------------
mov r10,rsp
invoke TestD,QWORD PTR [r10],QWORD PTR [r10+8]
mov ss_mid2,rax ;计算结果与ss_mid1是一样的
;--------------------------------------------------
; 也可以使用以下方法调用TestD函数
;--------------------------------------------------
mov rcx,[rsp]
mov rdx,[rsp+8]
call TestD
mov ss_mid2,rax ;计算结果与ss_mid1是一样的
ret
TestA endp
4. 函数的跨节点返回
所谓跨节点返回,是指函数不返回到父函数(即调用者),而直接返回到父函数的父函数,或更远。如TestA调用TestB,TestB调用TestC,TestC调用TestD,而TestD直接返回到TestB或TestA。这种技术在编写调试类软件时是很有用的。
在Win32中这种跨节点返回是不可能的事,而在Win64中采用了静态栈帧,而且每个函数体内用RBP来指向本函数的栈底,RSP当然是栈顶,这样跨节点返回就很容易了。当然不能返回到当前线程外面,也不要返回到API,并且要注意返回码的问题。
例:
;=========================
; 跨节点返回例子
;=========================
TestD proc
leave ;返回到TestB
; leave ;如果不注释掉的话,连续2个leave指令就返回到TestA
xor rax,rax ;默认的返回码
ret
TestD endp
;=========================
; 跨节点返回例子
;=========================
NOSTACKFRAME
TestC proc
ENTER 0,16
call TestD
leave
ret
TestC endp
STACKFRAME
;=========================
; 跨节点返回例子
;=========================
TestB proc
invoke TestC
mov rax,2
invoke MessageBox,0,str$(rax),"rax",MB_OK ;显示rax值
ret
TestB endp
;=========================
; 跨节点返回例子
;=========================
TestA proc
call TestB
mov rax,1
invoke MessageBox,0,str$(rax),"rax",MB_OK ;显示rax值
ret
TestA endp