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

目录

[1. 例释环境和预备知识](#1. 例释环境和预备知识)

[1.1 运行环境](#1.1 运行环境)

[1.2 预备知识](#1.2 预备知识)

[2. 函数调用约定](#2. 函数调用约定)

[3. 关键点说明](#3. 关键点说明)

[3.1 影子空间(shadow space)](#3.1 影子空间(shadow space))

[3.2 栈内存的分配方式](#3.2 栈内存的分配方式)

[4. 实例分析](#4. 实例分析)

[4.1 例1:查看影子内存的分配](#4.1 例1:查看影子内存的分配)

[4.2 例2:带参数的函数](#4.2 例2:带参数的函数)

[4.3 例3:使用局部变量的函数](#4.3 例3:使用局部变量的函数)

[4.4 例4:超过4个参数的参数传递](#4.4 例4:超过4个参数的参数传递)


1. 例释环境和预备知识

1.1 运行环境

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

1.2 预备知识

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

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

https://blog.csdn.net/ComputerInBook/article/details/122955217

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

https://blog.csdn.net/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]

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

相关推荐
方圆工作室7 小时前
【C语言图形学】用*号绘制完美圆的三种算法详解与实现【AI】
c语言·开发语言·算法
love530love9 小时前
ComfyUI Hunyuan-3D-2 插件安装问题解决方案
人工智能·windows·python·3d·comfyui·hunyuan-3d-2·pygit2
菩提树下的凡夫10 小时前
基于windows X64 NVIDA显卡的onnxruntime环境下GPU加速C++部署教程
windows
取个名字太难了a10 小时前
用户 APC 的执行过程(下)
windows
sycmancia10 小时前
C语言学习03——数据类型
c语言
黎雁·泠崖13 小时前
整数的N进制字符串表示【递归+循环双版满分实现】
c语言·开发语言
小美单片机13 小时前
Proteus 报错 Unable to open HEX file ‘..\1、程序\jio\jtd.hex‘. [U1]
c语言·单片机·嵌入式硬件·51单片机·proteus
QQ121546146814 小时前
使用远程桌面连接Windows 2012 R2 Standard服务器报错:出现身份验证错误。要求的函数不受支持。这可能是由于CredSSP加密数据库修正。
服务器·windows·windows server
worilb14 小时前
WinSW XML 配置参数介绍
windows