吼吼吼,喷火👀
提示:本文意在使用汇编的语言给大家介绍函数调用中栈区上的过程变化,加深我们对于代码底层的理解,由于不同的编译器使用下,可能造成一些差异,但这并不影响我们对于知识原理的掌握,所以本文不必过多纠结细节处的变化,将内容原理学会才是最重要的。接下来就开始今天的学习吧
一、知识准备工作
1. 寄存器
寄存器是集成到CPU内部的用来存放数据的一些小型存储区域,可以暂时存放参与运算的数据和运算结果。
分为标志寄存器FR,指令指针寄存器IP,段寄存器,指针和变址寄存器,通用寄存器组等……
>我们今天所讲到的都是通用寄存器
寄存器名称 | 功能 |
eax | 累加寄存器,相对于其他寄存器,在进行运算方面比较常用。 |
ebx | 基地址寄存器,通常作为内存偏移指针使用(相对于EAX、 ECX、EDX) |
ecx | 计数寄存器,通常用于特定指令的计数,可用于循环操作。比如:重复的字符存储操作,或者数字统计 |
edi | 通常在内存操作指令中作为“目的地址指针”使用。当然, EDI可以被装入任意的数值, 但通常没有人把它当作通用寄存器来用 |
esi | 通常在内存操作指令中作为“源地址指针”使用。当然, ESI也可以被装入任意的数值, 但通常没有人把它当作通用寄存器来用。 |
esp | 为扩展基址指针寄存器,也被称为帧指针寄存器,用于存放函数栈底指针。它会随着我们栈空间的大小变化,从而改变其所指地址的位置,以适应栈帧空间的大小变化 |
ebp | 为扩展栈指针寄存器,是指针寄存器的一种,用于存放函数栈顶指针。作为一个完整函数所对应的栈帧空间的底线 |
2. 汇编指令
1.push
压栈操作,他会改变esp所指向的位置,从而适应栈帧空间的扩大,操作方式就是将操作数直接压栈到栈帧空间
注意:在x86的环境下,esp的地址以4字节为单位
004018B0 55 push ebp 004018B9 53 push ebx 004018BA 56 push esi
以上就是push指令的代码形式
2.pop
出栈操作,他也会改变esp所指向的位置,从而适应栈帧空间的减小,操作方式就是将操作数直接跳出,离开栈帧空间
注意:在x86的环境下,esp的地址以4字节为单位
00401910 5F pop edi 00401911 5E pop esi 00401912 5B pop ebx
以上就是pop指令的代码形式
3.add
用于将两个运算子相加,将所得结果写入第一个运算子,可以用于改变esp,edp等所指位置,调整他俩所维护的函数栈帧空间
00401913 81 C4 E4 00 00 00 add esp,0E4h 004018F7 83 C4 08 add esp,8 00401875 83 C4 18 add esp,18h
以上就是add指令的代码形式。从代码可以看出,add操作后,改变了esp所指位置,效果和pop与push指令相似
4.sub
减操作指令,从寄存器中减去后运算子,并将结果保存到目标寄存器中
004018B3 81 EC E4 00 00 00 sub esp,0E4h 00401833 81 EC C0 00 00 00 sub esp,0C0h
以上就是sub指令的代码形式。从代码可以看出,sub操作后,改变了esp所指位置,效果和pop与push指令相似
5.lea
lea指令即为load effective address,其实这个指令的功能比较常见的就是将地址赋值给我的操作数,相当于我对地址进行一个临时拷贝放到目的地址指针里面去
004018BC 8D 7D DC lea edi,[ebp-24h]
上面的代码,就是将[edp-24h]的地址放到edi目的地址指针里面去,这也是汇编当中常见的一种写法
6.mov
将一个源操作地址传送到目的地地址(相当于赋值操作),源操作地址并不会被改变
004018B1 8B EC mov ebp,esp 004018BF B9 09 00 00 00 mov ecx,9 这里赋值给ecx计数寄存器,进行循环 004018C4 B8 CC CC CC CC mov eax,0CCCCCCCCh
上面就是mov指令的代码形式,第二行和第三行,我分别将9(16进制下的表示形式)和1个数据(16进制下的表示形式)赋值给ecx计数寄存器和eax累加寄存器
7.rep和stos
rep就是repeat,它其实就是一个重复前缀指令,需要搭配其他指令,补全具体的功能
stos就是store string,它其实就是一个串存储指令,它的功能是将eax中的数据放入的edi所指的地址中,同时,edi会增加4个字节,rep使指令重复执行ecx中填写的次数。
004018BC 8D 7D DC lea edi,[ebp-24h] 004018BF B9 09 00 00 00 mov ecx,9 004018C4 B8 CC CC CC CC mov eax,0CCCCCCCCh 004018C9 F3 AB rep stos dword ptr es:[edi]
以上代码,就是一个完整功能的汇编指令。其功能为,将一个开辟好的函数栈帧内容用eax寄存器里面的内容赋值
dword是指double word,即为双字大小,一个字的字节大小是2字节,所以双字的字节大小就为4字节
ptr指的是pointer,我们最后一行指令就是将eax里面的内容赋值到edi(目的地址指针)所指向的地址处,一次赋值4个字节,重复ecx次
由于栈帧空间使用习惯是,先使用高地址再使用低地址,所以我们会以edi为起点,进行循环赋值,直到9h减为0次,才会停止。
注意:向下赋值,从低地址向高地址处进行赋值,以edi中地址指针为起点向下,直到遇到了ebp指针
8.jmp
无条件跳转指令,以下代码就是无条件跳转到IP地址为0401770h处,其实这个地址就是Add函数的地址
004010B4 E9 B7 06 00 00 jmp Add (0401770h)
下面这张图片就是跳转之后的结果,由光标的位置我们可以看到,通过jmp指令,我们确实跳转到了相应的地址处
9.call
将程序下一条指令的位置的IP压入堆栈中,转移到调用的子程序
一般来说,执行一条CALL指令相当于执行一条push指令加一条jmp指令。
call指令是调用子程序,后面紧跟的应该是子程序名或者过程名。
004018F2 E8 BD F7 FF FF call _Add (04010B4h)
下面图片就是call指令执行后的结果,压栈的操作,可以通过监视窗口,观察esp的地址变化来看
10.ret
用于终止当前函数的执行,将运行权交还给上层函数。也就是,当前函数的帧将被回收。
并且pop掉栈帧空间的call指令的下一条指令的地址,用于回到上层函数中call指令的下一条指令,同时esp指针地址+4字节(因为call下一条指令的IP被pop掉了)
004017BB C3 ret • 1
二、函数栈帧的创建与销毁过程(从汇编角度去看)
1.从下面的原码中我们也可以看出,其实我们的main函数也是被其他函数调用的。在vs2022的环境底下,main函数被_SCRT_STARTUP_MAIN函数调用了
#if defined _SCRT_STARTUP_MAIN using main_policy = __scrt_main_policy; using file_policy = __scrt_file_policy; using argv_policy = __scrt_narrow_argv_policy; using environment_policy = __scrt_narrow_environment_policy; static int __cdecl invoke_main() { return main(__argc, __argv, _get_initial_narrow_environment()); }
2.下面的代码分别是C语言代码和汇编语言代码
#define _CRT_SRCURE_NO_WARNINGS 1 #pragma warning (disable:4996) #include <stdio.h> int Add(int x, int y) { int z = 0; z = x + y; return z; } int main() { int a = 10; int b = 20; int c = 0; c = Add(a, b); printf("%d", c); return 0; }
int main() main函数内部的汇编代码 { 004018B0 55 push ebp 004018B1 8B EC mov ebp,esp 004018B3 81 EC E4 00 00 00 sub esp,0E4h 004018B9 53 push ebx 004018BA 56 push esi 004018BB 57 push edi 004018BC 8D 7D DC lea edi,[ebp-24h] 004018BF B9 09 00 00 00 mov ecx,9 004018C4 B8 CC CC CC CC mov eax,0CCCCCCCCh 004018C9 F3 AB rep stos dword ptr es:[edi] 004018CB B9 08 C0 40 00 mov ecx,offset _EDC442E6_函数栈帧的创建和销毁\函数栈帧的创建和销毁\函数栈帧的创建和销毁@c (040C008h) 004018D0 E8 46 FA FF FF call @__CheckForDebuggerJustMyCode@4 (040131Bh) int a = 10; 004018D5 C7 45 F8 0A 00 00 00 mov dword ptr [a],0Ah int b = 20; 004018DC C7 45 EC 14 00 00 00 mov dword ptr [b],14h int c = 0; 004018E3 C7 45 E0 00 00 00 00 mov dword ptr [c],0 c = Add(a, b); 004018EA 8B 45 EC mov eax,dword ptr [b] 004018ED 50 push eax 004018EE 8B 4D F8 mov ecx,dword ptr [a] 004018F1 51 push ecx 004018F2 E8 BD F7 FF FF call _Add (04010B4h) 004018F7 83 C4 08 add esp,8 004018FA 89 45 E0 mov dword ptr [c],eax printf("%d", c); 004018FD 8B 45 E0 mov eax,dword ptr [c] 00401900 50 push eax 00401901 68 30 7B 40 00 push offset string "%d" (0407B30h) 00401906 E8 C7 F7 FF FF call _printf (04010D2h) 0040190B 83 C4 08 add esp,8 return 0; 0040190E 33 C0 xor eax,eax } 00401910 5F pop edi 00401911 5E pop esi 00401912 5B pop ebx 00401913 81 C4 E4 00 00 00 add esp,0E4h 00401919 3B EC cmp ebp,esp 0040191B E8 24 F9 FF FF call __RTC_CheckEsp (0401244h) 00401920 8B E5 mov esp,ebp 00401922 5D pop ebp 00401923 C3 ret int Add(int x, int y) Add函数内部的汇编代码 { 00E11770 55 push ebp 00E11771 8B EC mov ebp,esp 00E11773 81 EC CC 00 00 00 sub esp,0CCh 00E11779 53 push ebx 00E1177A 56 push esi 00E1177B 57 push edi 00E1177C 8D 7D F4 lea edi,[ebp-0Ch] 00E1177F B9 03 00 00 00 mov ecx,3 00E11784 B8 CC CC CC CC mov eax,0CCCCCCCCh 00E11789 F3 AB rep stos dword ptr es:[edi] 00E1178B B9 08 C0 E1 00 mov ecx,offset _EDC442E6_函数栈帧的创建和销毁\函数栈帧的创建和销毁\函数栈帧的创建和销毁@c (0E1C008h) 00E11790 E8 86 FB FF FF call @__CheckForDebuggerJustMyCode@4 (0E1131Bh) int z = 0; 00E11795 C7 45 F8 00 00 00 00 mov dword ptr [z],0 z = x + y; 00E1179C 8B 45 08 mov eax,dword ptr [x] 00E1179F 03 45 0C add eax,dword ptr [y] 00E117A2 89 45 F8 mov dword ptr [z],eax return z; 00E117A5 8B 45 F8 mov eax,dword ptr [z] } 00E117A8 5F pop edi 00E117A9 5E pop esi 00E117AA 5B pop ebx 00E117AB 81 C4 CC 00 00 00 add esp,0CCh 00E117B1 3B EC cmp ebp,esp 00E117B3 E8 8C FA FF FF call __RTC_CheckEsp (0E11244h) 00E117B8 8B E5 mov esp,ebp 00E117BA 5D pop ebp 00E117BB C3 ret
2.1 main函数栈帧的创建和初始化
int main() main函数内部的汇编代码 { 004018B0 55 push ebp 004018B1 8B EC mov ebp,esp 004018B3 81 EC E4 00 00 00 sub esp,0E4h //将esp位置上移 004018B9 53 push ebx 004018BA 56 push esi 004018BB 57 push edi 004018BC 8D 7D DC lea edi,[ebp-24h] //将ebp-24h这个地址给到目的地址指针edi中 004018BF B9 09 00 00 00 mov ecx,9 //将9给到ecx寄存器 004018C4 B8 CC CC CC CC mov eax,0CCCCCCCCh //把0cccccccch这个内容给到eax寄存器 004018C9 F3 AB rep stos dword ptr es:[edi] //从edi此时存储位置开始,逐渐向高地址开始初始化内容,直到ebp所指位置为终点,结束初始化内容的步骤
开始讲解:
由于我们压栈edp,所以esp会先增加4字节的大小,然后又mov把esp给到edp,然后又对esp减小0E4h大小,所以esp指针向上移动了0E4h字节的大小,其实这就是为main函数的栈帧开辟空间大小,此时esp的位置便指向函数栈帧的顶端,然后我们在分别压栈ebx,esi,edi,下一步我们对main函数栈帧内容进行初始化,每一次初始化double word字节大小的内容,初始化为0cccccccch这个内容,重复循环9次
2.2 局部变量的初始化和分配栈帧空间
int a = 10; 00E118D5 C7 45 F8 0A 00 00 00 mov dword ptr [ebp-8],0Ah int b = 20; 00E118DC C7 45 EC 14 00 00 00 mov dword ptr [ebp-14h],14h int c = 0; 00E118E3 C7 45 E0 00 00 00 00 mov dword ptr [ebp-20h],0
我们其实只要用局部变量的值覆盖掉main函数栈帧中刚开始初始化的内容,这样就完成了局部变量的内容初始化和空间的分配这个步骤了
2.3 函数调用前的准备工作
c = Add(a, b); 00E118EA 8B 45 EC mov eax,dword ptr [ebp-14h] //先将ebp-14h地址处的值给到我们的eax寄存器 00E118ED 50 push eax //然后再将eax中的值进行压栈操作 00E118EE 8B 4D F8 mov ecx,dword ptr [ebp-8] //先将ebp-8h地址处的值给到我们的ecx寄存器 00E118F1 51 push ecx //然后再将ecx中的值进行压栈操作 00E118F2 E8 BD F7 FF FF call 00E110B4 //这里就是跳转到add函数内部的一条指令,并且将00E110B4地址进行压栈操作
我们再函数调用前肯定是要有准备的,由汇编可以看出,我们进行两次的压栈操作,这其实就是在开辟形参x y,他们只是实参a b的一份临时拷贝,另外一个重要的点就是,我们在进行压栈操作时,会先对变量b进行压栈操作,然后在对变量a进行压栈操作
下面就是执行call指令后的画面,再次逐语句调试后就来到了Add函数内部的汇编语言代码
2.4 Add函数栈帧的创建和初始化
00E11770 55 push ebp //压栈main函数栈帧底部的地址 00E11771 8B EC mov ebp,esp 00E11773 81 EC CC 00 00 00 sub esp,0CCh 00E11779 53 push ebx 00E1177A 56 push esi 00E1177B 57 push edi 00E1177C 8D 7D F4 lea edi,[ebp-0Ch] 00E1177F B9 03 00 00 00 mov ecx,3 00E11784 B8 CC CC CC CC mov eax,0CCCCCCCCh 00E11789 F3 AB rep stos dword ptr es:[edi]
注意观察Add函数中的第一条汇编代码,我们将ebp指针地址压栈到了栈帧空间当中,而这个edp其实就是指向main函数栈帧底部的指针,随后我们又进行了调整ebp和esp位置的汇编指令操作,其目的就是重新改变ebp和esp所维护的函数栈帧空间,由原来的维护main函数改成维护Add函数。
然后后面的指令又和main函数栈帧创建的指令一样了,还是分配空间,压栈ebx,esi,edi,然后再进行Add函数栈帧的初始化内容操作。
2.5 Add函数内部进行的变量初始化和分配空间
int z = 0; 00E11795 C7 45 F8 00 00 00 00 mov dword ptr [ebp-8],0 z = x + y; 00E1179C 8B 45 08 mov eax,dword ptr [ebp+8] 00E1179F 03 45 0C add eax,dword ptr [ebp+0Ch] 00E117A2 89 45 F8 mov dword ptr [ebp-8],eax
我们这里其实就是在ebp-8地址处进行了变量c的内容初始化和分配空间,将其内容初始化为0
然后我们将ebp+8处的值放到eax寄存器当中,再将eax中的值加上ebp+0ch处的值,再次给到eax寄存器当中,然后我们在把eax中的值mov到ebp-8(其实就是变量z所在的位置),就是z=x+y的操作
2.6 Add函数栈帧的销毁和返回值的带回
return z; 00E117A5 8B 45 F8 mov eax,dword ptr [ebp-8] //用eax存储了ebp-8处的值 } 00E117A8 5F pop edi 00E117A9 5E pop esi 00E117AA 5B pop ebx 00E117AB 81 C4 CC 00 00 00 add esp,0CCh 00E117B1 3B EC cmp ebp,esp 00E117B3 E8 8C FA FF FF call 00E11244 00E117B8 8B E5 mov esp,ebp 00E117BA 5D pop ebp //让ebp重新回到main函数栈帧的底部 00E117BB C3 ret //执行这条指令后我们会直接回到main函数中call指令的下一条指令的位置
注意我们的指令,我们将ebp-8处的值重新放回到eax寄存器当中(这么做的原因是什么呢?其实我们都知道离开函数时,变量z就会被销毁,其中所被赋有的值也会灰飞烟灭,但我们的寄存器可不会因为函数调用的结束而被销毁,它可是被集成在CPU上的啊,怎么可能说销毁就销毁)
我们将edi,esi,ebx等进行了出栈操作pop(弹出)然后增加了esp的值,其实就是向下移动,改变他和ebp所维护的栈帧空间了,最后我们pop了ebp的值,值得注意的是此刻的ebp可不是一般的ebp(他是王维诗里的ebp),他其实就是调用函数前我们压栈进去的main函数栈帧底部的地址位置,所以我们就能通过pop找回了main栈帧的底部,让ebp重新回到main的底部,也就是让他和esp重新去维护main函数,离开Add函数栈帧(也就是销毁Add函数栈帧)
2.7 重新回到main函数后,形参会怎么样呢?
c = Add(a, b); 00E118EA 8B 45 EC mov eax,dword ptr [ebp-14h] 00E118ED 50 push eax 00E118EE 8B 4D F8 mov ecx,dword ptr [ebp-8] 00E118F1 51 push ecx 00E118F2 E8 BD F7 FF FF call 00E110B4 00E118F7 83 C4 08 add esp,8 //这就是Add函数调用结束后,我们要做的第一件事,也是第一条指令 00E118FA 89 45 E0 mov dword ptr [ebp-20h],eax
注意我们的光标位置,他此时就是指向我们call指令的下一条指令
我们的esp在经过add汇编指令之后会向下移动8个字节的位置,正好跳过了我们为形参x y开辟的栈帧空间,此时也就是销毁了形参x y
读到这里我们今天的学习就结束了,我们讲解了Add函数在汇编角度下是如何被调用的?又是如何将返回值带回?又是如何开辟函数栈帧?如何销毁函数栈帧?等等各种问题,如果你还是有疑问的话,建议将这篇文章收藏起来,多看几次,当然光看肯定是学不会的,你可以在自己的电脑上试一试,观察一下具体的现象,加深理解
三、回答几个问题,检查你看懂没😐
1.局部变量是怎么创建的?
我们先为变量所在的函数开辟一块儿函数栈帧空间,为其分配好相应的大小,并且对其进行初始化,初始化的内容就是(0cccccccch),正因为如此,如果我们不对局部变量进行初始化的话,他的值其实就是0cccccccch,这个值正是烫烫烫的原因,所以我们局部变量的创建,是在函数栈帧开辟好的前提下,在里面寻找一个地址,把这个地址的空间分配给我们的变量,如果你想初始化这个变量,就把(0ccccccch)用你想要的值将其给覆盖掉,就可以了。
2.为什么局部变量的值是随机值?
因为函数栈帧开辟后,会先对函数栈帧进行内容初始化,初始化为0CCCCCCCCh。这正是随机值的原因
3.函数是怎么传参的?传参的顺序是怎样的?
我们会在调用函数前进行函数参数的内容,进行一个压栈操作,当进入到被调用函数内部的时候,我们会通过指针的偏移量找到函数参数,并对其进行操作。
由上面的讲解,我们可以知道,传参时,以从右向左的顺序来进行压栈操作,我们先将右边的参数压栈,然后再对左边的参数压栈。
所以传参顺序是从左向右的。
4.形参和实参是什么关系?
形参是实参的一份临时拷贝,从图中我们也可以看出,改变形参是不会影响实参的,他只是进行了值的一份临时拷贝,并不会影响到我们的实参。
所以修改形参,是不会对实参有所改变的。并且离开函数后,形参会快速被销毁,可见其生命周期的短暂
5.函数调用是怎么做的?
我们会通过汇编语言中的call指令,先将其下一条指令的IP压栈到我们的栈帧空间当中,并且指向call指令,会进入到被调用函数的汇编代码当中,进行被调用函数的汇编指令
并且我们函数调用结束后,通过ret指令能够回到上一层函数中call指令的下一条指令,因为我们的栈帧空间当中已经压栈了call指令的下一条指令的IP
6.函数调用结束后是怎么返回的
我们是通过eax寄存器将我们被调用函数中的返回值,存储起来,等回到上一层函数后,再将eax寄存器中的值释放出来,给到我们想要的变量里面。
这么做的原因,其实就是因为函数调用结束后,其中变量所占空间都会还给操作系统,也就是我们俗称的变量销毁,如果我们想要将这个值带回的话,我们就需要那么一个东西暂存一下这个值,并且这个东西是不会因为函数调用结束而销毁的,那这个东西是什么呢?额额额,不就是eax寄存器么🥱🥱🥱