x64汇编学习(10)-- vtable

Last Updated: 2023-12-28 03:49:33 Thursday

-- TOC --

让我们通过对汇编的分析,来探寻一下C++虚函数表vtable的本质。


测试用C++代码如下:

#include <iostream>

struct tom{
    int a;
    int b;
    void f0(){}
    virtual void f1(){}
    virtual void f2(){}
};

int main(){
    tom t{};
    t.f0();
    t.f1();
    t.f2();
    std::cout << t.a << t.b << std::endl;
    std::cout << sizeof(tom);  // 16
    return 0;
}

tom类型t,有1个普通成员函数f0,两个虚函数f1和f2。

含有虚函数的class,sizeof要大8bytes!

汇编如下:

0000000000401186 <main>:
  401186:       55                      push   rbp
  401187:       48 89 e5                mov    rbp,rsp
                                        # 这16bytes,都属于t对象
  40118a:       48 83 ec 10             sub    rsp,0x10
  40118e:       48 c7 45 f0 00 00 00    mov    QWORD PTR [rbp-0x10],0x0
  401195:       00
  401196:       c7 45 f8 00 00 00 00    mov    DWORD PTR [rbp-0x8],0x0
  40119d:       c7 45 fc 00 00 00 00    mov    DWORD PTR [rbp-0x4],0x0
                                        # 用stack上的地址,创建tom对象t
  4011a4:       48 8d 45 f0             lea    rax,[rbp-0x10]
  4011a8:       48 89 c7                mov    rdi,rax
  4011ab:       e8 de 00 00 00          call   40128e <tom::tom()>
  4011b0:       48 8d 45 f0             lea    rax,[rbp-0x10]
  4011b4:       48 89 c7                mov    rdi,rax
  4011b7:       e8 ae 00 00 00          call   40126a <tom::f0()>
  4011bc:       48 8d 45 f0             lea    rax,[rbp-0x10]
  4011c0:       48 89 c7                mov    rdi,rax
  4011c3:       e8 ae 00 00 00          call   401276 <tom::f1()>
  4011c8:       48 8d 45 f0             lea    rax,[rbp-0x10]
  4011cc:       48 89 c7                mov    rdi,rax
  4011cf:       e8 ae 00 00 00          call   401282 <tom::f2()>
  4011d4:       8b 45 f8                mov    eax,DWORD PTR [rbp-0x8]
  4011d7:       89 c6                   mov    esi,eax
  4011d9:       bf 80 40 40 00          mov    edi,0x404080
  4011de:       e8 9d fe ff ff          call   401080 <std::ostream::operator<<(int)@plt>
  4011e3:       48 89 c2                mov    rdx,rax
  4011e6:       8b 45 fc                mov    eax,DWORD PTR [rbp-0x4]
  4011e9:       89 c6                   mov    esi,eax
  4011eb:       48 89 d7                mov    rdi,rdx
  4011ee:       e8 8d fe ff ff          call   401080 <std::ostream::operator<<(int)@plt>
  4011f3:       be 40 10 40 00          mov    esi,0x401040
  4011f8:       48 89 c7                mov    rdi,rax
  4011fb:       e8 60 fe ff ff          call   401060 <std::ostream::operator<<(std::ostream& (*)(std::ostream&))@plt>
  401200:       be 10 00 00 00          mov    esi,0x10
  401205:       bf 80 40 40 00          mov    edi,0x404080
  40120a:       e8 21 fe ff ff          call   401030 <std::ostream::operator<<(unsigned long)@plt>
  40120f:       b8 00 00 00 00          mov    eax,0x0
  401214:       c9                      leave
  401215:       c3                      ret

000000000040126a <tom::f0()>:
  40126a:       55                      push   rbp
  40126b:       48 89 e5                mov    rbp,rsp
  40126e:       48 89 7d f8             mov    QWORD PTR [rbp-0x8],rdi
  401272:       90                      nop
  401273:       5d                      pop    rbp
  401274:       c3                      ret
  401275:       90                      nop

0000000000401276 <tom::f1()>:
  401276:       55                      push   rbp
  401277:       48 89 e5                mov    rbp,rsp
  40127a:       48 89 7d f8             mov    QWORD PTR [rbp-0x8],rdi
  40127e:       90                      nop
  40127f:       5d                      pop    rbp
  401280:       c3                      ret
  401281:       90                      nop

0000000000401282 <tom::f2()>:
  401282:       55                      push   rbp
  401283:       48 89 e5                mov    rbp,rsp
  401286:       48 89 7d f8             mov    QWORD PTR [rbp-0x8],rdi
  40128a:       90                      nop
  40128b:       5d                      pop    rbp
  40128c:       c3                      ret
  40128d:       90                      nop

000000000040128e <tom::tom()>:
  40128e:       55                      push   rbp
  40128f:       48 89 e5                mov    rbp,rsp
  401292:       48 89 7d f8             mov    QWORD PTR [rbp-0x8],rdi
                                        # 0x402020是tom类型vtable地址+16bytes,
                                        # 这个值,被放入了this指针指向的地址,这就是__vptr,
                                        # 这就是有虚函数的class,sizeof要多8bytes的原因,
                                        # 创建含有虚函数的对象,编译器就会将vtable存入this指针指向的位置
  401296:       ba 20 20 40 00          mov    edx,0x402020
  40129b:       48 8b 45 f8             mov    rax,QWORD PTR [rbp-0x8]
  40129f:       48 89 10                mov    QWORD PTR [rax],rdx
  4012a2:       90                      nop
  4012a3:       5d                      pop    rbp
  4012a4:       c3                      ret

分析汇编,看到的第1个问题是,那个0x402020地址是什么?

这个地址位于ELF文件的.rodata区域:

$ readelf -x16 a.out

Hex dump of section '.rodata':
  0x00402000 01000200 00000000 00000000 00000000 ................
  0x00402010 00000000 00000000 30204000 00000000 ........0 @.....
  0x00402020 76124000 00000000 82124000 00000000 v.@.......@.....
  0x00402030 a03d4000 00000000 40204000 00000000 .=@.....@ @.....
  0x00402040 33746f6d 00                         3tom.

这里存放的,就是class tom的vtable。(vtable在编译时生成,存放在.rodata section)

用Compiler Explorer看直观一些(要在output中打开Demangle identifiers开关):

tom::tom() [base object constructor]:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        mov     edx, OFFSET FLAT:vtable for tom+16
        mov     rax, QWORD PTR [rbp-8]
        mov     QWORD PTR [rax], rdx
        nop
        pop     rbp
        ret

vtable for tom:
        .quad   0
        .quad   typeinfo for tom
        .quad   tom::f1()
        .quad   tom::f2()
typeinfo for tom:
        .quad   vtable for __cxxabiv1::__class_type_info+16
        .quad   typeinfo name for tom
typeinfo name for tom:
        .string "3tom"

vtable+16,就是tom::f1(),0x401276。

这个case虽然给class定义了虚函数,但最后生成的汇编,并没有看到动态绑定的影子!应该是因为这个case没有inherit和override。


测试用C++代码:

#include <iostream>

struct tom{
    int a;
    int b;
    void f0(){}
    virtual void f1(){}
    virtual void f2(){}
};

struct jack: tom{
    void f1() override{}
};

int main(){
    jack j{};
    tom *t { &j };
    t->f0();
    t->f1();
    t->f2();
    return 0;
}

汇编如下:

tom::f0():
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        nop
        pop     rbp
        ret
tom::f1():
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        nop
        pop     rbp
        ret
tom::f2():
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        nop
        pop     rbp
        ret
jack::f1():
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        nop
        pop     rbp
        ret
tom::tom() [base object constructor]:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-8], rdi
        mov     edx, OFFSET FLAT:vtable for tom+16
        mov     rax, QWORD PTR [rbp-8]
        mov     QWORD PTR [rax], rdx
        nop
        pop     rbp
        ret
jack::jack() [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    tom::tom() [base object constructor]
        mov     edx, OFFSET FLAT:vtable for jack+16
        mov     rax, QWORD PTR [rbp-8]
        mov     QWORD PTR [rax], rdx
        nop
        leave
        ret
main:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 32
        mov     QWORD PTR [rbp-32], 0
        mov     DWORD PTR [rbp-24], 0
        mov     DWORD PTR [rbp-20], 0
        lea     rax, [rbp-32]
        mov     rdi, rax
        call    jack::jack() [complete object constructor]
        lea     rax, [rbp-32]
        mov     QWORD PTR [rbp-8], rax
        mov     rax, QWORD PTR [rbp-8]
        mov     rdi, rax
        # 直接调用f0
        call    tom::f0()
        mov     rax, QWORD PTR [rbp-8]
        mov     rax, QWORD PTR [rax]
        mov     rdx, QWORD PTR [rax]
        mov     rax, QWORD PTR [rbp-8]
        mov     rdi, rax
        # rdx是this指针的内容,f1
        call    rdx
        mov     rax, QWORD PTR [rbp-8]
        mov     rax, QWORD PTR [rax]
        # this+8,指向f2
        add     rax, 8
        mov     rdx, QWORD PTR [rax]
        mov     rax, QWORD PTR [rbp-8]
        mov     rdi, rax
        call    rdx
        mov     eax, 0
        leave
        ret
vtable for jack:
        .quad   0
        .quad   typeinfo for jack
        .quad   jack::f1()
        .quad   tom::f2()
vtable for tom:
        .quad   0
        .quad   typeinfo for tom
        .quad   tom::f1()
        .quad   tom::f2()
typeinfo for jack:
        .quad   vtable for __cxxabiv1::__si_class_type_info+16
        .quad   typeinfo name for jack
        .quad   typeinfo for tom
typeinfo name for jack:
        .string "4jack"
typeinfo for tom:
        .quad   vtable for __cxxabiv1::__class_type_info+16
        .quad   typeinfo name for tom
typeinfo name for tom:
        .string "3tom"
__static_initialization_and_destruction_0(int, int):
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        mov     DWORD PTR [rbp-4], edi
        mov     DWORD PTR [rbp-8], esi
        cmp     DWORD PTR [rbp-4], 1
        jne     .L11
        cmp     DWORD PTR [rbp-8], 65535
        jne     .L11
        mov     edi, OFFSET FLAT:_ZStL8__ioinit
        call    std::ios_base::Init::Init() [complete object constructor]
        mov     edx, OFFSET FLAT:__dso_handle
        mov     esi, OFFSET FLAT:_ZStL8__ioinit
        mov     edi, OFFSET FLAT:_ZNSt8ios_base4InitD1Ev
        call    __cxa_atexit
.L11:
        nop
        leave
        ret
_GLOBAL__sub_I_main:
        push    rbp
        mov     rbp, rsp
        mov     esi, 65535
        mov     edi, 1
        call    __static_initialization_and_destruction_0(int, int)
        pop     rbp
        ret

jack继承tom,重写f1,编译器为jack生成的vtable如下:

vtable for jack:
        .quad   0
        .quad   typeinfo for jack
        .quad   jack::f1()
        .quad   tom::f2()

指向jack vtable的指针,存放在j对象this位置。(创建jack对象时,先调用tom的ctor,用到了tom的vtable,但调用结束后,jack的this指针位置,就被jack的vtable覆盖)

直接调用f0,通过vtable调用f1和f2,对象的vtable就存放在对象this指针的位置,vtable的内容由编译器生成。编译器为每个有虚函数的class生成一个vtable,实例化的相同class的对象,指向同一个vtable。

关于this指针的思考

this指针是个神奇的存在,我们一般在代码中,有如下两种使用方式:

// return this object's reference,
// in copy/move assignment interface.
return *this;
// access member
this->member = value; 

在C++中,this是默认的,固定的。固定用this这个名称,固定作为成员函数接口的第一个入参,不用把它写出来,它一直都在那里,它的工作由编译器完成。当使用*this时,基本都是将其cast为对象的引用,估计这样设计是有语义上的考虑,*this表示对象,而this是对象的地址,对象的引用在语法上就是对象别名,而不是指针地址。恰巧,这里出现了一个空间,通过this访问member,都需要显示的将其member name写出来,如果将这些member的存储,做一点偏移,并不会有任何影响。而为vtable偏移出来的这8个字节,刚好用来存放vtable的地址。每个对象的vtable在编译之后,都存放.rodata区域。

所以,我们可以在代码中获取对象vtable的地址,即(void*)*this

vtable机制对运行时性能的影响很轻微:

This virtual call mechanism can be made almost as efficient as the “normal function call” mechanism (within 25%). Its space overhead is one pointer in each object of a class with virtual functions plus one vtbl for each such class.

normal function call的地址是直接的,编译器已经完成了地址分配。而virtual function call,需要在vtable的地址上,多计算一次偏移(偏移距离编译后固定)和一次dereference。空间上,每个含有virtual function定义的对象,多8字节用来存放vtable地址,在.rodata区域,每个对象的每个virtual function使用8个字节来保存其地址。

virtual destructor的设计原理

virtual interface class一般都要写上一个virtual destructor,而且还没有=0,不纯虚!为什么?原因如下:

当一个interface class类型的指针被delete的时候,vtable的机制就保证了,这个指针指向的具体对象的destructor能够被调用。

本文链接:https://cs.pynote.net/hd/asm/202302231/

-- EOF --

-- MORE --