【c】为啥有时传参要传指针?实参形参到底是个啥?栈帧和他俩到底啥关系?
前言:🎅想必在学习c的过程中,你可能产生过这样的疑惑,为什么在进行传参时总是会选择传入一个
指针
呢😟?例如你想完成一个add函数,其功能实现为c=a+b,但是在返回值为void
的情况下传入a,b两个参数却改变不了c
。也许老师给你解释的是形参是实参的拷贝
,但你却不是很理解,也许你还遇到过这样的问题:1.局部变量是如何创建
的?为何局部变量不初始化会出现随机值
?2.在函数调用时参数如何传递
?又以何种方式
传递?3.函数返回值是如何带回主函数
?那么就接着往下看吧。😜
🌈tip:因为不同的编译器带来的反编译效果不同,所以当你们进行测试时会与文章展示有所差异,但不影响理解。推荐vs2013,对整个栈帧创建和销毁的过程展示的更加全面。接下来就以一份以vs2013为平台进行编译的代码进行分析。
🚗准备
🚎栈与栈帧
栈在数据结构这一课也算是一个典型的例子,有着先进后出
的明显特点。栈被当作一种特殊的容器,数据可以被压入栈(push),也可以将压入的数据弹出(pop)。
在经典的操作系统中,栈总是向下增长(由高地址向低地址)
的。
栈可以认为是CPU寄存器里的某个指针
所指向的一片内存区域
。在我们常见的i386或者x86-64下,栈顶由被称为 esp 的寄存器(也就是才提到的某个指针)进行定位的。
而栈帧则是被叫做过程活动记录
,是编译器用来实现过程/函数调用
的一种数据结构。栈帧就是利用EBP寄存器访问局部变量、参数、函数返回地址等的手段。
总而言之:
- 每一次
函数调用
,都要为本次函数调用开辟空间
,就是函数栈帧
的空间。 - 这块空间的维护是使用了2个寄存器: esp 和 ebp , ebp 记录的是
栈底
的地址, esp 记录的是栈顶
的地址。 - ebp寄存器又被称为帧指针(Frame Pointer),esp寄存器又被称为栈指针(Stack Pointer)。
- 当前程序正在
调用哪一个函数
,ebp与esp就维护哪一个函数栈帧(这点很重要)。
🚄寄存器与汇编指令
以下是后文会用到所以需要了解的知识点。
相关寄存器
eax:通用寄存器,保留临时数据,常用于返回值
ebx:通用寄存器,保留临时数据
ebp:栈底寄存器
esp:栈顶寄存器
eip:指令寄存器,保存当前指令的下一条指令的地址
相关汇编命令
mov:数据转移指令
push:数据入栈,同时esp栈顶寄存器也要发生改变
pop:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变
sub:减法命令
add:加法命令
call:函数调用,1. 压入返回地址 2. 转入目标函数
jump:通过修改eip,转入目标函数,进行调用
ret:恢复返回地址,压入eip,类似pop eip命令
🚖正文开始
🚞代码示例
就以简单的add函数来梳理栈帧吧。
1 | int add(int x, int y) |
在vs中启动调试,找到调用堆栈
的选项。
可以发现,在vs对main函数进行调用前,就已经调用了很多其他的函数了,例如invoke_main。
那么就可以得知,不仅是我们所看到的main或者add,还有许多被调用的函数例如invoke_main,都有自己的栈帧
,并且可以通过ebp,esp等指针来对自己的栈帧进行一个维护。
那么鼠标右击转到反汇编
,就可以开始了解栈帧的维护过程了。
✈️main的栈帧
我们就从main函数开始讲解。
这里是vs2022开启反汇编后的截图。
但后续展示的代码来自vs2013
,因为2013对此过程的展示更加的清晰明了,有更多的细节。
🚀栈帧的建立
1 | //函数栈帧的创建 |
第一行,push ebp代表着将ebp寄存器的值进行压栈
,并且在此时,ebp中存放的是invoke main函数栈帧的ebp(在调用main前需要调用invoke_main)。
同时,在ebp压栈后,esp会往上移,地址会变小
。
第二行,move指令的意思就是将esp的值存到ebp中。那么也就是ebp存储的地址指向了esp,换言之,此时esp与ebp是指向一块空间
的。
第三行,给esp地址进行了一个减法,进而产生了新的esp。注意,地址是由低到高
的,也就是esp往上走了,此时,esp与edp之间就是main函数
维护的栈帧空间。
接下来进行了三次push,压入三个数据(在这里不作详细解释,与我们讨论的问题无太大关系)。需要注意的是esp是会不断往上移的。
1 | 00BE1829 push ebx //将寄存器ebx的值压栈,esp-4 |
1 | 00BE182C lea edi,[ebp-24h] |
其作用为: 先把ebp-24h的地址,放在edi中,再把9放在ecx中,然后将0xCCCCCCCC放在eax中,最后将从edp-0x2h到ebp这一段的内存的每个字节都初始化为0xCC
。
也可以换成如下的伪代码:
1 | edi = ebp-0x24; |
到这里mian函数的栈帧建立也就基本完毕。
🚊进入mian函数
1 | int a = 3; |
🚔调用add函数
再往下走就需要调用add函数了。
1 | //调用Add函数 |
eax与ecx作为保存临时值的寄存器,分别存放了b与a的值,再将二者进行压栈。
存储到eax与ecx中的值就是形参。
1 | //跳转调用函数 |
这里要重点理解一下call指令
。 call 指令是要执行函数调用
逻辑的,在执行call指令之前先会把call指令的下一条指令的地址
进行压栈操作,这个操作是为了解决当函数调用结束后要回到call指令的下一条指令的地方,继续往后执行 。
也就是压入00BE185D
这个值。
接下来正式进入add函数的栈帧。
1 | int Add(int x, int y) |
建立add栈帧与建立main栈帧几乎一样,只是栈帧的大小不一样而已。
1 | 0BE1760 push ebp //将main函数栈帧的ebp保存,esp-4 |
接下来就是执行add函数的语句。
1 | int z = 0; |
还是先创建了一个z,然后使用eax先存储了ebp+8的值,也就是ecx的值,再加上eax的值,就完成了两数相加的过程。
再将这个值放入z中。此时计算就已完成,下一步就是return一个值,将z的值存储到eax寄存器中,通过eax寄存器带回结果。
💼tip:一般情况下都是通过寄存器
来带回返回值的,如果返回对象过大,一般会在主调函数
的栈帧中开辟一块空间,然后把这块空间的地址,隐式传递给被调函数,在被调函数中通过地址找到主调函数中预留的空间,将返回值直接保存
到主调函数。
这里也有力的佐证了形参是实参的一份拷贝
的说法,所以改变形参是无法改变实参的哦。想要对值进行改变,要不就进行return,要不然就采用指针的形式,传入想要改变的值的地址。
👩👧👧栈帧销毁
1 | 00BE177F pop edi //在栈顶弹出一个值,存放到edi中,esp+4 |
这里需要关注到第四行,将当前的esp指向了ebp
,也就说明此时add的栈帧被回收掉了。此时的栈顶不正好就是main的ebp吗?
此时pop ebp就代表将栈顶的值传给ebp,也就是说明ebp重新指向了维护main这块栈帧空间的ebp
。
esp+4,向下移一格。
此时esp与ebp之间的空间不就是之前main函数的栈帧与一个单独的指令地址吗?
接下来执行ret,从栈顶弹出值,此时的栈顶存放着call指令的下一条指令地址,那么也就顺理成章的执行完add函数,开始接着执行main函数里的其他语句了。
🥊小结
在很多地方我们都会考虑函数的传参形式,所以了解其底层的原理也是很有必要的。何时去传值,何时去传指针,什么时候使用void,什么时候又会考虑return一个int或者char,这些都需要根据实际的情况去写代码。
例如,传数组进入函数,我们通常会选择传数组的指针
,也就是数组的第一个地址,这样可以有效的降低程序对空间的开销,结构体
也是一个道理。又比如,我们传的值并不需要改变,只需要作为计算的媒介,那么直接将其拷贝到函数里即可,但要是需要改变的值,就要考虑传指针了。
在学习到链表那一章节时,如果不考虑增设一个哨兵节点
,那么大概率就会遇到链表为空与不为空
的情况,此时,就要去考虑是否要设立一个二级指针或者将函数的返回类型进行改变。不然,链表为空时想要对其进行尾插头插,都不可避免的需要改变指针的本身指向
,而非改变其值,所以我们要传指针的指针才行。这里可能会有些绕,需要大家自己多画图多理解传参的本质。
总之请记住,当你想改变一个值,传参时就考虑传他的指针,本质上也就是传递他的地址
。或者说对这个值进行一个返回。但我更推荐前者,更灵活也更方便。