C++的虚函数,在机器层面的实现方式
一点五编程
的核心技巧之一:点五
,也就是(*p)->f(p)
,其原理源自于C++
的虚函数。
一点五编程
在继承虚函数原理的同时,去掉了外部语法的迷雾,只保留了虚函数的骨骼,且放弃“多虚表”的特性,
在实用性和简洁性上达到了一个美妙的平衡。
在推广一点五编程
过程中,发现很多C++
用户,对C++
虚表的实现方式不太了解,经常会被外部语法蒙蔽,看不到它和点五
的一致性。
所以决定写下这篇文章,专门阐述下,C++
的虚表是什么原理。希望能够从这个角度,增进大家对一点五编程
的理解。
实例分析
为了把虚函数的特性分析明白,本示例特意使用了多重继承,类型C
同时继承了来自A
和B
的虚函数。此时,类型C
其实有两个虚函数表。
class A {
public:
virtual int af1(int v);
virtual int af2(int v);
};
class B {
public:
virtual int bf1(int v);
virtual int bf2(int v);
virtual int bf3(int v);
};
class C : public A, public B {
private:
int i;
int j;
public:
int af1(int v) override;
int bf1(int v) override;
};
编译器生成的代码(文末会提供完整C程序和汇编代码)中,可以看到虚表的部分是这样的:
vtable for C:
.quad 0 ;;虚表1
.quad typeinfo for C
.quad C::af1(int) ;;虚表1入口
.quad A::af2(int)
.quad C::bf1(int)
.quad -8 ;;虚表2
.quad typeinfo for C
.quad non-virtual thunk to C::bf1(int) ;;虚表2入口
.quad B::bf2(int)
.quad B::bf3(int)
首先可以看到,虚表的起始部分,并不是虚表的入口(也就是虚表指针指向的地方)。 在虚表入口上方,还有两个字段,一个是虚表的偏移量,一个是类型信息。
这个类型信息,就是C++
支持RTTI
(Run-time type information)的关键。
这个信息挂载在虚表上,所以RTTI
只能在有虚函数的类上使用。
通过类型C
的构造函数,我们能一窥这两个虚表的使用方式:
...
call A::A() [base object constructor]
...
call B::B() [base object constructor]
mov edx, OFFSET FLAT:vtable for C+16 ;;获取第1个虚表地址(虚表1入口)
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], rdx ;;将第1个虚表地址,写入“对象地址”对应的单元
mov edx, OFFSET FLAT:vtable for C+56 ;;获取第2个虚表地址(虚表2入口)
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax+8], rdx ;;将第2个虚表地址,写入“对象地址+8”对应的单元
nop
leave
ret
接下来,我们通过两个例子,来彻底讲清楚虚函数的调用方式。
第1个例子,通过A
类型的指针来访问C
对象:
A *c1 = new C();
int tmp = c1->af1(11);
mov edi, 24 ;;给new的参数(需要申请的内存大小)
call operator new(unsigned long) ;;分配内存
mov rbx, rax
mov QWORD PTR [rbx], 0 ;;初始化内存为0
mov QWORD PTR [rbx+8], 0
mov DWORD PTR [rbx+16], 0
mov DWORD PTR [rbx+20], 0
mov rdi, rbx ;;设置C构造函数的参数
call C::C() [complete object constructor] ;;调用构造函数
mov QWORD PTR [rbp-24], rbx
mov rax, QWORD PTR [rbp-24] ;;载入c1地址(即第一个虚表指针的地址)到rax寄存器
mov rax, QWORD PTR [rax] ;;对第1个虚表指针2次间接寻址获取目标虚函数
mov rdx, QWORD PTR [rax]
mov rax, QWORD PTR [rbp-24]
mov esi, 11 ;;调用虚函数前,设置其第2个参数11
mov rdi, rax ;;调用虚函数前,设置其第1个参数c1
call rdx ;;调用虚函数前
通过上面的例子,可以看到调用虚函数,代码上看是:
c1->af1(11);
实际上,从汇编代码分析就能知道,它其实是:
(*c1)->af1(c1, 11);
忽略掉业务参数,只保留核心部分,其实它就是:
(*p)->f(p);
这就是文章开头所说的:
一点五编程
的核心技巧之一:点五
,也就是(*p)->f(p)
,其原理源自于C++
的虚函数。
C++
的多个虚函数表
然而,C++
的虚表,并没有到此为止,它的复杂性,在下面多重继承(多虚表)的情况下,马上就会暴露出来了。
第2个例子,通过B
类型的指针来访问C
对象:
B *c2 = new C();
int tmp = c2->bf1(22);
mov edi, 24 ;;给new的参数(需要申请的内存大小)
call operator new(unsigned long) ;;分配内存
mov rbx, rax
mov QWORD PTR [rbx], 0 ;;初始化内存为0
mov QWORD PTR [rbx+8], 0
mov DWORD PTR [rbx+16], 0
mov DWORD PTR [rbx+20], 0
mov rdi, rbx ;;设置C构造函数的参数
call C::C() [complete object constructor] ;;调用构造函数
test rbx, rbx
je .L9
lea rax, [rbx+8] ;;调整rax寄存器中的地址值!让其指向对象的第2个虚表指针
jmp .L10
.L9:
mov eax, 0
.L10:
mov QWORD PTR [rbp-32], rax
mov rax, QWORD PTR [rbp-32] ;;对第2个虚表指针2次间接寻址获取目标虚函数
mov rax, QWORD PTR [rax]
mov rdx, QWORD PTR [rax]
mov rax, QWORD PTR [rbp-32]
mov esi, 22
mov rdi, rax
call rdx
细心的读者可能发现了,第二个虚函数调用,它的对象地址是“错”的!本应该是对象地址
的地方,却是对象地址 + 8
。
虚函数的实现部分,是必须要传入对象地址的,因为虚函数本身并不知道自己会被放到哪个地方,offset是多少。
那么这个问题是怎么解决的呢?
回头看上面虚表的结构,能发现第2个虚表里面,有一个虚函数名字很奇怪,跟其他虚函数可以说是格格不如,
它叫non-virtual thunk to C::bf1(int)
,这个函数的内容如下:
non-virtual thunk to C::bf1(int):
sub rdi, 8
jmp .LTHUNK0
它做的事情很简单,就是把第一个参数(此编译器的传参规则,第一个参数放在rdi
里)减8,
由于前面虚函数调用的时候,传入的对象地址是虚函数表2的地址,它减8即调整回真实对象的地址。
经过这个调整之后,它才会去调用真正的虚函数C::bf1
。
进一步思考下,把这个减法的逻辑,融入到前面的公式,可以得到:
(*p)->f(p - N);
这个公式还出现在了后面的一篇文章《C语言实现“多接口”的另一种方式》中,大家可以对比理解,融会贯通。
完整示例源码
class A {
public:
virtual int af1(int v);
virtual int af2(int v);
};
class B {
public:
virtual int bf1(int v);
virtual int bf2(int v);
virtual int bf3(int v);
};
class C : public A, public B {
private:
int i;
int j;
public:
int af1(int v) override;
int bf1(int v) override;
};
int C::af1(int v) {
return i + v;
}
int C::bf1(int v) {
return i + v;
}
int f(void) {
int tmp;
A *c1 = new C();
tmp = c1->af1(11);
B *c2 = new C();
tmp = c2->bf1(22);
return 0;
}
x86-64 gcc 14.1
的生成的汇编代码:
C::af1(int):
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi
mov DWORD PTR [rbp-12], esi
mov rax, QWORD PTR [rbp-8]
mov edx, DWORD PTR [rax+16]
mov eax, DWORD PTR [rbp-12]
add eax, edx
pop rbp
ret
C::bf1(int):
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi
mov DWORD PTR [rbp-12], esi
mov rax, QWORD PTR [rbp-8]
mov edx, DWORD PTR [rax+16]
mov eax, DWORD PTR [rbp-12]
add eax, edx
pop rbp
ret
non-virtual thunk to C::bf1(int):
sub rdi, 8
jmp .LTHUNK0
A::A() [base object constructor]:
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi
mov edx, OFFSET FLAT:vtable for A+16
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], rdx
nop
pop rbp
ret
B::B() [base object constructor]:
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi
mov edx, OFFSET FLAT:vtable for B+16
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], rdx
nop
pop rbp
ret
C::C() [base object constructor]:
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR [rbp-8], rdi
mov rax, QWORD PTR [rbp-8]
mov rdi, rax
call A::A() [base object constructor]
mov rax, QWORD PTR [rbp-8]
add rax, 8
mov rdi, rax
call B::B() [base object constructor]
mov edx, OFFSET FLAT:vtable for C+16
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], rdx
mov edx, OFFSET FLAT:vtable for C+56
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax+8], rdx
nop
leave
ret
f():
push rbp
mov rbp, rsp
push rbx
sub rsp, 40
mov edi, 24
call operator new(unsigned long)
mov rbx, rax
mov QWORD PTR [rbx], 0
mov QWORD PTR [rbx+8], 0
mov DWORD PTR [rbx+16], 0
mov DWORD PTR [rbx+20], 0
mov rdi, rbx
call C::C() [complete object constructor]
mov QWORD PTR [rbp-24], rbx
mov rax, QWORD PTR [rbp-24]
mov rax, QWORD PTR [rax]
mov rdx, QWORD PTR [rax]
mov rax, QWORD PTR [rbp-24]
mov esi, 11
mov rdi, rax
call rdx
mov DWORD PTR [rbp-36], eax
mov edi, 24
call operator new(unsigned long)
mov rbx, rax
mov QWORD PTR [rbx], 0
mov QWORD PTR [rbx+8], 0
mov DWORD PTR [rbx+16], 0
mov DWORD PTR [rbx+20], 0
mov rdi, rbx
call C::C() [complete object constructor]
test rbx, rbx
je .L9
lea rax, [rbx+8]
jmp .L10
.L9:
mov eax, 0
.L10:
mov QWORD PTR [rbp-32], rax
mov rax, QWORD PTR [rbp-32]
mov rax, QWORD PTR [rax]
mov rdx, QWORD PTR [rax]
mov rax, QWORD PTR [rbp-32]
mov esi, 22
mov rdi, rax
call rdx
mov DWORD PTR [rbp-36], eax
mov eax, 0
mov rbx, QWORD PTR [rbp-8]
leave
ret
vtable for C:
.quad 0
.quad typeinfo for C
.quad C::af1(int)
.quad A::af2(int)
.quad C::bf1(int)
.quad -8
.quad typeinfo for C
.quad non-virtual thunk to C::bf1(int)
.quad B::bf2(int)
.quad B::bf3(int)
typeinfo for C:
.quad vtable for __cxxabiv1::__vmi_class_type_info+16
.quad typeinfo name for C
.long 0
.long 2
.quad typeinfo for A
.quad 2
.quad typeinfo for B
.quad 2050
typeinfo name for C:
.string "1C"