函数栈帧的创建和销毁
内存布局概览(我的问题:局部变量??临时变量??,C/C++区别??
)
main函数的调用:
在VS2017下,C++main函数调用过程
F11逐语句进入调试
- 点击调用堆栈
- 如下图勾选上显示外部代码
- 先是
mainCRTStartup()
调用__scrt_common_main()
__scrt_common_main()
再调用__scrt_common_main_seh()
__scrt_common_main_seh()
再调用invoke_main()
- 最后
invoke_main()
调用main(__argc, __argv, _get_initial_narrow_environment())
函数
在VS2013下,C语言调用过程:
mainCRTStartup()
调用_tmainCRTStartup()
再调用main()
具体过程
①过程中用到的寄存器和操作
寄存器
:
- ax(累积寄存器),bx(基底寄存器),cx(计数寄存器),dx(资料寄存器)
eax,ebx,ecx,edx
(分别为ax,bx,cx,dx的延伸,各为32位
)- si(来源索引寄存器),di(目的索引寄存器)
esi,edi
(分别为si,di的延伸,32位
)- sp(堆叠指标寄存器),bp(基底指标寄存器)
ebp(栈底指针),esp(栈顶指针)
(sp 是esp的低16位,esp是rsp的低32位,ss是16位堆栈段寄存器,ebp同esp
)- cs(代码段寄存器,用来存放当前程序代码段的地址),ip(指令指针寄存器,用来存储将要执行的下一条指令的偏移量)
在8086机中,任意时刻,CPU将CS:IP指向的内容当作指令来执行
。了解更多:x86汇编学习历程3----计算机寄存器分类简介及指令(转载)
指令操作
:
- push 压栈
- move a,b(把a移到b里)
- sub 减
- lea是“load effective address”的缩写,简单的说,lea指令可以用来将一个内存地址直接赋给目的操作数
- rep指令的目的是重复其上面的指令
- STOS指令将al/ax/eax的值存储到[edi]指定的内存单元中
- ptr是规定的字(既保留字),是用来临时指定类型的。可以理解为,ptr是临时的类型转换,相当于C语言中的强制类型转换
- call:第一步将当前的 IP 或 CS和IP 压入栈中;第二步转移到紧跟的标号行地址执行程序。
- jump 跳转
- ret指令用栈中的数据,修改IP的内容,从而实现近转移
②分析
以C为例 代码如下
:#include <stdio.h> int Add(int x, int y) { int z = x + y; return z; } int main() { int a = 8; int b = 10; int c = 0; c= Add(a, b); return 0; }
main函数
反汇编出的汇编代码如下
:
int main()
{
00F84610 push ebp
00F84611 mov ebp,esp
00F84613 sub esp,0E4h
(注意:esp是向上走)
0E4h ,10进制的228,esp和ebp间为main函数开辟的空间
00F84619 push ebx
00F8461A push esi
00F8461B push edi
这里将三个寄存器压栈的原因就是相关调用约定(操作系统中ABI的约定
)将这三个寄存器规定为非易失寄存器。
00F8461C lea edi,[ebp+0E4h]
将ebp向上走0E4h的地址加载给edi(目的索引寄存器)
00F84622 mov ecx,39h
ecx(计数寄存器)记录 39h
00F84627 mov eax,0CCCCCCCCh
eax(累积寄存器)记录 ‘0CCCCCCCCh’
00F8462C rep stos dword ptr es:[edi]
rep指令的目的是重复其上面的指令,由于ecx是计数寄存器,所以此条指令是从es:[edi] (也就是下图蓝色部分顶部
)按双字(dword
)向下填充eax里的值(0CCCCCCCCh
),repeat十六进制的39次
关于0CCCCCCCCh的解释
:
- 写入0CCCCCCCCh由于按双字(
dword
)向下填充,所以空间内的每个字节都是CCh- 而之所以要写入CCh(也就是
机器码的软件中断指令 INT 3
),是因为如果程序没有按照正常的轨道运行的话,就会去执行INT 3的内容,从而报错,也就是为了便于检错与调试- 这就是为什么我们看到我们的运行黑框中‘烫烫烫…’的原因,0CCCCCCCCh字符串就是‘烫烫烫’
拓展1
:
- 微软编译器会把
未初始化的堆内存
上的指针全部填成 0xCDCDCDCDh,字符串就是 ‘屯屯屯屯’(自动初始化的目的是为了方便我们确定错误也就是野指针问题
)- 0xFEEEFEEEh用来标记堆上已释放的内存 ,如果调试中有值为0xfeeefeee的指针,说明对应的内存已被释放
- 0xabababab:被微软的 HeapAlloc() 用于在分配堆内存后标记“无人区”保护字节
- 0xabadcafe :启动到此值以初始化所有空闲内存以捕获错误指针
- 0xbaadf00d :由 Microsoft 的 LocalAlloc(LMEM_FIXED) 用于标记未初始化分配的堆内存
- 0xbadcab1e:当与调试器的连接断开时,错误代码返回给 Microsoft eVC 调试器
- 0xbeefcace : Microsoft .NET 用作资源文件中的幻数
拓展2
:
- Debug版本是将每个字节位都赋成0xcc,而Release版本的赋值近似于随机
所以有时在debug中没错误到了release就会出现问题,所以我们最好在声明变量后马上对其初始化一个默认值
00F8462E mov ecx,0F8C003h
00F84633 call 00F81208
找到00F81208
地址,此地址的指令是跳转到call指令的下一条指令
这里的__CheckForDebuggerJustMyCode
应该是类似下面出栈时__RTC_CheckEsp
的检查操作
int a = 8;
00F84638 mov dword ptr [ebp-8],8
把8也就是a放到ebp往上8个字节的位置(一行是4个字节,一个双字)
int b = 10;
00F8463F mov dword ptr [ebp-14h],0Ah
把10也就是b放到ebp往上20个字节的位置,(14h就是十进制20)
int c = 0;
00F84646 mov dword ptr [ebp-20h],0
把0也就是c放到ebp往上32个字节的位置,(20h就是十进制32)
画图时空间有限所以变量之间只隔了一行(四个字节),实际上按照汇编代码应该隔8字节
- 变量内存地址是根据当前的内存管理情况分配的,不能预先确定
- 数组是有规律的,地址连续
- 变量和数组在空间中的位置可能出现这种情况:C语言题目—程序死循环解释
c= Add(a, b); (
形参是从右向左压栈
)
00F8464D mov eax,dword ptr [ebp-14h]
00F84650 push eax
把[ebp-14h]地址的值也就是b放入eax寄存器,eax入栈
00F84651 mov ecx,dword ptr [ebp-8]
00F84654 push ecx
把[ebp-8]地址的值也就是a放入eax寄存器,ecx入栈
这里充分体现了形参只是实参的一份零时拷贝,改变形参,不会影响实参
00F84655 call 00F813E3
00F813E3
地址处的指令如下图,而00F82540
正是Add函数第一条指令的地址,
将当前的cs和ip(cs当前地址加ip下一条指令的偏移量
)也就是下一条指令的地址 压入栈中相当于记录此位置,从Add函数回来后紧跟着执行
在这里跳转到Add函数
Add函数
int Add(int x, int y)
{
00F82540 push ebp
为了在返回时恢复 ebp 的值,我们需要一句 push ebp 来先保存 ebp 的值
00F82541 mov ebp,esp
00F82543 sub esp,0CCh
(注意:esp是向上走)
0CCh ,10进制的204,esp和ebp间为Add函数开辟的空间
00F82549 push ebx
00F8254A push esi
00F8254B push edi
00F8254C lea edi,[ebp+FFFFFF34h]
00F82552 mov ecx,33h
00F82557 mov eax,0CCCCCCCCh
00F8255C rep stos dword ptr es:[edi]
与上面main函数填充0CCCCCCCCh一样这里省略
00F8255E mov ecx,0F8C003h
00F82563 call 00F81208
同上__CheckForDebuggerJustMyCode
int z = x + y;
00F82568 mov eax,dword ptr [ebp+8]
[ebp+8]刚好是图上蓝色的之前的ecx里存储的a的值8,再放到eax寄存器里
00F8256B add eax,dword ptr [ebp+0Ch]
[ebp+0Ch]是图上的蓝色之前的eax里存储的b的值10,和现在eax里的值8相加再放会eax里
00F8256E mov dword ptr [ebp-8],eax
最后再把eax的值放到ebp向上8给字节的位置
return z;
00F82571 mov eax,dword ptr [ebp-8]
到了返回的时候,先把z的值放到eax寄存器里,函数调用完会销毁
,但eax并不会被销毁
}
Add函数的销毁
00F82574 pop edi
00F82575 pop esi
00F82576 pop ebx
弹出三个寄存器
00F82577 add esp,0CCh
esp向下走0CCh,add是向下 sub是向上
00F8257D cmp ebp,esp
00F8257F call 00F81212
c/c++ 生成debug函数,使用API会检查堆栈平衡
拓展3:
1.VC6会自动在每个函数的末尾插入指令来调用一个名为_chkesp的函数,_chkesp是C运行库(CRT)中的一个函数,用来检查栈指针的完好性,
检查方法是比较ESP和EBP寄存器的值,看其是否相等,如果相等则通过
,否则就准备参数调用_CrtDbgReport函数报告错误。编译器的编译选项/GZ用来控制是否插入栈指针检查函数。
2. VC8的_RTC_CheckEsp函数与_chkesp原理和工作方法是一样的,只是改变了函数名称和报错的方式
3. _RTC_CheckEsp_这个函数用于检查缓冲区溢出
00F82584 mov esp,ebp
esp(栈顶) 指向 ebp(栈底)
00F82586 pop ebp
弹出这里的ebp,ebp回到最初开辟main函数空间时的ebp的位置,esp指向00F84655
,此时esp ebp又恢复到共同维护main函数空间的状态
00F82587 ret
CPU执行ret指令时,进行下面2步操作(相当于pop ip)
- (IP)=((ss)∗16+(sp))
- (sp)=(sp)+2
回到main函数
00F8465A add esp,8
esp向下走8个字节,相当于把10和8两个形参空间销毁
00F8465D mov dword ptr [ebp-20h],eax
由于之前Add函数销毁前把返回的值放入了eax寄存器,此时是吧eax的值给[ebp-20h]地址也就是图中的c处
return 0;
00F84660 xor eax,eax
将eax寄存器清零,此指令效率比mov eax,0
高,所以一般用它
}
00F84662 pop edi
00F84663 pop esi
00F84664 pop ebx
00F84665 add esp,0E4h
00F8466B cmp ebp,esp
00F8466D call 00F81212
00F84672 mov esp,ebp
00F84674 pop ebp
00F84675 ret
main函数的销毁,和Add函数的销毁一样,这里省略,ret回到_tmainCRTStartup()中
以上是完整的汇编代码分析过程