修炼C语言内功:函数栈帧的创建和销毁

前言

今天这篇内容,是分析函数栈帧的创建和销毁,会分析汇编代码,难度较大。但是啃下这块硬骨头,对你的C语言的内功有很大的提升,更加深入理解函数的运行过程。希望通过这篇文章的学习,你能给出下面的问题的答案。

  • 1.局部变量是怎么创建的?
  • 2.为什么局部变量的值是随机值?
  • 3.函数是怎么传参的?传参的顺序是怎么样的?
  • 4.形参和实参是什么关系?
  • 5.函数调用是怎么做的?
  • 6.函数调用时结束后怎么返回的?

你也可以自行打开VS,一起运行汇编代码,观察监视和内存窗口。不过得注意如果是VS2022版本,下面main函数和Add函数两部分完整汇编代码,有注明一些代码可以忽略。如果是VS2013就不用注意,并且这是在X86的环境下,不要搞错了!


1.函数栈帧创建的总过程(粗略)

首先我们得了解什么是寄存器:

  • 寄存器是一种位于计算机中央处理器(CPU)内部的高速存储器。它们主要被用于存储和执行指令,以及在计算过程中暂时存储和处理数据。寄存器以字节为单位存储数据,并且每个寄存器都有一个唯一的标识符,称为寄存器号码或寄存器名字。计算机的指令集架构决定了有多少个寄存器以及每个寄存器的位数。
  • 寄存器可以被程序直接访问和使用,它们的读写速度非常快,比内存快得多。因此,寄存器被广泛用于存储临时数据、地址、指令和算术运算的操作数。寄存器还可以用于优化程序的执行效率,通过减少内存访问次数,提高计算机的运行速度。

我们今天分析的代码是在VS,X86(32位)的环境下运行的。有这两类寄存器:

  • eax,ebx,ecx和edx是x86架构中的通用寄存器。它们是32位寄存器,可以存储数据和执行算术运算。
  • 与通用寄存器相比,esp(堆栈指针寄存器)和ebp(基指针寄存器)在C语言中通常用作堆栈操作和函数调用的寄存器。ebp,esp这两个寄存器中存放的是地址,这两个地址是用来维护函数栈帧的。

每一个函数调用,都要创建一个内存空间。如下图,系统运行时在栈空间中调用main函数,所以开辟一块空间,然后由栈顶指针esp和栈底指针ebp维护main函数的函数栈帧。在main函数运行时,需要调用Add函数,需要再次开辟一块内存空间。

这只是其中的一部分,在VS2013版本中,你开启调试,在窗口中打开调用堆栈,按F10运行完,可以转到命令行,你会发现main函数被__tmainCRTStartup函数调用,而__tmainCRTStartup被mainCRTStartup函数调用。因此在main函数之前,就开辟了两块内存空间,给__tmainCRTStartup函数和mainCRTStartup函数使用。

这便是函数栈帧创建的全过程,过程比较粗略,想必你到这里应该对函数栈帧有个大致的了解。

2. 分析汇编代码

在VS中,开启调式模式,点击鼠标右键,转到反汇编。要想尝试一起分析,记得要把显示符号名勾选去掉,才可观察到寄存器eax,ebx,ecx,edx,并且把这几个寄存器看作变量。

//main函数的汇编代码
int main()
{
00CB18D0  push        ebp  
00CB18D1  mov         ebp,esp  
00CB18D3  sub         esp,0E4h  
00CB18D9  push        ebx  
00CB18DA  push        esi  
00CB18DB  push        edi  
00CB18DC  lea         edi,[ebp-24h]  
00CB18DF  mov         ecx,9  
00CB18E4  mov         eax,0CCCCCCCCh  
00CB18E9  rep stos    dword ptr es:[edi]  
00CB18EB  mov         ecx,0CBC008h  //可以忽略
00CB18F0  call        00CB132F  //可以忽略
	int a = 10;
00CB18F5  mov         dword ptr [ebp-8],0Ah  
	int b = 20;
00CB18FC  mov         dword ptr [ebp-14h],14h  
	int c = 0;
00CB1903  mov         dword ptr [ebp-20h],0  

	c = Add(a, b);
00CB190A  mov         eax,dword ptr [ebp-14h]  
00CB190D  push        eax  
00CB190E  mov         ecx,dword ptr [ebp-8]  
00CB1911  push        ecx  
00CB1912  call        00CB10B9  
00CB1917  add         esp,8  
00CB191A  mov         dword ptr [ebp-20h],eax  

	printf("%d\n", c);
00CB191D  mov         eax,dword ptr [ebp-20h]  
00CB1920  push        eax  
00CB1921  push        0CB7B30h  
00CB1926  call        00CB10D7  
00CB192B  add         esp,8  

	return 0;
00CB192E  xor         eax,eax  
}
00CB1930  pop         edi  
00CB1931  pop         esi  
00CB1932  pop         ebx  
00CB1933  add         esp,0E4h  //可以忽略
00CB1939  cmp         ebp,esp  //可以忽略
00CB193B  call        00CB1253  //可以忽略
00CB1940  mov         esp,ebp  
00CB1942  pop         ebp  
00CB1943  ret  

 我们知道了,main函数是被__tmainCRTStartup调用,所以先从这个函数开始。打开监视窗口,输入esp和ebp,可以看到这两个指针分别存放着低地址0x12ff85c和高地址0x12ff878,而内存分配的空间一般是由高到低的。

 

 2.1 main函数开辟空间和初始化

 2.1.1 push(压栈)

00CB18D0  push        ebp  
00CB18D1  mov         ebp,esp  
00CB18D3  sub         esp,0E4h  

 先看第一行汇编代码push,压栈的意思。压栈是将栈顶指针esp本身的值减去四,就是往低地址移动,并且esp这个指针指向的值(解引用)改变成push指令后这个变量的值,这里的变量是ebp,所以放入ebp的值。

 运行push后。如下图,esp存放的指针变量(地址)从0x012ff85c变成0x12ff858(0x代表十六进制的数),减少了4。

再看内存,VS是小端字节序,需要倒着读所以0x12FF858指向的值是012ff878(这点得记住)。 

2.1.2 move

00CB18D0  push        ebp  
00CB18D1  mov         ebp,esp  
00CB18D3  sub         esp,0E4h  

第二行行代码是move,move的意思是把后面变量的值赋给前面的变量,在这里可以理解为C语言中ebp = esp

相当于解引用ebp这个指针,指向的位置跟esp相同。

2.1.3 sub

00CB18D0  push        ebp  
00CB18D1  mov         ebp,esp  
00CB18D3  sub         esp,0E4h  

sub是英文subtract的缩写,有减去的意思是,sub指令意思是前面的变量的值减去后面的值。换成C语言,可理解为esp - 0E4h。如下图,你想知道这个值可以在监视窗口名称中输入0E4h,并将值转换成十进制展示,0E4h是个八进制数,十进制下是228。

 esp减去一个值必定发生变化。在监视窗口中,运行汇编代码,可以看到从原来的0x012ff858变成了0x012ff774.

 esp本身的值(指针变量的值表示地址)减小了,所以它指向的位置,应往低地址方向移动。并且esp和ebp的位置都发生了变化,它们俩之间维护的函数栈帧,就是为main函数申请的空间,也是main函数的函数栈帧

2.4 再次压栈

00CB18D9  push        ebx  
00CB18DA  push        esi  
00CB18DB  push        edi  

第一步的压栈你搞明白后,这三次push就是依次将esp的值减去4,往低地址方向移动。并依次放进去ebx,esi和edi这三个元素,最后esp本身的值(地址)减去12,如下图:

 

在监视窗口中,输入ebx,edi和esi,可以知道这三个元素的值。在内存窗口中,输入esp,可以找到esp指针指向的值,一行一行运行汇编代码,esp由原来指向0x012ff774,到0x012ff770,0x012ff6c,0x012ff768,并且这三个地址的值改变成监视窗口那三个元素对应的值。(VS是小端字节序,需要倒着读)

2.4 初始化main函数栈帧

00CB18DC  lea         edi,[ebp-24h]  
00CB18DF  mov         ecx,9  
00CB18E4  mov         eax,0CCCCCCCCh  
00CB18E9  rep stos    dword ptr es:[edi]  
00CB18EB  mov         ecx,0CBC008h  
  • lea全称是“load effective address”,意思为加载有效地址。lea功能与move相同,是吧后面的值赋值给前面的变量,即edi = ebp - 24h,这里得注意加上h的数字,可能是八进制数字,或者十六进制数字,可以放在监视窗口查看,这里后面的基本是十六进制数字。24h相当于十进制的36,所以ebp-24h=ebp-36,跳过9个空间,一个空间为四个字节。
  • 接下来的两个move指令就是赋值,令ecx = 9,eax = 0CCCCCCCCh。其中0CCCCCCCCh表示十六进制的0xcccccccc。

  • 真正发生变化的是rep stos这行指令。它的意思是将从edi指向的位置到ebp指向的位置,执行ecx次,即九次。其中dword为double word,word代表两个字节,所以dword是四个字节,内部初始化为eax的值,即0xCCCCCCCC。如下图:

 

 观察内存中的变化,可以发现从ebp0x012ff858开始,往上初始化了九次,每次初始化四个字节,所以你在创建局部变量不初始化的时候,就会分配到随机值。

运行完这几条指令后,edi的值会变成ebp的值,ecx变成0。(这里的变化可以忽略)

 

2.2 main函数代码

2.2.1 创建变量

	int a = 10;
00CB18F5  mov         dword ptr [ebp-8],0Ah  
	int b = 20;
00CB18FC  mov         dword ptr [ebp-14h],14h  
	int c = 0;
00CB1903  mov         dword ptr [ebp-20h],0  

mov指令就是赋值。0Ah,14h,20h代表十六进制分别是10,20和32。需要注意的就是位置:

  • ebp-8表示ebp的值减去8。如下图,dword ptr意思是是对ebp-8解引用,将这个位置放入10这个值,十六进制为0x0000000a。
  • ebp-14h,ebp减去的是20,与ebp-8隔着两个整型大小的距离。
  • ebp-20h,ebp减去的是32,与ebp-14h隔着两个整型大小的距离

 在内存的显示如下,可以找到0000000a0000001400000000所在的位置是不是都隔着两个整形大小的距离。

 

2.2.2 Add函数

1.传参
c = Add(a, b);
00CB190A  mov         eax,dword ptr [ebp-14h]  
00CB190D  push        eax  
00CB190E  mov         ecx,dword ptr [ebp-8]  
00CB1911  push        ecx  
00CB1912  call        00CB10B9  
  • 先看第一对mov和push。mov这条指令换成C语言的形式是eax = *(ebp-14h),赋值是没有问题的,只不过dword ptr相当解引用ebp-14h这个地址,而ebp-14h在之前创建变量的时候放进去的是20。
  • 然后push是压栈,将esp本身的值加4,指向的位置想低地址移动,还不清楚,可以刻第一步push的讲解。
  • 那么接下来的mov和push也是相同的,先赋值再压栈。观察下图,发现esp只想位置发生变化。

这就是函数传参的操作,所以说形参是实参的一份临时拷贝,这句话十分正确。

 观察内存窗口,0x012ff768是esp原来的位置,往上压进去了00000014和0000000a。监视窗口也可以看到esp的值从0x012ff768变成了0x012ff760。

 

2.call
	c = Add(a, b);
00CB190A  mov         eax,dword ptr [ebp-14h]  
00CB190D  push        eax  
00CB190E  mov         ecx,dword ptr [ebp-8]  
00CB1911  push        ecx  
00CB1912  call        00CB10B9 
00CB1917  add         esp,8  
00CB191A  mov         dword ptr [ebp-20h],eax  

 两对mov和push指令执行完之后,到call指令。此时按下两下F11进入Add函数内部。不过这一步会将call指令下一步指令的地址00CB1917压栈进去了。

 观察监视窗口,esp的值发生了变化,减去了4。

 

3.Add函数完整代码展示

 这下面就是Add函数内部的汇编代码了。

int Add(int x, int y)
{
00CB1790  push        ebp  
00CB1791  mov         ebp,esp  
00CB1793  sub         esp,0CCh  
00CB1799  push        ebx  
00CB179A  push        esi  
00CB179B  push        edi  
00CB179C  lea         edi,[ebp-0Ch]  
00CB179F  mov         ecx,3  
00CB17A4  mov         eax,0CCCCCCCCh  
00CB17A9  rep stos    dword ptr es:[edi]  
00CB17AB  mov         ecx,0CBC008h  //可以忽略
00CB17B0  call        00CB132F   //可以忽略
	int z = 0;
00CB17B5  mov         dword ptr [ebp-8],0  
	z = x + y;
00CB17BC  mov         eax,dword ptr [ebp+8]  
00CB17BF  add         eax,dword ptr [ebp+0Ch]  
00CB17C2  mov         dword ptr [ebp-8],eax  
	return z;
00CB17C5  mov         eax,dword ptr [ebp-8]  
}
00CB17C8  pop         edi  
00CB17C9  pop         esi  
00CB17CA  pop         ebx  
00CB17CB  add         esp,0CCh  //可以忽略
00CB17D1  cmp         ebp,esp  //可以忽略
00CB17D3  call        00CB1253 //可以忽略
00CB17D8  mov         esp,ebp  
00CB17DA  pop         ebp  
00CB17DB  ret  

 

4.Add函数内部压栈
int Add(int x, int y)
{
00CB1790  push        ebp  
00CB1791  mov         ebp,esp  
00CB1793  sub         esp,0CCh  
00CB1799  push        ebx  
00CB179A  push        esi  
00CB179B  push        edi  
00CB179C  lea         edi,[ebp-0Ch]  
00CB179F  mov         ecx,3  
00CB17A4  mov         eax,0CCCCCCCCh  
00CB17A9  rep stos    dword ptr es:[edi]  

 main函数在执行C语言代码时会开辟空间和初始化。Add函数是被main函数调用,也会有这两步。这里的节奏会加快。

  • push是在栈顶指针上压一个元素,指向位置往低地址移动。
  • mov是赋值,即ebp = esp,ebp指向的位置与esp相同。
  • sub,是esp-0CCh,相当于esp-204,往低地址移动这么多的字节数,就已经开辟Add函数的函数栈帧。
  • 之后三个push,将ebx,esi和edi这三个元素压到栈顶指针上。
  • lea加载有效地址,两个mov指令就是赋值,0Ch转换成十进制是12。
  • rep stos就是从edi所存的地址处到ebp的地址,将其中内容初始化成0xCCCCCCCC,执行三次。

 在内存窗口就能看到变化,三个整形空间初始化成CCCCCCCC。

 

5. Add函数代码
int z = 0;
00CB17B5  mov         dword ptr [ebp-8],0  
	z = x + y;
00CB17BC  mov         eax,dword ptr [ebp+8]  
00CB17BF  add         eax,dword ptr [ebp+0Ch]  
00CB17C2  mov         dword ptr [ebp-8],eax  
	return z;
00CB17C5  mov         eax,dword ptr [ebp-8]  

 到这里就是真正的计算了,我们逐个分析:

  • 第一个mov指令是赋值,改写成C语言是*(ebp - 8) = 0,就是将ebp-8这个地址放入0。
  • 接下来是指令是完成C语言z = x + y,第二个mov将ebp+8位置的值赋值给eax这个寄存器中,eax = *(ebp + 8)。而ebp+8这个位置的值是传参这一步为a创建临时变量的位置,ebp+0Ch相当于ebp+12,也是传参时为b创建临时变量的位置。则add这个指令是加上ebp+0Ch的值,改写成从语言就是eax = *(ebp + 8) + *(ebp + 0Ch)。这边完成了相加的步骤。
  • 第三个mov,就是*(ebp - 8) = eax,将相加的值放进这个位置。
  • return z这一步是把相加的值放到eax这个寄存器中,当这个函数销毁的时候,寄存器不会销毁其中的内容,改写成C语言就是eax = *(ebp - 8)。

这是第一个mov指令,监视窗口和内存窗口:

 这是第三个mov指令,监视窗口和内存窗口:

 

6.Add函数返回
00CB17C8  pop         edi  
00CB17C9  pop         esi  
00CB17CA  pop         ebx  
00CB17D8  mov         esp,ebp  
00CB17DA  pop         ebp  
00CB17DB  ret  

 先看三个pop指令,pop意思是出栈,意思是esp指向的位置向高地址移动,没去减去四。这样就把edi,esi和ebx这个元素还给操作系统。如下图:

 

00CB17D8  mov         esp,ebp  

看这个指令mov,将ebp的值赋值给esp,即esp = ebp,相当于把中间的内存空间返还,如下图。

 

00CB17DA  pop         ebp   

 pop就是出栈,因为之前记录了ebp-main函数中的地址,所以ebp返回到main函数一开始的栈底位置,esp往高地址移动,esp减去4。

监视窗口中可以看到ebp和esp的值变回了开始维护mian函数的位置。

 

00CB17DB  ret 

此时,esp指向的位置,是call指令下一条指令的地址(十分重要),通过这个地址,回到main函数汇编代码中,继续执行,esp往高地址移动,esp减去4。如下图:

3. main函数剩下的代码

00CB1917  add         esp,8  
00CB191A  mov         dword ptr [ebp-20h],eax  

	printf("%d\n", c);
00CB191D  mov         eax,dword ptr [ebp-20h]  
00CB1920  push        eax  
00CB1921  push        0CB7B30h  
00CB1926  call        00CB10D7  
00CB192B  add         esp,8  

	return 0;
00CB192E  xor         eax,eax  
}
00CB1930  pop         edi  
00CB1931  pop         esi  
00CB1932  pop         ebx  
00CB1933  add         esp,0E4h  
00CB1939  cmp         ebp,esp  
00CB193B  call        00CB1253  
00CB1940  mov         esp,ebp  
00CB1942  pop         ebp  
00CB1943  ret  

 3.1Add函数形参的销毁

00CB1917  add         esp,8  
00CB191A  mov         dword ptr [ebp-20h],eax  
  •  esp加8把形参销毁了,指针指向下面。
  • ebp-20h,20h是十六进制,转换成十进制就是32。将eax的值放入ebp-32这个位置,这个位置就是变量z的位置,至此完成加法。

3.2 main函数printf和return

	printf("%d\n", c);
00CB191D  mov         eax,dword ptr [ebp-20h]  
00CB1920  push        eax  
00CB1921  push        0CB7B30h  
00CB1926  call        00CB10D7  
00CB192B  add         esp,8  

	return 0;
00CB192E  xor         eax,eax  
}
00CB1930  pop         edi  
00CB1931  pop         esi  
00CB1932  pop         ebx  
00CB1933  add         esp,0E4h  
00CB1939  cmp         ebp,esp  
00CB193B  call        00CB1253  
00CB1940  mov         esp,ebp  
00CB1942  pop         ebp  
00CB1943  ret  

这里的printf就不分析了,主要是函数压栈和出栈,还有形参实参的分析。而return 0这条代码返回和Add函数返回相同,可以仔细分析一下。

4. 对于前言中问题的见解

  • 1.局部变量是怎么创建的?
  • 2.为什么局部变量的值是随机值?
  • 3.函数是怎么传参的?传参的顺序是怎么样的?
  • 4.形参和实参是什么关系?
  • 5.函数调用是怎么做的?
  • 6.函数调用时结束后怎么返回的?

1.局部变量是在初始化后创建的,若不初始化变量会随机配数值。

2.函数传参是通过压栈,从左往右传参的。形参是实参的一份临时拷贝。

3。函数调用要先开辟一块空间,返回是一个一个出栈,并且记住call指令的下一条指令的地址,返回到原来函数中。


总结

这张内容比较特殊,学到的是C语言函数栈帧的创建和销毁,接近底层逻辑。当捋顺整个过程之后,你会对C语言函数代码有了更深的理解。章节内容较多,可以反复观看,上手操作(前言有提到注意事项)。

所以说这次创作十分不易,如果喜欢这篇文章,请留下你的三连哦,你的支持的我最大的动力!!!

 

  • 25
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
提供的源码资源涵盖了安卓应用、小程序、Python应用和Java应用等多个领域,每个领域都包含了丰富的实例和项目。这些源码都是基于各自平台的最新技术和标准编写,确保了在对应环境下能够无缝运行。同时,源码中配备了详细的注释和文档,帮助用户快速理解代码结构和实现逻辑。 适用人群: 这些源码资源特别适合大学生群体。无论你是计算机相关专业的学生,还是对其他领域编程感兴趣的学生,这些资源都能为你提供宝贵的学习和实践机会。通过学习和运行这些源码,你可以掌握各平台开发的基础知识,提升编程能力和项目实战经验。 使用场景及目标: 在学习阶段,你可以利用这些源码资源进行课程实践、课外项目或毕业设计。通过分析和运行源码,你将深入了解各平台开发的技术细节和最佳实践,逐步培养起自己的项目开发和问题解决能力。此外,在求职或创业过程中,具备跨平台开发能力的大学生将更具竞争力。 其他说明: 为了确保源码资源的可运行性和易用性,特别注意了以下几点:首先,每份源码都提供了详细的运行环境和依赖说明,确保用户能够轻松搭建起开发环境;其次,源码中的注释和文档都非常完善,方便用户快速上手和理解代码;最后,我会定期更新这些源码资源,以适应各平台技术的最新发展和市场需求。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值