函数调用栈帧分析(Windows平台)

目录

1.  例释环境和预备知识

1.1 运行环境

1.2  预备知识

2.  函数调用约定

3.  关键点说明

3.1  影子空间(shadow space)

3.2  栈内存的分配方式

4.  实例分析

4.1  例1:查看影子内存的分配

4.2  例2:带参数的函数

4.3  例3:使用局部变量的函数

4.4  例4:超过4个参数的参数传递


1.  例释环境和预备知识

1.1 运行环境

本示例运行环境为Windows 10平台,所示例的程序或动态库为VS2022平台下编写并编译的X64程序。

1.2  预备知识

理解本示例需要具务如下知识:

关于栈桢的约定的相关知识,详情请参见Windows平台的相关约定:

https://blog.youkuaiyun.com/ComputerInBook/article/details/122955217

而关于Linux或Unix平台的栈帧的约定稍有不同,请参见:

https://blog.youkuaiyun.com/ComputerInBook/article/details/125008649

以上两者虽然有一些差异,但它们又有共同之处,即保有影子内存,序言和结语,以方便对于调用异常的处理。

2.  函数调用约定

与X86调用约定不同,C/C++编译器在64位平台上仅支持一种调用约定。这种调用约定利用了64位平台上可获得的新增寄存器数量:

(1) 前4个整数或者指针参数依次通过 rcx,rdx,r8和 r9 传递。

(2) 前4个浮点参数通过前4个SSE寄存器xmm0-xmm3传递。

(3) 由调用者为寄存器中的参数传递保留栈上的空间(至少在运行栈上分配32字节的阴影空间(shadow space))。被调用函数可以访问这个栈空间,将寄存器中的内容写回栈空间

(4) 任何多余4个参数的其它参数都使用栈来传递,并按照从左到右的次序(即,从第5个参数开始,使用栈传递参数)。

(5) 任何调用返回的整数或者指针值都放在rax寄存器中(调用完成执行返回动作时放在rax寄存器中,例如,调用ret指令时),而浮点数的返回值放在寄存器xmmO中。

(6) rax,rcx,rdx,r8-r11寄存器是易失性的(volatile)。

(7) rbx,rbp,rdi,rsi,r12-r15 寄存器是非易失性的(nonvolatile)。

这些调用约定与C++ 非常类似:指针默认作为第一个参数传递,其它三个参数利用余下的3个寄存器,多出4个的参数则使用栈传递。

(8) call指令从rsp(栈指针)寄存器中减去 8,表示空出8字节的栈空间用于存放函数返回地址,因为地址是64位长(8字节)。

(9) 当调用一个子过程(subroutine)的时候,规定指令指针(rip)必须在一个16字节的边界对齐(也就是128位,即16的倍数,这可能是在设计CPU时综合性能考量)。call指令将一个8字节的返回地址压入栈中,因此,调用程序必须从栈指针中减去8(除了32),它已经减去了影子空间。即,call指令做了两件事:

·  rsp = rsp – 8h (从当前栈减去8,分配8个字节的栈空间)

·  在新分配的栈空间地址处存入call完成之后的这条指令的地址。

3.  关键点说明

3.1  影子空间(shadow space)

在 Windows 系统的 x64 汇编语言中,影子空间是指栈上一个强制性的 32 字节区域,调用函数(调用方)在调用被调函数之前必须分配这块区域。设计这个区域的目的是为了让被调函数在需要时能够将被寄存器传递的前四个参数保存到栈上,主要用于方便进行可靠的调试以及支持可变参数函数。

要理解影子空间,需要简要了解一下 Microsoft x64 调用约定,该约定规定了函数如何传递参数和管理堆栈。

·  基于寄存器的参数传递:在 Windows x64 系统中,前四个整数或指针参数分别使用特定的易失性寄存器(RCX ,RDX , R8 和 R9) 传递给函数。

·  寄存器需要一个“存放位置”:由于这些寄存器是易失性的(这意味着被调用的函数可以在不事先保存的情况下覆盖它们),因此如果被调用函数想要将这些特定寄存器用于自身目的,或者如果调试器需要对内存中的所有参数保持一致的视图,则需要一种机制来解决这个问题。

·  调用方负责制:Windows x64 应用程序二进制接口 (ABI) 规定,调用方必须在执行 CALL 指令之前,立即在栈上预留这 32 字节(4 个寄存器 * 8 字节/寄存器)的“主空间(home)”或“溢出空间(spill)”。这可以通过使用 SUB RSP, 32(或等效的栈调整指令)来实现。

· 被调者的特权被调用函数可以根据需要将寄存器值保存到这块分配的空间中。在未优化的构建版本中,编译器通常会使用这块区域,以便调试器可以轻松地在标准化的内存位置检查函数参数,从而创建完整且可重构的栈帧。在优化后的构建版本中,这块空间可能仅用作通用临时存储空间。

3.2  栈内存的分配方式

    栈指针(栈寄存器)(RSP)指向可用栈的栈顶(高地址端),当需要分配栈空间时减去一个数,当需要回收栈空间时加上一个数。栈指针永远指向可用栈空间的栈顶。

4.  实例分析

以下示例中,我们使用 _cdecl 调用约定。__cdecl 是 C 和 C++ 程序的默认调用约定。由于调用方负责清理栈空间,因此它可以支持可变参数函数。__cdecl 调用约定生成的程序文件比 __stdcall 调用约定生成的程序文件更大,因为它要求每一个函数调用都包含栈空间清理代码。以下列表显示了此调用约定的实现方式。__cdecl 修饰符是 Microsoft 特有的。

元素

实现

参数传递顺序

从右到左

维护栈的责职

调用函数会从栈中弹出参数。

名称修饰约定

除了导出使用 C 语言链接的 __cdecl 函数之外,名称前面都会加上下划线字符 (_)。

大小写编译约定

4.1  例1:查看影子内存的分配

void _cdecl demo1();

实现为空函数体:

void demo1()

{

}

调用函数:

void Test()

{

    demo1();

}

Test() 的汇编代码分析:

00007FF6263D4870  sub         rsp,28h 

    demo1();

00007FF6263D4874  call        demo1 (07FF6263C3553h) 

}

00007FF6263D4879  add         rsp,28h 

00007FF6263D487D  ret 

(1) demo1();

调用这个函数会生成一个调用指令:

00007FF6263D489D  call        Test (07FF6263C740Fh)

这个 call 负责分配函数调用完成后的返回地址和影子内存。

(2) sub         rsp,28h

影子内存字节数为4*8 = 32 字节。但这里多减了8个字节,原因在于,这个函数被调时,call 指令自动减去了 8 个字节,当进入被调函数以后,为了满足 16字节的边界对齐,这里再次减去 8个字节。因此,分配字节数为:8 + 4*8 = 40(28h) 字节。

(3)  00007FF6263D4879  add         rsp,28h

调用完成,调用函数负责恢复栈空间。

(4) 00007FF6263D487D  ret 

即花括号“}”干的事情:恢复调用栈,取得返回地址,跳转到调用函数前的内存地址。

(5) 由于 demo1() 内部没有代码,编译器生成一个返回代码:

00007FF6263C3553  jmp         demo1 (07FF6263D4860h)  

00007FF6263D4860  ret         0

以上 ret  应该跳到地址 00007FF6263D4879  处继续执行。

4.2  例2:带参数的函数

int _cdecl demo2(int x,int y);

int demo2(int x, int y)

{

    return x + y;

}

void Test()

{

    demo2(1,2);

}

Test() 的汇编代码分析:

void Test()

{

00007FF668CF4880  sub         rsp,28h 

    demo2(1,2);

00007FF668CF4884  mov         edx,2 

00007FF668CF4889  mov         ecx,1 

00007FF668CF488E  call        demo2 (07FF668CE7491h) 

}

00007FF668CF4893  add         rsp,28h 

00007FF668CF4897  ret 

(1)  使用寄存器传参,且从右向右

00007FF668CF4884  mov         edx,2 

00007FF668CF4889  mov         ecx,1 

先传 2 ,再传 1

(2)  00007FF668CF488E  call        demo2 (07FF668CE7491h)

执行这条指令后,rsp 的值减去 8 ,且这个空间存存的值为 00007FF668CF4893 ,即 00007FF668CF4893  add         rsp,28h 

这条指令的地址。

Demo2() 的汇编代码分析:

int demo2(int x, int y)

{

00007FF668CF4860  mov         dword ptr [y],edx 

00007FF668CF4864  mov         dword ptr [x],ecx 

    return x + y;

00007FF668CF4868  mov         eax,dword ptr [y] 

00007FF668CF486C  mov         ecx,dword ptr [x] 

00007FF668CF4870  add         ecx,eax 

00007FF668CF4872  mov         eax,ecx 

}

00007FF668CF4874  ret

(1)  使用寄存器传参,且从右向左(对应栈从高地址到低地址存放)

00007FF668CF4860  mov         dword ptr [y],edx 

00007FF668CF4864  mov         dword ptr [x],ecx 

取出寄存器传的参数,放入栈中。实际上,

指令

mov         dword ptr [y],edx

将寄存器 edx 的参数值存入前面分配的 32 字节的影子内存中,因此函数内部没有分配栈空间。

(2)  ret 语句返回函数调用前的位置,

00007FF668CF4874  ret

ret 语句加一个 8 字节,与 call 减去的 8 字节对应,然后跳转到子函数被调用前的位置的下一个地址。即跳转到

00007FF668CF4893  add         rsp,28h 

00007FF668CF4897  ret 

4.3  例3:使用局部变量的函数

int _cdecl demo3(int x,int y);

int demo3(int x, int y)

{

    int sum = 5 * x + 6 * y;

    return sum;

}

void Test()

{

    int ret = demo3(1,2);

}

demo3() 的汇编代码分析:

int demo3(int x, int y)

{

00007FF6F4664860  mov         dword ptr [rsp+10h],edx 

00007FF6F4664864  mov         dword ptr [rsp+8],ecx 

00007FF6F4664868  sub         rsp,18h 

    int sum = 5 * x + 6 * y;

00007FF6F466486C  imul        eax,dword ptr [x],5 

00007FF6F4664871  imul        ecx,dword ptr [y],6 

00007FF6F4664876  add         eax,ecx 

00007FF6F4664878  mov         dword ptr [rsp],eax 

    return sum;

00007FF6F466487B  mov         eax,dword ptr [rsp] 

}

00007FF6F466487E  add         rsp,18h 

00007FF6F4664882  ret

(1)  利用影子内存存储形参

00007FF6F4664860  mov         dword ptr [rsp+10h],edx 

00007FF6F4664864  mov         dword ptr [rsp+8],ecx 

从这个代码可以看出,编译器利用了影子内存存储形参,其中,最右边的参数存放在最右端,栈中对应高地址,最左边的参数存放在最左端,栈中低地址端。

(2)  移动栈指针

00007FF6F4664868  sub         rsp,18h 

减 18h = 24 个字节,栈指针指向了影子空间中的 17 个字节的起始处,后面用它存储计算的临时值。

(3)  计算

00007FF6F466486C  imul        eax,dword ptr [x],5 

00007FF6F4664871  imul        ecx,dword ptr [y],6 

在寄存器中完成计算(任何计算一定要有寄存器参与,内存本身不能计算,只能存储)。

(4)  将计算结果从寄存器存入内存(在这里是影子空间)

00007FF6F4664878  mov         dword ptr [rsp],eax

(5)  将返回结果存入寄存器 eax

00007FF6F466487B  mov         eax,dword ptr [rsp] 

(6)  恢复栈,返回

00007FF6F466487E  add         rsp,18h 

00007FF6F4664882  ret

Test() 的汇编代码分析:

void Test()

{

00007FF6D6064890  sub         rsp,38h 

    int ret = demo3(1,2);

00007FF6D6064894  mov         edx,2 

00007FF6D6064899  mov         ecx,1 

00007FF6D606489E  call        demo3 (07FF6D6057496h) 

00007FF6D60648A3  mov         dword ptr [ret],eax 

}

00007FF6D60648A7  add         rsp,38h 

00007FF6D60648AB  ret 

(1)  分配栈空间

00007FF6D6064890  sub         rsp,38h 

本来局部变量 ret 只需要 4 个字节,但处理器规定必需在 16 倍数的地址对齐,因此分配了 16个字节,加上为平对齐 call 指令减去的 8 字节后果的内存,减了 8 个字节,再加上影子内存 32 字节,因此一共是减了 16 + 8 + 32 = 56 = 38h字节。

(2)  取出返回结果

00007FF6D60648A3  mov         dword ptr [ret],eax 

从寄存器 eax 取出结果(规范约定)。

注意:从以上代码看出,影子内存除了用作参数传递,在参数未占用影子内存的时候,影子内存也用作计算的临时存储。

4.4  例4:超过4个参数的参数传递

int _cdecl demo4(int v1, int v2, int v3, int v4, int v5);

int demo4(int v1, int v2, int v3, int v4, int v5)

{

    int sum = v1 + v2 + v3 + v4 + v5;

    return sum;

}

void Test()

{

    int ret = demo4(1,2,3,4,5);

}

Test() 的汇编代码分析:

void Test()

{

00007FF612AF4890  sub         rsp,48h 

    int ret = demo4(1,2,3,4,5);

00007FF612AF4894  mov         dword ptr [rsp+20h],5 

00007FF612AF489C  mov         r9d,4 

00007FF612AF48A2  mov         r8d,3 

00007FF612AF48A8  mov         edx,2 

00007FF612AF48AD  mov         ecx,1 

00007FF612AF48B2  call        demo4 (07FF612AE749Bh) 

00007FF612AF48B7  mov         dword ptr [ret],eax 

}

00007FF612AF48BB  add         rsp,48h 

00007FF612AF48BF  ret 

(1)  超出 4 个参数用栈空间传递

00007FF612AF4894  mov         dword ptr [rsp+20h],5

Demo4() 的汇编代码分析:

int demo4(int v1, int v2, int v3, int v4, int v5)

{

00007FF612AF4850  mov         dword ptr [v1],r9d 

00007FF612AF4855  mov         dword ptr [rsp+18h],r8d 

00007FF612AF485A  mov         dword ptr [rsp+10h],edx 

00007FF612AF485E  mov         dword ptr [rsp+8],ecx 

00007FF612AF4862  sub         rsp,18h 

    int sum = v1 + v2 + v3 + v4 + v5;

00007FF612AF4866  mov         eax,dword ptr [v2] 

00007FF612AF486A  mov         ecx,dword ptr [v1] 

00007FF612AF486E  add         ecx,eax 

00007FF612AF4870  mov         eax,ecx 

00007FF612AF4872  add         eax,dword ptr [v3] 

00007FF612AF4876  add         eax,dword ptr [v4] 

00007FF612AF487A  add         eax,dword ptr [v5] 

00007FF612AF487E  mov         dword ptr [rsp],eax 

    return sum;

00007FF612AF4881  mov         eax,dword ptr [rsp] 

}

00007FF612AF4884  add         rsp,18h 

00007FF612AF4888  ret 

(1)  取第 4 个参数的栈地址并赋值

00007FF612AF4850  mov         dword ptr [v1],r9d 

注意,这个 dword ptr [v1] 引用的是存放 4 个栈参数的阴影空间的第 4 个参数的地址,即 v4 的地址,而不是 v1 的地址 以上语句将第 4 个参数值存入阴影空间的栈顶的地址。这里这个值为 4 。

当前 RSP = 0x000000A3CC4FFA08 , RSP + 8h(call 调用减去的字节数) + 18h(24)(3个参数占用的栈大小) = 0x000000A3CC4FFA28 ,查看这个地址处的值恰好等于 4:

(2)  依次取出寄存器中的其余3个值放入影子空间

00007FF612AF4855  mov         dword ptr [rsp+18h],r8d 

00007FF612AF485A  mov         dword ptr [rsp+10h],edx 

00007FF612AF485E  mov         dword ptr [rsp+8],ecx 

参数从右向左传递:依次为 3(v3),2(v2),1(v1) ,完成后栈空间值分布:

(2)  本身就是栈传递的参数则直接使用

00007FF612AF487A  add         eax,dword ptr [v5] 

注意:前四个参数由于是寄存器传递,因此前四个值利用了影子空间来存储参数值并进行计算。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值