一、问题引入
在学完c语言后,你可能会有部分疑惑?
比如:
1.局部变量是怎么创建的?
2.为什么如果不初始化局部变量就会使随机值?
3.函数是怎么传参的?传参的顺序是怎么样的?
4.形参和实参的关系是怎么样的?
5.函数调用是怎么做的?
6.调用完后是怎么返回的?
看完本文相信你能对这些问题有更深入的理解
二、
1.基础知识储备:
我们知道main()函数是程序的入口,其他函数都是通过main()函数调用的。但是问题来了:main()又是由谁条用的呢?其实main()函数也是由其他函数调用的,不是由我们自已写的函数,而是编译器自带的运行时库函数。比如在VS2022下,main()先是由mainCRTStartup调用_tmainCRTStartup,再由_tmainCRTStartup调用main()函数的。
所以在执行main()函数之前就已经存在_tmainCRTStartup的栈帧

2.实战操练
注:环境为debug x86
接下来我用简单的代码带你了解函数栈帧创建的过程:
点击查看代码
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);
}
1.首先我们先转到汇编层面:
F10->右击鼠标->转到反汇编
2.我们可以看到以下的陌生代码:

别着急,我们一步一步拆解:
- push指令:压栈,即将ebp压入栈顶,同时esp往上挪动(减去地址)

- mov指令:寄存器之间的数据传送,即将esp的值赋给ebp

- sub指令:用于执行减法运算,即将esp减去部分值(具体的数由编译器决定)

这时main()函数的栈帧已经初具雏形了
- 三个push:将ebx、esi、edi压入栈中,若想具体了解用处,请看寄存器的作用(来自网络)

- lea指令:用于加载有效地址,即它将内存地址加载到寄存器中。这里是将ebp-24h(局部变量开始的地址)的地址加载到edi中
- mov指令:将9赋给ecx寄存器,存放重复指令的次数
- mov指令:将0cccccccch赋给eax寄存器
- rep stos指令:REP是一个前缀指令,表示重复执行其后的指令,而STOS指令的作用是将寄存器EAX中的值存储到ES:EDI指向的内存地址中,dword表示双字,一个双字代表4个字节。即重复ecx(9)次(初始化了32个字节),将eax(0cccccccch)的值存储到edi(ebp-24h)中,简单的来说就是将[ebp-24h,ebp-4h]初始化成0cccccccch。(解释了问题1随机值的来源)

- 那么为什么不是直接从ebp开始呢?还有为什么只初始化36个字节呢?而不是初始化E4h(228)个字节呢?
- 为什么不从ebp开始:因为ebp此时存着的是原来栈帧的ebp,如果被初始化,原有栈帧值被覆盖,就回不到原有栈帧了
- 为什么只初始化36个字节:因为编译器会自动识别代码长度,从而决定初始化数量,后续的字节是用来:栈对齐:x86 CPU 要求栈地址是 4/8 字节对齐,编译器会多分配空间保证对齐,提升执行效率,安全缓冲区:Debug 模式下的 “栈保护” 机制,预留空间防止轻微的栈溢出直接崩溃
总结一下:

- mov指令:将0BBC008h(成员变量的地址)赋给ecx
- call 00BB132F:调用地址 0x00BB132F 处的成员函数(配合 Visual Studio 的 “仅我的代码(Just My Code)” 调试功能,用来判断当前执行的代码是否属于你自己写的代码,还是系统或第三方库的代码
- nop:无实际功能,仅用于让代码地址对齐,提升 CPU 执行效率
3.创建变量:


解释了问题1
4.传入形参:

- mov:将ebp-14h地址的值(b=20)赋给eax寄存器
- push:将eax压入栈顶
- mov:将ebp-8地址的值(a=10)赋给ecx寄存器
- push:将ecx压入栈顶(解释了问题3(传参是从右往做传的))

5.进入Add函数:

步骤跟main函数一致,都是在创建栈帧然后初始化

点击查看代码
int z = 0;
z = x + y;
- 执行z = 0语句并调用add指令将eax寄存器中的值与ebp+0Ch中的值相加,然后重新将eax寄存器中的值返回z的地址
点击查看代码
return z;
- 将z的值放入eax寄存器中

- pop指令:从栈顶部弹出数据到寄存器或内存单元,即将edi、esi、ebx弹出栈顶
- add指令:将esp将回到ebp
- cmp指令:检查esp和ebp之间的差值是否正确
- call指令:如果这一步发现异常,call 00BB1253 会立刻终止程序并告诉你 “栈被破坏了”
- mov指令:双重保险,哪怕栈已经乱了,也能强行把 esp 拉回正确位置
- pop指令:弹出ebp,并将此时ebp1的值赋给ebp
经过这一步,ebp又回到了开头的位置

- ret指令:ret 是最终跳转,能将栈顶的数据弹出,并赋值给指令指针寄存器(IP),从而使程序继续执行调用子程序之前的指令。

- add指令:清理形参a,b(解决了问题4(形参只是实参的拷贝,改变形参不影响实参))
- mov指令:将之前存在eax寄存器中的值赋给c
最后打印……
三、总结
相信你看完本篇已经能回答前五个问题了
第6个问题:其实cpu在执行call指令的时候,会自动的将cpu下一条指令压入栈中,ret指令弹出栈顶数据弹出的就是call函数的下一条指令,并且赋值给了指令指针寄存器(IP),这样cpu就能按照原来的逻辑继续运行了