基础知识 | 函数基础 1 —— 基本概念 & 如何调用外部模块的函数

缘起

最近遇到了几个跟虚函数有关的问题,既有编译链接问题,又有运行问题,归根到底是基本功不扎实导致的问题。本文将会简单梳理一下函数相关的基本知识:

  • 函数是什么?
  • 函数调用约定有哪些?有什么作用?
  • 普通函数、类静态函数、类成员函数的区别是什么?
  • 什么时候会调用构造函数,什么时候会调用析构函数?
  • 调用虚函数与调用其它函数的区别是什么?
  • B 模块如何调用 A 模块的函数?

本文适合初学者,如果您已经有一定的开发经验,可以跳过本文。

函数是什么?

函数其实就是一段可以被 CPU 执行的二进制代码。一般情况下,函数编译后的二进制代码会被存储在可执行文件(又叫 Portable Executive,简称 PE )的代码段中,程序启动时会加载到内存中。

有几个关键点需要牢记于心:

  1. 每个函数的二进制代码都会存储在对应的模块中,相对模块基址一定的偏移处
  2. 模块加载到内存后会占据一段内存空间,这段内存空间中包含当前模块的函数、全局变量等
  3. 函数的虚拟地址是由 模块基址+函数相对于模块基址的偏移 决定的
  4. 模块中函数地址相对于模块基址的偏移不会改变,模块的基址发生变化后,函数地址也会跟着变

下图是用 IDA 查看 ntoskrnl.exe 中的函数情况。

functions-in-module

ntoskrnl.exe 的基址是 0x00000001 40000000,每个函数相对于模块有一定的偏移。比如,NtSetEvent 相对于模块基址的偏移是 0x00000001 406B2B60 - 0x00000001 40000000 = 0x006B2B60

如果下次启动的时候,ntoskrnl.exe 基址变了,NtSetEvent 的地址也会跟着变,但是相对于模块基址的偏移不会变。

函数调用约定

函数调用约定有哪些?有什么作用?

c++ 中,常用的调用约定有 __cdecl__stdcall__fastcall__thiscall

__cdeclvs 工程属性中默认的调用约定(可以在 vs 工程属性中设置,如下图),__thiscall 是类成员函数默认的调用约定,Windows API 一般会显式使用 __stdcall

set-default-calling-convention

调用约定的主要作用:

  • 影响函数名称,每种调用约定生成的函数名称不一样
  • 影响参数传递方式
  • 影响谁来平衡调用栈(调用者还是被调用者)

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
2
3
4
5
6
7
8
9
10
11
12
// a in RCX, b in RDX, c in R8, d in R9, f then e pushed on stack
func1(int a, int b, int c, int d, int e, int f);

// a in XMM0, b in XMM1, c in XMM2, d in XMM3, f then e pushed on stack
func2(float a, double b, float c, double d, float e, float f);

// a in RCX, b in XMM1, c in R8, d in XMM3, f then e pushed on stack
func3(int a, double b, int c, float d, int e, float f);

// a in RCX, ptr to b in RDX, ptr to c in R8, d in XMM3,
// ptr to f pushed on stack, then ptr to e pushed on stack
func4(__m64 a, __m128 b, struct c, float d, __m128 e, __m128 f);

各种类型的函数比较

普通函数、类静态函数、类成员函数的区别是什么?

平时开发过程中,经常遇到的函数有普通函数、类静态成员函数、类成员函数(构造函数、析构函数等)。

它们的共同特点是:它们都是函数,编译后都是一段可以被 CPU 执行的二进制代码,都会保存在某个模块中。

它们最主要的区别在调用的写法上:

  • 调用普通函数,直接通过函数名即可

  • 构造函数、析构函数会被自动调用

    说明: 虽然是自动调用,其实是编译器生成了调用代码,不用我们手动写而已

  • 调用类成员函数的时候,需要通过类对象或类对象指针进行调用

  • 调用类静态成员函数的时候需要加上类名限定

    说明: 也可以通过类对象或类对象指针进行调用,编译器会自动推断类型

以下示例代码展示了这三种函数的调用方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class CDemo
{
public:
static void T1(int) {}
void T2(int) {}
};

void T1(int) {}
void T2(CDemo*, int) {}

int main()
{
T1(0);
CDemo::T1(0);

CDemo demo;
demo.T2(0);

T2(&demo, 0);
return 0;
}

x64 程序中,T1()CDemo::T1()T2()CDemo::T2() 是等价的,会生成同样的汇编代码。如下图:

function-call-disassembly-x64

x86 程序中,由于调用约定不同,T2()CDemo::T2() 的参数传递方式不同,如下图:

function-call-disassembly-x86

构造函数与析构函数

什么时候会调用构造函数,什么时候会调用析构函数?

  • 当一个类对象被构造出来的时候,会调用构造函数

    比如有一个名为 CTest 的类。下面两句代码都会导致类构造函数被调用。

    1
    2
    3
    4
    5
    int main()
    {
    CTest t1;
    CTest* p = new CTest();
    }
  • 当一个类对象的生命周期结束的时候,会调用析构函数

    一个对象的生命周期什么时候结束呢?有两种情况:

    1. 变量超出作用域
    2. 显式调用 delete
    1
    2
    3
    4
    5
    6
    int 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 中有三种设置方法:

解决库依赖的三种方法

  1. 在代码中使用 #pragma comment(lib, libA) 进行依赖

    可以不修改工程配置,直接在代码中设置依赖

  1. B 模块对应项目的链接器设置中,添加对项目 A 的依赖

    这是比较常规的做法,设置方法如下图:add-lib-in-project-link-option

  1. B 模块对应项目的引用配置中添加对项目 A 的引用

    此方法最简单,最省心,甚至都不用考虑被依赖的库文件的生成路径!add-project-reference

注意: 前两种方法,可能需要在附加库目录中配置 libA 的路径,第三种方法不用。

总结

  • 函数是一段可以被 CPU 执行的二进制代码,会被存储在模块中的某个位置,相对于模块基址的偏移不会改变,模块的基址发生变化后,函数地址也会跟着变
  • 调用约定会影响编译后的函数名、参数传递方式、谁来平衡调用栈
  • x86 程序有各种调用约定,x64 程序只有 __fastcall 一种调用约定
  • 调用函数的两个关键点是:
    • 找到函数地址
    • 明确参数传递方式(由调用约定决定)
  • 调用外部模块的函数,需要依赖对应的库。在 vs 中解决库依赖有三种方法:
    • 在代码中使用 #pragma comment(lib, libA) 进行依赖
    • B 模块对应项目的链接器设置中,添加对项目 A 的依赖
    • B 模块对应项目的引用配置中添加对项目 A 的引用

参考资料

BianChengNan wechat
扫描左侧二维码关注公众号,扫描右侧二维码加我个人微信:)
0%