内功修炼《函数栈帧的创建和销毁》建议收藏

Posted 跳动的bit

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了内功修炼《函数栈帧的创建和销毁》建议收藏相关的知识,希望对你有一定的参考价值。

前言

在前期的学习过程中,我们可能会有很多的困惑:

1️⃣ 局部变量是怎么创建的?

2️⃣ 为什么未初始化的局部变量的值是随机值?

3️⃣ 函数是如何传参的?以及传参的顺序是怎样的?

4️⃣ 形参和实参是什么关系?

5️⃣ 函数调用是怎么做的?

6️⃣ 函数调用结束后是怎么返回的?

⚠ 这里使用的环境是 Visual Studio 2013 ,提示不要使用太过高级的编译器,因为越高级的编译器越不容易观察。同时这里需要注意的是在不同的编译器下,函数调用过程中栈帧的创建是略有差异的,具体细节取决于编译器。

一、 寄存器的概念

寄存器的功能是存储二进制代码,它是由具有存储功能的触发器组合起来构成的。一个触发器可以存储1位二进制代码,故存放n位二进制代码的寄存器,需用n个触发器来构成。
按照功能的不同,可将寄存器分为基本寄存器和移位寄存器两大类。基本寄存器只能并行送入数据,也只能并行输出。移位寄存器中的数据可以在移位脉冲作用下依次逐位右移或左移,数据既可以并行输入、并行输出,也可以串行输入、串行输出,还可以并行输入、串行输出,或串行输入、并行输出,十分灵活,用途也很广。

二、 通用寄存器的结构

通用寄存器组包括AX、BX、CX、DX4个16位寄存器,用以存放16位数据或地址。也可用作8位寄存器。用作8位寄存器时分别记为AH、AL、BH、BL、CH、CL、DH、DL,只能存放8位数据,不能存放地址

1️⃣ AX(AH、AL):累加器。有些指令约定以AX(或AL)为源或目的寄存器。输入/输出指令必须通过AX或AL实现。

2️⃣ BX(BH、BL):基址寄存器。BX可用作间接寻址的地址寄存器和基地址寄存器,BH、BL可用作8位通用数据寄存器。

3️⃣ CX(CH、CL):计数寄存器。CX在循环和串操作中充当计数器,指令执行后CX内容自动修改,因此称为计数寄存器。

4️⃣ DX(DH、DL):数据寄存器。除用作通用寄存器外,在I/O指令中可用作端口地址寄存器,乘除指令中用作辅助累加器。

三、 指针寄存器和变址寄存器

1️⃣ BP( Base Pointer Register):基址指针寄存器。

2️⃣ SP( Stack Pointer Register):堆栈指针寄存器。

3️⃣ SI( Source Index Register):源变址寄存器。

4️⃣ DI( Destination Index Register):目的变址寄存器。

这组寄存器存放的内容是某一段内地址偏移量,用来形成操作数地址,主要在堆栈操作和变址运算中使用。BP和SP寄存器称为指针寄存器,与SS联用,为访问现行堆栈段提供方便。通常BP寄存器在间接寻址中使用,操作数在堆栈段中,由SS段寄存器与BP组合形成操作数地址即BP中存放现行堆栈段中一个数据区的“基址”的偏移量,所以称BP寄存器为基址指针。
SP寄存器在堆栈操作中使用,PUSH和POP指令是从SP寄存器得到现行堆栈段的段内地址偏移量,所以称SP寄存器为堆栈指针,SP始终指向栈顶。
寄存器SI和DI称为变址寄存器,通常与DS一起使用,为访问现行数据段提供段内地址偏移量。在串指令中,其中源操作数的偏移量存放在SⅠ中,目的操作数的偏移量存放在DI中,SI和DI的作用不能互换,否则传送地址相反。在串指令中,SI、DI均为隐含寻址,此时,SI和DS联用,Dl和ES联用。

四、 EBP和ESP

这是单独把 EBP 和 ESP单独拎出来,不用说它两肯定和我们的主题脱不了干系 —— EBP 和 ESP 是用来维护函数栈帧的。

下面就以一段简单的代码来演示:

#include<stdio.h>

int Add(int x, int y)
{
	int z = 0;
	z = x + y;
	return z;
}
int main()
{
	int a = 10;
	int b = 20;
	int c = 0;
	c = Add(a, b);
	printf("%d\\n", c);
	return 0;
}

不知道大家有没有好奇 Add 被 main 函数调用,而 main 函数被谁调用呢 ❔❓

1、调试代码后,打开调用堆栈

2、这时看到调用堆栈这个窗口 main 函数被调用了,问题也就出现了 —— main函数被谁调用了

3、当程序继续走时,它跳到了crtexe.c文件中,这时再看堆栈窗口发现 main 函数被 __tmainCRTStartup() 调用

4、而 __tmainCRTStartup() 又被 mainCRTStartup() 调用


解决完疑问后,这里就细看代码的底层是如何执行的 ❔❓

这里观察C语言代码所对应的汇编代码 —— 调试代码后,右击鼠标转到反汇编

1️⃣ 先为调用 main 函数的这个函数 __tmainCRTStartup 开辟函数栈帧,并用 esp 和 ebp 维护

2️⃣ 为 main 函数开辟函数栈帧

2.1、push ebp

压栈 ebp,esp 指向的位置也随之改变 (地址减小)

▶ 压栈前

▶ 压栈后

📐 验证

1.2、mov ebp, esp

同 ebp = esp

▶ 赋值前

▶ 赋值后

1.3、sub esp, 0E4h

同 esp = esp - 0E4h

▶ 没减前

▶ 减完后

1.4、push ebx

压栈后 ebx,esp指向的位置也随之改变 (地址减小)

▶ 压栈前

▶ 压栈后

📐 验证

1.5、push esi

压栈 esi,esp指向的位置也随之改变 (地址减小)

▶ 压栈前

▶ 压栈后

📐 验证

1.6、push edi

▶ 压栈前

▶ 压栈后

📐 验证

1.7、lea edi, [ebp - 0E4h]

load effecitve address,把 ebp - 0E4h 这个地址加载到 edi 里

▶ 加载前

▶ 加载后

1.8、mov ecx, 39h
         mov eax, 0cccccccch
         rep stos dword ptr es : [edi]

把 edi 这个位置开始向下的 39h 次 dword 数据全部改为0xcccccccc (word是2个字节,dword是4个字节)

📐 验证

3️⃣ 初始化 a、b、c 局部变量

3.1、mov dword ptr [ebp-8], 0Ah

把 0Ah(10) 放到 ebp-8 的位置

📐 验证

2.2、mov dword ptr [ebp-14h], 14h

把 14h(20) 放到 ebp-14h(ebp-20) 的位置

📐 验证

2.3、mov dword ptr [ebp-20h], 0

把 0 放到 ebp-20h(ebp-32) 的位置

📐 验证

通过以上这里就可以验证未初始化的局部变量是随机值

4️⃣ 调用 Add 函数

4.1、mov eax, dword ptr [ebp-14h]

把 ebp-14h 的值 20 放到 eax 里去

4.2、push eax

压栈 eax(20),esp指向的位置也随之改变 (地址减小)

▶ 压栈前

▶ 压栈后

4.3、mov ecx, dword ptr [ebp-8]

把 ebp-8 的值 10 放到 ecx 里去

4.4、push ecx

压栈 ecx(10),esp指向的位置也随之改变 (地址减小)

▶ 压栈前

▶ 压栈后

4.5、
00C2144B call 00C210E1
00C21450 … (这是下一条指令的地址)

call 指令调用 Add 函数,这里逐语句(F11)执行,发现这里竟然存储着下一条指令的地址,事实上 call 指令把下一条指令的地址压栈了(为了 Add 函数结束后能找回来)

▶ 压栈前

▶ 压栈前

4.6、进入 Add 函数前,会先为 Add 函数开辟函数栈帧,这里开辟的方式类似于上面 main,所以这里就不细谈了

4.7、mov dword ptr [ebp-8], 0

把 0 放到 ebp-8 的位置

4.8、mov eax, dword ptr [ebp+8]

把 ebp+8 的值 10 放到 eax 里

4.9、add eax, dowrd ptr [ebp+0ch]

把 ebp+0ch 的值 20 和 eax 的值 10 相加

4.9.1、mov dowrd ptr [ebp-8], eax

把 eax 的值 30 放到 ebp-8(z) 里去

4.9.2、mov eax, dword ptr [ebp-8]

把 ebp-8 的值 30 放到 eax 里去,这也就是为什么函数结束、局部变量销毁,却能把返回值带回来的原因

4.9.3、
pop edi
pop esi
pop ebx

把栈顶的数据 edi 依次弹出放到 edi 寄存器里去,每一次弹出,esp都向下加一次

▶ pop前

▶ pop edi

▶ pop esi

▶ pop ebx

4.9.4、mov esp, ebp

同 esp = ebp

▶ 赋值前

▶ 赋值后

4.9.5、pop ebp

这里弹出的是 main 函数的栈底,ebp 就找到了 main 函数的栈底,且 esp 往下加了一步

▶ pop前

▶ pop后

4.9.5、ret

ret 指令就是让栈顶弹出 call 指令的下一条指令的地址,esp 往下加

▶ ret 前

▶ ret 后

4.9.6、add esp, 8

此时 esp 指向的形参,让 esp + 8 后,形参销毁

▶ add 前

▶ add 后

4.9.7、mov dword ptr [ebp-20h], eax

把 eax 的值 30 放到 ebp-20h ( c ) 的位置


动画演示:

五、总结

1️⃣ 局部变量是怎么创建的?

首先为这个函数分配好栈帧空间,并初始化一部分空间为0xcccccccc,再为局部变量分配空间

2️⃣ 为什么未初始化的局部变量的值是随机值?

在开辟好栈帧空间后,会初始化 0xcccccccc 这样的随机值,而局部变量的初始化操作就会将随机值覆盖

3️⃣ 函数是如何传参的?以及传参的顺序是怎样的?

在调用函数前,会先将函数参数从后向前依次压栈,而进入函数后,它会通过指针的偏移量找到形参

4️⃣ 形参和实参是什么关系?

形参是在压栈时开辟的空间,实参和形参只是值相同,空间是独立的。所以形参是实参的一份临时拷贝,改变形参不会改变实参

5️⃣ 函数调用是怎么做的?

函数调用前,它会记住下一条指令的地址,这样做是为了函数结束后能回的来

6️⃣ 函数调用结束后是怎么返回的?

通过寄存器 eax 返回的,在返回前它会将计算好的值放在 eax 里

以上是关于内功修炼《函数栈帧的创建和销毁》建议收藏的主要内容,如果未能解决你的问题,请参考以下文章

学好C语言,还需要掌握这个内功——函数栈帧的创建与销毁

C语言进阶 顶级神功! 函数栈帧的创建和销毁

图解C/C++语言底层:函数调用过程之函数栈帧的创建和销毁(上)

图解C/C++语言底层:函数调用过程之函数栈帧的创建和销毁(上)

函数栈帧的创建与销毁,带你了解代码底层原理

函数栈帧的创建与销毁