前言:
用C语言写代码,如果一个工程相对复杂时,我们往往会采取封装函数的方式。在主函数中调用函数 这一看似简单的过程,实际上有很多不宜观察的细节,这篇博客我将带大家深入探究函数调用的每个细节。
注:
内容偏向底层原理,可能会比较复杂,但我相信看完后你会对函数调用有一个更加深刻的认识。
目录
💖Part1: 相关问题及概念铺垫
1.几个相关问题
2.寄存器
3.函数栈帧
4.函数调用栈
5.相关汇编指令
💗Part2: 函数栈帧的创建销毁具体过程
1.前期准备
2. main 函数预开辟栈帧
3.实参的创建和初始化
4.Add函数的调用
5.栈帧的销毁
❤️Part3: 问题答案揭晓
Part1: 相关问题及概念铺垫
1.几个相关问题
• 局部变量是怎么创建的?
• 为何局部变量出现屯屯烫烫等随机值?
• 函数是怎么传参的?传参的顺序?
• 实参和形参有何关系?
• 函数调用的过程?
• 函数调用结束,怎么返回?
如果没有进行函数栈帧的学习,我相信你也会像我当初一样懵逼🤣
好在接下来我会带大家逐步分析每一个过程,了解完整个过程后就会豁然开朗~
2.寄存器
寄存器是 CPU 内部用来存放数据的一些小型存储区域,用来暂时存放参与运算的数据和运算结果。
常见的寄存器有:
eax: 累加(Accumulator)寄存器 , 常用于乘、除法和函数返回值
ebx: 基址(Base)寄存器 , 常做内存数据的指针, 或者说常以它为基址来访问内存
ecx: 计数器(Counter)寄存器 , 常做字符串和循环操作中的计数器
edx: 数据(Data)寄存器 , 常用于乘、除法和 I/O 指针
sbp: 基址指针(Base Point)寄存器 , 只做堆栈指针, 可以访问堆栈内任意地址, 经常用于中转esp 中的数据
esp: 堆栈指针(Stack Point)寄存器 , 只做堆栈的栈顶指针; 不能用于算术运算与数据传送
有关函数栈帧的是 ebp , esp 这两个寄存器,其中存放的是地址,
这两个寄存器是用来 维护函数栈帧 的。
3.函数栈帧
C语言中,每个栈帧对应着一个未运行完的函数。栈帧中保存了该函数的返回地址和局部变量。
每一个函数调用,都要在 栈区 开辟一段空间。
例如,我写下这一段代码:
#include<stdio.h> //这里把代码拆的很细,更加易于看清细节。 int Add(int x, int y) { int 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; }
main 函数中调用了 Add 函数。
如图所示,在栈区为 main 函数开辟了一段空间,并且由 ebp 和 esp 这两个寄存器维护。
4.函数调用栈
函数调用栈是一种容器,具有后进先出的特性。在函数调用过程中,我们利用了栈的特性,当调用一个新的函数时,进行压栈Push,这个函数执行完进行出栈Pop。
简单来说,当有函数被调用时,该函数就被添加到栈中,在执行完所有任务后,该栈帧就会被删除。
这时就要问了:main 函数也是函数,难道还有其他函数调用它吗?
是的,main 函数也是其他函数调用的,不过这在 Visual Studio 2013 中有体现。
下面我以 VS2013 演示:
调试 --> 窗口 --> 调用堆栈
此时可以看到 main 函数被调用了:
按 F10 继续调试,直到程序结束:
此时看到了两个陌生的函数:
__tmainCRTStartup 和 mainCRTStartup
通过对 crtexe.c 文件的观察,我们可以得出下列结论:
对应栈帧的开辟:
5.相关汇编指令
我们是在反汇编的模式下观察函数栈帧的动作的,因此需要一些汇编指令:
push:数据压入栈
pop:数据弹出栈
mov:数据转移
add:加法命令
sub:减法命令
call:函数调用
jump:转到目标函数,进行调用
ret:恢复返回地址
进行了相关知识的铺垫,
那么接下来就是对具体动作的探究了:
Part2: 函数栈帧的创建销毁具体过程
1.前期准备
F10 调试 --> 鼠标右键 --> 转到汇编
在反汇编下可以清楚地观察函数栈帧的动作
2. main 函数预开辟栈帧
由于 main 函数是由其他函数调用的,所以在调用 main 函数之前就已经开辟好了相关函数的栈帧
00C21410 push ebp //将ebp压入 00C21411 mov ebp,esp //移动esp,让其指向压入的ebp;移动ebp,让其也指向压入的ebp 00C21413 sub esp, 0E4h //esp减去0E4h,指向位置更低的空间,相当于为main函数预开辟空间
执行完三步后的图示
//依次将ebx,esi,edi压入栈帧 00C21419 push ebx 00C2141A push esi 00C2141B push edi //从edi开始,将接下来39h个双字节都改为 OCCCCCCCCh(eax中的内容) 00C2141C lea edi, [ebp+FFFFFF1Ch] 00C21422 mov ecx, 39h 00C21427 mov eax, OCCCCCCCCh 00C2142C rep stos dword ptr es:[edi]
在 main 函数预开辟之后,接下来就要执行有效的代码了:
3.实参的创建和初始化
我们继续:
int a = 10; //将0A(十进制下是 10)放在 ebp-8 的位置上 00C2142E C7 45 F8 0A 00 00 00 mov dword ptr [ebp-8], 0Ah int b = 20; //将14(十进制下是 20)放在 ebp-14 的位置上 00C21435 C7 45 EC 14 00 00 00 mov dword ptr [ebp-14h], 14h int c = 0; //将0(十进制下是 0)放在 qbe-20 的位置上 00C2143C C7 45 E0 00 00 00 00 mov dword ptr [qbe 20], 0
执行实参的创建和初始化
4.Add函数的调用
C = Add(a, b); //创建形参并传值 00C21443 8B 45 EC mov eax, dword ptr [ebp-14h] 00C21446 50 push eax 00C21447 8B 4D F8 mov ecx, dword ptr [ebp-8] 00C2144A 51 push ecx //调用函数,记录call下一次指令的地址,方便返回 00C2144B E8 91 FC FF FF call 00C210E1 00C21450 83 C4 08 add esp,8 00C21453 89 45 E0 mov dword ptr [ebp- 20h], eax
此时才真正进入Add:
欸?是不是与之前 main 函数的调用有些相似?
对的,还是先压几个寄存器,再填充CCC...
接下来的就是把事先传过来的形参进行运算:
调用了数值之后将要返回的结果放入Add函数的栈帧中。
5.栈帧的销毁
//将 edi,esi,ebx 弹出 00C213F1 5F pop edi 00C213F2 5E pop esi 00C213F3 5B pop ebx //移动 esp,ebp,找到高地址的寄存器 00C213F4 8B E5 mov esp,ebp 00C213F6 5D pop ebp //返回值 00C213F7 C3 ret
最终就把Add函数的栈帧销毁了。
Part3: 问题答案揭晓
回到开头的几个问题,在这里做一下回答:
• 局部变量是怎么创建的?
先创建函数的栈帧,在函数栈帧里为局部变量分配空间。
• 为何局部变量出现屯屯烫烫等随机值?
在创建函数栈帧时会事先填充CCC...,打印出来就是 屯屯烫烫等随机值了,所以要养成局部变量初始化的习惯。
• 函数是怎么传参的?
在调用函数之前就把参数压栈了,当函数中使用参数时,再通过指针偏移量找到事先压好的参数
• 实参和形参有何关系?
形参是实参的临时拷贝,两者的空间独立,形参的改变不会改变实参。
• 函数调用的过程?
压栈,创建空间...
• 函数调用结束,怎么返回?
call 事先记录了下一条指令的地址,可以找到此位置,再通过寄存器带回。
总结:
带大家探究了调用函数时的细节,重点是函数栈帧的创建和销毁。