一、 什么是函数栈帧
我们在写C语言代码的时候,经常会把一个 独立的功能抽象为函数,所以C程序是以函数为基本单位的。那函数是如何调用的?函数的返回值又是如何待会的?函数参数是如何传递的?这些问题都和==函数栈帧==有关系。
**函数栈帧(stack frame)就是函数调用过程中在程序的调用栈(call stack)所开辟的空间,这些空间
是用来存放:**
- 函数参数和函数返回值
- 临时变量(包括函数的非静态的局部变量以及编译器自动生产的其他临时变量)
- 保存上下文信息(包括在函数调用前后需要保持不变的寄存器)
二、 理解函数栈帧能解决什么问题呢?
只要理解了函数栈帧的创建和销毁,以下问题就能够很好的额理解了
📚局部变量是如何创建的?
📚为什么局部变量不初始化内容是随机的?
📚函数调用时参数时如何传递的?传参的顺序是怎样的?
📚函数的形参和实参分别是怎样实例化的?
📚函数调用是怎么做的?函数的返回值是如何带会的?
我们就带着这些疑问一起走近函数栈帧吧:walking:
三、 函数栈帧的创建和销毁解析
1、什么是栈?
栈(stack)是现代计算机程序里最为重要的概念之一,几乎每一个程序都使用了栈,没有栈就没有函数,没有局部变量,也就没有我们如今看到的所有的计算机语言。
- 在经典的计算机科学中,栈被定义为一种特殊的容器,用户可以将数据压入栈中(入栈,push),也可以将已经压入栈中的数据弹出(出栈,pop),但是栈这个容器必须遵守一条规则:先入栈的数据后出栈(First In Last Out, FIFO)。就像叠成一叠的术,先叠上去的书在最下面,因此要最后才能取出
- 在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据从栈顶弹出。压栈操作使得栈增大,而弹出操作使得栈减小。
==光看文字可能有点晦涩难懂,可以看看我的这篇文章==——> 数据结构 | 站与队列的交际舞
不过大家不要将内存中的【栈】和数据结构中的【栈】混为一谈,二者还是有区别的
在经典的操作系统中,栈总是向下增长(由高地址向低地址)的
在我们常见的i386或者x86-64下,栈顶由成为 esp 的寄存器进行定位的
2、认识相关寄存器和汇编指令
接着我来了解一下函数栈帧的相关寄存器,以及常用的汇编指令
2.1 相关寄存器
- [x] 【eax】:通用寄存器,保留临时数据,常用于返回值
- [x] 【ebx】 :通用寄存器,保留临时数据
- [x] 【ebp】:栈底寄存器
- [x] 【esp】:栈顶寄存器
- [x] 【eip】:指令寄存器,保存当前指令的下一条指令的地址
2.2 相关汇编命令
- [x] 【mov】:数据转移指令
- [x] 【push】:数据入栈,同时esp栈顶寄存器也要发生改变
- [x] 【pop】:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变
- [x] 【add】:加法命令
- [x] 【sub】:减法命令
- [x] 【lea】 :load effective address,加载有效地址
- [x] 【call】:函数调用,1. 压入返回地址 2. 转入目标函数
- [x] 【jump】:通过修改eip,转入目标函数,进行调用
- [x] 【ret】:恢复返回地址,压入eip,类似pop eip命令
3、解析函数栈帧的创建和销毁
3.1 预备知识
首先我们达成一些预备知识才能有效的帮助我们理解,函数栈帧的创建和销毁📕
- 每一次函数调用,都要为本次函数调用开辟空间,就是函数栈帧的空间
- 这块空间的维护是使用了2个寄存器: esp 和 ebp ,【ebp】 记录的是栈底的地址, 【esp】 记录的是栈顶的地址
==如图所示==
3.2 代码 && 环境搭建
- 对于代码而言我就以最简单的两数相加为例,而且每一步都详细展开,方便观看每一个变量是如何被创建和销毁的
#include <stdio.h>
int Add(int x, int y)
{
int z = 0;
z = x + y;
return z;
}
int main(void)
{ //从这里开始运行,按下F10
int a = 10;
int b = 20;
int c = 0;
c = Add(a, b);
printf("c = %d\n", c);
return 0;
}
- 然后我们来把环境做一个搭建,首先直接在键盘上按下F10【笔记本按下Fn + F10】。以往写代码的时候,我们都知道有这么一个main函数,如果程序要运行起来那就必须要在main函数里面对应的代码,无论是封装了多少的函数,最后都要在main函数里进行一个调用,==但是你有想过吗,这个main函数是被谁调用的呢?==
- 首先我们来做一些环境的搭建工作
- 接下去在按下F10后到监视窗口打开【调用堆栈】的窗口
- 然后就出现了这样的界面。此时我们的main函数就从第13行开始运行了
- 接下去一直按F10,当调试箭头运行到第【22行】的时候,就会自动进入到==exe_common.inl==,此时我们就可以观察到底是哪个函数调用了main函数
- 通过下图可知是==invoke_main==这个函数调用的,我们了解到这里就可以了,再深挖下去的话可能就比较难以理解了
- 好,了解了这个知识后我们就要正式开始进入函数的分析了,关掉这个【调用堆栈】的窗口后,我们准备调出【反汇编】【内存】【监视】这三个窗口
==【反汇编】==
==【内存】==
==【监视】==
好,到这么为止我们的环境已经全部搭建好了
3.3 函数栈帧的创建【保姆式教学】
- 接下去,我们正式开始分析函数栈帧究竟是如何创建的
- 从上图看到此时此刻我们已经进入到main函数了,那么通过刚才的【调用堆栈】可以知道,main函数是由invoke_main这个函数来进行调用的,所以我们先画出它的函数栈帧
- 首先看到左边的两个寄存器【esp】和【ebp】,分别用来维护栈顶和栈顶。可以看到左右有个双向箭头,因为对于栈来说是从【高地址】向【低地址】使用的,内存是向上漫延的。
==第一条指令==
- 好,接下去的话就要执行第一条指令了。也就是向栈中push一个ebp,即将ebp中的值进行一个压栈的操作,此时的ebp中存放的是invoke_main函数栈帧的ebp
00461820 55 push ebp
- 然后随着push入栈的操作,维护栈顶的esp就要往上
- 此时我们还可以到VS中来观察一下寄存器所存放内存地址的变化
- 好,然后我们执行一下push这句指令,你就会发现【esp】中所存放的地址变小了,然后它里面存放的就是原先【ebp】中的值,只是这个存放的形式是倒着存放的,这一块涉及我们后面的大小端存放问题,这里就先记住是倒着存放的
==第二条指令==
- 接下来第二条,可以看到对应的汇编指令发生了变化,【mov】我们在上面有讲到过是一个数据转移指令。所以这条指令的含义就是把esp的值存放到ebp中去
00461821 8B EC mov ebp,esp
- 通过画图也可以这么来理解。此时相当于产生了main函数的【ebp】,这个值就是invoke_main函数栈帧的【esp】,从这里开始就要开始维护main函数的函数栈帧了
- 一样,通过VS再来看一下。你就会看到【ebp】中就会存放【esp】的地址了
==第三条指令==
- 接下来第三条,看到的汇编指令是【sub】,对于sub我们上面讲到过是一条减法命令,那意思就是让esp中的地址减去一个16进制数字【0xe4】,产生新的esp,此时的esp是main函数栈帧的esp
00461823 81 EC E4 00 00 00 sub esp,0E4h
- 此时结合上一条指令的ebp和当前的esp,ebp和esp之间维护了一个块栈空间,这块栈空间就是为main函数开辟的,就是main函数的栈帧空间,这一段空间中将存储main函数中的局部变量,临时数据以及调试信息等
- 通过图,此时你也可以认为【esp】指向了低地址的一块空间
- 来看一下寄存器中存放的内存变化
==第四、五、六条指令==
- 好,接下去的三条指令我们一起说,因为都是一样的操作
00461829 53 push ebx //将寄存器ebx的值压栈,esp-4
0046182A 56 push esi //将寄存器esi的值压栈,esp-4
0046182B 57 push edi //将寄存器edi的值压栈,esp-4
- 上面3条指令保存了3个寄存器的值在栈区,这3个寄存器的在函数随后执行中可能会被修改,所以先保存寄存器原来的值,以便在退出函数时恢复
- 那随着寄存器的入栈,维护栈顶的寄存器也将发生变化
- 到VS里来看一下三次push后内存地址的变化
==第七、八、九、十条指令==
- 接下去的四条指令是关键,要涉及栈帧的初始化操作,要重点掌握⭐
0046182C 8D 7D DC lea edi,[ebp-24h]
0046182F B9 09 00 00 00 mov ecx,9
00461834 B8 CC CC CC CC mov eax,0CCCCCCCCh
00461839 F3 AB rep stos dword ptr es:[edi]
- 首先要来看的就是【lea】就是我们在上面讲到过的【load effective address】加载有效地址的意思,那也就是从【ebp】这个维护栈顶的寄存器减去24h的位置,加载到寄存器【edi】里面去
- 然后再将9放到【ecx】中去;以及将【0CCCCCCCCh】这块地址存到【eax】中去;最后一句指令的意思比较难理解,也就是从【edi】所存放的这块地址的开始,每次初始化4个字节的数据,dword值得就是4个字节的大小
- 上面这四条指令也可以写成下面四句伪代码
edi = ebp-0x24;
ecx = 9;
eax = 0xCCCCCCCC;
while(--ecx)
{
*(int*)edi = eax;
edi+=4;
}
- 总结一下这四句代码,就是从edi开始,每次初始化4个字节的数据,总共初始化ecx次,初始化的内容为【0xCCCCCCCC】,总共初始化到ebp的地址结束
- 我们到VS里再来看看
- 可以看到,main函数的空间被初始化完成了
- 可以看到,到这里为止,main函数才刚刚被初始化完成
- 这里再补充一个小知识:book:我们来执行一下下面这两行代码,你觉得会输出什么内容呢,
char arr[20];
printf("%s",arr);
- 之所以上面的程序输出“烫”这么一个奇怪的字,是因为main函数调用时,在栈区开辟的空间的其中每一个字节都被初始化为0xCC,而arr数组是一个未初始化的数组,恰好在这块空间上创建的,0xCCCC(两个连续排列的0xCC)的汉字编码就是“烫”,所以0xCCCC被当作文本就是“烫”。
==第十一、十二、十三条指令==
- 我们开始初始化三个变量,每条指令对应上一条代码
int a = 10;
0046183B C7 45 F8 0A 00 00 00 mov dword ptr [ebp-8],0Ah
int b = 20;
00461842 C7 45 EC 14 00 00 00 mov dword ptr [ebp-14h],14h
int c = 0;
00461849 C7 45 E0 00 00 00 00 mov dword ptr [ebp-20h],0
- 解读一下上面三句指令的含义,【mov】是数据转移指令,第一句是将10这个值【ebp - 8】这块地址上,为什么说0Ah就是10呢?因为0Ah是10的十六进制表示形式,在十六进制中A值得就是10,所以对于14h的话就是16 * 1 + 4 = 20,那就是将20这个值放到【ebp - 14】这块地址上去;最后一句就是将0这个值放到【ebp - 20】这块地址上去
- 一定有同学会比较疑惑为什么-8,-14,-20呢,这个的话其实我也不知道,,ԾㅂԾ,,,这是取决于编译器本身的,我是用的是VS2019,可能你到其他编译器上就不一样了,这就可以得出一个结论我们所定义的变量在栈内存中并不是呈现一个连续存放的,可能是分散的,
- 接下去继续到VS中来看看
- 然后将它们放入main函数的栈帧中
==第十四、十五、十六、十七条指令==
- 我们再来看四条指令,此时main函数中的变量创建好了,那就要调用Add函数了,该如何调用呢?我们来看看
00461850 8B 45 EC mov eax,dword ptr [ebp-14h]
00461853 50 push eax
00461854 8B 4D F8 mov ecx,dword ptr [ebp-8]
00461857 51 push ecx
- 首先来看第一条,【mov】是数据转移指令,也就是将【ebp-14h】这块地址的内容放到寄存器【eax】中去,那这个时候你就会想到这个【ebp-14】不是我们刚才放数值20的地方吗;然后由可以看到下一条就是将【ebp-8】中的内容放到寄存器【ecx】中去,它【ebp-8】的地方存放的就是我们刚才放10的地方。然后分别再将这两个寄存器push入栈,一目了然
- 这样其实就可以看出,这两个变量相当于实参的一份临时拷贝,那在函数中实参的【临时拷贝】是什么呢?没错,就是==形参==。这个我们后面还会再用到,因此先入栈
- 再来到VS中看看
==第十八条指令==
- 这条指令单独做讲解,因为其为main函数进入到Add函数的一个转折点:mountain:
00461858 E8 57 F8 FF FF call 004610B4
- 对于这条【call】指令而言,比较特殊,它有两个作用①压入返回地址 ②转入目标函数,那有同学就问了,压地址?压哪条地址呀?这里的话我告诉你,要压的是 call指令的下一条地址
0046185D //这条就是要压入的地址
- 我们一起先到VS里来看看。当运行到这一句时,我们不能再按F10了,要按下F11,这和调试是一个道理
- 继续按下F10,我们就可以进入到Add函数中
- 把这块地址压入栈中
==第十九、二十、二一条指令==
- 到19条指令开始,就进入Add函数了,此时我们可以先浏览一下Add函数中的前几条指令。可以看到是不是非常熟悉呢。因为在main函数中的前面也是这几条指令
- 那其实聪明的你一定可以猜到这是在为Add函数开辟函数栈帧
00461760 55 push ebp
00461761 8B EC mov ebp,esp
00461763 81 EC CC 00 00 00 sub esp,0CCh
00461769 53 push ebx
0046176A 56 push esi
0046176B 57 push edi
- 首先来看第一条指令。也就是将之前的【ebp】栈底寄存器的值压入到栈顶中
00461760 55 push ebp
- 这个【ebp】我们似乎好久都没有关注了,到VS中来看看吧
- 可以看到,对于此处的【ebp】,自从它在维护main函数的栈底后就没有再动过来,所以这里push上来的就是main函数的【ebp】
00461761 8B EC mov ebp,esp
- 接着再来看第二条,也就是将main函数的【esp】重新赋给【ebp】,这里要注意了,不要搞混,此时的【ebp】应该算是在维护Add函数的栈底了
- 于是,栈就变成了这样,此时就等待【esp】做一个变化
00461763 81 EC CC 00 00 00 sub esp,0CCh
- 接着第三条,【sub】命令使得【esp】存放的地址块减去一个CC的大小,继续结合上面那条指令,此时Add函数的栈顶和栈底都被找到了
- 此时就相当于是在做一个迭代的操作
==第二二、二三、二四条指令==
- 接下去还是一样的三条压栈操作
00461769 53 push ebx
0046176A 56 push esi
0046176B 57 push edi
- 首先到VS中观看【esp】的变化
- 接着将这三个寄存器压入栈
==第二五、二六、二七、二八条指令==
- 对于这四条指令和上面main函数的创建过程类似,便不做不过分析
0046177B 57 lea edi, [ebp-0ch]
0046177C 58 mov ecx, 3
0046177D 59 mov eax,0CCCCCCCCCCh
0046177E 60 rep stos dword ptr es:[edi]
==第二十九条指令==
- 接下去我们进入第二十九条指令,也就是对Add函数中存放计算总和的变量z进行初始化操作。【mov】做数据转移,将0放到【ebp-8】这块地址上去
int z = 0;
0046176C C7 45 F8 00 00 00 00 mov dword ptr [ebp-8],0
- 然后我们在Add的栈帧中初始化这个变量z
==第三十、三十一、三十二条指令==
- 接下去的三条指令就是对两个形参的值进行一个相加
00461773 8B 45 08 mov eax,dword ptr [ebp+8]
00461776 03 45 0C add eax,dword ptr [ebp+0Ch]
00461779 89 45 F8 mov dword ptr [ebp-8],eax
- 但是有同学一定很困惑,上面不是只初始化了一个变量z吗,变量x和变量y在哪里呢?那你可能忘了我们之前有做过了一步操作。也就是将这两个实参的拷贝进行了一个压栈操作,那时就说了对于这个就是形参
00461850 8B 45 EC mov eax,dword ptr [ebp-14h]
00461853 50 push eax
00461854 8B 4D F8 mov ecx,dword ptr [ebp-8]
00461857 51 push ecx
- 此时我们就要通过这三句指令去找回这两个形参的值,关键的就是【ebp+8】和【ebp+0Ch】。因为我们在入栈的时候【ebp】寄存器存放的地址都是逐渐变小的,因为 栈是从高地址往低地址生长的,所以我们要去找回之前压入的内容,就要把地址加回去
- 如下图所示
- 找到这两个值之后,首先将【10】放到【eax】寄存器中去,然后再将【20】在加到寄存器【eax】原有的值上去,此时【eax】中存放的便是【30】
- 我们到VS中来看看
- 注意看寄存器【eax】的变化
- 教你一个方法,还可以直接到指令这里来看。直接将鼠标放到【z】上面就可以看到了
- 然后再将计算出来存放在【eax】中的值再放回【ebp-8】这块地址上去
00461779 89 45 F8 mov dword ptr [ebp-8],eax
- 首先到VS中来看看变化
- 然后修改一下之前Add函数栈帧中存放z的内容
==第三十三条指令==
- z计算出来了,此时就要执行【return z】这句代码,将z返回给main函数,但是函数栈帧中可不是这么做的
return z;
0046177C 8B 45 F8 mov eax,dword ptr [ebp-8]
- 看上面的指令可以看到,是将【ebp-8】中的内容转存到寄存器【eax】中去,这里有同学肯定有疑惑,【eax】上面不是刚用到过吗,是存放计算出来的值,现在怎么又放回去了呢?
- 这一块就涉及汇编指令和寄存器的一些知识了,从【eax】~【ebx】这些寄存器都可以用来存放临时数据,并不是说上一次用过了就不能再用了,这其实和我们在定义一个变量后进行反复使用是一个道理。
- 然后在Add函数调用结束后,它所对应的函数栈帧就会被销毁,此时被创建出来的临时变量【z】就不复存在了,因为【z】也是存放在Add的函数栈帧中的,所以这一步的操作其实就是将我们在Add函数中计算出来的值给保存起来,因为寄存器而言程序没有结束的话它是不会被销毁的,我们后面还可以到这个寄存器中去取数据
- 但是因为上一次我们刚好使用到了【eax】,所以在VS中看不出变化,还是【1e 00 00 00】
好,到这里为止,函数栈帧的创建和执行操作就全部结束了,你学废︿( ̄︶ ̄)︿了吗🐂
3.4 函数栈帧的销毁【保姆式教学】
接下去要进行的就是函数栈帧的销毁操作
==第三十四、三十五、三十六条指令==
- 接下来就是三条pop的指令,也就是在栈顶弹出对应的值,然后放到对应的寄存器中去
0046177F 5F pop edi //在栈顶弹出一个值,存放到edi中,esp+4
00461780 5E pop esi //在栈顶弹出一个值,存放到esi中,esp+4
00461781 5B pop ebx //在栈顶弹出一个值,存放到ebx中,esp+4
- 我们先到VS中来看看
- 然后来看一下维护栈顶寄存器【esp】的变化
==第三十七条指令==
- 上面我们有讲到过,当给Add函数预开辟函数栈帧的时候,最后一步是吧【esp】中存放的内容给到【ebp】,那相当于就是让【ebp】指向和【esp】的同一块空间
- 下面这句指令就是将【ebp】中存放的内容给到【esp】,那其实就是让【esp】指向和【ebp】的同一块空间
00461782 8B E5 mov esp,ebp
- 通过图示来看一下
- 到VS中来看一下
==第三十八条指令==
- 这句指令很重要,因为此时Add函数的函数栈帧已经被销毁了,此时我们要回到main函数的函数栈帧,那么两个维护栈顶和栈底的寄存器就要发生变化
00461784 5D pop ebp
- 然后仔细看,此时我们要pop的【ebp】是之前压栈进来的main函数的ebp,通过下图你一定能回忆起来。再来仔细看pop的作用:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变
- 所以此时应该发生这样的变化,pop了之后【esp】也要发生一个变化
- 到VS中再来看一下变化。此时不要混淆了,栈是从高地址往低地址增长的,所以栈底的地址来的大一些
==第三十九条指令==
- 第三十九条指令,可以看到只有一个【ret】,这个指令的执行会从栈顶弹出一个值,那这个时候从上图其实可以看到此时的【esp】栈顶寄存器指向的这块地址,这是什么地址呢?没错,就是call指令的下一条指令地址,即是我们在进入Add函数前提前压入的地址
00461785 C3 ret
- 此时就会直接跳转到call指令下一条指令的地址处,继续往下执行。我们到VS中来瞧瞧
- 再来看看【esp】的变化
==第四十条指令!!!==
- 本条指令非常重要,可以解决大多数人的困惑
- 但是有同学看了之后就觉得,这不是就是一个【esp】的变化嘛。【add】是加法命令,也就是将【esp】的位置加上一个8,一块内存空间是4,加8的话那此时【esp】是不是就来到了【edi】的位置
- 这其实就是在【销毁Add函数的函数形参x,y】,这下你应该明白函数形参是在什么时候销毁的了吧,没错,就是从Add函数回到main函数之后
0046185D 83 C4 08 add esp,8
- 我们来看看示意图
- 一样,VS也来看看【esp】的变化
==第四十一条指令==
- 接下去这条指令就是将我们现在保存在【eax】中的30,放到【ebp-20h】的地方。那我们回忆一下,这块地方不就是main函数变量c的空间嘛,是吧:smile:
- 那其实这个逻辑就全部打通了,先前在Add函数中计算出来的30,首先放到【eax】寄存器中保存起来,现在过来好几条指令后,它还保存在里面,我们只需要使用【mov】将数据做一个转移即可
00461860 89 45 E0 mov dword ptr [ebp-20h],eax
- 到VS里来看看变化
- 再来更新一下【ebp-20h】这块栈空间
- 最后的话只需要将结果打印出来即可
好,讲到这里,函数栈帧的销毁也结束了,你学废︿( ̄︶ ̄)︿了吗c
- 以下是这个栈的全局浏览图
四、开局疑难解答
在看了本文的内容后,我们来解答一下在开头提出来的这五个问题,这也是面试中可能会考到的,如果你在了解了函数栈帧的创建和销毁后去回答这些问题,那
面试官一定会被你折服的
(☆▽☆)
==① 局部变量是如何创建的?==
- [x] 首先为函数分配好栈帧空间,将这块栈帧空间初始化好后,然后给局部在栈帧里分配空间
==② 为什么局部变量不初始化内容是随机的?==
- [x] 因为函数栈帧中的空间是预先初始化好的【0xCCCCCCCCh】,若是不为变量初始化内容,那使用的就是初始化好后的内容,以字符的形式打印出来便是烫烫烫烫烫烫
==③ 函数调用时参数时如何传递的?传参的顺序是怎样的?==
- [x] 当还没有进入函数的时候,就已经将函数实参做了一份临时拷贝,并从右向左压入栈中【FILO】,当真正进入到函数栈帧中时,通过指针的偏移量,就可以顺着找回来,找到这份临时拷贝的形参
==④ 函数的形参和实参分别是怎样实例化的?==
- [x] 形参确实是我在压栈的时候开辟的一块空间,它和实参只是值相同,但是空间是独立的,所以形参是实参的一份临时拷贝,改变形参的值不会影响到实参
==⑤ 函数调用是怎么做的?返回值是如何带会的?==
- [x] 当执行到【call】指令的时候,把call指令的下一条指令地址压入栈中,相当于记住了这个地址。接着进入到函数中,当函数执行结束的时候,回到主函数中,再执行【ret】指令就可以回到call指令的下一条指令地址
- [x] 返回值是通过寄存器带回来的、将函数中计算出来的返回值存放到寄存器中,因为寄存器不会随着函数的调用结束而被销毁,最后再将寄存器中存放的数据转存回对应的内存块中即可
五、总结与提炼
好,我们来回顾一下本文所学习的内容,在本文中,主要带大家了解了
函数栈帧的创建和销毁
- 首先我们知道什么是函数栈帧,学习函数栈帧可以解决哪些我们所不了解的知识点
- 接着我们初步了解了一下对于汇编代码所需要使用到的常用【寄存器】和【指令】,为后文的学习打下了坚实的基础
- 然后我便用了很大的篇幅通过VS中的【反汇编】【内存块】【调试】这些窗口一步步展现了函数栈帧是如何建立和销毁的,真正地理解了函数传参和调用的过程
- 理解了这些之后,对底层的一个脉络有了清晰的认识,便可以轻松地回答出开头提出的那些问题,使面试官被你所折服
最后,非常感谢您对本文的阅读,期待您的一键三连哦:heart::heart::heart: