缘起
最近遇到了几个跟虚函数有关的问题,既有编译链接问题,又有运行问题,归根到底是基本功不扎实导致的问题。本文将会简单梳理一下函数相关的基本知识:
- 函数是什么?
- 函数调用约定有哪些?有什么作用?
- 普通函数、类静态函数、类成员函数的区别是什么?
- 什么时候会调用构造函数,什么时候会调用析构函数?
- 调用虚函数与调用其它函数的区别是什么?
B
模块如何调用A
模块的函数?
本文适合初学者,如果您已经有一定的开发经验,可以跳过本文。
函数是什么?
函数其实就是一段可以被 CPU
执行的二进制代码。一般情况下,函数编译后的二进制代码会被存储在可执行文件(又叫 Portable Executive
,简称 PE
)的代码段中,程序启动时会加载到内存中。
有几个关键点需要牢记于心:
- 每个函数的二进制代码都会存储在对应的模块中,相对模块基址一定的偏移处
- 模块加载到内存后会占据一段内存空间,这段内存空间中包含当前模块的函数、全局变量等
- 函数的虚拟地址是由 模块基址+函数相对于模块基址的偏移 决定的
- 模块中函数地址相对于模块基址的偏移不会改变,模块的基址发生变化后,函数地址也会跟着变
下图是用 IDA
查看 ntoskrnl.exe
中的函数情况。
ntoskrnl.exe
的基址是 0x00000001 40000000
,每个函数相对于模块有一定的偏移。比如,NtSetEvent
相对于模块基址的偏移是 0x00000001 406B2B60 - 0x00000001 40000000 = 0x006B2B60
。
如果下次启动的时候,ntoskrnl.exe
基址变了,NtSetEvent
的地址也会跟着变,但是相对于模块基址的偏移不会变。
函数调用约定
函数调用约定有哪些?有什么作用?
在 c++
中,常用的调用约定有 __cdecl
,__stdcall
, __fastcall
, __thiscall
。
__cdecl
是 vs
工程属性中默认的调用约定(可以在 vs
工程属性中设置,如下图),__thiscall
是类成员函数默认的调用约定,Windows API
一般会显式使用 __stdcall
。
调用约定的主要作用:
- 影响函数名称,每种调用约定生成的函数名称不一样
- 影响参数传递方式
- 影响谁来平衡调用栈(调用者还是被调用者)
x86 程序
在 x86
程序中有各种调用约定,我简单的整理成了表格,如下:
调用约定 | 参数传递方式 | 谁来平衡堆栈 |
---|---|---|
__cdecl | 所有参数通过栈传递,从右向左依次入栈,ebp + 8 指向第一个参数 |
调用者 |
__stdcall | 与 __cdecl 调用约定一样 |
被调用者 |
__fastcall | 前两个 DWORD 类型的参数通过 ecx , edx 传递,其余参数从右向左依次入栈 |
被调用者 |
__thiscall | 对象指针通过 ecx 传递,其余参数与 __cdecl 调用约定一样通过栈传递 |
被调用者 |
说明: MSDN 官方文档 提到了更多种调用约定,感兴趣的小伙伴儿可以自行查看
关于名字修饰规则可以参考《软件调试》第 1
版 第 25
章,740
页。
小贴士: 可以使用
vs
自带的工具undname.exe
查看修饰前的函数名
x64 程序
在 x64
程序中只有一种调用约定 —— __fastcall
。即使显式指定了调用约定,最后也会按 __fastcall
生成代码。
参数传递方式如下表(摘录自MSDN 官方文档):
Parameter type | fifth and higher | fourth | third | second | leftmost |
---|---|---|---|---|---|
floating-point | stack | XMM3 | XMM2 | XMM1 | XMM0 |
integer | stack | R9 | R8 | RDX | RCX |
Aggregates (8, 16, 32, or 64 bits) and __m64 |
stack | R9 | R8 | RDX | RCX |
Other aggregates, as pointers | stack | R9 | R8 | RDX | RCX |
__m128 , as a pointer |
stack | R9 | R8 | RDX | RCX |
各种典型情况下参数传递方式列举如下(摘录自同一个 MSDN 官方文档,注释按习惯调整到上方了):
1 | // a in RCX, b in RDX, c in R8, d in R9, f then e pushed on stack |
各种类型的函数比较
普通函数、类静态函数、类成员函数的区别是什么?
平时开发过程中,经常遇到的函数有普通函数、类静态成员函数、类成员函数(构造函数、析构函数等)。
它们的共同特点是:它们都是函数,编译后都是一段可以被 CPU
执行的二进制代码,都会保存在某个模块中。
它们最主要的区别在调用的写法上:
调用普通函数,直接通过函数名即可
构造函数、析构函数会被自动调用
说明: 虽然是自动调用,其实是编译器生成了调用代码,不用我们手动写而已
调用类成员函数的时候,需要通过类对象或类对象指针进行调用
调用类静态成员函数的时候需要加上类名限定
说明: 也可以通过类对象或类对象指针进行调用,编译器会自动推断类型
以下示例代码展示了这三种函数的调用方式:
1 | class CDemo |
在 x64
程序中,T1()
与 CDemo::T1()
,T2()
与 CDemo::T2()
是等价的,会生成同样的汇编代码。如下图:
在 x86
程序中,由于调用约定不同,T2()
与 CDemo::T2()
的参数传递方式不同,如下图:
构造函数与析构函数
什么时候会调用构造函数,什么时候会调用析构函数?
当一个类对象被构造出来的时候,会调用构造函数
比如有一个名为
CTest
的类。下面两句代码都会导致类构造函数被调用。1
2
3
4
5int main()
{
CTest t1;
CTest* p = new CTest();
}当一个类对象的生命周期结束的时候,会调用析构函数
一个对象的生命周期什么时候结束呢?有两种情况:
- 变量超出作用域
- 显式调用
delete
1
2
3
4
5
6int main()
{
CTest t1;
CTest* p = new CTest();
delete p; // delete 内部会调用析构函数
} // t1 会在这里被析构
敲黑板: 如果不在
B
模块中实例化A
模块中的类对象,那么对B
模块而言A
模块的构造函数不必是导出的。析构函数也是一样的道理。
虚函数 vs 其它函数
调用虚函数与调用其它函数的区别是什么?
我之前写过一篇关于虚函数的总结 —— 《基础知识 | C++ 虚函数简介》(如果图挂了可以看这里)。
介绍了虚函数的相关内容:虚表都包含哪些内容、虚表指针的初始化时机、虚函数是如何支持多态的。这里再简单总结一下:
每个包含虚函数的类对象都有一个虚表指针,该指针指向了虚表,会在类对象的构造函数中初始化
虚表中按顺序存放了虚函数地址
虚函数调用是通过虚表实现的。大体调用过程如下:
- 通过类对象找到虚表指针,进而找到虚表
- 根据头文件中虚函数的顺序得到索引
- 根据索引从虚表中取出函数地址进行调用
调用虚函数与调用其它函数最主要的区别是:
调用普通函数的时候,会直接跳转到函数首地址;调用虚函数的时候,会通过虚表跳转到函数首地址。
跨模块调用
B
模块如何调用 A
模块中的函数?
B
模块要想调用 A
模块中的函数,最重要的原则是:B
模块要能找到 A
模块中的函数地址,一般情况下需要做两件事:
首先,A
模块中的函数需要声明为导出的。导出的函数会记录在 A
模块的导出表中
其次,通过头文件 + 库文件,让 B
模块依赖 A
模块。编译器会在 B
模块的导入表中添加对应项
说明: 还可以通过
GetAddressProc()
找到函数地址进行调用
B
模块依赖 A
模块,在 vs
中有三种设置方法:
解决库依赖的三种方法
在代码中使用
#pragma comment(lib, libA)
进行依赖可以不修改工程配置,直接在代码中设置依赖
在
B
模块对应项目的链接器设置中,添加对项目A
的依赖这是比较常规的做法,设置方法如下图:
在
B
模块对应项目的引用配置中添加对项目A
的引用此方法最简单,最省心,甚至都不用考虑被依赖的库文件的生成路径!
注意: 前两种方法,可能需要在附加库目录中配置
libA
的路径,第三种方法不用。
总结
- 函数是一段可以被
CPU
执行的二进制代码,会被存储在模块中的某个位置,相对于模块基址的偏移不会改变,模块的基址发生变化后,函数地址也会跟着变 - 调用约定会影响编译后的函数名、参数传递方式、谁来平衡调用栈
x86
程序有各种调用约定,x64
程序只有__fastcall
一种调用约定- 调用函数的两个关键点是:
- 找到函数地址
- 明确参数传递方式(由调用约定决定)
- 调用外部模块的函数,需要依赖对应的库。在
vs
中解决库依赖有三种方法:- 在代码中使用
#pragma comment(lib, libA)
进行依赖 - 在
B
模块对应项目的链接器设置中,添加对项目A
的依赖 - 在
B
模块对应项目的引用配置中添加对项目A
的引用
- 在代码中使用