目录
前言
1.函数栈帧创建的总过程(粗略)
2. 分析汇编代码
2.1 main函数开辟空间和初始化
2.1.1 push(压栈)
2.1.2 move
2.1.3 sub
2.4 再次压栈
2.4 初始化main函数栈帧
2.2 main函数代码
2.2.1 创建变量
2.2.2 Add函数
1.传参
2.call
3.Add函数完整代码展示
4.Add函数内部压栈
5. Add函数代码
6.Add函数返回
编辑
3. main函数剩下的代码
3.1Add函数形参的销毁
3.2 main函数printf和return
4. 对于前言中问题的见解
总结
今天这篇内容,是分析函数栈帧的创建和销毁,会分析汇编代码,难度较大。但是啃下这块硬骨头,对你的C语言的内功有很大的提升,更加深入理解函数的运行过程。希望通过这篇文章的学习,你能给出下面的问题的答案。
你也可以自行打开VS,一起运行汇编代码,观察监视和内存窗口。不过得注意如果是VS2022版本,下面main函数和Add函数两部分完整汇编代码,有注明一些代码可以忽略。如果是VS2013就不用注意,并且这是在X86的环境下,不要搞错了!
首先我们得了解什么是寄存器:
- 寄存器是一种位于计算机中央处理器(CPU)内部的高速存储器。它们主要被用于存储和执行指令,以及在计算过程中暂时存储和处理数据。寄存器以字节为单位存储数据,并且每个寄存器都有一个唯一的标识符,称为寄存器号码或寄存器名字。计算机的指令集架构决定了有多少个寄存器以及每个寄存器的位数。
- 寄存器可以被程序直接访问和使用,它们的读写速度非常快,比内存快得多。因此,寄存器被广泛用于存储临时数据、地址、指令和算术运算的操作数。寄存器还可以用于优化程序的执行效率,通过减少内存访问次数,提高计算机的运行速度。
我们今天分析的代码是在VS,X86(32位)的环境下运行的。有这两类寄存器:
每一个函数调用,都要创建一个内存空间。如下图,系统运行时在栈空间中调用main函数,所以开辟一块空间,然后由栈顶指针esp和栈底指针ebp维护main函数的函数栈帧。在main函数运行时,需要调用Add函数,需要再次开辟一块内存空间。
这只是其中的一部分,在VS2013版本中,你开启调试,在窗口中打开调用堆栈,按F10运行完,可以转到命令行,你会发现main函数被__tmainCRTStartup函数调用,而__tmainCRTStartup被mainCRTStartup函数调用。因此在main函数之前,就开辟了两块内存空间,给__tmainCRTStartup函数和mainCRTStartup函数使用。
这便是函数栈帧创建的全过程,过程比较粗略,想必你到这里应该对函数栈帧有个大致的了解。
在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,而内存分配的空间一般是由高到低的。
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(这点得记住)。
00CB18D0 push ebp
00CB18D1 mov ebp,esp
00CB18D3 sub esp,0E4h
第二行行代码是move,move的意思是把后面变量的值赋给前面的变量,在这里可以理解为C语言中ebp = esp。
相当于解引用ebp这个指针,指向的位置跟esp相同。
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函数的函数栈帧。
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是小端字节序,需要倒着读)
00CB18DC lea edi,[ebp-24h]
00CB18DF mov ecx,9
00CB18E4 mov eax,0CCCCCCCCh
00CB18E9 rep stos dword ptr es:[edi]
00CB18EB mov ecx,0CBC008h
观察内存中的变化,可以发现从ebp0x012ff858开始,往上初始化了九次,每次初始化四个字节,所以你在创建局部变量不初始化的时候,就会分配到随机值。
运行完这几条指令后,edi的值会变成ebp的值,ecx变成0。(这里的变化可以忽略)
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。需要注意的就是位置:
在内存的显示如下,可以找到0000000a,00000014和00000000所在的位置是不是都隔着两个整形大小的距离。
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
这就是函数传参的操作,所以说形参是实参的一份临时拷贝,这句话十分正确。
观察内存窗口,0x012ff768是esp原来的位置,往上压进去了00000014和0000000a。监视窗口也可以看到esp的值从0x012ff768变成了0x012ff760。
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。
这下面就是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
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函数调用,也会有这两步。这里的节奏会加快。
在内存窗口就能看到变化,三个整形空间初始化成CCCCCCCC。
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指令,监视窗口和内存窗口:
这是第三个mov指令,监视窗口和内存窗口:
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。如下图:
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
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
这里的printf就不分析了,主要是函数压栈和出栈,还有形参实参的分析。而return 0这条代码返回和Add函数返回相同,可以仔细分析一下。
1.局部变量是在初始化后创建的,若不初始化变量会随机配数值。
2.函数传参是通过压栈,从左往右传参的。形参是实参的一份临时拷贝。
3。函数调用要先开辟一块空间,返回是一个一个出栈,并且记住call指令的下一条指令的地址,返回到原来函数中。
这张内容比较特殊,学到的是C语言函数栈帧的创建和销毁,接近底层逻辑。当捋顺整个过程之后,你会对C语言函数代码有了更深的理解。章节内容较多,可以反复观看,上手操作(前言有提到注意事项)。
所以说这次创作十分不易,如果喜欢这篇文章,请留下你的三连哦,你的支持的我最大的动力!!!