C语言之反汇编查看函数栈帧的创建与销毁(一):https://developer.aliyun.com/article/1427046
第十一、十二、十三条指令
- 我们开始初始化三个变量,每条指令对应上一条代码
int a = 10; 00EE18F5 mov dword ptr [ebp-8],0Ah int b = 20; 00EE18FC mov dword ptr [ebp-14h],14h int c = 0; 00EE1903 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呢,这是取决于编译器本身的,我是用的是VS2022,可能你到其他编译器上就不一样了
- 这就可以得出一个结论:我们所定义的变量在栈内存中并不是呈现一个连续存放的,可能是分散的,
- 接下去继续来看这三次的存放值的变化~~
- 我们再来看图,也将这些画出来。
第十四、十五、十六、十七条指令
- 此时main函数中的变量创建好了,那就要调用Add函数了
00EE190A mov eax,dword ptr [ebp-14h] 00EE190D push eax 00EE190E mov ecx,dword ptr [ebp-8] 00EE1911 push ecx
- 来看第一条,将【ebp-14h】这块地址的内容放到寄存器【eax】中去,那这个时候你就会想到这个【ebp-14】是刚才放数值20,然后压栈。
- 第三条就是将【ebp-8】中的内容放到寄存器【ecx】中去,它【ebp-8】的地方存放的就是我们刚才放10的地方,然后压栈。
- 这样就可以看出,这两个变量相当于实参的一份临时拷贝,这里就回到我们前面学的函数的形参就是实参的一份临时拷贝
再来到VS中看看
第十八条指令
00EE1912 call 00EE10B9
- 对于这条【call】指令而言,比较特殊,它有两个作用
①压入返回地址
②转入目标函数
- 这里就是要压的是 call指令的下一条地址
00EE1917 //这条就是要压入的地址
- 然后我们来在vs中看一下,当运行到图中的那条语句的时候就要按F11,不能按F10,和调试一个道理
- 把这块地址压入栈中
第十九、二十、二一条指令
- 到19条指令开始,就进入Add函数了,这里的函数前面和在main函数中的前面也是非常的相似
- 所以这个就是在开辟栈帧
00EE1790 push ebp 00EE1791 mov ebp,esp 00EE1793 sub esp,0CCh 00EE1799 push ebx 00EE179A push esi 00EE179B push edi
- 首先来看第一条指令。也就是将之前的【ebp】栈底寄存器的值压入到栈顶中
00EE1790 push ebp
- 对于此处的【ebp】,自从它在维护main函数的栈底后就没有再动过来,所以这里push上来的就是main函数的【ebp】
00EE1791 mov ebp,esp
- 接着再来看第二条,也就是将main函数的【esp】重新赋给【ebp】,这里要注意了,不要搞混,此时的【ebp】应该算是在维护Add函数的栈底了
- 于是,栈就变成了这样:
00EE1793 sub esp,0CCh
- 接着第三条,【sub】命令使得【esp】存放的地址块减去一个CC的大小,继续结合上面那条指令,此时Add函数的栈顶和栈底都被找到了
- 此时就相当于是在做一个迭代的操作
第二二、二三、二四条指令
00EE1799 push ebx 00EE179A push esi 00EE179B push edi
- 接下去还是一样的三条压栈操作
- 来到VS中观看【esp】的变化
- 接着将这三个寄存器压入栈
第二五、二六、二七、二八条指令
- 对于这四条指令和上面main函数的创建过程类似,便不做不过分析
00EE179C lea edi,[ebp-0Ch] 00EE179F mov ecx,3 00EE17A4 mov eax,0CCCCCCCCh 00EE17A9 rep stos dword ptr es:[edi]
- 继续到VS中观看的变化
第二十九条指令
- 接下去我们进入第二十九条指令,也就是对Add函数中存放计算总和的变量z进行初始化操作。
- 【mov】做数据转移,将0放到【ebp-8】这块地址上去
int z = 0; 00EE17B5 mov dword ptr [ebp-8],0
- 然后我们在Add的栈帧中初始化这个变量z
第三十、三十一、三十二条指令
- 接下去的三条指令就是对两个形参的值进行一个相加
z = x + y; 00EE17BC mov eax,dword ptr [ebp+8] 00EE17BF add eax,dword ptr [ebp+0Ch] 00EE17C2 mov dword ptr [ebp-8],eax
- 那么上面不是只初始化了一个变量z吗,变量x和变量y在哪里呢?
- 我们之前有做过了一步操作,也就是将这两个实参的拷贝进行了一个压栈操作,那时就说了对于这个就是形参
00EE190A mov eax,dword ptr [ebp-14h] 00EE190D push eax 00EE190E mov ecx,dword ptr [ebp-8] 00EE1911 push ecx
- 此时我们就要通过这三句指令去找回这两个形参的值,关键的就是【ebp+8】和【ebp+0Ch】。因为我们在入栈的时候【ebp】寄存器存放的地址都是逐渐变小的,因为 栈是从高地址往低地址生长的,所以我们要去找回之前压入的内容,就要把地址加回去
- 如下图所示
- 找到这两个值之后,首先将【10】放到【eax】寄存器中去,然后再将【20】在加到寄存器【eax】原有的值上去,此时【eax】中存放的便是【30】
- 注意看寄存器【eax】的变化
- 这里还可以直接到指令这里来看。直接将鼠标放到【z】上面就可以看到了
- 然后再将计算出来存放在【eax】中的值再放回【ebp-8】这块地址上去
00EE17C2 mov dword ptr [ebp-8],eax
- 首先到VS中来看看变化
- 然后修改一下之前Add函数栈帧中存放z的内容
第三十三条指令
z
计算出来了,此时就要执行【return z】这句代码,将z返回给main函数,但是函数栈帧中可不是这么做的
return z; 00EE17C5 mov eax,dword ptr [ebp-8]
- 看上面的指令可以看到,是将【ebp-8】中的内容转存到寄存器【eax】中去
- 从【eax】~【ebx】这些寄存器都可以用来存放临时数据,并不是说上一次用过了就不能再用了,这其实和我们在定义一个变量后进行反复使用是一个道理。
- 然后在Add函数调用结束后,它所对应的函数栈帧就会被销毁,此时被创建出来的临时变量【z】就不复存在了,因为【z】也是存放在Add的函数栈帧中的,所以这一步的操作其实就是将我们在Add函数中计算出来的值给保存起来,因为寄存器而言程序没有结束的话它是不会被销毁的,我们后面还可以到这个寄存器中去取数据
3.3.4 函数栈帧的销毁
接下去要进行的就是函数栈帧的销毁操作
第三十四、三十五、三十六条指令
- 接下来就是三条pop的指令,也就是在栈顶弹出对应的值,然后放到对应的寄存器中去
00EE17C8 pop edi //在栈顶弹出一个值,存放到edi中,esp+4 00EE17C9 pop esi //在栈顶弹出一个值,存放到esi中,esp+4 00EE17CA pop ebx //在栈顶弹出一个值,存放到ebx中,esp+4
- 我们先到VS中来看看
- 通过图示来看一下
第三十七条指令
- 当给Add函数预开辟函数栈帧的时候,最后一步是把【esp】中存放的内容给到【ebp】,也就是相当于就是让【ebp】指向和【esp】的同一块空间
- 下面这句指令就是将【ebp】中存放的内容给到【esp】,那其实就是让【esp】指向和【ebp】的同一块空间
00EE17D8 mov esp,ebp
- 通过图示来看一下
- 到VS中来看一下
第三十八条指令
00EE17DA pop ebp
- 这句指令很重要,因为此时Add函数的函数栈帧已经被销毁了,此时我们要回到main函数的函数栈帧,那么两个维护栈顶和栈底的寄存器就要发生变化,此时我们要pop的【ebp】是之前压栈进来的main函数的ebp
- pop的作用:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变
- pop了之后【esp】也要发生一个变化
- 到VS中再来看一下变化。此时不要混淆了,栈是从高地址往低地址增长的,所以栈底的地址来的大一些
第三十九条指令
- 这里只有一个【ret】,这个指令会从栈顶弹出一个值,那这个时候从上图其实可以看到此时的【esp】栈顶寄存器指向的这块地址,这块地址是call指令的下一条指令地址,就是我们在进入Add函数前提前压入的地址
00EE17DB ret
- 此时就会直接跳转到call指令下一条指令的地址处,继续往下执行
- 再来看看【esp】的变化
第四十条指令
- 有的同学看到的就是一个【esp】的变化,【add】是加法命令,也就是将【esp】的位置加上一个8,一块内存空间是4,加8的话那此时【esp】是不是就来到了【edi】的位置
- 这其实就是在【销毁Add函数的函数形参x,y】,这下你应该明白函数形参是在什么时候销毁的了吧,没错,就是从Add函数回到main函数之后
0046185D 83 C4 08 add esp,8
- 一样,VS也来看看【esp】的变化
第四十一条指令
00EE191A mov dword ptr [ebp-20h],eax
- 将eax中值,存档到ebp-0x20的地址处,其实就是存储到main函数中ret变量中,而此时eax中就是Add函数中计算的x和y的和,可以看出来,本次函数的返回值是由eax寄存器带回来的。程序是在函数调用返回之后,在eax中去读取返回值的。
- 先前在Add函数中计算出来的30,首先放到【eax】寄存器中保存起来,现在过来好几条指令后,它还保存在里面,我们只需要使用【mov】将数据做一个转移即可
- 到VS里来看看变化
- 最后main函数栈帧的销毁也同理,这里就不再介绍了
- 以下是这个栈的全局浏览图
- 拓展了解:
其实返回对象时内置类型时,一般都是通过寄存器来带回返回值的,返回对象如果时较大的对象时,一般会在主调函数的栈帧中开辟一块空间,然后把这块空间的地址,隐式传递给被调函数,在被调函数中通过地址找到主调函数中预留的空间,将返回值直接保存到主调函数的。具体可以参考《程序员的自我修养》一书的第10章。
到这里我们给大家完整的演示了main函数栈帧的创建,Add函数站真的额创建和销毁的过程,相信大家已经能够基本理解函数的调用过程,函数传参的方式,也能够回答最开始的问题了
四、总结与开局疑难解答
① 局部变量是如何创建的?
首先为函数分配好栈帧空间,将这块栈帧空间初始化好后,然后给局部在栈帧里分配空间
② 为什么局部变量不初始化内容是随机的?
- 因为函数栈帧中的空间是预先初始化好的【0xCCCCCCCCh】,若是不为变量初始化内容,那使用的就是初始化好后的内容,以字符的形式打印出来便是烫烫烫烫烫烫
③ 函数调用时参数时如何传递的?传参的顺序是怎样的?
- 当还没有进入函数的时候,就已经将函数实参做了一份临时拷贝,并从右向左压入栈中【FILO】,当真正进入到函数栈帧中时,通过指针的偏移量,就可以顺着找回来,找到这份临时拷贝的形参
④ 函数的形参和实参分别是怎样实例化的?
- 形参确实是我在压栈的时候开辟的一块空间,它和实参只是值相同,但是空间是独立的,所以形参是实参的一份临时拷贝,改变形参的值不会影响到实参
⑤ 函数调用是怎么做的?返回值是如何带会的?
- 当执行到【call】指令的时候,把call指令的下一条指令地址压入栈中,相当于记住了这个地址。接着进入到函数中,当函数执行结束的时候,回到主函数中,再执行【ret】指令就可以回到call指令的下一条指令地址
- 返回值是通过寄存器带回来的、将函数中计算出来的返回值存放到寄存器中,因为寄存器不会随着函数的调用结束而被销毁,最后再将寄存器中存放的数据转存回对应的内存块中即可