C语言学习笔记-从汇编代码的角度观察函数栈帧的创建和销毁-创新互联
- 一、前置知识
- 1.函数栈帧的概念
- 2.基本的寄存器
- 3.基本的汇编指令
- 二、具体过程
- 1.main函数的调用
- 2.main函数栈帧的创建
- 3.main函数中变量的创建
- 4.SUB函数的调用以及栈帧创建
- 5.SUB函数中变量的创建以及运算
- 6.SUB函数栈帧的销毁以及返回值的接收
- 7.main函数栈帧的销毁
- 三、补充说明
一、前置知识 1.函数栈帧的概念
函数在被调用的时候,操作系统会在内存栈区为这个函数开辟一块空间,提供给这个函数使用,这个栈空间就是该函数的栈帧。函数的返回地址、函数中创建的局部变量、以及一些寄存器信息都会保存在这块栈空间中。在函数运行完毕后,函数栈帧会销毁,将内存空间释放出来。
2.基本的寄存器2.1 ebp、esp
esp即extended stack pointer,扩展栈指针寄存器,是指针寄存器的一种,用于存放函数栈顶指针。
ebp即extended base pointer,扩展基址指针寄存器,也属于指针寄存器,与esp对应,ebp用于存放函数栈底指针。
函数的栈帧空间就是由这两个寄存器来维护的。
2.2 edi、esi
这两个属于变址寄存器,用于存放存储单元在段内的偏移量。
edi:源索引寄存器,一般用于在串操作中存放数据源的地址
esi:目标索引寄存器,一般用于在串操作中存放目标地址
2.3 ecx、ebx、eax
这三个属于通用寄存器,在程序执行的过程中,大部分时间都是通过操作这些寄存器来实现指令功能的。
ecx:计数器,用于存放重复、循环等指令的执行次数计数
ebx:基地址寄存器,在内存寻址时存放基地址
eax:累加器,在进行加法运算时使用,也用于存放函数的返回值
2.4 psw
psw是标志寄存器,是存放标志信息的寄存器。标志信息的作用是为CPU执行相关指令提供行为依据,或者控制CPU的相关工作方式。
- 标志寄存器与其它寄存器相比有一个明显的特点,其他寄存器是用整个寄存器来存放某个特定的数据,而标志寄存器却是用寄存器中的某一位来记录特定信息。因此标志寄存器的每一位都有不同的专门的含义,记录不同的特定信息。
- 这篇博客中只涉及了其中一个标志位:方向标志位(DF),它位于psw的第10位,决定了串操作中寄存器中的地址值是增加还是减少。
3.0 操作数
大部分的汇编指令中,左边的操作数都是目标操作数,右边的操作数都是源操作数
3.1 push、pop
push即压栈,使一个寄存器中的数据入栈,然后使栈顶指针的值相应减小
pop即弹出、出栈,将栈顶的数据存到一个寄存器中,然后使栈顶指针的值相应增加,相当于从栈里面弹出了一个数据
push ebp //将ebp中的值入栈
pop edi //将栈顶元素出栈,并储存到寄存器edi中
3.2 mov
将源地址中的一个值赋到一个目标地址中,源地址中的值不受影响
mov ebp,esp //将esp中存的值赋给ebp,即esp中的值不变,ebp中的值变为esp中的值
3.3 sub、add
sub即减法,将一个数据减小一定的值
add即加法,将一个数据增加一定的值
sub esp,0E4h //将esp中存的值减小0E4h
add esp,0E4h //将esp中存的值增加0E4h
3.4 lea
lea即load effective address,加载有效地址,将一个地址加载到一个寄存器中
lea edi,[ebp-04Eh] //将[ebp-04Eh]这个地址加载到寄存器edi中
3.5 rep
rep即repeat,重复,这是一个前缀指令,指令的作用是重复执行后面的指令
rep指令每次执行的时候都从寄存器ecx里面读取值,当ecx中的值大于0,就执行后面语句,执行完以后,会让ecx - 1,然后再执行rep后面的指令,直到减小到0,因此每次rep执行之前,一般都会先将重复次数存到ecx中,执行完后ecx中的值都会变为0
rep add esp,1 //将后面的add语句重复执行,重复次数由寄存器ecx中的值决定
3.6 stos
stos即store string,串储存,将寄存器eax中存的值赋值到目标地址(这个地址一般都是es:[edi])
stos dword ptr es:[edi] //将寄存器eax中一个双字长度的数据赋值到es:[edi]这个地址
赋值之后还会执行一次使edi中存的值加4或减4,具体是执行加还是减由标志寄存器中的方向标志位DF来决定,DF为0时执行加,DF为1时执行减,可以使用cld指令和std指令来对DF进行设置
cld指令:将标志寄存器的DF位设置为0
std指令:将标志寄存器的DF位设置为1
3.7 call、jmp
call是子程序调用指令,使程序跳转到目标地址来执行子程序,跳转之前会先将call指令的下一条指令的地址进行压栈,执行完子程序之后会跳转回到call指令的下一条指令的位置,然后再继续按顺序执行指令
jmp是无条件转移指令,使程序直接跳转到目标地址执行下一条指令,之后程序按顺序执行
call @ILT+215(_SUB) (0DF10DCh) //首先将下一条语句的地址入栈,然后程序发生跳转,跳转的目标位置是0x0df10dc
jmp SUB (0DF1380h) //使程序跳转到地址0xdf1380处(SUB函数的第一条指令)
3.8 ret
ret即return,返回,弹出栈顶的元素,并使程序跳转到栈顶元素储存的地址对应的指令
3.9 xor
xor即exclusive or,异或,将源操作数与目标操作数进行按位异或,得到的值保存到目标操作数中
xor eax,ebx //将eax中存的值与ebx中存的值异或,所得结果存到eax中
二、具体过程
使用一段简单的C语言代码,通过VS2010中的反汇编功能,从汇编代码的角度观察这段代码中的函数在内存中是怎么调用、怎么实现功能的。
//非常简单的一段C语言代码,用作观察对象
#includeint SUB(int x, int y)
{int z = 0;
z = x - y;
return z;
}
int main()
{int a = 1;
int b = 3;
int c = 0;
c = SUB(a, b);
return 0;
}
在VS2010中对这段代码进行调试,按F10进入调试模式后,可以看见反汇编、调用堆栈、监视、内存这几个窗口。
接下来通过这几个窗口来观察这段代码在内存中是怎么执行的。
首先,在调用堆栈窗口可以看到函数的调用情况。容易发现main函数是被__tmainCRTstartup函数调用的,而__tmainCRTstartup函数又是被mainCRTstartup函数调用的。其中mainCRTstartup函数是启动函数,与C语言程序的启动有关,功能大致是在程序启动之前做一些准备工作。
这说明在调用main函数之前,内存中已经为之前的__tmainCRTstartup函数开辟了栈帧空间,因此在程序刚开始运行的时候,ebp和esp寄存器正在维护的是__tmainCRTstartup函数的栈帧,此时内存栈区中的情况是这样的:
此时程序还没开始执行第一条语句,处于准备进入main函数的状态。
在执行第一条语句之前,先在内存中为main函数开辟一块空间,即创建栈帧。这部分对应的汇编代码如下:
int main()
{00DF13D0 push ebp //将ebp的值入栈,此时栈顶指针esp的值减小,因为栈中压入了新元素
00DF13D1 mov ebp,esp //将esp的值赋给ebp,即令ebp指向esp指向的地址
00DF13D3 sub esp,0E4h //将esp的值减小0E4h
00DF13D9 push ebx //ebx入栈
00DF13DA push esi //esi入栈
00DF13DB push edi //edi入栈
00DF13DC lea edi,[ebp-0E4h] //将ebp-0E4h这个地址赋给edi(作为开始地址)
00DF13E2 mov ecx,39h //将39h赋给ecx(作为重复次数)
00DF13E7 mov eax,0CCCCCCCCh //将0CCCCCCCCh赋给eax(用作赋值内容)
00DF13EC rep stos dword ptr es:[edi] //重复赋值,dword表示双字(作为每次赋值的长度),es:[edi]为赋值的目标地址
//上面四个语句合起来的效果是:
//从es:[edi]这个地址开始,向高地址方向重复赋值,每次赋值的长度为双字(四个字节)
//每次赋值后edi中的地址的值会增加4,从而实现向高地址方向重复多次赋值
//赋值的内容为eax中的值,重复次数为ecx中的值(39h次)
int a = 1;
......
这段过程中,内存中的情况是这样的:
接下来在主函数中创建并初始化变量。这部分对应的汇编代码如下:
......
int a = 1;
00DF13EE mov dword ptr [ebp-8],1 //将1这个值存到ebp-8这个地址中
int b = 3;
00DF13F5 mov dword ptr [ebp-14h],3 //将3这个值存到ebp-14h这个地址中
int c = 0;
00DF13FC mov dword ptr [ebp-20h],0 //将0这个值存到ebp-20h这个地址中
c = SUB(a, b);
......
由此可见这几个int变量的存放位置恰好是从ebp-8开始,每隔8个字节存放一个数据。变量的数据在栈帧存放的位置是由编译器决定的,不同的编译器下存放的位置可能不同。
这段过程中,内存中的情况是这样的:
接下来调用SUB函数,首先进行的是函数传参以及程序的跳转。这部分对应的汇编代码如下:
......
c = SUB(a, b);
//下面四条指令完成的是函数传参
00DF1403 mov eax,dword ptr [ebp-14h] //将双字指针ebp-14h中的值(即b的值)存入寄存器eax中
00DF1406 push eax //eax入栈
00DF1407 mov ecx,dword ptr [ebp-8] //将ebp-8中的值(即a的值)存入寄存器ecx中
00DF140A push ecx //ecx入栈
//call指令调用SUB函数
00DF140B call @ILT+215(_SUB) (0DF10DCh) //子程序调用指令
//首先将下一条语句的地址(0x00df1410)入栈,然后程序发生跳转,跳转的目标位置是0x0df10dc,对应一条使程序跳转到SUB函数的jmp语句
//执行完SUB函数之后,程序会再跳转回到此处,执行下一条语句
00DF1410 add esp,8
00DF1413 mov dword ptr [c],eax
return 0;
......
......
@ILT+215(_SUB):
00DF10DC jmp SUB (0DF1380h) //无条件转移指令
//前面的call指令会使程序跳转到这条指令,而这条指令会使程序跳转到SUB函数
//地址0x0df1380h对应的就是SUB函数中第一条指令的地址
......
由此可见:
(1)SUB函数的形参在函数栈帧创建之前就已经创建好了,而且形参是实参的一份临时拷贝,对形参的修改不影响实参。
(2)call函数在调用子程序之前会先将其下一条指令的地址入栈,用于在结束调用之后使程序返回到原来的位置继续执行指令。
这段过程中,内存中的情况是这样的:
jmp指令完成跳转之后,就开始创建SUB函数的栈帧。这部分对应的汇编代码如下:
......
int SUB(int x, int y)
{00DF1380 push ebp //ebp入栈
00DF1381 mov ebp,esp //将esp中存的值赋给ebp
00DF1383 sub esp,0CCh //将esp的值减小0CCh
00DF1389 push ebx //ebx入栈
00DF138A push esi //esi入栈
00DF138B push edi //edi入栈
00DF138C lea edi,[ebp-0CCh] //将ebp-0CCh这个地址赋给edi(作为开始地址)
00DF1392 mov ecx,33h //将33h赋给ecx(作为重复次数)
00DF1397 mov eax,0CCCCCCCCh //将0CCCCCCCCh赋给eax(用作赋值内容)
00DF139C rep stos dword ptr es:[edi] //重复赋值,dword表示双字(作为每次赋值的长度),es:[edi]为赋值的目标地址
//上面四个语句合起来的效果是:
//从es:[edi]这个地址开始,向高地址方向重复多次赋值,每次赋值的长度为双字(四个字节)
//每次赋值后edi中的地址的值会增加4,从而实现向高地址方向重复多次赋值
//赋值的内容为eax中的值,重复次数为ecx中的值(33h次)
int z = 0;
......
这段过程中,内存中的情况是这样的:
可以观察到,SUB函数栈帧的创建过程与前面main函数栈帧的创建几乎是完全一样的。
5.SUB函数中变量的创建以及运算接下来在SUB函数中创建变量,并与传过来的形参进行运算,然后把返回值返回到主函数。这部分对应的汇编代码如下:
......
int z = 0;
00DF139E mov dword ptr [ebp-8],0 //将0这个值存到ebp-8这个地址中
z = x - y;
00DF13A5 mov eax,dword ptr [ebp+8] //将ebp+8这个地址中存的值存到寄存器eax中
//ebp+8这个地址是0x00d3fd38,存的是形参x的值
00DF13A8 sub eax,dword ptr [ebp+0Ch] //将eax中存的数据减小一定值,减小的值为ebp+0Ch中存的值
//ebp+0Ch这个地址是0x00d3fd3c,存的是形参y的值
00DF13AB mov dword ptr [ebp-8],eax //将eax中存的值存到ebp-8这个地址中(即存到变量z中)
return z;
00DF13AE mov eax,dword ptr [ebp-8] //将ebp-8中存的值存到寄存器eax中(相当于将变量z中的值返回)
//局部变量z会随着SUB函数运行结束而销毁,将z的值存到寄存器eax中就可以保存下来,并返回到主函数中
}
......
这段过程中,内存中的情况是这样的:
可以观察到,SUB函数的返回值储存在了寄存器eax中,如果主函数中需要接收返回值,就可以从eax中取出。
6.SUB函数栈帧的销毁以及返回值的接收接下来进行SUB函数栈帧的销毁以及返回值的接收。这部分对应的汇编代码如下:
......
return z;
}
00DF13B1 pop edi //将栈顶元素出栈,并存到寄存器edi中,同时栈顶指针的值相应增加
00DF13B2 pop esi //将栈顶元素出栈,并存到寄存器esi中
00DF13B3 pop ebx //将栈顶元素出栈,并存到寄存器ebx中
00DF13B4 mov esp,ebp //将ebp的值赋给esp,即令esp指向ebp指向的地址,
00DF13B6 pop ebp //将栈顶元素出栈,并存到寄存器ebp中,即令ebp指向main函数的栈底
00DF13B7 ret //返回,弹出栈顶的元素,并使程序跳转到栈顶元素储存的地址对应的指令
//此时的栈顶元素是0x00df1410,正好是call指令的下一条指令的地址,因此程序返回到call指令的下一条指令
......
00DF140B call 00DF10DC //子程序调用指令
//执行ret后,程序返回到此处,往下接着执行指令
00DF1410 add esp,8 //将esp中存的值增加8(相当于销毁了形参x和y)
00DF1413 mov dword ptr [ebp-20h],eax //将eax中存的值存放到ebp-20h这个地址中(相当于变量c接收了返回值)
return 0;
这段过程中,内存中的情况是这样的:
由此可见,形参x和y的销毁是在SUB函数栈帧销毁之后进行的,变量c是通过寄存器eax来接收SUB函数的返回值的。
7.main函数栈帧的销毁接下来进行main函数栈帧的销毁。这部分对应的汇编代码如下:
......
return 0;
00DF1416 xor eax,eax //将eax中存的值与eax中存的值异或,所得结果存到eax中(相当于把eax存的值置为0)
}
00DF1418 pop edi //栈顶元素出栈到edi中
00DF1419 pop esi //栈顶元素出栈到esi中
00DF141A pop ebx //栈顶元素出栈到ebx中
00DF141B add esp,0E4h //将esp中存的值增加0E4h(相当于销毁了main函数的栈帧)
......
这段过程中,内存中的情况是这样的:
可以观察到,main函数栈帧的销毁过程与SUB函数略有不同,但基本是一致的,都是通过将栈顶指针向高地址移动来实现的。
至此,虽然整个程序还没有彻底运行结束,但是main函数和SUB函数的栈帧的创建和销毁都已经完成。
三、补充说明
这篇博客的主要目标是观察函数栈帧的创建销毁过程并记录,通过观察汇编代码对内存的操作,可以加深对内存管理的理解,还可以清楚地感受到程序员前辈们设计逻辑的严密。
如果文章中有任何问题,欢迎来纠正我。这是我第一次写博客,以后一定会更加细心。
你是否还在寻找稳定的海外服务器提供商?创新互联www.cdcxhl.cn海外机房具备T级流量清洗系统配攻击溯源,准确流量调度确保服务器高可用性,企业级服务器适合批量采购,新人活动首月15元起,快前往官网查看详情吧
网页标题:C语言学习笔记-从汇编代码的角度观察函数栈帧的创建和销毁-创新互联
文章转载:http://ybzwz.com/article/eccpe.html