栈帧:很少有人能讲清楚的底层原理
栈帧,知道这个概念的人不算太多,知道它的原理的人更少,而能把它讲清楚的屈指可数。
这个视频我将带着你深入海底,彻底弄清楚栈帧的原理,建议先点赞收藏后再观看。

给不同水平程序员的建议
如果你是一位C、C++老手,强烈建议你仔细听完,你的编程技能将上一个台阶。
如果你是一位C、C++新手,我建议多听几次,最少能大概明白它是什么,这将对你受益匪浅。
不管你是老手还是新手,请保持耐心听完它,这也是你是否能成为编程高手的关键。
现在开始我们的深海之旅
演示程序介绍
先简单介绍一下我们的演示程序。
在MAIN函数中有2个局部变量 localVar1 =1 和 localVar2=2,然后调用子函数subFunction1并传入localVar1和localVar2。
在subFunction1中也有一个INT型局部变量localVariable=100,首先输出localVariable的值,然后又调用subFunction2并把传入的参数A和B又传入subFunction2中。
最后在subFunction2中将a,b相加结果返回。
这是一个非常简单的C++程序,仅仅为了是让大家能理解栈帧的原理。
设置断点进入汇编模式
我们在subFunction1的调用处设置断点,并运行程序。
我们进入汇编模式,想要弄清楚栈帧的原理,那必须要看汇编代码。
但是你不要害怕,你跟着我一步步走,我向你保证,你也可以看懂这些晦涩的汇编代码。
我们左边窗口是汇编代码窗口,中间是内存窗口,右边是CPU寄存器窗口。
理解栈帧的核心寄存器:ESP和EBP
寄存器是CPU内部的一种高速存储器,它用来存储计算时的临时数据,你可以简单的把寄存器理解为CPU的“笔记本”。
为了理解栈帧,我们现在只需要关注ESP和EBP这两个寄存器。ESP和EBP是理解栈帧的关键。
ESP永远指向栈帧的顶部,当向栈帧中压入一个值,ESP则会自动向栈顶端移动一步,而当POP弹出一个值,它就会自动向栈底部移动一步。
嗯,这是一个典型的栈结构是吧。对,所以它的名叫栈帧。
如果你看过我之前的视频,你应该知道,栈内存是从内存的高地址向低地址移动的。
因此ESP的向栈顶端移动一步,实际上它是向内存的低地址走一步的,而它向栈底部移动一步则其实是向内存高地址移动一步。
EBP则永远指向栈帧的基地址。
ESP和EBP各自的作用
那它们分别有什么作用呢,为什么栈帧需要一个EBP来指向基地址,还需要一个ESP指向栈顶?
其实很简单:ESP的作用仅仅是用来告诉CPU当执行PUSH的时候,应该把值压入到哪里。
而EBP因为它在自己的栈帧中的地址是固定不变的,因此用它来读取栈帧中的值就很方便,根据它的地址向前或者向后偏移一个地址就能读取到传入的参数或者是局部变量。
实战演示:一步步跟踪栈帧变化
现在我将结合我们的演示代码中,让你会明白我说的是什么意思。
我们先记录一下我们的EBP的值它是010FFC1C,再记录一下我们的ESP值它是010FFB38。
然后看代码。
这一行是MOV,MOV指令的意思移动一个值,它的规则是将右边的值移到左边,也就是将localVar2加载到EAX寄存器中。
我们执行它,执行完这行代码看到EBP和ESP的值都没有改变,因为没有任何栈操作。
小小提示,如果值有改变它是会变成红色的。
第一次PUSH操作
但是,请注意,下一步就是一个PUSH EAX,它的意思是将EAX寄存器中的值,也就是localVar1的值压入栈帧中。
既然有压栈,那么ESP一定会改变的,我们要注意一下。
还有就是注意,现在的栈帧其实是MAIN函数的栈帧。
我们现在把ESP指向的内存地址粘贴内存窗口中,这样可以同时查看到栈内存中的变化。
然后向上滚动栈内存窗口的滚动条,稍微前滚动几行,为什么是向前滚呢?对,因为栈内存是向低地址增长的。
因为即将压栈的值是一个INT型,它占用4个字节,所以接下来,它会把值压入到这里,所以重点看这个地方的内存变化。
现在我们运行这条PUSH指令。
看到了吗,现在这里的内存值变成了02 00 00 00,它是小端模式存储的,也就是localVar2的值2。
至于什么是小端模式,请查看我前几天发的关于大端小端的视频哈。
同时,我们看到ESP的值变成了红色,说明被修改了,它现在变成了010FFB34,相对于之前的值减小了4,也就是向低地址移动了4个字节。
第二次PUSH操作
那么接下来这一条MOV就是把localVar1加载到ECX寄存器中。
然后下一条又是把ECX也就是localVar1的值压入栈帧中。
因为要压栈了,所以也特别注意这个地方的内存值,同时注意ESP的变化。
我们执行这条PUSH。
看到了吗,现在这个地方的内存值变成了01,同时ESP变成了010FFB30,又向低地址移动了4个字节。
进入subFunction1:函数调用与新栈帧创建
现在调用subFunction1函数,大家注意了,这里有难点。
我们按F11跟进去,然后这里还不是真实的subFunction1,它有一个JMP指令来跳转到真实的subFunction1函数。
我们再按F11跟进去。
现在来到了真实的subFunction1函数中。
请注意,现在ESP 的值变成了 010FFB2C,它又向低地址移动了4个字节。
可是,刚才我们并没有执行过PUSH,为什么ESP自动退了4个字节?
其实这是因为当执行CALL指令的时候,CPU会自动将调用函数的返回地址保存到了栈中,以便被调用函数返回后能继续回到MAIN函数中,所以ESP自动移动了4个字节。
保存旧EBP并设置新栈帧
在这个函数中,它的第一条汇编指令是push ebp,这里就是把上一个函数的栈帧基地址压栈保存下来,这是在创建新的栈帧前,要将上一个栈帧的基地址保存起来,以便在subFunction1函数返回的时候再次恢复上一个函数的栈帧。
好,我们执行这个PUSH操作,同时注意这里的内存变化,以及ESP的变化。
现在ESP变成了010FFB28,它又向低地址移动了4个字节。
这里的内存也变成了1C FC 0F 01,嗯,它就是EBP指向的内存地址是不是,因为它是小端模式存储,所以刚好和你看到的相反。
然后下一步指令是mov ebp,esp,将ESP的值加载到EBP中,也就是让EBP指向栈顶。
其实这里就是在创建一个新的栈帧了,它会使新创建的栈帧的基地址指向当前栈顶。
我们运行这行代码,看到了吧,现在EBP就等于ESP的地址了。
为局部变量分配空间
然后这行sub esp,0CCh,它会将ESP的地址向低地址移动CC个字节,十六进制的CC就是204个字节。
其实这里就是给函数预留栈内存用于函数的局部变量。
那我们的这个函数中只有一个局部变量,为什么是预留204个字节的栈内存呢?
这是编译器为了满足对齐要求、固定栈帧布局、调试信息存储、调用约定或者潜在的扩展需求预留了额外空间。
执行完这行代码后,ESP会减去204,此时ESP的值是010FFB28,我们注意看它,我们运行它。
现在ESP变成了010FFA5C,刚好是减去CC后的结果。
后续调用与栈帧平衡
接下来3个PUSH是保存寄存器的值,我们这里不对EBX, ESI, EDI这三个寄存器的作用做讲解,你只要知道这里每个PUSH,后边函数结束时都会有对应的POP操作就行了。
然后下边这一堆汇编是执行调试相关的检查我跳过它。
再下边这堆汇编是我们的C++中的COUT打印输出的代码,我们也跳过它。
我们来到subFunction2函数的调用这里。
首先它把B参数加载到EAX,然后再把EAX压入到栈帧中,接着再把A参数压入到栈帧中,这和我们前边看过的类似,就是为了传递subFunction2函数的参数。
然后我们跟到subFunction2函数中去,接着它又开始将上一个栈帧基地址也就是subFunction1函数的栈帧基地址入栈保护,因为它马上要为subFunction2函数创建新的栈帧了,然后就是重复subFunction1函数中一样的操作,例如设置新的栈帧的基地址,预留局部变量内存,以及保护上一个函数调用中的相关寄存器的值等。
栈帧的平衡与清理
到这里,关于栈帧你应该已经明白得差不多了吧。
另外有一点要注意的就是平衡栈的操作,其实你看汇编代码,只要前边有PUSH的在函数结束时它就会POP,前边是把ESP减去多少个字节,那函数结束时就要再加上多少个字节,这样就能一直保持栈的平衡。
总结
怎么样,关于栈帧你明白了吗?
本文为视频内容整理,适合C/C++开发者深入学习栈帧底层原理。


