本文涉及汇编知识,没有基础的朋友请移步汇编入门
本文参考:为什么用0xcc初始化内存C/C++函数调用约定与函数名称修饰规则

函数执行流

栈帧本质上是一种栈,只是这种栈专门用于保存函数调用过程中的各种信息(参数,返回地址,本地变量等)

我们使用 VS 反汇编以下代码:

int add(int a, int b)
{
	return a + b;
}
int main()
{
	int a = 1;
	int b = 2;
	int c = add(1, 2);
	return 0;
}

得到如下汇编代码:

int main()
{
00041DB0  push        ebp               ;保存原函数栈底
00041DB1  mov         ebp,esp           ;ebp指向新栈底
00041DB3  sub         esp,0E4h          ;开辟栈帧,大小为0x0E4H
00041DB9  push        ebx               ;6,7,8行保存现场
00041DBA  push        esi  
00041DBB  push        edi  
00041DBC  lea         edi,[ebp-24h]     ;将起始地址ebp-24h填入edi
00041DBF  mov         ecx,9             ;重复stos的次数
00041DC4  mov         eax,0CCCCCCCCh    ;内存初始值设置为0xCCCCCCCC
00041DC9  rep stos    dword ptr es:[edi];开始初始化内存

;---------------------------------忽略以下两行代码,vs增加的调试指令
00041DCB  mov         ecx,offset _206B94B3_源@c (01AB000h)  
00041DD0  call        @__CheckForDebuggerJustMyCode@4 (041307h)  
;--------------------------------------------------------------
	int a = 1;
00041DD5  mov         dword ptr [a],1   ;a是vs转换后的结果,方便我们查看,实际上a为[ebp-8]
	int b = 2;
00041DDC  mov         dword ptr [b],2   ;实际上b为[ebp-14h]
	int c = add(1, 2);
00041DE3  push        2                 ;压入形参
00041DE5  push        1  				
00041DE7  call        _add (04139Dh)    
00041DEC  add         esp,8             ;外平栈
00041DEF  mov         dword ptr [c],eax ;将返回值赋值给c,c实际为[ebp-20h]
	return 0;
00041DF2  xor         eax,eax           ;返回值为0
}
000A17E4  pop         edi  
000A17E5  pop         esi  
000A17E6  pop         ebx  
000A17E7  add         esp,0E4h  
000A17ED  cmp         ebp,esp  
000A17EF  call        __RTC_CheckEsp (0A1235h)  
000A17F4  mov         esp,ebp  
000A17F6  pop         ebp  
000A17F7  ret  
;=============================================================
int add(int a, int b)                   ;与上类似,不再注释
{
00FB1750  push        ebp  
00FB1751  mov         ebp,esp  
00FB1753  sub         esp,0C0h  
00FB1759  push        ebx  
00FB175A  push        esi  
00FB175B  push        edi  
00FB175C  mov         edi,ebp  
00FB175E  xor         ecx,ecx  
00FB1760  mov         eax,0CCCCCCCCh  
00FB1765  rep stos    dword ptr es:[edi]  
00FB1767  mov         ecx,offset _206B94B3_源@c (0FBC000h)  
00FB176C  call        @__CheckForDebuggerJustMyCode@4 (0FB130Ch)  
	return a + b;
00FB1771  mov         eax,dword ptr [a]  ;[ebp+8]
00FB1774  add         eax,dword ptr [b]  ;[ebp+0Ch]
}
00FB1777  pop         edi  
00FB1778  pop         esi  
00FB1779  pop         ebx  
00FB177A  add         esp,0C0h  
00FB1780  cmp         ebp,esp  
00FB1782  call        __RTC_CheckEsp (0FB1235h)  ;上行和本行,检查堆栈平衡(ebp==esp)
00FB1787  mov         esp,ebp  
00FB1789  pop         ebp  
00FB178A  ret  

分析:

  1. 虽然开辟了 0xe4 的空间,但仅初始化了 0x24 个字节的内存。

  2. 为什么要用 0xcc 初始化内存?

    x86系列处理器从其第一代产品英特尔8086开始就提供了一条专门用来支持调试的指令,即 INT 3,其机器码就是我们熟悉的0XCC,转换成十进制为-858993460,转换成汉字就是“烫”。简单地说,这条指令的目的就是使CPU中断(break)到调试器,以供调试者对执行现场进行各种分析。如果因为缓冲区或堆栈溢出时程序指针意外指向了这些区域,那么便会因为遇到INT 3指令而马上中断到调试器debug 模式才会用 0xcc 初始化内存

  3. 第 9 行 lea 指令比 mov 指令更方便。以下两种方式等价:

    lea   edi,[ebp-24h]
    ;=================================
    sub   ebp,24h
    mov   edi,ebp
    
  4. 第 12 行:stos指令,它的功能是将 eax 中的数据放入的 edi 所指的地址中 ,同时,edi 会增加 4 个字节,rep 使指令重复执行 ecx 中填写的次数。

  5. 第 59 行,eax 寄存器通常用来装载返回值

  6. 第 10 行用到了 ecx,那么为啥保存现场时没 push ecx 呢?这涉及到 ABI 规则,参见另一篇文章C和汇编混合编程

  7. 第 26 行,由于之前 push 了两个参数,现在要恢复栈状态以保持堆栈平衡,所以必须平栈 ,此处 __cdecl 采用外平栈。内平栈方式见文末。

  8. 第 19,21,56,57 行代码,可以看出编译器 通过 EBP 来访问形参和创建局部变量 。 为啥用 EBP 定位?因为 EBP 指向栈底,固定不动,而 ESP 指向栈顶,会发生浮动,所以 EBP 才能作为基准。

  9. 第 36 行的 __RTC_CheckEsp 函数是用来检测堆栈平衡的,即是否有 ESP=EBP

结合上面代码及其注释,给出如下堆栈图(绿色箭头为 ESP,红色箭头为 EBP):

可见,EBP 永远指向当前(被调)函数的栈底,而当前栈底保存的永远是调用函数栈底。
另外,上图中栈从低到高生长,实际上在内存中,栈是从高地址向低地址生长!

调用约定

C/C++ 调用约定和平台相关,不同平台有不同调用方式,常见有如下几种:

调用方式 平台 传参方式 平栈方
__stdcall (pascal) Windows API 压栈传参,从右向左 内平栈(被调用者)
__cdel C/C++默认方式;
可变参函数必须使用此方式
压栈传参,从右向左 外平栈(调用者)
__fastcall Linux 下默认 32位:用 ECX 和 EDX 传送右两个参数,其余栈传递
64位:右六个参数用寄存器传参,其他用栈传。
栈传递仍从右向左。
Linux:外平栈
Windows:内平栈
__thiscall C++ 成员函数 参数个数确定:this指针通过通过 ECX 传递给被调用者;
如果参数个数不确定:this指针在所有参数压栈后被压入堆栈
参数个数确定:内平栈
参数个数不定:外平栈

C语言编译时函数名修饰约定规则

调用惯例 名字修饰
cdecl 下划线+函数名, 如函数 max() 的修饰名为 _max
stdcall 下划线+函数名+@+参数的字节数, 如函数 int max(int m, int n) 的修饰名为 _max@8
fastcall @+函数名+@+参数的字节数,如 int add(int c,int b,int c) 的修饰名为 @add@12

C++编译时非成员函数函数名修饰约定规则
C++的函数名修饰规则有些复杂,但是信息更充分,通过分析修饰名不仅能够知道函数的调用方式,返回值类型,参数个数甚至参数类型。在 Visual C++ 下 ,不管_cdecl,_fastcall还是_stdcall调用方式,函数修饰都是以一个“?”开始,后面紧跟函数的名字,再后面是参数表的开始标识和按照参数类型代号拼出的参数表。对于_stdcall方式,参数表的开始标识是 @@YG,对于_cdecl方式则是 @@YA ,对于_fastcall方式则是 @@YI 。参数表后以 @Z 标识整个名字的结束,如果该函数无参数,则以“Z”标识结束。参数表的拼写代号如下所示:

X D E F H I J K M N _N U
void char unsigned char short int unsigned int long unsigned long float double bool struct

函数参数表的第一项实际上是表示函数的返回值类型 。举例如下:

函数原型 生成函数名
int __cdecl add(int a, int b) ?add@@YGHHH@Z
int __fastcall sub(int a, int b) ?sub@@YIHHH@Z
int __stdcall mul(int a, int b) ?mul@@YAHHH@Z

HHH :第 1 个 H 表示返回值为 int,第 2、3 个 H 表示两个参数的类型为 int。

指针的方式有些特别,用 PA 表示指针,用 PB 表示 const 类型的指针 。后面的代号表明指针类型,如果相同类型的指针连续出现,以“0”代替,一个“0”代表一次重复。如下:

函数原型 生成函数名
int __cdecl add(int a, int b)** ?sub@@YIHPBH0@Z
int __fastcall sub(const int a,const int b)** ?add@@YAHPAH0@Z

U表示结构类型,通常后跟结构体的类型名,用“@@”表示结构类型名的结束 ,如果相同类型的结构体连续出现,以“0”代替,一个“0”代表一次重复,如下:

函数原型 生成函数名
int __cdecl add(stu a, stu b) ?add@@YAHUstu@@0@Z

C++编译时成员函数函数名修饰约定规则

函数名字和参数表之间插入以“@”字符引导的类名;其次是参数表的开始标识不同,公有(public)成员函数的标识是 @@QAE ,保护(protected)成员函数的标识是 @@IAE ,私有(private)成员函数的标识是 @@AAE,如果函数声明使用了 const 关键字,则相应的标识应分为 @@QBE@@IBE@@ABE 。如果参数类型是实例的引用,则使用 AAH ,对于 const 类型的引用,则使用 ABH

注意,以上仅是 Visual C++ 编译器下的修饰规则,gcc 则是另一套规则。记是记不住的,这辈子都记不住,只需大概了解即可。

平栈方式

平栈方式分为内平栈(被调用者平栈)和外平栈(调用栈平栈)。内平栈已在上述代码中分析过,下面我们观察外平栈的方式。之前的代码默认采用的 __cdecl ,下面代码显式采用 __stdcall,其他代码不变,汇编如下:

int __stdcall add(int a, int b)
{
00611750  push        ebp  
00611751  mov         ebp,esp  
00611753  sub         esp,0C0h  
00611759  push        ebx  
0061175A  push        esi  
0061175B  push        edi  
0061175C  mov         edi,ebp  
0061175E  xor         ecx,ecx  
00611760  mov         eax,0CCCCCCCCh  
00611765  rep stos    dword ptr es:[edi]  
00611767  mov         ecx,offset _206B94B3_源@c (061C000h)  
0061176C  call        @__CheckForDebuggerJustMyCode@4 (061130Ch)  
	return a + b;
00611771  mov         eax,dword ptr [a]  
00611774  add         eax,dword ptr [b]  
}
00611777  pop         edi  
00611778  pop         esi  
00611779  pop         ebx  
0061177A  add         esp,0C0h  
00611780  cmp         ebp,esp  
00611782  call        __RTC_CheckEsp (0611235h)  
00611787  mov         esp,ebp  
00611789  pop         ebp  
0061178A  ret         8  
//===========================================
int main()
{
006117A0  push        ebp  
006117A1  mov         ebp,esp  
006117A3  sub         esp,0E4h  
006117A9  push        ebx  
006117AA  push        esi  
006117AB  push        edi  
006117AC  lea         edi,[ebp-24h]  
006117AF  mov         ecx,9  
006117B4  mov         eax,0CCCCCCCCh  
006117B9  rep stos    dword ptr es:[edi]  
006117BB  mov         ecx,offset _206B94B3_源@c (061C000h)  
006117C0  call        @__CheckForDebuggerJustMyCode@4 (061130Ch)  
	int a = 1;
006117C5  mov         dword ptr [a],1  
	int b = 2;
006117CC  mov         dword ptr [b],2  
	int c = add(1, 2);
006117D3  push        2  
006117D5  push        1  
006117D7  call        _add@8 (0611104h)  
006117DC  mov         dword ptr [c],eax  
	return 0;
006117DF  xor         eax,eax  
}
006117E1  pop         edi  
006117E2  pop         esi  
006117E3  pop         ebx  
006117E4  add         esp,0E4h  
006117EA  cmp         ebp,esp  
006117EC  call        __RTC_CheckEsp (0611235h)  
006117F1  mov         esp,ebp  
006117F3  pop         ebp  
006117F4  ret  

观察到第 27 行,ret 8 ,这条指令很奇怪,因为我们以前都是直接 ret ,怎么这个 ret 后面还有数字?这其实就是内平栈,该指令的作用相当于:

pop  eip 
pop  cs
add  esp,8

最后的 add esp,8 就起到了平栈的作用。

extern “c”

使用 C/C++ 语言开发软件的程序员经常碰到这样的问题:有时候是程序编译没有问题,但是链接的时候总是报告函数不存在(经典的LNK 2001错误),有时候是程序编译和链接都没有错误,但是只要调用库中的函数就会出现堆栈异常。这些现象通常是出现在 C 和 C++ 的代码混合使用的情况下或在 C++ 程序中使用第三方的库的情况下(不是用C++语言开发的),其实这都是函数调用约定(Calling Convention)和函数名修饰(Decorated Name)规则惹的祸。函数调用方式决定了函数参数入栈的顺序,是由调用者函数还是被调用函数负责清除栈中的参数等问题,而函数名修饰规则决定了编译器使用何种名字修饰方式来区分不同的函数,如果函数之间的调用约定不匹配或者名字修饰不匹配就会产生以上的问题。一个具体的常见例子是 C++ 代码中的 extern "c" 语句,考虑具体的场景如下:

//当前头文件func.h
#ifdef cplusplus
#define extern "c"{
#endif

int function(int, int);

#ifdef cplusplus
}
#endif

假设我们在 C++ 项目中包含了 func.h 并使用了其中的 function() 函数,且函数的定义是通过 C 编译的静态链接库 func.lib 引入的,那么如果没有 extern "c" 语句,将会报链接错误。这是因为:如果没有 extern "c" ,则 C++ 编译器可能会将项目中的 function 解析为 ?function@@YIHHH@Z ,但是,由于 func.lib 是提前用 C 语言编译好的,其中的 function 已经被解析为 _function@8 ,如此一来,我们编译好的 C++ 项目在链接 func.lib 后,就无法通过 ?function@@YIHHH@Z 找到 _function@8 ,于是提示找不到函数定义,即报链接错误。

文章作者: 极简
版权声明: 本站所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 后端技术分享
C/C++
喜欢就支持一下吧