C语言学习笔记 - 从汇编代码的角度观察函数栈帧的创建和销毁


一、前置知识

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.基本的汇编指令

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语言代码,用作观察对象
#include<stdio.h>
int 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进入调试模式后,可以看见反汇编、调用堆栈、监视、内存这几个窗口。

接下来通过这几个窗口来观察这段代码在内存中是怎么执行的。

1.main函数的调用

首先,在调用堆栈窗口可以看到函数的调用情况。容易发现main函数是被__tmainCRTstartup函数调用的,而__tmainCRTstartup函数又是被mainCRTstartup函数调用的。其中mainCRTstartup函数是启动函数,与C语言程序的启动有关,功能大致是在程序启动之前做一些准备工作。

这说明在调用main函数之前,内存中已经为之前的__tmainCRTstartup函数开辟了栈帧空间,因此在程序刚开始运行的时候,ebp和esp寄存器正在维护的是__tmainCRTstartup函数的栈帧,此时内存栈区中的情况是这样的:
寄存器中存的地址可以在监视窗口和内存窗口看到

寄存器中存的地址可以在监视窗口和内存窗口看到

此时程序还没开始执行第一条语句,处于准备进入main函数的状态。
黄色箭头指向的是当前要执行的语句

黄色箭头指向的是当前要执行的语句

2.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;
	......

这段过程中,内存中的情况是这样的:
main函数栈帧创建的过程

main函数栈帧创建的过程

3.main函数中变量的创建

接下来在主函数中创建并初始化变量。这部分对应的汇编代码如下:

	......
	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个字节存放一个数据。变量的数据在栈帧存放的位置是由编译器决定的,不同的编译器下存放的位置可能不同。
这段过程中,内存中的情况是这样的:
main函数中变量创建的过程

main函数中变量创建的过程

4.SUB函数的调用以及栈帧创建

接下来调用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函数在调用子程序之前会先将其下一条指令的地址入栈,用于在结束调用之后使程序返回到原来的位置继续执行指令。
这段过程中,内存中的情况是这样的:
函数传参以及call指令将下一条指令的地址入栈的过程

函数传参以及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函数栈帧的创建过程

SUB函数栈帧的创建过程

可以观察到,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的过程

SUB函数中创建变量并与形参进行运算,最终将返回值存到寄存器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;

这段过程中,内存中的情况是这样的:
SUB函数栈帧销毁以及变量c接收返回值的过程

SUB函数栈帧销毁以及变量c接收返回值的过程

由此可见,形参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函数栈帧销毁的过程

main函数栈帧销毁的过程

可以观察到,main函数栈帧的销毁过程与SUB函数略有不同,但基本是一致的,都是通过将栈顶指针向高地址移动来实现的。

至此,虽然整个程序还没有彻底运行结束,但是main函数和SUB函数的栈帧的创建和销毁都已经完成。


三、补充说明

这篇博客的主要目标是观察函数栈帧的创建销毁过程并记录,通过观察汇编代码对内存的操作,可以加深对内存管理的理解,还可以清楚地感受到程序员前辈们设计逻辑的严密。

如果文章中有任何问题,欢迎来纠正我。这是我第一次写博客,以后一定会更加细心。

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值