文章目录
  1. 1. 简介
  2. 2. 准备工作
  3. 3. 普通对象
  4. 4. 单继承
  5. 5. 包含虚函数的单继承
  6. 6. 小结

LLVM IR

简介

C++对象模型指的是C++语言中的“对象”如何映射到低级语言,包括内存布局、成员函数调用等,也就是说,我们日常使用的类、继承、虚函数、多继承,它们在编译器中都会有一套映射到低级语言的模型,所以,这些高级特性并不是与生俱来的。

而了解对象模型的一个很好的方式便是看高级语言如何映射到低级语言。我们通常所说的低级语言便是汇编,但了解汇编的都知道,它的可读性很低,没有类型,全是基于寄存器、内存的计算。如果从汇编来看C++的对象模型,它们之间的跨度太大,很可能让人不知所云。本文所要采用的低级语言是LLVM IR,它是LLVM(Low Level Virtual Machine)项目所使用的Immediate Representation,高级语言会编译成LLVM IR,之后再编译成汇编,直到机器码。LLVM IR的特点是它的表示足够底层,能够表示绝大多数高级语言的特性,同时,它也具备不错的可读性(相对于汇编)。所以,用LLVM IR来看对象模型是一个不错的选择。

准备工作

这里使用Clang编译器,它是LLVM的C/C++前端。从C++代码编译到LLVM IR,需要执行以下命令:

1
clang++ -emit-llvm -S -fno-exceptions -fno-rtti test.cc

这里使用了no-exceptions和no-rtti参数,屏蔽了异常处理和RTTI的信息,减少了噪声。如果有需要了解exception和RTTI也可以加上这两个参数看看效果。

普通对象

首先看看普通对象,即仅有数据成员、非虚函数、无继承的对象。实例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Animal {
public:
char a;
short b;
int c;
double d;

double foo() {
return d;
}
};

int main() {
Animal *a = new Animal();
a->foo();
delete a;
return 0;
}

这是一个简单的Animal类,内含几个成员变量,以及一个成员函数,在用Clang编译之后,会得到IR:

1
%class.Animal = type { i8, i16, i32, double }

首先是类的成员变量,char映射到i8,即int8,short是i16,以此类推。但是在class.Animal中,并没有foo函数的身影,这说明,普通的成员函数和非成员函数并没有什么不同。接着我们找到函数foo的定义:

1
2
3
4
5
6
7
8
define linkonce_odr double @_ZN6Animal3fooEv(%class.Animal* %this) #0 comdat align 2 {
%1 = alloca %class.Animal*, align 8
store %class.Animal* %this, %class.Animal** %1, align 8
%2 = load %class.Animal*, %class.Animal** %1
%3 = getelementptr inbounds %class.Animal, %class.Animal* %2, i32 0, i32 3
%4 = load double, double* %3, align 8
ret double %4
}

函数用define关键字定义,和高级编程语言有几分相似之处。忽略一些函数的属性,直接看函数名—@_ZN6Animal3fooEv,这是经过了mangling的之后的函数名,可以发现类名、函数名、参数都编码进去了。而括号中的自然是参数列表,一个class.Animal类型的指针,名为%this。我们原本的foo函数是没有参数的,到这里却有了一个Animal类指针的参数,这是因为这个函数要和对象关联到一起,必须把这个对象的指针传进去。接下来几行是对成员变量d的寻址:d的地址可以直接通过对象基址的偏移得到,即

1
%3 = getelementptr inbounds %class.Animal, %class.Animal* %2, i32 0, i32 3

这句的意思是先对this指针解引用,然后取第3个元素,便得到了d的地址。

这里顺便解释一下LLVM IR,它是一种SSA形式的IR,即所有变量都是不可变的。变量的命名用%开头,局部变量会从1开始递增下去。LLVM IR中的指令大多都是二元指令,并且包含类型。例如

1
store %class.Animal* %this, %class.Animal** %1, align 8

这条store指令表示把%this变量的值存到%1变量中;load指令则是加载变量,返回结果。

单继承

接下来看看单继承情况下的对象模型。示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Animal {
public:
int Run() {
return data_;
}

int data_;
};

class Dog: public Animal {
public:
double Say() {
return d_;
}
double d_;
};

int main() {
Dog d;
d.Run();
d.Say();
return 0;
}

内存模型如下:

1
2
%class.Animal = type { i32 }
%class.Dog = type { %class.Animal, double }

由于Dog继承了Animal,所以Dog对象中包含了一个Animal对象,以及Dog自己的成员。

对于成员函数的调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
define i32 @main() #0 {
%1 = alloca i32, align 4
%d = alloca %class.Dog, align 8
store i32 0, i32* %1
%2 = bitcast %class.Dog* %d to %class.Animal*
%3 = call i32 @_ZN6Animal3RunEv(%class.Animal* %2)
%4 = call double @_ZN3Dog3SayEv(%class.Dog* %d)
ret i32 0
}

; Function Attrs: nounwind uwtable
define linkonce_odr i32 @_ZN6Animal3RunEv(%class.Animal* %this) #0 comdat align 2 {
%1 = alloca %class.Animal*, align 8
store %class.Animal* %this, %class.Animal** %1, align 8
%2 = load %class.Animal*, %class.Animal** %1
%3 = getelementptr inbounds %class.Animal, %class.Animal* %2, i32 0, i32 0
%4 = load i32, i32* %3, align 4
ret i32 %4
}

; Function Attrs: nounwind uwtable
define linkonce_odr double @_ZN3Dog3SayEv(%class.Dog* %this) #0 comdat align 2 {
%1 = alloca %class.Dog*, align 8
store %class.Dog* %this, %class.Dog** %1, align 8
%2 = load %class.Dog*, %class.Dog** %1
%3 = getelementptr inbounds %class.Dog, %class.Dog* %2, i32 0, i32 1
%4 = load double, double* %3, align 8
ret double %4
}

对于Animal类和Dog类的两个成员函数Run和Say,也是分别编译为两个独立的函数,只是传入的this指针有所区别。在对成员变量的寻址时,也是根据基址加偏移来计算。

包含虚函数的单继承

C++在实现虚函数的时候,用了虚函数表的机制,把对象和函数绑定在一起。试想一下,我们拿到了一个Animal类型的指针,它可能会指向Animal对象,也可能指向继承Animal的Dog对象,这两个对象拥有不同的Say函数,我们想要达到的目的是,如果对象是Animal,则调用Animal::Say;如果对象时Dog,则调用Dog::Say。为了达到这个目的,对象的类型信息必须写到对象所占据的内存中,以实现把函数和对象绑定的目的。C++为了实现这一特性,使用了虚函数表这一技术:在每个对象中加入一个虚函数表指针,指向这个类的虚函数表;而虚函数表中则包含了类所具有的虚函数。因此通过对象基址和相对偏移就可以计算出相应的虚函数的地址了。

示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Animal {
public:
virtual int Say() {
return a;
}

short a;
char padding[6];
};

class Dog: public Animal {
public:
int Say() {
return d;
}

int d;
};

int main() {
Animal *a = new Dog();
a->Say();
return 0;
}

编译得到的IR如下:

1
2
3
4
5
%class.Animal = type { i32 (...)**, i16, [6 x i8] }
%class.Dog = type <{ %class.Animal, i32, [4 x i8] }>

@_ZTV3Dog = linkonce_odr unnamed_addr constant [3 x i8*] [i8* null, i8* null, i8* bitcast (i32 (%class.Dog*)* @_ZN3Dog3SayEv to i8*)], comdat, align 8
@_ZTV6Animal = linkonce_odr unnamed_addr constant [3 x i8*] [i8* null, i8* null, i8* bitcast (i32 (%class.Animal*)* @_ZN6Animal3SayEv to i8*)], comdat, align 8

可以看到在%class.Animal中包含了一个指针,这就是虚表指针,解引用之后可到虚表的地址,虚表看做一个数组,数组中包含了函数指针,所以i32 (...)**会有两层指针。%class.Dog对象直接包含了Animal对象。

下面两行则是对虚表的初始化,Dog和Animal的虚表都在第三个位置存入了一个函数指针,分别是Dog::Say和Animal::Say。

至于对虚函数的调用,则需要通过对象基址,找到虚表指针,然后解引用找到虚表,再找到对应的函数:

1
2
3
4
5
6
%6 = load %class.Animal*, %class.Animal** %a, align 8                                     ; find vptr
%7 = bitcast %class.Animal* %6 to i32 (%class.Animal*)*** ; type cast
%8 = load i32 (%class.Animal*)**, i32 (%class.Animal*)*** %7 ; dereference vptr, get virtual function table
%9 = getelementptr inbounds i32 (%class.Animal*)*, i32 (%class.Animal*)** %8, i64 0 ; retrieve function from function table
%10 = load i32 (%class.Animal*)*, i32 (%class.Animal*)** %9 ; dereference entity in function table, get function pointer
%11 = call i32 %10(%class.Animal* %6) ; call function via function pointer

我在右边写了注释,简单解释了计算虚函数的过程。

小结

普通对象,单继承,包含虚函数的单继承都是最最基本的OO,实现也相对简单,而C++中还有多继承,虚继承,这几个特性实现起来相对复杂,且看下回分解。

文章目录
  1. 1. 简介
  2. 2. 准备工作
  3. 3. 普通对象
  4. 4. 单继承
  5. 5. 包含虚函数的单继承
  6. 6. 小结