虚函数实现与c语言中的回调函数
虚函数的发明初衷在于使得程序具有更好的通用性,从代码编写的角度看,其使用基类的指针指向子类的对象,并调用虚函数,使得根据指向的对象 而调用不同的子类函数,从而实现运行时多态(Runtime polymorphism ),这个特性与c语言中的函数指针回调非常相似,在c语言中,我们将函数指针作为参数传递给其他函数,然后调用,根据其指向的不同函数而产生不同的行为,c代码示例如下
#include <stdio.h>
typedef void(*func)(void);
void foo() {
printf("foo() called\n");
}
void bar() {
printf("bar() called\n");
}
void callback(void* fn) {
((func)fn)();
}
int main() {
callback((void*)foo);
callback((void*)bar);
return 0;
}
可见,callback() 函数根据传入的函数指针的不同使得其调用的函数不同,因此对于callback函数来说,其发生了运行时多态(Runtime polymorphism ),从汇编的角度看,其对应的汇编代码可以是
## call
call *%rdi <fn>
其中 %rdi 寄存器保存函数的第一个参数,*%rdi 表示寄存器存的是调用地址,此行汇编执行完后,栈空间将返回地址压入栈中,程序计数寄存器 pc的值将保存为%rdi 里的内容,控制转移至调用的函数中。
下面来看C++ 中虚函数是如何实现多态的,先给出一个使用virtual 关键字的多态实现
#include <iostream>
using std::cout;
using std::endl;
class A {
public :
virtual void foo() {
cout << "A::foo() Virtual Table address : "
<<(void*)(*reinterpret_cast<int*>(this)) << endl;
}
virtual ~A() {
cout << "~A() Virtual Table address : "
<<(void*)(*reinterpret_cast<int*>(this)) << endl;
}
};
class B : public A {
public :
void foo() {
cout << "B::foo() Virtual Table address : "
<<(void*)(*reinterpret_cast<int*>(this)) << endl;
}
~B() {
cout << "~B() Virtual Table address : "
<<(void*)(*reinterpret_cast<int*>(this)) << endl;
}
};
int main() {
A* p = new B();
p->foo();
delete p;
return 0;
}
===========================================
output:
B::foo() Virtual Table address : 0x1fe2048
~B() Virtual Table address : 0x1fe2048
~A() Virtual Table address : 0x1fe2098
可以看到当析构函数定义为虚函数时,先执行子类B的析构函数,可以看到此时对象首地址4字节指向的是子类B的虚函数表,当调用父类 A的析构函数时,此时对象首地址4字节指向的是类A的虚函数表。因此析构过程大概如下
- 先根据子类B的虚函数表(对象首4字节)找到子类B的析构函数,然后执行
- 将父类虚函数表的地址覆盖对象首4字节,然后找到父类A的析构函数,并执行
虚函数的模拟实现
下面来模拟c++虚函数的实现,定义一个父类A,和子类 B.C,子类均继承类A。并定义三个全局数组 vtableA,vtableB,vtableC (模拟编译器创建的虚函数表),存放在全局数据,且每个类均有一个4字节的成员变量指针vptr_,在对象初始化的时候,该vptr_指向自己的虚函数表
#include <iostream>
using std::cout;
using std::endl;
typedef void(*fn)(void);
fn vtableA[1];
fn vtableB[1];
fn vtableC[1];
class A {
public :
A() {
vptr_ = (int*)vtableA;
}
void WapperFoo() {
((fn*)(this->vptr_))[0]();
}
static void foo() {
cout << "A::foo()" << endl;
}
public :
int* vptr_;
};
class B : public A {
public :
B() {
vptr_ = (int*)vtableB;
}
static void foo() {
cout << "B::foo()" << endl;
}
};
class C : public A {
public :
C() {
vptr_ = (int*)vtableC;
}
static void foo() {
cout << "C::foo()" << endl;
}
};
int main() {
vtableA[0] = &A::foo;
vtableB[0] = &B::foo;
vtableC[0] = &C::foo;
A* p = new B();
p->WapperFoo();
delete p;
p = new C();
p->WapperFoo();
delete p;
return 0;
}
====================
output
B::foo()
C::foo()
程序初始化时,分别填写各个类的虚函数表条目,vtableA的第一个条目为A类的foo()成员函数,vtableB的第一个条目为类B的foo()成员函数,vtableC的第一个条目为类C的foo()成员函数。因此当基类的指针p 指向类B的对象时,调用WapperFoo()(可以看成是编译器封装的foo()函数)函数将调用类B的foo(),同理当p指向类C的对象时,调用WapperFoo()函数将调用类C的foo(),从而实现了运行时多态。可以看到和c语言的多态实现是相似的。
因此从汇编代码(机器码)的角度看,实现多态的本质是在调用callback 函数调用时,是直接调用还是间接调用,直接调用下,函数地址在编译期确定。而在间接调用下,函数地址在运行时确定
# call
call 0x000400008 <fn>
## call
call *%rax <fn>
虚函数的真正实现
g++版本:Apple clang version 13.0.0 (clang-1300.0.29.30)
下面的代码基于clang-1300.0.29.3 版本的g++编译,通过对含有虚函数的C++ 进行编译,并通过分析其生成的汇编代码来查看C++的虚函数和多态是如何实现的
C++源程序代码为
#include <iostream>
using namespace std;
class A {
public :
virtual void foo() {
}
virtual void bar() {
}
virtual void car() {
}
};
class B : public A {
public :
void foo() {
}
void bar() {
}
void car() {
}
};
int foo() {
static const int a = 999;
return a;
}
int main() {
B b;
A* p = &b;
p->car();
return 0;
}
其对应的汇编代码为
.section __TEXT,__text,regular,pure_instructions
.build_version macos, 11, 0 sdk_version 12, 1
.globl __Z3foov ## -- Begin function _Z3foov
.p2align 4, 0x90
__Z3foov: ## @_Z3foov
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
movl $999, %eax ## imm = 0x3E7
popq %rbp
retq
.cfi_endproc
## -- End function
.globl _main ## -- Begin function main
.p2align 4, 0x90
_main: ## @main
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
subq $32, %rsp
movl $0, -4(%rbp)
leaq -16(%rbp), %rdi
callq __ZN1BC1Ev
leaq -16(%rbp), %rax
movq %rax, -24(%rbp)
movq -24(%rbp), %rdi
movq (%rdi), %rax
callq *16(%rax)
xorl %eax, %eax
addq $32, %rsp
popq %rbp
retq
.cfi_endproc
## -- End function
.globl __ZN1BC1Ev ## -- Begin function _ZN1BC1Ev
.weak_def_can_be_hidden __ZN1BC1Ev
.p2align 4, 0x90
__ZN1BC1Ev: ## @_ZN1BC1Ev
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
subq $16, %rsp
movq %rdi, -8(%rbp)
movq -8(%rbp), %rdi
callq __ZN1BC2Ev
addq $16, %rsp
popq %rbp
retq
.cfi_endproc
## -- End function
.globl __ZN1BC2Ev ## -- Begin function _ZN1BC2Ev
.weak_def_can_be_hidden __ZN1BC2Ev
.p2align 4, 0x90
__ZN1BC2Ev: ## @_ZN1BC2Ev
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
subq $16, %rsp
movq %rdi, -8(%rbp)
movq -8(%rbp), %rdi
movq %rdi, -16(%rbp) ## 8-byte Spill
callq __ZN1AC2Ev
movq -16(%rbp), %rax ## 8-byte Reload
movq __ZTV1B@GOTPCREL(%rip), %rcx
addq $16, %rcx
movq %rcx, (%rax)
addq $16, %rsp
popq %rbp
retq
.cfi_endproc
## -- End function
.globl __ZN1AC2Ev ## -- Begin function _ZN1AC2Ev
.weak_def_can_be_hidden __ZN1AC2Ev
.p2align 4, 0x90
__ZN1AC2Ev: ## @_ZN1AC2Ev
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
movq __ZTV1A@GOTPCREL(%rip), %rcx
addq $16, %rcx
movq %rdi, -8(%rbp)
movq -8(%rbp), %rax
movq %rcx, (%rax)
popq %rbp
retq
.cfi_endproc
## -- End function
.globl __ZN1B3fooEv ## -- Begin function _ZN1B3fooEv
.weak_def_can_be_hidden __ZN1B3fooEv
.p2align 4, 0x90
__ZN1B3fooEv: ## @_ZN1B3fooEv
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
movq %rdi, -8(%rbp)
popq %rbp
retq
.cfi_endproc
## -- End function
.globl __ZN1B3barEv ## -- Begin function _ZN1B3barEv
.weak_def_can_be_hidden __ZN1B3barEv
.p2align 4, 0x90
__ZN1B3barEv: ## @_ZN1B3barEv
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
movq %rdi, -8(%rbp)
popq %rbp
retq
.cfi_endproc
## -- End function
.globl __ZN1B3carEv ## -- Begin function _ZN1B3carEv
.weak_def_can_be_hidden __ZN1B3carEv
.p2align 4, 0x90
__ZN1B3carEv: ## @_ZN1B3carEv
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
movq %rdi, -8(%rbp)
popq %rbp
retq
.cfi_endproc
## -- End function
.globl __ZN1A3fooEv ## -- Begin function _ZN1A3fooEv
.weak_def_can_be_hidden __ZN1A3fooEv
.p2align 4, 0x90
__ZN1A3fooEv: ## @_ZN1A3fooEv
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
movq %rdi, -8(%rbp)
popq %rbp
retq
.cfi_endproc
## -- End function
.globl __ZN1A3barEv ## -- Begin function _ZN1A3barEv
.weak_def_can_be_hidden __ZN1A3barEv
.p2align 4, 0x90
__ZN1A3barEv: ## @_ZN1A3barEv
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
movq %rdi, -8(%rbp)
popq %rbp
retq
.cfi_endproc
## -- End function
.globl __ZN1A3carEv ## -- Begin function _ZN1A3carEv
.weak_def_can_be_hidden __ZN1A3carEv
.p2align 4, 0x90
__ZN1A3carEv: ## @_ZN1A3carEv
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
movq %rdi, -8(%rbp)
popq %rbp
retq
.cfi_endproc
## -- End function
.section __TEXT,__const
.p2align 2 ## @_ZZ3foovE1a
__ZZ3foovE1a:
.long 999 ## 0x3e7
.section __DATA,__const
.globl __ZTV1B ## @_ZTV1B
.weak_def_can_be_hidden __ZTV1B
.p2align 3
__ZTV1B:
.quad 0
.quad __ZTI1B
.quad __ZN1B3fooEv
.quad __ZN1B3barEv
.quad __ZN1B3carEv
.section __TEXT,__const
.globl __ZTS1B ## @_ZTS1B
.weak_definition __ZTS1B
__ZTS1B:
.asciz "1B"
.globl __ZTS1A ## @_ZTS1A
.weak_definition __ZTS1A
__ZTS1A:
.asciz "1A"
.section __DATA,__const
.globl __ZTI1A ## @_ZTI1A
.weak_definition __ZTI1A
.p2align 3
__ZTI1A:
.quad __ZTVN10__cxxabiv117__class_type_infoE+16
.quad __ZTS1A
.globl __ZTI1B ## @_ZTI1B
.weak_definition __ZTI1B
.p2align 3
__ZTI1B:
.quad __ZTVN10__cxxabiv120__si_class_type_infoE+16
.quad __ZTS1B
.quad __ZTI1A
.globl __ZTV1A ## @_ZTV1A
.weak_def_can_be_hidden __ZTV1A
.p2align 3
__ZTV1A:
.quad 0
.quad __ZTI1A
.quad __ZN1A3fooEv
.quad __ZN1A3barEv
.quad __ZN1A3carEv
.subsections_via_symbols
我们下面来不断的对比汇编代码和C++代码
首先查看 main函数
int main() {
B b;
A* p = &b;
p->cat();
return 0;
}
.globl _main ## -- Begin function main
.p2align 4, 0x90
_main: ## @main
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
subq $32, %rsp
movl $0, -4(%rbp)
leaq -16(%rbp), %rdi
callq __ZN1BC1Ev
leaq -16(%rbp), %rax
movq %rax, -24(%rbp)
movq -24(%rbp), %rdi
movq (%rdi), %rax
callq *16(%rax)
xorl %eax, %eax
addq $32, %rsp
popq %rbp
retq
.cfi_endproc
## -- End function
上述main函数中,我们在栈中使用sizeof(B) 的大小来分配对象b的内存,其中B本身虽然没有(显示的)成员变量,但是由于虚函数的存在,其对象前8个字节为指向虚函数表的地址,所以sizeof(B) =8(64-bit机器上)。同时定义一个栈变量A* p 来保存对象 b的地址,p为地址类型,64-bit 机器上地址占用空间大小8 bytes ,同时为了内存对齐,所以一共需要32字节栈空间大小。
leaq -16(%rbp), %rdi
即把分配给对象b的栈空间首地址传递给 rdi寄存器,作为下个函数的第一个参数
callq __ZN1BC1Ev
即为调用 __ZN1BC1Ev函数,通过命令c++filt 可知其为B的构造函数B() 。
c++filt __ZN1BC1Ev
B::B()
接下来我们看一下函数__ZN1BC1Ev 的汇编实现
.globl __ZN1BC1Ev ## -- Begin function _ZN1BC1Ev
.weak_def_can_be_hidden __ZN1BC1Ev
.p2align 4, 0x90
__ZN1BC1Ev: ## @_ZN1BC1Ev
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
subq $16, %rsp
movq %rdi, -8(%rbp)
movq -8(%rbp), %rdi
callq __ZN1BC2Ev
addq $16, %rsp
popq %rbp
retq
.cfi_endproc
## -- End function
可以看到其最终调用 __ZN1BC2Ev 函数,通过c++filt 发现其亦为B的构造函数B() ,进一步查看其汇编函数体
.globl __ZN1BC2Ev ## -- Begin function _ZN1BC2Ev
.weak_def_can_be_hidden __ZN1BC2Ev
.p2align 4, 0x90
__ZN1BC2Ev: ## @_ZN1BC2Ev
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
subq $16, %rsp
movq %rdi, -8(%rbp)
movq -8(%rbp), %rdi
movq %rdi, -16(%rbp) ## 8-byte Spill
callq __ZN1AC2Ev
movq -16(%rbp), %rax ## 8-byte Reload
movq __ZTV1B@GOTPCREL(%rip), %rcx
addq $16, %rcx
movq %rcx, (%rax)
addq $16, %rsp
popq %rbp
retq
.cfi_endproc
## -- End function
其主要过程为
- 传递对象b的栈空间地址。注。这里在连续的两个8字节栈内存 拷贝了两次 对象b的栈空间地址,对应的汇编代码为
movq %rdi, -8(%rbp) 和 movq %rdi, -16(%rbp) - 调用父类A的构造函数
callq __ZN1AC2Ev - 因为该函数的调用者为B(),所以把class B的虚函数表
__ZTV1B 的地址取出来,并赋值 rcx寄存器 movq __ZTV1B@GOTPCREL(%rip), %rcx - 然后找到
__ZTV1B 虚函数地址条目的开始处 ,并赋值给 对象b的栈空间的首8个字节数据内容
addq $16, %rcx 和 movq %rcx, (%rax)
我们可以看到 class B的虚函数表__ZTV1B 内容为
.globl __ZTV1B ## @_ZTV1B
.weak_def_can_be_hidden __ZTV1B
.p2align 3
__ZTV1B:
.quad 0
.quad __ZTI1B
.quad __ZN1B3fooEv
.quad __ZN1B3barEv
.quad __ZN1B3carEv
.section __TEXT,__const
.globl __ZTI1B ## @_ZTI1B
.weak_definition __ZTI1B
.p2align 3
__ZTI1B:
.quad __ZTVN10__cxxabiv120__si_class_type_infoE+16
.quad __ZTS1B
.quad __ZTI1A
可以看到其第一个条目为0,第二个条目为 __ZTI1B ,其主要保存了类Btypeinfo,typeinfo name等信息,第三个条目开始才是虚函数条目(这里条目存的是地址),这也是为什么要+16的原因(为了跳过前两个条目)。
class B的构造函数B()执行完成后(指令 callq __ZN1BC1Ev 执行后),我们再次回到主函数。
callq __ZN1BC1Ev
leaq -16(%rbp), %rax
movq %rax, -24(%rbp)
movq -24(%rbp), %rdi
movq (%rdi), %rax
callq *16(%rax)
leaq -16(%rbp), %rax 表示取出 rbp 基地址寄存器向栈顶方向偏移16bytes的地址,这个地址是对象b的栈空间起始地址,将其缓存在 rax寄存器中,并将其值拷贝给 rbp 基地址寄存器向栈顶方向偏移24bytes的栈内存空间,其对应栈变量 A* p 的值,因此对应c++代码
A* p = &b;
接下来 调用指令movq -24(%rbp), %rdi 将指针p的栈空间内容暂存在 rdi寄存器中,并调用 指令movq (%rdi), %rax 将p解引用,取出指针p所指向的数据内容,这其实是class B的虚函数表的地址,即__ZTV1B 中的第三个条目的地址。接着调用指令 callq *16(%rax) 调用最终的虚函数,此虚函数的参数为指针p,16(%rax) 的含义为rax 寄存器指向的地址偏移16个字节(偏移两个条目,car()函数是第三个虚函数) *16(%rax) 表示本次callq 为间接调用方式(无法在编译期确定函数调用地址),这恰好印证了上一节虚函数的模拟实现的推论是正确的。
参考链接
- https://stackoverflow.com/questions/66713460/what-does-vargotpcrelrip-mean
- http://t.zoukankan.com/tgycoder-p-5238954.html
- https://stackoverflow.com/questions/43005411/how-does-the-quad-directive-work-in-assembly
|