纯干货:C语言中函数栈帧的介绍
函数栈帧的创建和销毁
- 压栈和出栈
- 问题引入
- 寄存器及栈区的使用方式
- main函数其实也是被其他函数调用的
- F10之后 转到反汇编开始观察
- S1:push ebp 压栈
- S2:mov ebp,esp
- S3:sub esp,0E4h->开辟main函数的函数栈帧
- S4:三次push
- S5:lea加载有效地址
- S6:默认初始化main函数的栈帧空间
- S7:开始给a分配空间(知道为什么局部变量不初始化是随机值了)
- S8:继续给b c分配空间
- 总结1:怎么创建局部变量的?
- 开始观察Add()函数
- A1:先传参b
- A2:再传参a
- 总结2:怎么传参的?(怎么和想象中不太一样?)
- A3:call-调用函数(理解函数是怎么返回的)
- 总结3:可以认为形参是在main函数的栈帧里创建的
- A4:进入Add之后 类似main 先开辟栈帧 然后初始化
- A5:给Add函数的局部变量z开辟空间
- A6:计算z = x + y
- A7:临时保存计算结果
- A8:三次pop
- A9:回收Add函数的栈帧
- A10:继续往下执行(之前存放的call指令下一条指令的地址派上用场了)
- A11:销毁形参
- A12:把A7的计算结果真正给到c
- 总结4:形参并不是是在Add函数内部创建的
- 总结5 Add()函数的整个调用流程
- 总结6:形参确实是实参的临时拷贝
- 总结7:什么叫销毁?
- 最后的总结
压栈和出栈
● push 压栈 在栈顶放一个元素
● pop esh 出栈 把栈顶的元素赋给esh
问题引入
在学习前面的内容 可能仍然存在很多困惑:
- 局部变量是如何创建的?
- 为什么局部变量的值不初始化就是随机值?
- 函数是怎么传参的?传参的顺序?
- 一直说形参是实参的临时拷贝 具体怎么实现的?
- 调用一个函数 具体过程是什么?
- 函数调用完 是怎么返回的?
在深入了解函数栈帧的创建和销毁
之后 以上问题都会迎刃而解
注意:
本小节使用如下代码讲解:
寄存器及栈区的使用方式
寄存器 是集成在CPU上的
和硬盘 内存之间是独立的- ebp esp看作
指针变量
里面放的是地址 - 正在调用哪个函数 ebp和esp维护的就是该函数的函数栈帧
- main函数被调用执行 也需要main函数的栈帧空间
- 栈区的使用方式:先使用高地址 再使用地地址(仿佛从栈顶往里东西 沉到栈底了)
- esp:栈顶指针
- ebp:栈底指针
main函数其实也是被其他函数调用的
- 然后下面就要调用Add()函数了
F10之后 转到反汇编开始观察
下面的Sn 就是在观察这部分代码怎么一步一步走的
可以对比观察
S1:push ebp 压栈
刚开始的样子:
push ebp:把ebp这个指针变量压栈 放在栈顶
与此同时 由于esp永远都指向栈顶
当ebp被压栈之后 esp也要往上走(也就是地址值会变低/变小) 直到他指向栈顶
S2:mov ebp,esp
也就是把esp的值赋给ebp 也就说这俩指针变量暂时指向同一块空间
S3:sub esp,0E4h->开辟main函数的函数栈帧
也就是esp = esp - 0E4h(八进制的228)
地址变小了 在图上 就是往上移动了
于是又维护了一个全新的函数栈帧 这其实就是main函数的栈帧
这其实就相当于:在栈区为main函数预开辟了一块空间
S4:三次push
之前说过了 esp永远指向栈顶
所以每次push一个元素到栈顶 esp就会往上移(其实是地址值变小)
S5:lea加载有效地址
先看看这个地址是啥 勾选上这个选项
原来是这样 下图圈起来的不是同一个意思吗?
那就是把S3esp指向的地址赋给edi
S6:默认初始化main函数的栈帧空间
从edi这个位置开始(刚好到ebp结束)
把39h这么多个 dword(相当于四个字节)
全都初始化成CCCCCCCCh
相当于把main预开辟的空间都改成0xCCCCCCCh
S7:开始给a分配空间(知道为什么局部变量不初始化是随机值了)
这表明把0Ah这个值 也就是10么 放到ebp-8的位置
也就是说 不赋值的话 a那块空间放的就是S6默认初始化的CCCCCCC
CCCCCCCvs2013的初始化方式
不同的编译器可能不一样
所以我们说是 “随机值”
S8:继续给b c分配空间
有可能有的编译器就是连着分配的 vs2013中间就隔了八个字节
如下图:
总结1:怎么创建局部变量的?
调用函数
先给函数栈帧开辟空间
然后给这部分栈帧全都初始化成CCCCCCCCC
然后再给函数里的局部变量分配空间 如果不初始化 就是CCCCCCCC
开始观察Add()函数
类似前面F10的操作 下面开始观察下图的过程
A1:先传参b
把ebp-14h里放的值(也就是十进制的20) 赋给eax
然后push eax 也就是把eax压栈
从栈顶放一个eax 也就是20
同时esp往上移动
A2:再传参a
和A1同理
把ebp-8这个位置的值 也就是十进制的10 也就是a了 赋给ecx
然后push exc
把10压栈
同时esp往上移动
总结2:怎么传参的?(怎么和想象中不太一样?)
A1 A2 就是在传参!
而且好像是b先传 a后传 也就是从右向左传!
和我们想象中的好像不太一样?
没错!! 继续往下看!!
A3:call-调用函数(理解函数是怎么返回的)
请记住下面这个地址
然后按
F11
下图是按F11之前esp的值:
按完F11:
还记得前面让记住一个地址了吗
所以我按完F11 也就是执行call指令
不仅跳进去Add函数了 而且把call指令的下一条指令的地址
给压栈了
压栈之后 esp肯定要继续往上移的
思考:F11跳到Add函数里 执行完怎么回来?
所以才会把call执行下一条指令的地址先压栈
待会就通过它 等函数执行完毕 可以找回来
然后继续往下执行
总结3:可以认为形参是在main函数的栈帧里创建的
其实是先push
下图的exc eax 就可以看做形参
然后push完 esp又跑到上面去了
这个时候仍然可以认为esp和ebp维护的还是main的栈帧
所以main的栈帧貌似扩大了一点(从ebp到esp)
而形参是在main的栈帧里的
可以这么理解但是也可以认为形参是独立的空间
总之明白形参本质上不是在Add()函数的栈帧里创建的就对了! 因为这个时候还没调用Add呢!!
A4:进入Add之后 类似main 先开辟栈帧 然后初始化
此时epb还在维护main的栈底呢
所以一上来先push 把main函数的ebp压栈
同时esp肯定要继续往上移
这个动作就很像:我要开始开辟全新的栈帧了!!
后续的过程不再详述 和前面
S1~S6
完全一致 就是给Add函数开辟栈帧 完成初始化的
最终如下图:
A5:给Add函数的局部变量z开辟空间
下面的步骤和main函数里一开始int a b c是十分类似的
同样都是在ebp-8的位置开始开辟
不过这个时候的ebp已经被更新过了 现在维护的是Add函数的栈帧
都是在各种的空间开辟局部变量的内存空间
把ebp-8这个空间初始化成0 也就是z了
A6:计算z = x + y
x y是什么呢?? 先回看A1和A2
结合A1 A2 和 A5的图
ebp+8和ebp+12 就是往下找 正好找到了A1 A2的exc和eax
- 找到ebp+8 也就是A2的a(也就是10) 把10给eax
- 然后再给eax add上ebp+0Ch里的东西(也就是ebp+12) 也就找到了A1的b 也就是12
- 然后再把eax的值放到ebp-8里去 其实就是把相加的结果放到了abp-8(其实就是z的位置)
最终如下图:
z已经放好了计算结果30
A7:临时保存计算结果
前面已经把eax里的计算结果放到ebp-8里了 其实就是z
然后又把ebp-8的值 也就是z的值(30)
临时放到了eax寄存器里(全局的寄存器)
A8:三次pop
注意pop出栈之后
esp由于一直要指向栈顶
也会一直往下移(其实是地址值增大了)
A9:回收Add函数的栈帧
执行完mov:
把ebp的值赋给了esp 这样Add的esp和ebp都指向了main之前的ebp
执行完pop:
也就是把栈顶的元素(main之前的ebp) 赋给了Add的ebp
每次pop完之后 esp也要下移
这样一来
Add的ebp走到一开始main的ebp的位置了
Add的esp恰好也是一开始main的esp的位置
总结来说就是 现在的esp和ebp 维护的又是之前main函数的栈帧了
A10:继续往下执行(之前存放的call指令下一条指令的地址派上用场了)
ret这个指令会:
- 先pop一下(也就是把栈顶的元素弹出
栈顶那个元素 就是call指令的下一条指令的地址
) 同时esp下移- 然后跳到刚刚pop的地址去
也就跳到call下面的执行 开始继续执行了
- 也就是说Add函数调用完毕 还能找到回去的路 继续往下执行
请看A9的图 栈顶上正放着call的下一条指令的地址呢!
A11:销毁形参
找回call 继续往下执行
把esp+8
也就是在A9的图的基础上esp往下移动两个元素 正好跳过了两个形参
相当于把形参的空间还给操作系统了
A12:把A7的计算结果真正给到c
- 请回头看A7 计算结果 临时放到了eax寄存器里(全局的寄存器)
- 然后这里把eax的结果 又给了ebp-20h 其实就是c!
总结4:形参并不是是在Add函数内部创建的
从总结3可以看出
a和b应该属于扩大之后的main函数的栈帧
而Add()函数在计算z = x + y的时候
是直接去访问到下面的a和b进行计算的 所以形参并不是在Add的函数栈帧创建的
`不难看出:
Add的栈帧里只创建了一个z
然后利用扩大之后的main函数的栈帧里的a和b
计算出x+y的值 赋给z
再把z临时保存在寄存器eax中
总结5 Add()函数的整个调用流程
再来看这个代码
- 开辟main栈帧
- 分配 abc的空间 完成初始化
- 在进入Add之前 已经把a和b压栈了 而且先压的是b 再压的a
- 进入Add之后 开辟add的栈帧 初始化局部变量z
- 计算x+y的时候 往下找到之前已经压栈的a b 利用他们的值 计算出x+y的值(a给x用 b给y用 ) 然后赋给z
- 然后把z里的计算结果 临时保存在全局寄存器eax之中
- 至此 就要开始销毁Add函数的栈帧了 Add函数调用结束
- 继续找到call的下一条指令继续执行
- 先销毁形参 然后把eax中的计算结果赋给c
- 再往后就是打印c 然后开始销毁main函数的栈帧 和Add的过程非常相似
总结6:形参确实是实参的临时拷贝
虽然a’ b’不是在Add内部创建的 但是确实是给了形参x y使用的
所以就可以把a' b'看做a b的形参
显然就是一份临时拷贝
而且a' b' 和 a b完全是独立的空间
所以修改a’ b’肯定不会影响a b
总结7:什么叫销毁?
所以销毁不是真的给他毁了啊! 叫做操作系统回收更好!
计算机的销毁就是:这块内存空间 可以被别人使用/覆盖了
就在这里而言:如果不再被esp和ebp维护 就可能会被分配给别人使用 就算是被销毁/回收了
最后的总结
让我们回到一开始的问题
- 局部变量是如何创建的?
先给函数分配完栈帧空间 然后初始化这块空间(这里是都初始化成CCCCCCCC) 然后给局部变量分配空间 如果不初始化 那就是随机值
- 为什么局部变量的值不初始化就是随机值?
上面已经回答
- 函数是怎么传参的?传参的顺序?
A1 A2的两次push 其实就是在传参(在创建两个临时变量) 而且是从右往左push的
- 一直说形参是实参的临时拷贝 具体怎么实现的?
看总结6
- 调用一个函数 具体过程是什么?
看总结5
- 函数调用完 是怎么返回的? 返回值怎么带回来的?
因为一开始就记住了call执行的下一条指令
所以执行ret指令的时候 pop一下就可以找回去 继续往下执行
而返回值其实是利用eax全局寄存器返回的