指向成员函数的指针如何工作?

dor*_*ron 2 c++ function-pointers

据我所知,普通函数指针包含所指向函数的起始地址,因此当使用普通函数指针时,我们只需跳转到存储的地址。但是指向对象成员函数的指针包含什么?

考虑:

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);
}
Run Code Online (Sandbox Code Playgroud)

在上面的程序中,函数指针可以从虚拟函数(需要通过 vtable 访问)或普通类成员(作为普通函数实现,并以隐式作为this第一个参数实现)获取其值。

那么,存储在指向成员函数的指针中的值是什么?编译器如何使事情按预期工作?

asc*_*ler 6

这当然取决于编译器和目标体系结构,并且有不止一种方法可以实现这一点。但我将描述它如何在我最常使用的系统(g++ for Linux x86_64)上工作。

\n

g++ 遵循Itanium C++ ABI,它描述了大多数体系结构在幕后实现各种 C++ 功能(包括虚拟函数)的一种方式的大量细节。

\n

ABI 在第 2.3 节中对成员函数的指针进行了这样的说明:

\n
\n

指向成员函数的指针是一对,如下所示:

\n

\xc2\xa0\xc2\xa0\xc2\xa0指针:

\n

对于非虚函数,该字段是一个简单的函数指针。...对于虚函数,它是 1 加上函数的虚表偏移量(以字节为单位),表示为ptrdiff_t。值零表示 NULL 指针,与下面的调整字段值无关。

\n

\xc2\xa0\xc2\xa0\xc2\xa0调整:

\n

所需的调整为this,表示为ptrdiff_t。

\n

它按顺序具有包含这两个成员的类的大小、数据大小和对齐方式。

\n
\n

虚拟函数的 ptr 的 +1 有助于检测该函数是否是虚拟的,因为对于大多数平台来说,所有函数指针值和 vtable 偏移量都是偶数。它还确保空成员函数指针具有与任何有效成员函数指针不同的值。

\n

您的类的 vtable / vptr 设置A将像以下 C 代码一样工作:

\n
struct A__virt_funcs {\n    int (*func2)(A*, int);\n};\n\nstruct A__vtable {\n    ptrdiff_t offset_to_top;\n    const std__typeinfo* typeinfo;\n    struct A__virt_funcs funcs;\n};\n\nstruct A {\n    const struct A__virt_funcs* vptr;\n};\n\nint A__func1(struct A*, int v) {\n    std__operator__ltlt(&std__cout, "fun1");\n    return v;\n}\n\nint A__func2(struct A*, int v) {\n    std__operator__ltlt(&std__cout, "fun2");\n    return v;\n}\n\nextern const std__typeinfo A__typeinfo;\n\nconst struct A__vtable vt_for_A = { 0, &A__typeinfo, { &A__func2 } };\n\nvoid A__initialize(A* a) {\n    a->vptr = &vt_for_A.funcs;\n}\n
Run Code Online (Sandbox Code Playgroud)\n

(是的,实名修改方案需要对函数参数类型进行一些操作以允许重载,并且还需要做更多的事情,因为涉及的operator<<实际上是函数模板专业化。但这不是这里的重点。)

\n

现在让我们看看我为您提供的程序集main()(带有选项-O0 -fno-stack-protector)。我的评论已添加。

\n
Dump of assembler code for function main:\n     // Standard stack adjustment for function setup.\n   0x00000000004007e6 <+0>: push   %rbp\n   0x00000000004007e7 <+1>: mov    %rsp,%rbp\n   0x00000000004007ea <+4>: push   %rbx\n   0x00000000004007eb <+5>: sub    $0x38,%rsp\n     // Put argc in the stack at %rbp-0x34.\n   0x00000000004007ef <+9>: mov    %edi,-0x34(%rbp)\n     // Put argv in the stack at %rbp-0x40.\n   0x00000000004007f2 <+12>:    mov    %rsi,-0x40(%rbp)\n     // Construct "a" on the stack at %rbp-0x20.\n     // 0x4009c0 is &vt_for_A.funcs.\n   0x00000000004007f6 <+16>:    mov    $0x4009c0,%esi\n   0x00000000004007fb <+21>:    mov    %rsi,-0x20(%rbp)\n     // Check if argc is more than 2.\n     // In both cases, "pf" will be on the stack at %rbp-0x30.\n   0x00000000004007ff <+25>:    cmpl   $0x2,-0x34(%rbp)\n   0x0000000000400803 <+29>:    jle    0x400819 <main+51>\n     // if (argc <= 2) {\n     //   Initialize pf to { &A__func2, 0 }.\n   0x0000000000400805 <+31>:    mov    $0x4008ce,%ecx\n   0x000000000040080a <+36>:    mov    $0x0,%ebx\n   0x000000000040080f <+41>:    mov    %rcx,-0x30(%rbp)\n   0x0000000000400813 <+45>:    mov    %rbx,-0x28(%rbp)\n   0x0000000000400817 <+49>:    jmp    0x40082b <main+69>\n     // } else { [argc > 2]\n     //   Initialize pf to { 1, 0 }.\n   0x0000000000400819 <+51>:    mov    $0x1,%eax\n   0x000000000040081e <+56>:    mov    $0x0,%edx\n   0x0000000000400823 <+61>:    mov    %rax,-0x30(%rbp)\n   0x0000000000400827 <+65>:    mov    %rdx,-0x28(%rbp)\n     // }\n     // Test whether pf.ptr is even or odd:\n   0x000000000040082b <+69>:    mov    -0x30(%rbp),%rax\n   0x000000000040082f <+73>:    and    $0x1,%eax\n   0x0000000000400832 <+76>:    test   %rax,%rax\n   0x0000000000400835 <+79>:    jne    0x40083d <main+87>\n     // int (*funcaddr)(A*, int); [will be in %rax]\n     // if (is_even(pf.ptr)) {\n     //   Just do:\n     //   funcaddr = pf.ptr;\n   0x0000000000400837 <+81>:    mov    -0x30(%rbp),%rax\n   0x000000000040083b <+85>:    jmp    0x40085c <main+118>\n     // } else { [is_odd(pf.ptr)]\n     //   Compute A* a2 = (A*)((char*)&a + pf.adj); [in %rax]\n   0x000000000040083d <+87>:    mov    -0x28(%rbp),%rax\n   0x0000000000400841 <+91>:    mov    %rax,%rdx\n   0x0000000000400844 <+94>:    lea    -0x20(%rbp),%rax\n   0x0000000000400848 <+98>:    add    %rdx,%rax\n     //   Compute funcaddr =\n     //     (int(*)(A*,int)) (((char*)(a2->vptr))[pf.ptr-1]);\n   0x000000000040084b <+101>:   mov    (%rax),%rax\n   0x000000000040084e <+104>:   mov    -0x30(%rbp),%rdx\n   0x0000000000400852 <+108>:   sub    $0x1,%rdx\n   0x0000000000400856 <+112>:   add    %rdx,%rax\n   0x0000000000400859 <+115>:   mov    (%rax),%rax\n     // }\n     // Compute A* a3 = (A*)((char*)&a + pf.adj); [in %rcx]\n   0x000000000040085c <+118>:   mov    -0x28(%rbp),%rdx\n   0x0000000000400860 <+122>:   mov    %rdx,%rcx\n   0x0000000000400863 <+125>:   lea    -0x20(%rbp),%rdx\n   0x0000000000400867 <+129>:   add    %rdx,%rcx\n     // Call int r = (*funcaddr)(a3, argc);\n   0x000000000040086a <+132>:   mov    -0x34(%rbp),%edx\n   0x000000000040086d <+135>:   mov    %edx,%esi\n   0x000000000040086f <+137>:   mov    %rcx,%rdi\n   0x0000000000400872 <+140>:   callq  *%rax\n     // Standard stack cleanup for function exit.\n   0x0000000000400874 <+142>:   add    $0x38,%rsp\n   0x0000000000400878 <+146>:   pop    %rbx\n   0x0000000000400879 <+147>:   pop    %rbp\n     // Return r.\n   0x000000000040087a <+148>:   retq   \nEnd of assembler dump.\n
Run Code Online (Sandbox Code Playgroud)\n

那么成员函数指针的值是怎么回事呢adj?程序集在执行 vtable 查找之前以及调用函数之前将其添加到地址中a,无论该函数是否为虚拟函数。但这两种情况都将main其设置为零,因此我们还没有真正看到它的实际效果。

\n

当我们有多重继承时,价值adj就出现了。现在假设我们有:

\n
class B\n{\npublic:\n    virtual void func3() {}\n    int n;\n};\n\nclass C : public B, public A\n{\npublic:\n    int func4(int v) { return v; }\n    int func2(int v) override { return v; }\n};\n
Run Code Online (Sandbox Code Playgroud)\n

类型对象的布局C包含一个B子对象(其中包含另一个 vptr 和一个int),然后是一个A子对象。A所以a中包含的地址C与本身的地址不同C。

\n

您可能知道,任何时候代码隐式或显式地将(非空)C*指针转换为A*指针,C++ 编译器都会通过向地址值添加正确的偏移量来解决此差异。C++ 还允许从指向 的成员函数的指针转换A为指向 的成员函数的指针C(因为 的任何成员A也是 的成员C),并且当发生这种情况时(对于非空成员函数指针),需要类似的偏移量调整被制造。所以如果我们有:

\n
int (A::*pf1)(int) = &A::func1;\nint (C::*pf2)(int) = pf1;\n
Run Code Online (Sandbox Code Playgroud)\n

成员函数指针内的值将是pf1 = { &A__func1, 0 };和pf2 = { &A__func1, offset_A_in_C };。

\n

然后如果我们有

\n
C c;\nint n = (c.*pf2)(3);\n
Run Code Online (Sandbox Code Playgroud)\n

编译器将通过将偏移量添加pf2.adj到地址&c来找到隐式“this”参数来实现对成员函数指针的调用,这很好,因为这样它将是预期的A*有效值A__func1。

\n

虚拟函数调用也是如此,只是如反汇编转储所示,需要偏移量来查找隐式“this”参数和查找包含实际函数代码地址的 vptr。虚拟情况还有一个额外的变化,但普通虚拟调用和使用指向成员函数的指针的调用都需要它:虚拟函数func2将使用“this”参数调用A*,因为\是原始重写声明所在的位置,编译器通常无法知道“this”参数实际上是否是任何其他类型。但 override 的定义C::func2需要一个C*“this”参数。因此,当最派生类型为 时C,子对象中的 vptrA将指向一个 vtable,该 vtable 的条目不是指向C::func2自身的代码,而是指向一个微小的“thunk”函数,该函数除了offset_A_in_C从“this”参数中减去之外什么也不做然后将控制权传递给实际的C::func2.

\n