X64 函数调用的一些问题

X64 函数调用的一些问题

对于X86 和 X64 的编码来说,主要有下面几个方面的不同:

1.      X64 具有 64-bit 的寻址能力

2.      16 个64-bit 整数寄存器以及16 个 XMM/YMM 寄存器用于浮点运算

3.      X64 默认使用的是__fastcall调用约定

4.      基于RISC 的异常处理结构

 

__fastcall调用约定

该调用约定使用寄存器来传递前4个参数,其余的参数使用栈来传递。尽管如此,主调函数依然为通过寄存器传递的参数保留了相应的栈空间。

Vs2010 关闭所有的优化选项,然后编译运行如下程序:

X64 函数调用的一些问题_第1张图片

#include

 

void Func6(inta,intb,intc,int d,inte,intf)

{

    return;

}

void Func5(inta,intb,intc,int d,inte)

{

    Func6(0,1,2,3,4,5);

}

 

void Func4(inta,intb,intc,int d)

{

    Func5(0,1,2,3,4);

}

void Func3(inta ,intb ,intc)

{

    Func4(0,1,2,3);

}

void Func2(inta,intb)

{

    Func3(0,1,2);

}

void Func1(inta)

{

    Func2(0,1);

}

void Func0()

{

    Func1(0);

}

int main()

{

    Func0();

    return 0;

}

无参数和一个参数

; int __cdecl main(int argc, const char**argv, const char **envp)

main proc near

sub    rsp, 28h

call   sub_140001130

xor    eax, eax

add    rsp, 28h

retn

main endp

 

main 函数刚开始申请了 28 h 的空间,然后调用子函数

sub_140001130 proc near

sub    rsp, 28h

xor    ecx, ecx

call   sub_140001110

add    rsp, 28h

retn

sub_140001130 endp

xor ecx,ecx 即参数第一个参数为0,然后调用子层函数。

 

两个参数、三个参数、四个参数

sub_140001110 proc near

 

arg_0= dword ptr  8

 

mov    [rsp+arg_0], ecx

sub    rsp, 28h

mov    edx, 1

xor    ecx, ecx

call   sub_1400010E0

add    rsp, 28h

retn

sub_140001110 endp

 

 

sub_1400010E0 proc near

 

arg_0= dword ptr  8

arg_8= dword ptr  10h

 

mov    [rsp+arg_8], edx

mov    [rsp+arg_0], ecx

sub    rsp, 28h

mov    r8d, 2

mov    edx, 1

xor    ecx, ecx

call   sub_1400010B0

add    rsp, 28h

retn

sub_1400010E0 endp

 

sub_1400010B0 proc near

 

arg_0= dword ptr  8

arg_8= dword ptr  10h

arg_10= dword ptr  18h

 

mov    [rsp+arg_10], r8d

mov    [rsp+arg_8], edx

mov    [rsp+arg_0], ecx

sub    rsp, 28h

mov    r9d, 3

mov    r8d, 2

mov    edx, 1

xor    ecx, ecx

call   sub_140001070

add    rsp, 28h

retn

sub_1400010B0 endp

上面三个函数调用分别传递了2,3,4个参数。我们知道x64 fast call 的参数传递规则,从右向左入栈且前四个参数由参数寄存器传递,即rcx,rdx,r8,r9。上面的汇编代码也证实了这点,那么我们就像知道,当参数个数超过4个时,参数如何传递,参数传递的位置又是哪儿呢?

五个参数

 

sub_140001070 proc near

 

var_18= dword ptr -18h

arg_0= dword ptr  8

arg_8= dword ptr  10h

arg_10= dword ptr  18h

arg_18= dword ptr  20h

 

mov    [rsp+arg_18], r9d

mov    [rsp+arg_10], r8d

mov    [rsp+arg_8], edx

mov    [rsp+arg_0], ecx

sub    rsp, 38h

mov    [rsp+38h+var_18], 4

mov    r9d, 3

mov    r8d, 2

mov     edx, 1

xor    ecx, ecx

call   sub_140001020

add    rsp, 38h

retn

sub_140001070 endp

上面的 mov [rsp+20h],4 操作对应的显然就是第5个参数4。而我们看到其位置为:

[4]

[]

[]

[]

[]rsp的位置

中间的四个位置用于什么用途呢?我们知道,参数寄存器的个数刚好为四个。当我们在无参数内部增加一些代码,并观察函数行为:

void Func5(inta,intb,intc,int d,inte)

{

    a += 1;

    b += 2;

    c += 3;

    d += 4;

    e += 5;

    Func6(0,1,2,3,4,5);

}

 

sub_140001020 proc near

 

var_18= dword ptr -18h

var_10= dword ptr -10h

arg_0= dword ptr  8

arg_8= dword ptr  10h

arg_10= dword ptr  18h

arg_18= dword ptr  20h

arg_20= dword ptr  28h

 

mov    [rsp+arg_18], r9d             ;将 r9d 保留到 rsp+20h

mov    [rsp+arg_10], r8d             ; 将 r8d 保留到 rsp + 18h

mov    [rsp+arg_8], edx               ; 将 edx 保留到 rsp + 10h

mov    [rsp+arg_0], ecx               ; 将ecx 保留到 rsp + 8h

sub    rsp, 38h

mov    eax, [rsp+38h+arg_0]

inc    eax                                         

mov    [rsp+38h+arg_0], eax     ; 这里 [rsp + 8h+38h]++

mov    eax, [rsp+38h+arg_8]

add    eax, 2

mov    [rsp+38h+arg_8], eax     ; 这里 [rsp + 10h+38h]+=2

mov    eax, [rsp+38h+arg_10]

add    eax, 3

mov    [rsp+38h+arg_10], eax   ;这里 [rsp + 18h+38h]+=3

mov    eax, [rsp+38h+arg_18]

add    eax, 4

mov    [rsp+38h+arg_18], eax   ;这里 [rsp + 20h+38h]+=4

mov     eax, [rsp+38h+arg_20]

add    eax, 5

mov    [rsp+38h+arg_20], eax   ;这里 [rsp + 28h+38h]+=5 就是我们传入的第5个参数的位置

mov    [rsp+38h+var_10], 5

mov    [rsp+38h+var_18], 4

mov    r9d, 3

mov    r8d, 2

mov    edx, 1

xor    ecx, ecx

call   sub_140001000

add    rsp, 38h

retn

sub_140001020 endp

 

通过汇编我们看到,当我们对形参进行修改的时候,函数将参数寄存器的值放入了栈空间中,然后再对栈空间进行操作,跟我们之前所熟悉的对于函数参数的操作是相同的。无论子函数的参数是多少个,我们的函数都为其至少预留了4*8 的栈空间,当参数更多的时候,其增加栈的大小。

 

六个参数

 

sub_140001020 proc near

 

var_18= dword ptr -18h

var_10= dword ptr -10h

arg_0= dword ptr  8

arg_8= dword ptr  10h

arg_10= dword ptr  18h

arg_18= dword ptr  20h

 

mov    [rsp+arg_18], r9d

mov    [rsp+arg_10], r8d

mov    [rsp+arg_8], edx

mov    [rsp+arg_0], ecx

sub    rsp, 38h

mov    [rsp+38h+var_10], 5

mov    [rsp+38h+var_18], 4

mov    r9d, 3

mov    r8d, 2

mov    edx, 1

xor    ecx, ecx

call   sub_140001000

add    rsp, 38h

retn

sub_140001020 endp

 

 

通过查看上面的几个函数调用我们发现,函数刚开始都进行了

sub rsp [某个值] 的操作

由于函数内部均没有使用局部变量,可以总结出这个值有如下规律

参数个数 <= 4 时28h,参数个数为5,6个时38h

看了以上的程序我们应该想到的一个问题:

函数刚开始的sub esp 操作应该开辟多达的栈空间?与子函数参数个数是否有联系

这里先给出一个结论:函数开始部分(即下面介绍的prolog)申请栈空间的时候(通过 push sub esp操作),大小需要符合16n+8,以满足在函数调用的时候rsp16h对齐的要求。call指令的时候使用了8字节。而要理解这个过程如何实现,需要先了解易失性寄存器、Prolog 和 epilog的概念。

易失性寄存器与非易失性寄存器

易失性寄存器 假设被调用者修改的寄存器,调用者负责其保存和恢复工作。

 

非易失性寄存器 被假设为“在函数调用之后依然不变”,因此被调用者负责其保存和恢复。

X64 函数调用的一些问题_第2张图片

图片来源:https://msdn.microsoft.com/en-us/library/9z1stfyw.aspx

 

常用非易失性寄存器有:R12 R15 RDI RSI RBX RBP RSP

 

关于 Prolog 和 Epilog

Prolog所有申请栈空间,调用子函数,保存非易失性寄存器或者使用SHE 的函数必须有prolog。而且其地址范围存储在与之相应函数表条目相关联的unwind(展开)数据中描述(PE 文件中)。

 

如果需要,prolog 将参数寄存器存储在其归属地址中,将堆栈上的非易失性寄存器分配给本地和临时的堆栈的固定部分,并且可选地建立帧指针。即

1.保存寄存器的值到栈空间中以便于函数返回的时候恢复

2.为函数执行操作所需要的局部变量以及函数调用申请栈空间。

相应的unwind 数据必须描述该prolog的动作,且提供足够的信息来撤销prolog所做的操作。Prolog 相当于函数执行前的准备工作。相应的Epilog 为函数执行完毕后的清理工作。

 

Epilog代码存储在与每个函数退出的时候。通常只有一个prolog,但是可以有很多epilog,Epilog代码将堆栈修改为固定分配大小(如果需要),释放固定堆栈分配,通过从堆栈中弹出其保存的值来恢复非易失性寄存器,并返回。

 

在prolog 中通常首先保存易失性寄存器,然后再分配固定的栈空间,否则在通过rsp 访问局部变量的时候需要一个额外的偏移量(push 操作的个数*8)。非易失性寄存器可以以任何顺序保存。但是,在prolog 中首次使用非易失性寄存器必须保存它。

 

Epilog 代码必须遵循一系列严格的规则,以使展开代码从中断和异常中可靠的展开。这减少了所需的展开数据结构的数量,因为不需要额外的数据来描述每个epilog。相反,展开代码可以通过向前扫描代码流来确定epilog正在执行,以识别一个epilog。

 

如果函数没有使用帧指针,那么 epilog 必须首先释放堆栈的固定部分,然后弹出非易失性寄存器,并将控制权返回给调用函数。

 

Epilog 的合法形式

Add rsp 或者 lea rsp,constant[FPReg]

后面加上一系列或者零个 pop 寄存器的操作。

最后为一个返回或者跳转操作。

 

在epilog 中,只有jmp 的一部分子集操作是允许的-----ModRM 跳转指令:且 ModRM mod 字段为00 -----即只允许直接内存跳转 [base]形式的memory寻址

如果帧指针没有使用,epilog必须使用add rsp,constant回收栈空间,可能不使用lea RSP,constant [RSP],由于这些限制的存在,展开代码在寻找epilogs时有较少的识别模式

遵循这些规则以允许展开代码确定epilog当前正在执行,并模拟epilog的其余部分的执行以允许重新创建调用函数的上下文。

参考链接:

http://www.mouseos.com/x64/doc6.html

 

其它的任何代码不能出现在epilog 中,

 

在了解上述概念之后我们开始解释上面的

16n + 8 的实现

通过上面的介绍,如果函数有prolog,其包含三部分的组成。Push 非易失性寄存器进栈,开辟固定大小的栈空间即 sub esp 【固定值】。

Push非易失性寄存器就是为了在函数中使用该寄存器之后的恢复。sub esp操作要负责一下几个方面:函数调用时参数的传递,函数内部的局部变量局部变量是固定的,由于栈空间在函数刚开始的代码中确定直到后面函数返回都不会变化,对于不同的子函数调用,分配的用于函数调用的栈空间并不会动态改变大小,因此,我们需要考虑的是函数参数最多的子函数的函数参数个数,另外,发生函数调用时,函数内部总是会为rcx,rdx,r8,r9保留栈空间。综上,函数内部申请固定大小栈空间的时候,其大小=(局部变量+ max(参数最多的函数的参数个数,4)*8。这个固定值+前面的push操作应该符合16n+8的要求。不同的编译器对于具体实现的方法不同,没有明确答案,但是其必须满足16 对齐。Vs2010 在实现的时候貌似其局部变量16对齐,然后函数参数个数16对齐,然后总的三个部分再合起来满足参数对齐的要求。比如下面这个函数:

void For()

{

    unsigned long long j;

    Func5(0,1,2,3,4);

}

微软sub rsp 48h,开48h栈空间=alignmen(8,16)+alignment(5*8,16) + 8(用于对齐)

而intel编译器是这样操作的:

push       rbp 

sub        rsp,50h

void For()

{

    unsigned long long j;

    unsigned long long i;

    Func6(0,1,2,3,4,5);

}

此时vs2010 同样开48h栈空间

Intel 编译器这样做:

push       rbp 

sub        rsp,50h

相比微软,函数开始添加了push rbp的操作。我们这里仅仅介绍了解vs2010的做法,其实不必深究,知道了在call之前,rsp 16对齐且有足够空间用于参数传递即可

实验认证16n+8

void For()

{

    char    szTemp[] = "Hello";

    for(inti = 0;i <sizeof(szTemp) ;i++)

    {

        putchar(szTemp[i]);

    }

    putchar('\n');

}

int main()

{

    For();

    return 0;

}

为了生成的汇编代码更简单易读,我们在上面的配置基础上进行如下配置:

 X64 函数调用的一些问题_第3张图片

sub_140001000 proc near

 

var_28= byte ptr -28h                         ; szTemp  其值指向的为一个栈空间的地址

var_20= dword ptr -20h                     ;i

 

push    rsi

push    rdi

sub     rsp, 38h                              ;这里就是prolog 分别push rsi rdi,申请38h

;很容易发现,38h + rsi + rdi = 48h符合 16n+8

lea    rax, [rsp+48h+var_28]

lea    rcx, aHello     ;"Hello"

mov    rdi, rax

mov    rsi, rcx

mov    ecx, 6

rep movsb                                              ;szTemp[] = “Hello”,拷贝操作需要循环,使用了rdi和dsi寄存器

mov    [rsp+48h+var_20], 0

jmp    short loc_140001034

 

loc_14000102A:

mov    eax, [rsp+48h+var_20]

inc    eax

mov    [rsp+48h+var_20], eax   ;i++

 

loc_140001034:

movsxd rax, [rsp+48h+var_20]

cmp    rax, 6

jnb    short loc_140001053

 

movsxd rax, [rsp+48h+var_20]

movsx  eax, [rsp+rax+48h+var_28]

mov    ecx, eax        ; Ch

call   cs:putchar

jmp    short loc_14000102A

 

loc_140001053:          ; Ch

mov    ecx, 0Ah

call   cs:putchar

add     rsp, 38h

pop     rdi

pop     rsi

retn                                                        ;这里就是epilog,先add rsp后恢复寄存器rdi rsi,为prolog的逆操作,然后retn返回

sub_140001000 endp

 

函数内部 38h = szTemp 六个字节对齐后为8个字节 + i变量8字节 + putchar 4 *8 字节参数对16对齐依然是20h。然后为了符合 16n + 8的格式而添加的8 字节,总共为38h。如下一个程序所示,如果我们添加对于5参数函数的调用,此时最大参数个数为5,对应的5*8 并16对齐后为30h,函数申请内存应变为0x48h。而如果添加6参数函数调用,同理该值变为 48h-----经验证上述假设推理完全正确

void Func5(inta,intb,intc,int d,inte)

{

    return;

}

 

void For()

{

    char    szTemp[] = "Hello";

    for(inti = 0;i <sizeof(szTemp) ;i++)

    {

        putchar(szTemp[i]);

    }

    putchar('\n');

    Func5(0,1,2,3,4);

}

int main()

{

    For();

    return 0;

}

 

 

sub_140001020 proc near

 

var_38= dword ptr -38h

var_28= byte ptr -28h

var_20= dword ptr -20h

 

push   rsi

push   rdi

sub     rsp, 48h

lea    rax, [rsp+58h+var_28]

lea    rcx, aHello     ;"Hello"

mov    rdi, rax

mov    rsi, rcx

mov    ecx, 6

rep movsb

mov    [rsp+58h+var_20], 0

jmp    short loc_140001054

 

另外补充一下X64 中的两类函数类型

帧函数与叶子函数

帧函数(frame function)

帧函数分配帧空间,调用其它函数,保存非易失性寄存器或使用异常处理函数,这样的函数需要一个function table entry 以及一个prolog 以及epilog,帧函数可以动态分配栈空间,使用栈帧。

叶子函数是不需要function table entry 的函数,不调用任何函数,不分配空间,或者保存非易失性寄存器,允许其在栈没有对齐的情况下执行。

 

 

关于函数参数传递的问题:

https://msdn.microsoft.com/en-us/library/zthk2dkh.aspx

你可能感兴趣的:(基础知识)