从汇编角度分析g++编译器如何实现虚函数动态绑定

前言

一直以来,都对c++如何实现虚函数动态绑定一知半解。虽然知道虚函数的动态绑定通过虚指针和虚表实现,虚指针存在每个对象中,它记录虚表的地址。但是对于具体的细节,还是不明白。

最近看了《深入理解计算机系统》这本书,在《程序的机器级表示》这一章中,讲到AT&T风格的x86汇编。学习这一章时,我同时还参考了《Professional Assembly Language》一书,对汇编有了一些基本了解。结合《深入理解计算机系统》一书所附带的bomb实验,对汇编有了基本的掌握。同时,在《深入理解计算机系统》一书的“链接”这一部分,讲到了ELF文件格式,以及可重定位目标文件和可执行目标文件,共享对象的格式。在学习链接这一部分时,发现《深入理解计算机系统》一书对于动态链接涉及内容不多,因此参考了《程序员的自我修养》一书(别被名字给骗了,这本书其实是讲Linux和Window的链接和装载的,顺便吐槽一下这本书,好多错误!)。

在了解这些的基础上,我萌发了看编译器编译结果的想法。本文直接从一段涉及动态绑定的c++代码编译出的汇编代码入手,逐行分析每条/连续若干条指令的意图。阅读本文需要具备以下两个条件:

  • 对可重定位目标文件结构有基本了解,知道.text.data section是什么
  • 了解基本的AT&T汇编语法,知道什么是label,汇编器伪指令,movpushpop等指令的用途

1. OS以及g++版本

  • OS,64位ubuntu
uname -a
Linux ubuntu 5.4.0-37-generic #41-Ubuntu SMP Wed Jun 3 18:57:02 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux
  • g++
g++ --version
g++ (Ubuntu 9.3.0-10ubuntu2) 9.3.0
Copyright (C) 2019 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

2.源码

class ClassA{
public:
    int field1;

    ClassA() : field1('a'){}
    ~ClassA(){}

    virtual int virtual_function1() = 0;
    virtual int virtual_function2() = 0;
};

class ClassB : public ClassA{
public:
    int field2;
    int field3;

    ClassB():field2('b'){
        field3 = 'c';
    }

    int virtual_function1() override { return 1; }
    int virtual_function2() override { return 2; }
};

int main(){
    ClassA *pointer = new ClassB();
    int result = pointer->virtual_function1();
    return 0;
}
  • 共两个class,其中ClassA是纯抽象类
    • 包含一个int成员,初始化为'a',即97
    • 两个纯虚函数virtual_function1virtual_function2
  • ClassB公有方式继承自ClassA,且提供了虚函数的实现,virtual_function1virtual_function2分别返回常量1和2
  • main函数中将一个ClassA类型的指针指向一个动态分配的ClassB对象,并通过该指针调用虚函数
  • 数据成员的目的在于观察构造函数初始化列表和的函数体的执行过程

3.编译过程

g++ main.cpp -g -S -o main.s
  • 上述命令产生main.s文件,也是本文的主要分析对象,main.s的全部源码附在文末
  • 不使用编译器优化选项,保证汇编代码的可读性
  • 分析过程 忽略与语义无关的伪指令 ,即那些以 .开头的指令,诸如.text,.global之类的,编译器还会在源码之中插入一些对齐、标准过程调用的伪指令,与语义无关,贴出来的关键部分代码都将忽略它们

4. 从main函数着手

    .text
    .globl    main
    .type    main, @function
main: ;从第4行开始,到35行`ret`指令结束,是main函数的函数体,下面逐行分析每一条指令的语义
.LFB11:
    endbr64 ; 64位模式下终止间接分支,不知道有啥用,汇编完是nop指令,貌似没有实际作用
    pushq    %rbp ; 7~8:变长栈帧的函数调用机制,与函数语义无关
    movq    %rsp, %rbp

    pushq    %rbx ; 保存rbx寄存器,函数体中修改了该寄存器存

    subq    $24, %rsp ; 为局部变量pointer和result分配空间,pointer占8B,result占4B

    movl    $24, %edi ; 以24为参数,调用一个动态链接的函数,这里我没有细究,猜测是分配动态存储的
    call    _Znwm@PLT ; 分配24B的动态存储,其基址保存在rax寄存器中

    movq    %rax, %rbx ;动态存储基址存在rbx寄存器中,也就是对象的基址

    movq    %rbx, %rdi ; 19~20: 以动态存储的基址为参数,调用了ClassB的构造函数,这里是我们下一步的分析目标,先记住
    call    _ZN6ClassBC1Ev

    movq    %rbx, -24(%rbp) ; 对象基址存在局部变量pointer中
    movq    -24(%rbp), %rax ; rax现在保存对象基址

    movq    (%rax), %rax ;取对象的前8B放入rax,实际上就是ClassB的虚函数表基址,是在ClassB的构造函数中填入的
    movq    (%rax), %rdx ;虚表其实是一个指针数组,取虚表第0项,也就是一个虚函数的地址,放入rdx
    movq    -24(%rbp), %rax ; 对象基址放入rax

    movq    %rax, %rdi ;29~30:以对象基址为第一个参数,调用虚函数
    call    *%rdx

    movl    %eax, -28(%rbp); 虚函数的返回地址,放入局部变量result

    movl    $0, %eax ;eax置0,准备从main函数返回了,main中return 0;还记得吗?
    addq    $24, %rsp ; 局部变量销毁

    popq    %rbx ;回复保存的寄存器rbx

    popq    %rbp ;销毁栈帧
    ret ;跳转到栈中记录的返回地址

重要的部分:

  • 19~20行,调用ClassB的构造函数
  • 22~30行,调用一个虚函数

5. ClassB Ctor.

主要到main的第20行,call _ZN6ClassBC1Ev,但是并没有发现_ZN6ClassBC1Ev这个label,但是发现了

    .weak    _ZN6ClassBC1Ev
    .set    _ZN6ClassBC1Ev,_ZN6ClassBC2Ev

原来_ZN6ClassBC1Ev只是_ZN6ClassBC2Ev的别名,所以,看_ZN6ClassBC2Ev就好了:

    .section    .text._ZN6ClassBC2Ev,"axG",@progbits,_ZN6ClassBC5Ev,comdat
    .align 2
    .weak    _ZN6ClassBC2Ev
    .type    _ZN6ClassBC2Ev, @function
_ZN6ClassBC2Ev:
.LFB7:
    endbr64
    pushq    %rbp
    movq    %rsp, %rbp ; 8~9: 变长栈帧的的函数调用惯用机制
    subq    $16, %rsp ;局部变量分配16B空间

    movq    %rdi, -8(%rbp) ; 构造函数只有一个参数——对象基址,存入局部变量,应该就是隐藏的this
    movq    -8(%rbp), %rax ; 

    movq    %rax, %rdi ; 15~16:以对象基址为参数,调用ClassA Ctor. 也是下一步的分析目标
    call    _ZN6ClassAC2Ev

    leaq    16+_ZTV6ClassB(%rip), %rdx ;使用PC相对寻址,计算出虚表基址,放入rdx寄存器,下一步看一看_ZTV6ClassB处到底是什么?
    movq    -8(%rbp), %rax ; 从局部变量中取出对象基址,放入rax
    movq    %rdx, (%rax) ; 对象的前8B存放了虚表的基址

    movq    -8(%rbp), %rax ; 对象的12~15B赋值为98,也就是字符'b'
    movl    $98, 12(%rax)

    movq    -8(%rbp), %rax ; 对象的16~19B赋值为99,也就是字符'c'
    movl    $99, 16(%rax)

    nop 
    leave ; 销毁栈帧并返回
    ret
  • 12~13行,保存对象基址到局部变量
  • 15~16行,以对象基址为参数,调用父类的构造函数
  • 18~20行,初始化虚指针
  • 22~26行,初始化成员变量

从上面的代码,我们可以得到几个结论:

  • 结论1:构造函数先调用了父类(ClassA)的构造函数
  • 结论2:虚表基址(虚指针)的填入在父类构造函数之后
  • 结论3:初始化列表在构造函数体之前执行
  • 结论4:返回之前没有修改eax寄存器,也就是说,Ctor.没有返回值!rax寄存器的值,完全依赖于函数体中如何使用它!

还产生了几个问题:

  • 问题1:第10行分配局部变量,何时销毁的?
  • 问题2:父类构造函数做了什么?
  • 问题3:label _ZTV6ClassB处存放的是什么?

带着问题,首先看_ZN6ClassAC2Ev——ClassA Ctor.

6. ClassA Ctor.

_ZN6ClassAC2Ev:
.LFB1:
    endbr64
    pushq    %rbp
    movq    %rsp, %rbp

    movq    %rdi, -8(%rbp) ;保存对象基址到局部变量this

    leaq    16+_ZTV6ClassA(%rip), %rdx ; 这里又一次遇到使用PC相对寻址计算一个虚表基址,_ZTV6ClassA和之前的_ZTV6ClassB何其相似!所以,我猜测_ZTV6ClassA+16就是Class的虚表基址
    movq    -8(%rbp), %rax 
    movq    %rdx, (%rax) ;这里也是填充到对象的前8B

    movq    -8(%rbp), %rax ;对象的8~11字节是成员变量,赋值97,不就是字符'a'吗?!
    movl    $97, 8(%rax)

    nop
    popq    %rbp ;构造函数结束!
    ret

结论:

  • ClassA Ctor.也在对象的前8B填充了一个地址
  • 又一次遇到类似于_ZTV6ClassB的label——_ZTV6ClassA

那么,_ZTV6ClassA_ZTV6ClassB处到底放的是啥?

7. 虚表的内容

    .weak    _ZTV6ClassA
    .section    .data.rel.ro._ZTV6ClassA,"awG",@progbits,_ZTV6ClassA,comdat
    .align 8
    .type    _ZTV6ClassA, @object
    .size    _ZTV6ClassA, 32
_ZTV6ClassA:
    .quad    0
    .quad    _ZTI6ClassA
    .quad    __cxa_pure_virtual
    .quad    __cxa_pure_virtual

7~10行,是label _ZTV6ClassA的内容,这其实就是一个数组,定义了4个quad,也就是4个4 字长的变量。

再看_ZTV6ClassB

    .weak    _ZTV6ClassB
    .section    .data.rel.ro.local._ZTV6ClassB,"awG",@progbits,_ZTV6ClassB,comdat
    .align 8
    .type    _ZTV6ClassB, @object
    .size    _ZTV6ClassB, 32
_ZTV6ClassB:
    .quad    0
    .quad    _ZTI6ClassB
    .quad    _ZN6ClassB17virtual_function1Ev
    .quad    _ZN6ClassB17virtual_function2Ev

从7~10行,以同样的格式定义了一个4个4字长的变量数组。

  • 定义数组元素为4字长的原因是,x86_64体系结构下,一个地址(虚拟地址)的长度是4字,也就是8B
  • 所处的section都是.data.rel.ro.xxx,只读数据区下的pseudo section
  • 数组第0项都是0,这是一个空指针!
  • 最后两个元素,通过label名字,可以猜测:就是函数的基址
  • _ZTV6ClassA的函数基址,通过label名字,可以猜测,是标准库预定义的某个函数,从名字上看,xxxx_pure_virtual,就是纯虚函数的实现,但是这两个函数永远不会被调用!ClassA Ctor.在对象中填充的虚表基址,在ClassB Ctor.中覆盖了!
  • Ctor.中计算虚表基址是,使用的表达式是16+_ZTV6ClassA(%rip)16+_ZTV6ClassB(%rip),都在label的基础上+16,正好跳过了数组的前两项。

问题:

  • 数组第二项是啥?
  • 对于3、4两项是虚函数实现的猜测是否正确?

8. 虚函数的实现

直接贴出_ZN6ClassB17virtual_function1Ev_ZN6ClassB17virtual_function2Ev处的代码:

    .section    .text._ZN6ClassB17virtual_function1Ev,"axG",@progbits,_ZN6ClassB17virtual_function1Ev,comdat
    .align 2
    .weak    _ZN6ClassB17virtual_function1Ev
    .type    _ZN6ClassB17virtual_function1Ev, @function
_ZN6ClassB17virtual_function1Ev:
.LFB9:
    endbr64
    pushq    %rbp
    movq    %rsp, %rbp

    movq    %rdi, -8(%rbp) ;第一个参数是对象基址,保存在局部变量中,也就是this

    movl    $1, %eax ;函数返回常量1,正好就是ClassB对于virtual_function1的实现逻辑
    popq    %rbp
    ret
    .section    .text._ZN6ClassB17virtual_function2Ev,"axG",@progbits,_ZN6ClassB17virtual_function2Ev,comdat
    .align 2
    .weak    _ZN6ClassB17virtual_function2Ev
    .type    _ZN6ClassB17virtual_function2Ev, @function
_ZN6ClassB17virtual_function2Ev:
.LFB10:
    endbr64
    pushq    %rbp
    movq    %rsp, %rbp

    movq    %rdi, -8(%rbp)

    movl    $2, %eax ;具有相同的逻辑,返回2,正好就是ClassB对于virtual_function2的实现
    popq    %rbp
    ret

以上虚函数的实现版本,都处于.text节,也就是代码段

9. 类型信息

_ZTV6ClassB_ZTV6ClassA数组的第2项,引用类似的label——_ZTI6ClassB_ZTI6ClassA,那么这两个lable存放的是什么?

    .weak    _ZTI6ClassA
    .section    .data.rel.ro._ZTI6ClassA,"awG",@progbits,_ZTI6ClassA,comdat
    .align 8
    .type    _ZTI6ClassA, @object
    .size    _ZTI6ClassA, 16
_ZTI6ClassA:
    .quad    _ZTVN10__cxxabiv117__class_type_infoE+16
    .quad    _ZTS6ClassA
    .weak    _ZTS6ClassA
    .section    .rodata._ZTS6ClassA,"aG",@progbits,_ZTS6ClassA,comdat
    .align 8
    .type    _ZTS6ClassA, @object
    .size    _ZTS6ClassA, 8
_ZTS6ClassA:
    .string    "6ClassA"

7~8行,又是数组,包含两个4字成员,即两个指针

  • 通过第0项所引用的label——_ZTVN10__cxxabiv117__class_type_infoE,可以猜测又是某个对象的基址,但是该label在当前文件中并不存在,可以猜测是动态库中定义的,这里暂不深究。
  • 第1项就定义在第15行,是一个字符串,猜测就是类型名称字符串,所处节在.rodata,即,只读数据区
  • 可以猜测,这就是ClassA的type_info结构体
    .weak    _ZTI6ClassB
    .section    .data.rel.ro._ZTI6ClassB,"awG",@progbits,_ZTI6ClassB,comdat
    .align 8
    .type    _ZTI6ClassB, @object
    .size    _ZTI6ClassB, 24
_ZTI6ClassB:
    .quad    _ZTVN10__cxxabiv120__si_class_type_infoE+16
    .quad    _ZTS6ClassB
    .quad    _ZTI6ClassA
    .weak    _ZTS6ClassB
    .section    .rodata._ZTS6ClassB,"aG",@progbits,_ZTS6ClassB,comdat
    .align 8
    .type    _ZTS6ClassB, @object
    .size    _ZTS6ClassB, 8
_ZTS6ClassB:
    .string    "6ClassB"

7~9行,一个长度为3的四字数组,共三个指针

  • 第0项,仍是一个动态库提供的label
  • 第1项,还是类型名称字符串,定义在第16行,处于.rodata节
  • 第2项,就是_ZTI6ClassA,即ClassA type info的地址

10. 结论

  • 对象的前8B存放虚表基址,因为是64位代码,一个指针占8字节
  • 有虚函数的类都有一个虚表,存放在数据区,并且是只读的
    • 对于纯虚函数,标准库提供了默认的实现,但是永远不会被调用
    • 对于虚函数的实现,函数体存放在代码段
    • 虚函数表之前是类型信息的指针和一个空指针
  • 虚表地址在构造函数执行期间填充到对象中,父类和子类都填充了,只不过父类填充的虚指针被子类构造函数覆盖了!
  • 不开编译优化,产生了大量没有用的指令!

最后来张图说明:
图片说明

附录 main.s主要内容(删除了调试信息和无用的伪指令)

    .file    "main.cpp"
    .text
.Ltext0:
    .section    .text._ZN6ClassAC2Ev,"axG",@progbits,_ZN6ClassAC5Ev,comdat
    .align 2
    .weak    _ZN6ClassAC2Ev
    .type    _ZN6ClassAC2Ev, @function
_ZN6ClassAC2Ev:
.LFB1:
    endbr64
    pushq    %rbp
    movq    %rsp, %rbp
    movq    %rdi, -8(%rbp)
    leaq    16+_ZTV6ClassA(%rip), %rdx
    movq    -8(%rbp), %rax
    movq    %rdx, (%rax)
    movq    -8(%rbp), %rax
    movl    $97, 8(%rax)
    nop
    popq    %rbp
    ret
.LFE1:
    .size    _ZN6ClassAC2Ev, .-_ZN6ClassAC2Ev
    .weak    _ZN6ClassAC1Ev
    .set    _ZN6ClassAC1Ev,_ZN6ClassAC2Ev
    .section    .text._ZN6ClassBC2Ev,"axG",@progbits,_ZN6ClassBC5Ev,comdat
    .align 2
    .weak    _ZN6ClassBC2Ev
    .type    _ZN6ClassBC2Ev, @function
_ZN6ClassBC2Ev:
.LFB7:
    endbr64
    pushq    %rbp
    movq    %rsp, %rbp

    subq    $16, %rsp

    movq    %rdi, -8(%rbp)
    movq    -8(%rbp), %rax
    movq    %rax, %rdi
    call    _ZN6ClassAC2Ev
    leaq    16+_ZTV6ClassB(%rip), %rdx
    movq    -8(%rbp), %rax
    movq    %rdx, (%rax)
    movq    -8(%rbp), %rax
    movl    $98, 12(%rax)
    movq    -8(%rbp), %rax
    movl    $99, 16(%rax)

    nop
    leave
    ret
.LFE7:
    .size    _ZN6ClassBC2Ev, .-_ZN6ClassBC2Ev
    .weak    _ZN6ClassBC1Ev
    .set    _ZN6ClassBC1Ev,_ZN6ClassBC2Ev
    .section    .text._ZN6ClassB17virtual_function1Ev,"axG",@progbits,_ZN6ClassB17virtual_function1Ev,comdat
    .align 2
    .weak    _ZN6ClassB17virtual_function1Ev
    .type    _ZN6ClassB17virtual_function1Ev, @function
_ZN6ClassB17virtual_function1Ev:
.LFB9:
    endbr64
    pushq    %rbp
    movq    %rsp, %rbp

    movq    %rdi, -8(%rbp)

    movl    $1, %eax

    popq    %rbp
    ret
.LFE9:
    .size    _ZN6ClassB17virtual_function1Ev, .-_ZN6ClassB17virtual_function1Ev
    .section    .text._ZN6ClassB17virtual_function2Ev,"axG",@progbits,_ZN6ClassB17virtual_function2Ev,comdat
    .align 2
    .weak    _ZN6ClassB17virtual_function2Ev
    .type    _ZN6ClassB17virtual_function2Ev, @function
_ZN6ClassB17virtual_function2Ev:
.LFB10:
    endbr64
    pushq    %rbp
    movq    %rsp, %rbp
    movq    %rdi, -8(%rbp)
    movl    $2, %eax
    popq    %rbp
    ret
.LFE10:
    .size    _ZN6ClassB17virtual_function2Ev, .-_ZN6ClassB17virtual_function2Ev
    .text
    .globl    main
    .type    main, @function
main:
.LFB11:
    endbr64
    pushq    %rbp
    movq    %rsp, %rbp

    pushq    %rbx

    subq    $24, %rsp

    movl    $24, %edi
    call    _Znwm@PLT

    movq    %rax, %rbx
    movq    %rbx, %rdi
    call    _ZN6ClassBC1Ev

    movq    %rbx, -24(%rbp)
    movq    -24(%rbp), %rax
    movq    (%rax), %rax
    movq    (%rax), %rdx
    movq    -24(%rbp), %rax
    movq    %rax, %rdi
    call    *%rdx
    movl    %eax, -28(%rbp)

    movl    $0, %eax

    addq    $24, %rsp
    popq    %rbx
    popq    %rbp
    ret
.LFE11:
    .size    main, .-main
    .weak    _ZTV6ClassB
    .section    .data.rel.ro.local._ZTV6ClassB,"awG",@progbits,_ZTV6ClassB,comdat
    .align 8
    .type    _ZTV6ClassB, @object
    .size    _ZTV6ClassB, 32
_ZTV6ClassB:
    .quad    0
    .quad    _ZTI6ClassB
    .quad    _ZN6ClassB17virtual_function1Ev
    .quad    _ZN6ClassB17virtual_function2Ev
    .weak    _ZTV6ClassA
    .section    .data.rel.ro._ZTV6ClassA,"awG",@progbits,_ZTV6ClassA,comdat
    .align 8
    .type    _ZTV6ClassA, @object
    .size    _ZTV6ClassA, 32
_ZTV6ClassA:
    .quad    0
    .quad    _ZTI6ClassA
    .quad    __cxa_pure_virtual
    .quad    __cxa_pure_virtual
    .weak    _ZTI6ClassB
    .section    .data.rel.ro._ZTI6ClassB,"awG",@progbits,_ZTI6ClassB,comdat
    .align 8
    .type    _ZTI6ClassB, @object
    .size    _ZTI6ClassB, 24
_ZTI6ClassB:
    .quad    _ZTVN10__cxxabiv120__si_class_type_infoE+16
    .quad    _ZTS6ClassB
    .quad    _ZTI6ClassA
    .weak    _ZTS6ClassB
    .section    .rodata._ZTS6ClassB,"aG",@progbits,_ZTS6ClassB,comdat
    .align 8
    .type    _ZTS6ClassB, @object
    .size    _ZTS6ClassB, 8
_ZTS6ClassB:
    .string    "6ClassB"
    .weak    _ZTI6ClassA
    .section    .data.rel.ro._ZTI6ClassA,"awG",@progbits,_ZTI6ClassA,comdat
    .align 8
    .type    _ZTI6ClassA, @object
    .size    _ZTI6ClassA, 16
_ZTI6ClassA:
    .quad    _ZTVN10__cxxabiv117__class_type_infoE+16
    .quad    _ZTS6ClassA
    .weak    _ZTS6ClassA
    .section    .rodata._ZTS6ClassA,"aG",@progbits,_ZTS6ClassA,comdat
    .align 8
    .type    _ZTS6ClassA, @object
    .size    _ZTS6ClassA, 8
_ZTS6ClassA:
    .string    "6ClassA"
    .text
全部评论
真的是无语了,牛客的markdown插代码总出问题!
点赞 回复
分享
发布于 2020-06-15 17:49

相关推荐

点赞 1 评论
分享
牛客网
牛客企业服务