指向成员函数的指针如何工作?
How do pointers to member functions work?
我知道普通函数指针包含被指向函数的起始地址,所以当使用普通函数指针时,我们只是跳转到存储的地址。但是指向对象成员函数的指针包含什么?
考虑:
class A
{
public:
int func1(int v) {
std::cout << "fun1";
return v;
}
virtual int func2(int v) {
std::cout << "fun2";
return v;
}
};
int main(int argc, char** argv)
{
A a;
int (A::*pf)(int a) = argc > 2 ? &A::func1 : &A::func2;
static_assert(sizeof(pf) == (sizeof(void*), "Unexpected function size");
return (a.*pf)(argc);
}
在上面的程序中,函数指针可以从虚函数(需要通过 vtable 访问)或普通 class 成员(作为普通函数实现隐含的 this
作为第一个参数。)
那么我指向成员函数的指针中存储的值是多少,编译器如何让事情按预期工作?
GCC documents PMF 被实现为知道如何计算 this
的值并进行任何 vtable 查找的结构。
这当然取决于编译器和目标架构,并且有不止一种方法可以做到。但我将描述它在我最常使用的系统上的工作方式,g++ for Linux x86_64.
g++ 遵循 Itanium C++ ABI,其中描述了多种 C++ 功能(包括虚函数)可以在大多数体系结构的幕后实现的一种方式的很多细节。
ABI 在第 2.3 节中对指向成员函数的指针进行了说明:
A pointer to member function is a pair as follows:
ptr:
For a non-virtual function, this field is a simple function pointer. ... For a virtual function, it is 1 plus the virtual table offset (in bytes) of the function, represented as a ptrdiff_t
. The value zero represents a NULL pointer, independent of the adjustment field value below.
adj:
The required adjustment to this
, represented as a ptrdiff_t
.
It has the size, data size, and alignment of a class containing those two members, in that order.
虚函数指针的 +1 有助于检测该函数是否为虚函数,因为对于大多数平台而言,所有函数指针值和虚表偏移量都是偶数。它还确保空成员函数指针具有与任何有效成员函数指针不同的值。
您的 class A
的 vtable / vptr 设置将像这样的 C 代码工作:
struct A__virt_funcs {
int (*func2)(A*, int);
};
struct A__vtable {
ptrdiff_t offset_to_top;
const std__typeinfo* typeinfo;
struct A__virt_funcs funcs;
};
struct A {
const struct A__virt_funcs* vptr;
};
int A__func1(struct A*, int v) {
std__operator__ltlt(&std__cout, "fun1");
return v;
}
int A__func2(struct A*, int v) {
std__operator__ltlt(&std__cout, "fun2");
return v;
}
extern const std__typeinfo A__typeinfo;
const struct A__vtable vt_for_A = { 0, &A__typeinfo, { &A__func2 } };
void A__initialize(A* a) {
a->vptr = &vt_for_A.funcs;
}
(是的,实名修改方案需要对函数参数类型做一些事情以允许重载,以及更多的事情,因为涉及的 operator<<
实际上是一个函数模板特化。但这不是重点在这里。)
现在让我们看看我为您的 main()
获得的程序集(带有选项 -O0 -fno-stack-protector
)。我的评论已添加。
Dump of assembler code for function main:
// Standard stack adjustment for function setup.
0x00000000004007e6 <+0>: push %rbp
0x00000000004007e7 <+1>: mov %rsp,%rbp
0x00000000004007ea <+4>: push %rbx
0x00000000004007eb <+5>: sub [=11=]x38,%rsp
// Put argc in the stack at %rbp-0x34.
0x00000000004007ef <+9>: mov %edi,-0x34(%rbp)
// Put argv in the stack at %rbp-0x40.
0x00000000004007f2 <+12>: mov %rsi,-0x40(%rbp)
// Construct "a" on the stack at %rbp-0x20.
// 0x4009c0 is &vt_for_A.funcs.
0x00000000004007f6 <+16>: mov [=11=]x4009c0,%esi
0x00000000004007fb <+21>: mov %rsi,-0x20(%rbp)
// Check if argc is more than 2.
// In both cases, "pf" will be on the stack at %rbp-0x30.
0x00000000004007ff <+25>: cmpl [=11=]x2,-0x34(%rbp)
0x0000000000400803 <+29>: jle 0x400819 <main+51>
// if (argc <= 2) {
// Initialize pf to { &A__func2, 0 }.
0x0000000000400805 <+31>: mov [=11=]x4008ce,%ecx
0x000000000040080a <+36>: mov [=11=]x0,%ebx
0x000000000040080f <+41>: mov %rcx,-0x30(%rbp)
0x0000000000400813 <+45>: mov %rbx,-0x28(%rbp)
0x0000000000400817 <+49>: jmp 0x40082b <main+69>
// } else { [argc > 2]
// Initialize pf to { 1, 0 }.
0x0000000000400819 <+51>: mov [=11=]x1,%eax
0x000000000040081e <+56>: mov [=11=]x0,%edx
0x0000000000400823 <+61>: mov %rax,-0x30(%rbp)
0x0000000000400827 <+65>: mov %rdx,-0x28(%rbp)
// }
// Test whether pf.ptr is even or odd:
0x000000000040082b <+69>: mov -0x30(%rbp),%rax
0x000000000040082f <+73>: and [=11=]x1,%eax
0x0000000000400832 <+76>: test %rax,%rax
0x0000000000400835 <+79>: jne 0x40083d <main+87>
// int (*funcaddr)(A*, int); [will be in %rax]
// if (is_even(pf.ptr)) {
// Just do:
// funcaddr = pf.ptr;
0x0000000000400837 <+81>: mov -0x30(%rbp),%rax
0x000000000040083b <+85>: jmp 0x40085c <main+118>
// } else { [is_odd(pf.ptr)]
// Compute A* a2 = (A*)((char*)&a + pf.adj); [in %rax]
0x000000000040083d <+87>: mov -0x28(%rbp),%rax
0x0000000000400841 <+91>: mov %rax,%rdx
0x0000000000400844 <+94>: lea -0x20(%rbp),%rax
0x0000000000400848 <+98>: add %rdx,%rax
// Compute funcaddr =
// (int(*)(A*,int)) (((char*)(a2->vptr))[pf.ptr-1]);
0x000000000040084b <+101>: mov (%rax),%rax
0x000000000040084e <+104>: mov -0x30(%rbp),%rdx
0x0000000000400852 <+108>: sub [=11=]x1,%rdx
0x0000000000400856 <+112>: add %rdx,%rax
0x0000000000400859 <+115>: mov (%rax),%rax
// }
// Compute A* a3 = (A*)((char*)&a + pf.adj); [in %rcx]
0x000000000040085c <+118>: mov -0x28(%rbp),%rdx
0x0000000000400860 <+122>: mov %rdx,%rcx
0x0000000000400863 <+125>: lea -0x20(%rbp),%rdx
0x0000000000400867 <+129>: add %rdx,%rcx
// Call int r = (*funcaddr)(a3, argc);
0x000000000040086a <+132>: mov -0x34(%rbp),%edx
0x000000000040086d <+135>: mov %edx,%esi
0x000000000040086f <+137>: mov %rcx,%rdi
0x0000000000400872 <+140>: callq *%rax
// Standard stack cleanup for function exit.
0x0000000000400874 <+142>: add [=11=]x38,%rsp
0x0000000000400878 <+146>: pop %rbx
0x0000000000400879 <+147>: pop %rbp
// Return r.
0x000000000040087a <+148>: retq
End of assembler dump.
但是成员函数指针的adj
值是怎么回事呢?在执行 vtable 查找之前以及调用函数之前,程序集将它添加到 a
的地址,无论该函数是否为虚函数。但是 main
中的两种情况都将其设置为零,因此我们还没有真正看到它的实际效果。
当我们有多重继承时,adj
值就会出现。所以现在假设我们有:
class B
{
public:
virtual void func3() {}
int n;
};
class C : public B, public A
{
public:
int func4(int v) { return v; }
int func2(int v) override { return v; }
};
C
类型对象的布局包含一个 B
子对象(其中包含另一个 vptr 和一个 int
),然后是一个 A
子对象。所以 C
中包含的 A
的地址与 C
本身的地址不同。
如您所知,任何时候代码隐式或显式地将 (non-null) C*
指针转换为 A*
指针,C++ 编译器通过添加地址值的正确偏移量。 C++ 还允许从指向 A
的成员函数的指针转换为指向 C
的成员函数的指针(因为 A
的任何成员也是 C
的成员),当发生这种情况时(对于 non-null 成员函数指针),需要进行类似的偏移量调整。所以如果我们有:
int (A::*pf1)(int) = &A::func1;
int (C::*pf2)(int) = pf1;
引擎盖下的成员函数指针中的值将是 pf1 = { &A__func1, 0 };
和 pf2 = { &A__func1, offset_A_in_C };
。
然后如果我们有
C c;
int n = (c.*pf2)(3);
编译器会通过在地址&c
上加上偏移量pf2.adj
来实现对成员函数指针的调用,从而找到隐含的“this”参数,这很好,因为那样的话A__func1
期望的有效 A*
值。
虚函数调用也是如此,除了如反汇编转储所示,需要偏移量才能找到隐式“this”参数和找到包含实际函数代码地址的 vptr。虚函数有一个额外的变化,但它是普通虚调用和使用指向成员函数的指针的调用所需要的:虚函数 func2
将用 A*
"this" 调用参数,因为那是原始被覆盖的声明所在的位置,编译器通常无法知道“this”参数是否实际上是任何其他类型。但是 override C::func2
的定义需要一个 C*
"this" 参数。因此,当最派生类型是 C
时,A
子对象中的 vptr 将指向一个 vtable,该 vtable 的入口不指向 C::func2
本身的代码,而是指向一个很小的“ thunk”函数,除了从“this”参数中减去 offset_A_in_C
,然后将控制权传递给实际的 C::func2
.
我知道普通函数指针包含被指向函数的起始地址,所以当使用普通函数指针时,我们只是跳转到存储的地址。但是指向对象成员函数的指针包含什么?
考虑:
class A
{
public:
int func1(int v) {
std::cout << "fun1";
return v;
}
virtual int func2(int v) {
std::cout << "fun2";
return v;
}
};
int main(int argc, char** argv)
{
A a;
int (A::*pf)(int a) = argc > 2 ? &A::func1 : &A::func2;
static_assert(sizeof(pf) == (sizeof(void*), "Unexpected function size");
return (a.*pf)(argc);
}
在上面的程序中,函数指针可以从虚函数(需要通过 vtable 访问)或普通 class 成员(作为普通函数实现隐含的 this
作为第一个参数。)
那么我指向成员函数的指针中存储的值是多少,编译器如何让事情按预期工作?
GCC documents PMF 被实现为知道如何计算 this
的值并进行任何 vtable 查找的结构。
这当然取决于编译器和目标架构,并且有不止一种方法可以做到。但我将描述它在我最常使用的系统上的工作方式,g++ for Linux x86_64.
g++ 遵循 Itanium C++ ABI,其中描述了多种 C++ 功能(包括虚函数)可以在大多数体系结构的幕后实现的一种方式的很多细节。
ABI 在第 2.3 节中对指向成员函数的指针进行了说明:
A pointer to member function is a pair as follows:
ptr:
For a non-virtual function, this field is a simple function pointer. ... For a virtual function, it is 1 plus the virtual table offset (in bytes) of the function, represented as a
ptrdiff_t
. The value zero represents a NULL pointer, independent of the adjustment field value below.adj:
The required adjustment to
this
, represented as aptrdiff_t
.It has the size, data size, and alignment of a class containing those two members, in that order.
虚函数指针的 +1 有助于检测该函数是否为虚函数,因为对于大多数平台而言,所有函数指针值和虚表偏移量都是偶数。它还确保空成员函数指针具有与任何有效成员函数指针不同的值。
您的 class A
的 vtable / vptr 设置将像这样的 C 代码工作:
struct A__virt_funcs {
int (*func2)(A*, int);
};
struct A__vtable {
ptrdiff_t offset_to_top;
const std__typeinfo* typeinfo;
struct A__virt_funcs funcs;
};
struct A {
const struct A__virt_funcs* vptr;
};
int A__func1(struct A*, int v) {
std__operator__ltlt(&std__cout, "fun1");
return v;
}
int A__func2(struct A*, int v) {
std__operator__ltlt(&std__cout, "fun2");
return v;
}
extern const std__typeinfo A__typeinfo;
const struct A__vtable vt_for_A = { 0, &A__typeinfo, { &A__func2 } };
void A__initialize(A* a) {
a->vptr = &vt_for_A.funcs;
}
(是的,实名修改方案需要对函数参数类型做一些事情以允许重载,以及更多的事情,因为涉及的 operator<<
实际上是一个函数模板特化。但这不是重点在这里。)
现在让我们看看我为您的 main()
获得的程序集(带有选项 -O0 -fno-stack-protector
)。我的评论已添加。
Dump of assembler code for function main:
// Standard stack adjustment for function setup.
0x00000000004007e6 <+0>: push %rbp
0x00000000004007e7 <+1>: mov %rsp,%rbp
0x00000000004007ea <+4>: push %rbx
0x00000000004007eb <+5>: sub [=11=]x38,%rsp
// Put argc in the stack at %rbp-0x34.
0x00000000004007ef <+9>: mov %edi,-0x34(%rbp)
// Put argv in the stack at %rbp-0x40.
0x00000000004007f2 <+12>: mov %rsi,-0x40(%rbp)
// Construct "a" on the stack at %rbp-0x20.
// 0x4009c0 is &vt_for_A.funcs.
0x00000000004007f6 <+16>: mov [=11=]x4009c0,%esi
0x00000000004007fb <+21>: mov %rsi,-0x20(%rbp)
// Check if argc is more than 2.
// In both cases, "pf" will be on the stack at %rbp-0x30.
0x00000000004007ff <+25>: cmpl [=11=]x2,-0x34(%rbp)
0x0000000000400803 <+29>: jle 0x400819 <main+51>
// if (argc <= 2) {
// Initialize pf to { &A__func2, 0 }.
0x0000000000400805 <+31>: mov [=11=]x4008ce,%ecx
0x000000000040080a <+36>: mov [=11=]x0,%ebx
0x000000000040080f <+41>: mov %rcx,-0x30(%rbp)
0x0000000000400813 <+45>: mov %rbx,-0x28(%rbp)
0x0000000000400817 <+49>: jmp 0x40082b <main+69>
// } else { [argc > 2]
// Initialize pf to { 1, 0 }.
0x0000000000400819 <+51>: mov [=11=]x1,%eax
0x000000000040081e <+56>: mov [=11=]x0,%edx
0x0000000000400823 <+61>: mov %rax,-0x30(%rbp)
0x0000000000400827 <+65>: mov %rdx,-0x28(%rbp)
// }
// Test whether pf.ptr is even or odd:
0x000000000040082b <+69>: mov -0x30(%rbp),%rax
0x000000000040082f <+73>: and [=11=]x1,%eax
0x0000000000400832 <+76>: test %rax,%rax
0x0000000000400835 <+79>: jne 0x40083d <main+87>
// int (*funcaddr)(A*, int); [will be in %rax]
// if (is_even(pf.ptr)) {
// Just do:
// funcaddr = pf.ptr;
0x0000000000400837 <+81>: mov -0x30(%rbp),%rax
0x000000000040083b <+85>: jmp 0x40085c <main+118>
// } else { [is_odd(pf.ptr)]
// Compute A* a2 = (A*)((char*)&a + pf.adj); [in %rax]
0x000000000040083d <+87>: mov -0x28(%rbp),%rax
0x0000000000400841 <+91>: mov %rax,%rdx
0x0000000000400844 <+94>: lea -0x20(%rbp),%rax
0x0000000000400848 <+98>: add %rdx,%rax
// Compute funcaddr =
// (int(*)(A*,int)) (((char*)(a2->vptr))[pf.ptr-1]);
0x000000000040084b <+101>: mov (%rax),%rax
0x000000000040084e <+104>: mov -0x30(%rbp),%rdx
0x0000000000400852 <+108>: sub [=11=]x1,%rdx
0x0000000000400856 <+112>: add %rdx,%rax
0x0000000000400859 <+115>: mov (%rax),%rax
// }
// Compute A* a3 = (A*)((char*)&a + pf.adj); [in %rcx]
0x000000000040085c <+118>: mov -0x28(%rbp),%rdx
0x0000000000400860 <+122>: mov %rdx,%rcx
0x0000000000400863 <+125>: lea -0x20(%rbp),%rdx
0x0000000000400867 <+129>: add %rdx,%rcx
// Call int r = (*funcaddr)(a3, argc);
0x000000000040086a <+132>: mov -0x34(%rbp),%edx
0x000000000040086d <+135>: mov %edx,%esi
0x000000000040086f <+137>: mov %rcx,%rdi
0x0000000000400872 <+140>: callq *%rax
// Standard stack cleanup for function exit.
0x0000000000400874 <+142>: add [=11=]x38,%rsp
0x0000000000400878 <+146>: pop %rbx
0x0000000000400879 <+147>: pop %rbp
// Return r.
0x000000000040087a <+148>: retq
End of assembler dump.
但是成员函数指针的adj
值是怎么回事呢?在执行 vtable 查找之前以及调用函数之前,程序集将它添加到 a
的地址,无论该函数是否为虚函数。但是 main
中的两种情况都将其设置为零,因此我们还没有真正看到它的实际效果。
当我们有多重继承时,adj
值就会出现。所以现在假设我们有:
class B
{
public:
virtual void func3() {}
int n;
};
class C : public B, public A
{
public:
int func4(int v) { return v; }
int func2(int v) override { return v; }
};
C
类型对象的布局包含一个 B
子对象(其中包含另一个 vptr 和一个 int
),然后是一个 A
子对象。所以 C
中包含的 A
的地址与 C
本身的地址不同。
如您所知,任何时候代码隐式或显式地将 (non-null) C*
指针转换为 A*
指针,C++ 编译器通过添加地址值的正确偏移量。 C++ 还允许从指向 A
的成员函数的指针转换为指向 C
的成员函数的指针(因为 A
的任何成员也是 C
的成员),当发生这种情况时(对于 non-null 成员函数指针),需要进行类似的偏移量调整。所以如果我们有:
int (A::*pf1)(int) = &A::func1;
int (C::*pf2)(int) = pf1;
引擎盖下的成员函数指针中的值将是 pf1 = { &A__func1, 0 };
和 pf2 = { &A__func1, offset_A_in_C };
。
然后如果我们有
C c;
int n = (c.*pf2)(3);
编译器会通过在地址&c
上加上偏移量pf2.adj
来实现对成员函数指针的调用,从而找到隐含的“this”参数,这很好,因为那样的话A__func1
期望的有效 A*
值。
虚函数调用也是如此,除了如反汇编转储所示,需要偏移量才能找到隐式“this”参数和找到包含实际函数代码地址的 vptr。虚函数有一个额外的变化,但它是普通虚调用和使用指向成员函数的指针的调用所需要的:虚函数 func2
将用 A*
"this" 调用参数,因为那是原始被覆盖的声明所在的位置,编译器通常无法知道“this”参数是否实际上是任何其他类型。但是 override C::func2
的定义需要一个 C*
"this" 参数。因此,当最派生类型是 C
时,A
子对象中的 vptr 将指向一个 vtable,该 vtable 的入口不指向 C::func2
本身的代码,而是指向一个很小的“ thunk”函数,除了从“this”参数中减去 offset_A_in_C
,然后将控制权传递给实际的 C::func2
.