目录
1.函数栈帧的含义
概念
要用到的汇编语言的知识
示例
2.理解栈帧
2.1 main函数栈帧的创建
2.2 局部变量的创建
2.3 函数传参
2.4 调用函数
2.5 函数返回
一个.c文件在调用函数的时候(包括main 函数),其内存中的栈区有什么变化?要压栈、出栈哪些寄存器呢?函数的参数是如何进行传递的呢?函数调用结束之后栈区又是如何变化的呢?本文通过使用汇编语言,对这些内容进行了较为详细的剖析。
1.函数栈帧的含义
概念
首先,栈的概念想必不需要过多解释,那么什么是栈帧?引用百度百科:C语言中,每个栈帧对应着一个未运行完的函数。栈帧中保存了该函数的返回地址和局部变量。从这句话中,可以提炼以下几点信息:
· 栈帧是一块因函数运行而临时开辟的空间。
· 每调用一次函数便会创建一个独立栈帧。
· 栈帧中存放的是函数中的必要信息,如局部变量、函数传参、返回值等。
· 当函数运行完毕栈帧将会销毁。
我们知道,C语言的内存区分成了 静态区、栈区、堆区,函数栈帧无疑是在栈区创建和销毁的,所以其要符合栈“后进先出”的特点。
要用到的汇编语言的知识
在这里使用汇编语言方面知识的原因是:通过它,我们可以深入底层了解一个程序是如何运行的,在何时——什么东西压栈,什么东西出栈,寄存器(汇编语言中一些用来暂时存储数据的东西)如何变化等等。这些都是C语言无法直观体现的,我们可以通过Visual Stdio 的在调试时的反汇编功能,将C语言代码转换成汇编语言代码,以便更好地观察。(另,C语言也是汇编语言编写的。)所以,简单地说,本文主要是在分析汇编语言的执行过程。
我们首先要了解几个汇编语言方面的东西,其中ESP和EBP时专门维护函数栈帧的,分别指向栈顶和栈底:
寄存器 | 用途 |
---|---|
EAX | 累加寄存器:用于乘除法、函数返回值 |
EBX | 用于存放内存数据指针 |
ECX | 计数器 |
EDX | 用于乘除法、IO指针 |
ESP | 存放栈顶指针(其值是地址) |
EBP | 存放栈底指针(其值是地址) |
汇编指令 | 用途 |
---|---|
mov | mov A,B 将数据B移动到A |
push | 压栈 |
pop | 出栈 |
call | 函数调用 |
add | 加法 |
sub | 减法 |
rep | 重复 |
lea | 加载有效地址 |
示例
比如,我们写下一个如下的C语言程序,非常容易,只有main() 函数和一个 Add() 函数,主函数里面调用了 Add() 。
#include<stdio.h>
int Add(int x, int y)
{
return x + y;
}
int main()
{
int a = 10;
int b = 20;
int c = Add(a, b);
return 0;
}
那么,一开始调用主函数的时候,主函数的函数栈帧就压栈;然后在主函数里面,调用了Add() 函数,此时Add() 的函数栈帧也要压栈。那么现在面临一个问题,是维护Add() 函数,还是维护main() 函数,亦或两者都维护?
其实,从平时使用Visual Stdio 调试的时候就可以看出来,当主函数内部调用一个函数A,按F11分步调试,会进入函数A的内部,函数A调用结束,会返回主函数。同理,实际上从进入函数A,一直到A 函数调用结束,这个过程都在维护函数A。所以,在main() 函数内部调用 Add() 函数之后,会出现如下图所示的情况,ebp和esp来维护Add() 函数的栈帧。当Add() 函数调用结束,它的函数栈帧自然就会出栈,此时ebp、esp又会返回维护main() 函数的栈帧。
2.理解栈帧
2.1 main函数栈帧的创建
实际上,main() 函数也是由其他函数调用的,其调用链条如下:
创建main函数的函数栈帧代码如下,汇编语言的注释是用 ; 所以这里 ; 后面的内容是注释,帮助理解代码。
006117A0 push ebp ; ebp 压栈
006117A1 mov ebp,esp ; 将 esp里面的值 赋给ebp
006117A3 sub esp,0E4h ; esp减去0E4h(十六进制),得到的结果赋给esp
006117A9 push ebx ; ebx 压栈
006117AA push esi ; esi 压栈
006117AB push edi ; edi 压栈
006117AC mov edi,[ebp-24h] ; 将ebp往上数,第24h(16进制)个字节开始,向下4个字节的值赋给edi
006117AF mov ecx,9 ; 这里到结束的意思是:
006117B4 mov eax,0CCCCCCCCh ; 将0CCCCCCCCh 赋值给某块空间,这块空间从附加段中 edi 指向的位置开始
006117B9 rep stos dword ptr es:[edi] ; 一共执行九次(0CCCCCCCCh 是四个字节的内容,每次操作四个字节,所以一共操作了36字节)
第一行
我们来开始逐句剖析上方代码,首先执行第一行(图中红色圆圈圈出来的黄色箭头,表示已经执行完其上一行,按F10调试就执行当前行),由于是压栈操作,所以esp的值会有所变化,如下图右边监视窗口,esp的值(十六进制显示的)相较之前改变了,所以变成红色:
如下,ebp压栈,同时esp上移:
第二行
该行是将esp的值赋给ebp,效果也如下图,右边监视窗口的红色部分所示。
如下,将esp的值赋给ebp之后,ebp和esp指向同一块地方:
第三行
该行是将esp的值减去0E4h(十六进制),得到的结果赋给esp,如下图。
如图所示,由于图中从下往上是地址高处到地址低处,所以esp值变小,实际上图中是上移。并且,现在ebp和esp维护的空间,就是main函数的函数栈帧:
第四行
压栈,压入ebx,改变栈顶指针esp的值。
如下,压栈,esp上移:
第五行
压入esi,改变esp的值。
如下,和上一步类似:
第六行
压入edi,改变esp的值。
和上一步也类似:
第七行
将[ebp-24h] 表示的地址赋值给edi。
这里就是把 edi 里面的值改变,从函数栈帧看不出什么,看上面的监视图就可以直到确实是改变了。
最后三行
如之前代码里的注释所说。
如下,两个箭头指示的值是一样的,其代表的是edi所表示的地址,从该地址开始,往后9个dw(double word 双字,一个双字等于四个字节)的内容,都赋值为cccccccc (十六进制)。
这三行代码效果如下:
整个过程可以用一张动图生动形象地展示:
2.2 局部变量的创建
接下来,我们在汇编代码中鼠标右击,然后将下图红色箭头所指示的"显示符号名" 的勾去掉。
发生改变的是下图中红色圆圈圈出来的,可以看出,原本所有的变量名,都变成了寄存器减去某个十六进制数字。他们实际上是等价的,即变量的地址就等于替换后的地址。
接下来分析局部变量创建过程。
首先,创建变量a,代码如下,其含义就是,将0Ah 这个十六进制数字,从ebp的地址低八位处开始放,占四个字节:
002C17C5 mov dword ptr [ebp-8],0Ah
如下图,可以通过两个红色箭头看到,右边监视的ebp的值就是左边 地址处的箭头指向的地址,说明这就是ebp的地址,然后减去八位,再根据栈从下往上使用以及Visual Stdio小端存储的特点,就成了内存区里面红色方框框出来的内容。(注意,比如 cc cc cc cc ,cc占据的是一个字节的空间,四个cc 就占据四个字节,而汇编语言中,地址-1,只跳过一个c,所以ebp-8是跳过8个c,即四个字节)
如下,图中一个小格子代表四个字节,不难看出,变量a存储的位置,在栈底指针往上跳过四个字节的地方。
然后创建局部变量b,通过内存图可以看出,变量b和变量a是间隔八个字节的:
如下图:
2.3 函数传参
代码如下:
002C17D3 mov eax,dword ptr [ebp-14h] ; 将变量b的值赋给eax
002C17D6 push eax ; eax压栈
002C17D7 mov ecx,dword ptr [ebp-8] ; 将变量a的值赋给ecx
002C17DA push ecx ; ecx压栈
执行前两行代码,确实将变量b的值赋给了eax,然后eax引起的压栈导致了esp改变:
如下图,不要忘了eax里面的值和变量b是一样的哦:
执行后两行代码:
其效果和前两行类似,同时ecx里面存的是变量a的值:
2.4 调用函数
上面的内容执行完之后,要执行如下语句,其意思是,执行 002C10B4 地址处的内容:
002C17DB call 002C10B4
然后我们将其滑倒该地,发现是这样的,意思是跳到002C1740地址处:
又找到该地址,发现如下,所以,通过这两步调用Add() 函数,如下红色部分,和创建main函数的函数栈帧类似,实际上就是创建了Add() 的函数栈帧:
效果如下,建立了Add函数的函数栈帧:
红色个方框后面两行代码不是很重要,是用来检查bug的,如下代码和图片:
002C1757 mov ecx,2CC003h
002C175C call 002C130C
2.5 函数返回
函数返回:
002C1761 mov eax,dword ptr [ebp+8]
002C1764 add eax,dword ptr [ebp+0Ch]
第一行代码: 将ebp+8 地址处的数据放到eax 。
第二行代码:将ebp+0Ch 地址处的数据和eax相加,结果存到eax里面。
如下图中,由于图片从下往上是地址从高到低,所以图片中ebp+8是在ebp下方。实际上就是ecx和eax的值相加,然后存到eax里面。eax里面存储变量b的值,ecx里面存储变量a的值,最后eax的值就是变量a、b之和。并且eax是不会随着Add() 函数的函数栈帧销毁而改变值。
通过监视也可以看出,eax的值变成0x0000001e,转换成十进制就是30。
此时已经拿到返回值,存储在eax里面,还要执行以下几行代码:
00AA13F1 pop edi
00AA13F2 pop esi
00AA13F3 pop ebx
00AA13F4 mov esp,ebp
00AA13F6 pop ebp
00AA13F7 ret
就是出栈、赋值等等,结果如下,回到了调用Add() 函数之前的状态:
然后执行main() 函数后续代码代码,如下图红色框出:
第一行:esp加8,即esp在途中向下移动四个字节。
第二行,将eax的值赋给ebp-20h 地址处。
执行完之后,调试图如下,通过对比两个红色方框的内容,左边红色方框的地址,和右边&c 的值一样,说明那就是变量c 存储的地方,其值也是变量c 的值:
示意图如下:
通过对函数栈帧创建、销毁过程的剖析使我们不仅了解计算机做了什么,还了解了它是如何做的。通过函数栈帧尝试解析递归等问题相信也会更加直观。由于本人水平有限,不足之处还请大家多多指教。