• C++ 虚函数分析


    C++ 虚函数分析

    虚函数调用属于运行时多态,在类的继承关系中,通过父类指针来调用不同子类对象的同名方法,而产生不同的效果。
    C++ 中的多态是通过晚绑定(对象构造时)来实现的。

    用法

    在函数之前声明关键字virtual表示这是一个虚函数,在函数后增加一个 = 0 表示这是一个纯虚函数,纯虚函数的类不能创建具体实例。

    该示例作后文分析使用,一个包含纯虚函数的父类,一个重写了父类方法的子类,一个无继承的类。

    struct Base {
        Base() : val(7777) {}
        virtual int fuck(int a) = 0;
        int val;
    };
    
    struct Der : public Base {
        Der() = default;
        int fuck(int a) override { return val + 4396; }
    };
    
    struct A {
        A() = default;
        void funny(int a) {}
    };
    
    int main() {
        Der der;
        Base *pbase = &der;
        pbase->fuck(sizeof(Der)); // 调用 Der::fuck(int a);
    
        A a;
        a.funny(sizeof(A));  // A::funny(int a);
    
        return 3;
    }
    

    实现

    原来就了解虚函数是通过虚表的偏移来获取实际调用函数地址来实现的,但是在何时确定这个偏移和具体的偏移细节也没有说明,今儿个来探探究竟。

    拿上面的代码进行反汇编获提取部分函数,main,Base::Base(), Base::fuck(), Der::Der(), Der::fuck, A::funny() 如下:

    _ZN4BaseC2Ev:
    .LFB1:
    	.cfi_startproc
    	pushq	%rbp
    	.cfi_def_cfa_offset 16
    	.cfi_offset 6, -16
    	movq	%rsp, %rbp
    	.cfi_def_cfa_register 6
    	movq	%rdi, -8(%rbp)   // 还是 main 函数的栈帧 -32(%rpb) 的地址
    	leaq	16+_ZTV4Base(%rip), %rdx  // 关键点来了,取虚表偏移 16 的地址也就是 __cxa_pure_virtual,这里是没有意义的
    	movq	-8(%rbp), %rax
    	movq	%rdx, (%rax)     // 将 __cxa_pure_virtual 的地址存放在 地址rax 的内存中(这个例子中也就是main 函数的栈帧 -32(%rpb) 的地方), 
    	movq	-8(%rbp), %rax   // 然后往后偏移 8 个字节,也就是跳过虚表指针,对成员变量 val 初始化。
    	movl	$7777, 8(%rax)
    	nop                      // 注:上面是用这个示例中实际的地址带入的,实际上对于一个有的类的处理是一个通用逻辑的,构造函数传入的第一个参数 rdi 是 this 指针,由于有虚表存在的影响,这里会修改 this 指针所在地址的内容,也就是虚表的偏移地址(非起始地址)
    	popq	%rbp
    	.cfi_def_cfa 7, 8
    	ret
    	.cfi_endproc
    .LFE1:
    	.size	_ZN4BaseC2Ev, .-_ZN4BaseC2Ev
    	.weak	_ZN4BaseC1Ev
    	.set	_ZN4BaseC1Ev,_ZN4BaseC2Ev
    	.section	.text._ZN3Der4fuckEi,"axG",@progbits,_ZN3Der4fuckEi,comdat
    	.align 2
    	.weak	_ZN3Der4fuckEi
    	.type	_ZN3Der4fuckEi, @function
    _ZN3Der4fuckEi:
    .LFB3:
    	.cfi_startproc
    	pushq	%rbp
    	.cfi_def_cfa_offset 16
    	.cfi_offset 6, -16
    	movq	%rsp, %rbp
    	.cfi_def_cfa_register 6
    	movq	%rdi, -8(%rbp)
    	movl	%esi, -12(%rbp)
    	movq	-8(%rbp), %rax
    	movl	8(%rax), %eax   // 成员变量 val,val 是从 rdi 中偏移 8 字节取的值
    	addl	$4396, %eax     // val + 4396
    	popq	%rbp
    	.cfi_def_cfa 7, 8
    	ret
    	.cfi_endproc
    .LFE3:
    	.size	_ZN3Der4fuckEi, .-_ZN3Der4fuckEi
    	.section	.text._ZN1A5funnyEi,"axG",@progbits,_ZN1A5funnyEi,comdat
    	.align 2
    	.weak	_ZN1A5funnyEi
    	.type	_ZN1A5funnyEi, @function
    _ZN1A5funnyEi:
    .LFB4:
    	.cfi_startproc
    	pushq	%rbp
    	.cfi_def_cfa_offset 16
    	.cfi_offset 6, -16
    	movq	%rsp, %rbp
    	.cfi_def_cfa_register 6
    	movq	%rdi, -8(%rbp)
    	movl	%esi, -12(%rbp)
    	nop
    	popq	%rbp
    	.cfi_def_cfa 7, 8
    	ret
    	.cfi_endproc
    .LFE4:
    	.size	_ZN1A5funnyEi, .-_ZN1A5funnyEi
    	.section	.text._ZN3DerC2Ev,"axG",@progbits,_ZN3DerC5Ev,comdat
    	.align 2
    	.weak	_ZN3DerC2Ev
    	.type	_ZN3DerC2Ev, @function
    _ZN3DerC2Ev:
    .LFB7:
    	.cfi_startproc
    	pushq	%rbp
    	.cfi_def_cfa_offset 16
    	.cfi_offset 6, -16
    	movq	%rsp, %rbp
    	.cfi_def_cfa_register 6
    	subq	$16, %rsp
    	movq	%rdi, -8(%rbp)   // rdi 是取的 main 栈帧 -32(%rbp) 的地址
    	movq	-8(%rbp), %rax
    	movq	%rax, %rdi
    	call	_ZN4BaseC2Ev     // Base 的构造函数,并且又把传进来的参数作为实参传进去了,这里跟踪进去
    	leaq	16+_ZTV3Der(%rip), %rdx  // 取虚表偏移16字节 _ZN3Der4fuckEi 的地址 
    	movq	-8(%rbp), %rax
    	movq	%rdx, (%rax)     // rax 在之前的 Base构造函数中是被修改了的,这里将继续修改内容,前一次的修改失效。
    	nop
    	leave
    	.cfi_def_cfa 7, 8
    	ret
    	.cfi_endproc
    .LFE7:
    	.size	_ZN3DerC2Ev, .-_ZN3DerC2Ev
    	.weak	_ZN3DerC1Ev
    	.set	_ZN3DerC1Ev,_ZN3DerC2Ev
    	.text
    	.globl	main
    	.type	main, @function
    main:
    .LFB5:
    	.cfi_startproc
    	pushq	%rbp
    	.cfi_def_cfa_offset 16
    	.cfi_offset 6, -16
    	movq	%rsp, %rbp
    	.cfi_def_cfa_register 6
    	subq	$48, %rsp
    	leaq	-32(%rbp), %rax // 取 -32(%rbp) 的地址,对应 Base *pbase;
    	movq	%rax, %rdi
    	call	_ZN3DerC1Ev     // 调用了构造函数,并且以-32(%rbp) 的地址作为参数,这里跟踪进去
    	leaq	-32(%rbp), %rax // -32(%rbp) 被修改,该内存中的内容为 Der 虚表的偏移地址 
    	movq	%rax, -8(%rbp)
    	movq	-8(%rbp), %rax
    	movq	(%rax), %rax    // rax = M[rax],取出虚表偏移中的地址
    	movq	(%rax), %rdx    // rdx = M[rax] , 取出虚表偏移的内容(也就是函数地址),算上上面这是做了两次解引用
    	movq	-8(%rbp), %rax
    	movl	$16, %esi       // sizeof(Der) = 16, 包含一个虚表指针和 int val;
    	movq	%rax, %rdi      // 虚表偏移中的地址
    	call	*%rdx           // 调用函数
    	leaq	-33(%rbp), %rax
    	movl	$1, %esi
    	movq	%rax, %rdi
    	call	_ZN1A5funnyEi   // 普通成员函数,实现简单
    	movl	$3, %eax
    	leave
    	.cfi_def_cfa 7, 8
    	ret
    	.cfi_endproc
    .LFE5:
    	.size	main, .-main
    	.weak	_ZTV3Der
    	.section	.data.rel.ro.local._ZTV3Der,"awG",@progbits,_ZTV3Der,comdat
    	.align 8
    	.type	_ZTV3Der, @object
    	.size	_ZTV3Der, 24
    _ZTV3Der:
    	.quad	0
    	.quad	_ZTI3Der
    	.quad	_ZN3Der4fuckEi  // Der::fuck(int a);
    	.weak	_ZTV4Base
    	.section	.data.rel.ro._ZTV4Base,"awG",@progbits,_ZTV4Base,comdat
    	.align 8
    	.type	_ZTV4Base, @object
    	.size	_ZTV4Base, 24
    _ZTV4Base:
    	.quad	0
    	.quad	_ZTI4Base
    	.quad	__cxa_pure_virtual  // 纯虚函数,无对应符号表
    	.weak	_ZTI3Der
    	.section	.data.rel.ro._ZTI3Der,"awG",@progbits,_ZTI3Der,comdat
    	.align 8
    	.type	_ZTI3Der, @object
    	.size	_ZTI3Der, 24
    

    现在是一个纯虚函数,类中也没有虚析构函数,通过反汇编来看一些这个实现。

    _ZTV3Der_ZTV4Base 是两个虚表,大小为 24, 8 字节对齐,分别对应 Der 子类和 Base 父类。虚表中偏移 16 字节(偏移大小可能和实现相关)为虚函数地址,每次构造函数的被调用的时候,会将该偏移地址存储到父类指针所在内存中,所以在上代码中看到,在 Base 和 Der 类的构函数中都出现了设置偏移地址的操作,但是子类构造函数会覆盖父类的修改。这样一来,实际的函数运行地址依赖构造函数,子类对象被构造就调用子类的方法,父类构造就调用父类的方法(非纯虚函数),实现了运行时多态。

    增加一个虚函数后, 后面的虚函数地址就添加到虚表之中,如下

    virtual void Base::shit() {}
    void Der::shit() override {}
    
    _ZTV3Der:
    	.quad	0
    	.quad	_ZTI3Der
    	.quad	_ZN3Der4fuckEi
    	.quad	_ZN3Der4shitEv
    	.weak	_ZTV4Base
    	.section	.data.rel.ro._ZTV4Base,"awG",@progbits,_ZTV4Base,comdat
    	.align 8
    	.type	_ZTV4Base, @object
    	.size	_ZTV4Base, 32
    _ZTV4Base:
    	.quad	0
    	.quad	_ZTI4Base
    	.quad	__cxa_pure_virtual
    	.quad	_ZN4Base4shitEv
    	.weak	_ZTI3Der
    	.section	.data.rel.ro._ZTI3Der,"awG",@progbits,_ZTI3Der,comdat
    	.align 8
    	.type	_ZTI3Der, @object
    	.size	_ZTI3Der, 24
    

    再调用另外一个虚函数就简单很多了,直接地址进行偏移(这里shit在fuck之后,所以+8)

    	movq	-8(%rbp), %rax
    	movq	(%rax), %rax
    	addq	$8, %rax
    	movq	(%rax), %rdx
    	movq	-8(%rbp), %rax
    	movq	%rax, %rdi
    	call	*%rdx
    

    简单画了一下此时虚函数运行的调用情况

    当再增加一个 Base 的子类 Dervied 时,

    struct Derived : public Base {
        Derived() = default;
        int fuck(int a) override { return val + 2200; }
        void shit() override {}
    };
    
    // main 中增加
        Derived dervied;
        pbase = &dervied;
        pbase->fuck(999);
    

    此时的的反汇编代码为

    	leaq	-48(%rbp), %rax
    	movq	%rax, %rdi
    	call	_ZN7DerivedC1Ev
    	leaq	-48(%rbp), %rax
    	movq	%rax, -8(%rbp)
    	movq	-8(%rbp), %rax
    	movq	(%rax), %rax
    	movq	(%rax), %rdx
    	movq	-8(%rbp), %rax
    	movl	$999, %esi
    	movq	%rax, %rdi
    	call	*%rdx
    

    和上面的 Der 对象构造时对比一下,也是这个情况,对象构造的地址的内容被修改,访问虚函数的时候,直接通过偏移就可以得到,父类指针可以指向子类对象不过是编译器的一种语法而已。在更底层的角度上看就是,对象在构造时,会修改对象的起始地址的内容为有效虚函数起始地址(通常意义上的虚表地址),发生多态的情况下,用这个起始地址的偏移来调用虚函数,父类指针仅仅是做了个类型的转换,地址还是不变的,这样访问的函数也就固定了。

  • 相关阅读:
    [Redis-CentOS7]Redis设置连接密码(九)
    [Redis-CentOS7]Redis数据持久化(八)
    PAT Advanced 1101 Quick Sort (25分)
    PAT Advanced 1043 Is It a Binary Search Tree (25分)
    HTTP协议
    PAT Advanced 1031 Hello World for U (20分)
    自然码双拼快速记忆方案
    macOS 原生输入法设置自然码
    PAT Advanced 1086 Tree Traversals Again (25分)
    PAT Advanced 1050 String Subtraction (20分)
  • 原文地址:https://www.cnblogs.com/shuqin/p/12252898.html
Copyright © 2020-2023  润新知