尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C语言函数栈帧篇

C语言函数栈帧篇 目录1.引言2.前期学习问题3.前置知识4.函数栈帧及 main 函数调用5.问题解决6.结语1.引言我们在扫雷与 VS 调试时已经简单了解过函数栈帧与计算机内存的相关知识本篇博客就通过实操的方式深入了解函数栈帧。同时通过函数栈帧我们对C语言的函数创建与销毁也会有一个新的认知。关于函数栈帧不同的编译器会有不同的表现博主使用的是 VS2022。2.前期学习问题我们之前讲过C语言函数与函数递归的知识在当时学习时各位小萌新可能会有不少问题在这里博主就列举一些其他没有提及的欢迎在评论区留言博主看到后也会及时回复。1.局部变量是如何创建的2.为什么局部变量不进行初始化其初始值是随机值3.函数是怎么进行传参的传参的顺序是怎样的4.形参与实参到底是什么关系5.函数调用是怎样的一个过程6.函数调用结束后返回值是怎么返回的关于上面这些问题不知道各位小萌新当时是否疑惑过即使有也无需担心在学完函数栈帧后我们就能解决上述问题了。3.前置知识在正式讲解前我们需要学习一下前置知识。首先了解一下寄存器寄存器是 cpu 内的小空间相比于内存他的运行速度要快很多常用来临时储存数据及地址函数的返回依赖于寄存器。常用的寄存器有 eaxebxecxedx 与 ebpesp。eax 是累加器常用于算术运算函数的返回值默认存放在 eax 中ebx 是基址寄存器用于存放内存地址可做内存寻址ecx 是计数器用于循环或重复指令的计数edx 用于配合 eax 实现乘除运算除法的余数存放在 edx 中ebp 为栈底指针而 esp 为栈顶指针这两个寄存器中存放的是地址用于维护函数栈帧函数的每一次调用都要在栈区创建其独立的空间。除此之外还要了解反汇编因为我们之前说过我们在编译器上所写的是源代码而计算机执行的是机器码。我们想要深入观察计算机的运行逻辑就要依靠反汇编。反汇编的作用就是将机器码翻译为更易理解的汇编指令那么在众多指令里我们需要知道 pushpopmovaddsubcalljmp 这几个指令的作用。push a 是压栈将数据 a 放入栈顶对应的 esp 减小pop a 是出栈将数据从栈顶取出并赋给 a对应的 esp 增大mov a,b 是拷贝将 b 的值赋给 aadd a,b 是做加法让 a 加上 b 的值sub a,b 是做减法让 a 减去 b 的值call a 实现函数调用在栈顶压入函数的返回地址配合 jmp 跳转到函数内部jmp a 是无条件跳转。4.函数栈帧及 main 函数调用接下来我们写一个简单的C语言程序并利用它来学习函数栈帧大概代码如下。注意编译器模式以及环境博主接下来的所有讲解都是在 VS2022Debug 模式x86 环境下进行的。我们按F10进入调试之后通过选择调试窗口下的调用堆栈可以发现C语言程序的main函数其实也是被调用过来的那main函数又是被谁调用的我们只要将显示外部代码点开就能看到main函数的调用顺序。越在下面的越早被调用那我们通过反向查找就能知道main函数的调用源头。双击第二行可以看到当下这个函数的返回就是main函数向上找到这个函数的函数名然后双击第三行同样可以看到这个函数调用了第二行的函数向上找到这个函数的函数名继续双击第四行然后不断重复上述过程我们就能理清楚main函数的整个调用过程总结一下就是mainCRTStartup 函数调用了 __scrt_common_main 函数__scrt_common_main 函数调用了 __scrt_common_main_seh 函数__scrt_common_main_seh 函数调用了 invoke_main 函数invoke_main 函数最终调用了 main 函数。在梳理完main函数的调用过程之后我们结束程序重新按F10此时在调试窗口下依次打开反汇编监视窗口与内存窗口。之后的讲解都是在反汇编页面进行调试操作监视窗口配合内存窗口可以让我们更方便了解函数的调用过程与数据的存放位置。大概的页面布局可以如上图那样也可以自己调整。鼠标点击进入反汇编页面后右侧监视窗口输入 esp 与 ebp为了后续观察方便我们可以右击鼠标勾选十六进制显示。然后记录一下现在的 esp 与 ebp 所指位置。回到反汇编页面按F10框起来部分的汇编指令就是在为main函数创建函数栈帧先 push将 ebp 压入栈顶对应的 esp 减小然后 mov 将 esp 的值赋给 ebp完成栈顶与栈底的更替最后 sub由于 x86 环境下栈区地址由高地址指向低地址所以 esp 会向低地址移动 0E4h 的距离。现在栈区的空间分布情况大概如下图这样。接下来这三行指令就是压入所需的寄存器。现在栈区的空间分布情况大概如下图这样。接下来的 lea 是做加载将后面这个地址加载到 edi 里面去可以认为 edi 此时存放了后面这个地址。接下来这三行指令就是初始化栈内存。将几行语句意思翻译一下就是从刚刚的 edi 地址开始向下也就是往高地址方向重复9次将里面的数据都改为 0CCCCCCCCh。现在的栈区的空间分布情况大概如下图这样。然后就是为创建的三个变量开辟空间并进行初始化赋值。后面的 b 与 c 也是同理就不再展示了此时的空间分布情况大概如下。然后就是实参的传参将 b 的值赋给 eax 并将 eax 压入栈顶将 a 的值赋给 ecx 并将 ecx 压入栈顶。从这里也可以发现实参在传参时是从右向左依次传参的。在实参 ab 完成传参后此时的空间分布情况大概如下。接下来我们在右侧内存窗口搜索 esp同时记录 add 指令的地址然后我们按下F11后就会触发 jmp 指令此时在右侧观察 esp 就会发现 call 指令的下一条指令也就是 add 指令它的地址006819E8 此时被压入栈顶之后继续按F11我们就来到自定义add函数的内部。现在进入了 add 函数但还没开始执行新的 push此时的空间分布情况大概如下。之后的指令与作用和 main 函数的相同用于为自定义 add 函数开辟函数栈帧压入所需的寄存器以及为栈内存初始化。现在为add函数开辟了对应的函数栈帧此时的空间分布情况大概如下。将add函数的三个寄存器压入栈顶后此时的空间分布情况大概如下。接下来就是进行栈内存的初始化。然后是在开辟好的空间里为变量 z 开辟空间并完成初始化。到这里此时的空间分布情况大概如下。接下来就是执行相加操作但实际上是直接找到存放了实参的寄存器然后执行相应操作所以与形参没有关系。在完成所有操作后就要返回函数的返回值并销毁对应的函数栈帧。虽然变量 z 是局部变量出了作用域就会被销毁但存放值的寄存器会永远存在所以先要将值存入寄存器。接着连续三个 pop 是依次将 ediesi 与 ebx 从栈顶弹出esp 对应增加也就是向高地址方向走。完成 pop 操作后此时的空间分布情况大概如下。后续的 add 操作本质就是让 esp 回到进入 add 函数时最开始的位置比较前面 esp 移动的距离可以发现是一样的。然后进行的 cmp 操作是做检验用于判断栈顶指针与栈底指针是否平衡保证函数栈帧的维护不会出现问题。最后再一步 pop此时是从栈顶弹出 ebp因为栈顶指针 esp 我们可以通过 pop 其他寄存器或进行 add 操作的方式让其回到原先的位置但对于 ebp每一次开辟函数栈帧时都是通过 mov 的方式让其直接更替到 esp 所在位置所以需要在开辟函数栈帧前先将 ebp 压入栈顶这样在弹出时就能获取 ebp 原先的位置正常实现函数栈帧的销毁。所以当我们按下F10进行 pop 操作之后esp 与 ebp 就都发生了变化。而之前我们在进入 add 函数前在栈顶压入过 call 指令的下一条指令的地址此时的 ret 指令就用于返回到栈顶指令的所在位置这样就成功返回函数结果的同时又能保证继续执行后续指令。接下来的 add 操作让 esp 在原基础上加 8 就是越过原本栈顶的 ecx 与 eax来到 main 函数的 edi 所在的位置。我们翻阅前面的代码可以知道之前在 ebp-20h 这个位置存放了变量 c 的值现在将 eax 中所存的计算结果赋给 c。所以整个运算过程中都没有形参的参与形参的改变不会影响到实参。后续就是将值存入 eax然后进行 printf 操作这里不展开细说了。关于最后 main 函数的销毁与 add 函数一样将 ediesi 与 ebx 从栈顶弹出后通过 add操作让 esp 回到一开始的位置通过取出栈顶记录的 ebp 旧地址让 ebp 回到一开始的位置。最后 ret 则是读取栈上的返回地址将 main 函数返回因为我们一开始就说过main 函数也是被调用的。至此通过实例的演示我们学习了函数栈帧从创建到销毁的整个过程。5.问题解决现在清楚了函数栈帧是怎么回事之后前面的这些问题我们也就知道该如何解决了。对于局部变量是如何创建的我们现在知道是在完成函数栈帧的创建后从高地址出发通过ebp-偏移量在已经完成初始化的栈内存里为局部变量开辟空间变量的地址就是ebp-偏移量。对于为什么局部变量不进行初始化其初始值是随机值我们现在知道是因为如果没有通过初始化方式为变量赋值那么在最开始时进行栈内存初始化时是以 0CCCCCCCCh 进行填充的最后就会得到一个很大的值或者出现烫烫烫的乱码而且刚刚在实操时细心的小萌新应该也发现了esp 与 ebp 在回退时只是进行了地址的移动并没有对地址内的存放数据进行修改如果使用新变量前不初始化还有可能用到其他变量的旧数据。对于函数是怎么进行传参的传参的顺序是怎样的我们现在知道是将实参的值放入寄存器然后将寄存器压入栈顶在函数调用时通过ebp偏移量直接获取拷贝的数据作为形参进行运算且传参的顺序是从右到左也就是从低地址到高地址依次传入。对于形参与实参到底是什么关系我们现在知道形参是在压栈时开辟的空间与实参虽然同值但属于不同的空间形参本质上是实参的临时拷贝修改形参不会影响实参。对于函数调用是怎样的一个过程我们现在知道是先在栈顶压入 call 指令的下一条指令地址然后通过 esp 与 ebp 的维护依靠在栈顶压入 ebp 旧地址后将 esp 的值赋给 ebp然后esp-偏移量开辟新的函数栈帧在新的函数栈帧里完成栈内存初始化变量空间开辟实参获取运算并将结果存入 eax在函数栈帧销毁后通过 ret 读取栈上返回地址回到函数调用的下一条指令所在位置完成整个函数调用过程。对于函数调用结束后返回值是怎么返回的我们现在知道是依靠 eax 存放返回值函数调用结束后再从 eax 获取值来实现对返回值的读取。除此之外我们现在也能理解为什么利用函数递归有些情况下会有栈溢出的问题函数递归每一次调用都会在栈上深入开辟新的函数栈帧而函数的返回值虽然存入eax但有很多层的情况下需要逐层销毁逐层回退最终才能获得eax里存放的值。如果函数递归的层数很深不断开辟就有可能引发栈溢出因为函数栈帧只有在返回阶段才会被销毁。6.结语本篇博客到这里就结束了希望能够帮到各位刚接触C语言的小萌新们下篇博客将继续讲解C语言的相关知识。
返回列表