详解函数调用过程和约定\extern c
本文涉及汇编知识,没有基础的朋友请移步汇编入门 。
本文参考:为什么用0xcc初始化内存 ,C/C++函数调用约定与函数名称修饰规则
函数执行流
graph LR 开辟栈帧 --> 分配栈内存 --> 保存现场 --> 代码逻辑 --> 恢复现场 --> 恢复栈帧
栈帧本质上是一种栈,只是这种栈专门用于保存函数调用过程中的各种信息(参数,返回地址,本地变量等)
我们使用 VS 反汇编以下代码:
1 | int add(int a, int b) |
得到如下汇编代码:
1 | int main() |
分析:
-
虽然开辟了 0xe4 的空间,但仅初始化了 0x24 个字节的内存。
-
为什么要用 0xcc 初始化内存?
x86系列处理器从其第一代产品英特尔8086开始就提供了一条专门用来支持调试的指令,即 INT 3,其机器码就是我们熟悉的0XCC,转换成十进制为-858993460,转换成汉字就是“烫”。简单地说,这条指令的目的就是使CPU中断(break)到调试器,以供调试者对执行现场进行各种分析。如果因为缓冲区或堆栈溢出时程序指针意外指向了这些区域,那么便会因为遇到INT 3指令而马上中断到调试器 。debug 模式才会用 0xcc 初始化内存 。
-
第 9 行 lea 指令比 mov 指令更方便。以下两种方式等价:
1
2
3
4lea edi,[ebp-24h]
;=================================
sub ebp,24h
mov edi,ebp -
第 12 行:stos指令,它的功能是将 eax 中的数据放入的 edi 所指的地址中 ,同时,edi 会增加 4 个字节,rep 使指令重复执行 ecx 中填写的次数。
-
第 59 行,eax 寄存器通常用来装载返回值 。
-
第 10 行用到了 ecx,那么为啥保存现场时没 push ecx 呢?这涉及到 ABI 规则,参见另一篇文章C和汇编混合编程。
-
第 26 行,由于之前 push 了两个参数,现在要恢复栈状态以保持堆栈平衡,所以必须平栈 ,此处 __cdecl 采用外平栈。内平栈方式见文末。
-
第 19,21,56,57 行代码,可以看出编译器 通过 EBP 来访问形参和创建局部变量 。 为啥用 EBP 定位?因为 EBP 指向栈底,固定不动,而 ESP 指向栈顶,会发生浮动,所以 EBP 才能作为基准。
-
第 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,其他代码不变,汇编如下:
1 | int __stdcall add(int a, int b) |
观察到第 27 行,ret 8
,这条指令很奇怪,因为我们以前都是直接 ret
,怎么这个 ret 后面还有数字?这其实就是内平栈,该指令的作用相当于:
1 | pop eip |
最后的 add esp,8
就起到了平栈的作用。
extern “c”
使用 C/C++ 语言开发软件的程序员经常碰到这样的问题:有时候是程序编译没有问题,但是链接的时候总是报告函数不存在(经典的LNK 2001错误),有时候是程序编译和链接都没有错误,但是只要调用库中的函数就会出现堆栈异常。这些现象通常是出现在 C 和 C++ 的代码混合使用的情况下或在 C++ 程序中使用第三方的库的情况下(不是用C++语言开发的),其实这都是函数调用约定(Calling Convention)和函数名修饰(Decorated Name)规则惹的祸。函数调用方式决定了函数参数入栈的顺序,是由调用者函数还是被调用函数负责清除栈中的参数等问题,而函数名修饰规则决定了编译器使用何种名字修饰方式来区分不同的函数,如果函数之间的调用约定不匹配或者名字修饰不匹配就会产生以上的问题。一个具体的常见例子是 C++ 代码中的 extern "c"
语句,考虑具体的场景如下:
1 | //当前头文件func.h |
假设我们在 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
,于是提示找不到函数定义,即报链接错误。