C++的虚函数与内存

概念

虚函数

由virtual描述的类成员函数,如

class A {
    virtual void foo1(void){
        printf("foo1\n");
    }
};

如果要给上述的虚函数下定义(函数签名)

typedef void (vfunc)(void);

含义:类型vfunc代表返回值void、参数列表void的函数

虚函数指针

也就是函数指针,只是指向的是虚函数的地址。函数指针的定义一般为

typedef void (*pvfunc)(void);
typedef vfunc *pvfunc;

类型pvfunc为指向返回值void、参数列表void的函数的函数的指针。

虚函数表/虚函数入口表

数组结构,每一项为虚函数指针。可以理解成虚函数指针的数组

pvfunc vtable[];
vfunc* vtable[];
typedef void(*vtable[])(void);

因为数组定义会有空间限制,上面的定义可以编译通过,但是运行时会访问越界。
所以直接定义成虚函数指针的指针更好些。

pvfunc *vtable;
vfunc **vtable;
typedef void(**vtable)(void);

总之这里的虚函数表也是个指针,是一队虚函数指针序列的首地址。

虚函数表指针

即虚函数表的地址,用指针变量来保存。以下定义都是这个概念。

typedef vtable *pvtable;
typedef vpvfunc **pvtable;
typedef vfunc ***pvtable;
typdef void(***pvtable)(void);

指针很多容易把人绕晕,这里抽出一些最直观的定义

typedef void (vfunc)(void);            // 虚函数签名
typedef vfunc *pvfunc;                  // 指向虚函数的指针        
typedef pvfunc *vtable;                 // 虚函数表
typedef vtable *pvtable;                // 指向虚函数表的指针

画在内存里是这样的

在这里插入图片描述

明确概念之后,我们可以用标准的语言来描述问题了

探究内容:

  1. 虚函数表(指针)在对象内存空间的什么位置?
  2. 虚函数表所指向的内存空间在运行时环境的哪个内存段?
  3. 虚函数表所指向的内存空间由谁创建的?编译器还是运行时库?
  4. 虚函数表所指向的内存空间是对象之间共享的还是对象私有的?
  5. 虚函数表的结构。父子类的虚函数表在内存中是连续的么?
  6. 虚函数表可以修改么?

虚函数表(指针)在对象内存空间的位置

先看没有虚函数实例的内存地址:取这个对象的内存地址,和它第一个成员变量的内存地址。
每个地址按%p和%lx两个格式打印。
(用long修饰的%x,是因为在64位机上测试)

address of b: 0x7ffcc8fdaae4 0x7ffcc8fdaae4 address of b.m_1:
0x7ffcc8fdaae4 0x7ffcc8fdaae4

得到这四个值都是一样的,在这个对象的内存中,顶头的就是它的第一个成员变量。
这个测试也证明了,指针变量按%p和%x都是一样的,输出的是指针变量的值,也就是其指向内存单元的地址。
在这里插入图片描述
同样的方法作用在有虚函数实例上,会得到这样的结果

address of a: 0x7ffcc8fdab10
address of a.m_1: 0x7ffcc8fdab18

两者相差8个字节,刚好就是一个指针的大小。这就是虚函数表的指针。所以
虚函数表指针在对象内存空间的第一个内存单元。

虚函数表是对象之间共享的还是对象私有的?

回答此问题,我们需要两个同类的实例,拿出他们各自的虚函数表指针,看看是否指向同一内存地址。
所以,我们需要做个转换,将实例的指针转换成虚函数表指针

A a;
pvtable vptr = (pvtable)(&a); // 也就是vtable *vptr = (vtable *)(&a);

对于假设&a操作后的指针叫pa,那么pa对应的数据空间(*pa操作)是完整的a对象空间。
在这里插入图片描述
经过转换后vptr指向的空间仅是一个指针空间,*vptr得到的是虚函数表的地址。也就是第1个虚函数指针的地址。

在这里插入图片描述

打印结果得到:

address of pvtable: 0x7ffcc8fdab10
address of vtable: 0x55dcb5615d28
address of 1st func: 0x55dcb5414dd8

pvtable其实是对象所在内存地址,标注到示意图上即
在这里插入图片描述
另一个对象a1的打印,除了该对象的内存地址不同外,其他均一致。

address of pvtable: 0x7ffcc8fdab20
address of vtable: 0x55dcb5615d28
address of 1st func: 0x55dcb5414dd8

所以,虚函数表是对象之间共享的。

虚函数表的内存空间在运行时环境的哪个内存段?

经过上面的折腾,从实际的地址数据基本上就能看出来了,以a对象的输出为例

address of pvtable: 0x7ffcc8fdab10
address of vtable: 0x55dcb5615d28
address of 1st func: 0x55dcb5414dd8

这个对象生成是在栈上的,所以0x7ffcc8fdab10是在栈上的内存地址。
指令肯定都是在代码段的,所以0x55dcb5414dd8是.text上的内存地址。
问题就在于这个0x55dcb5615d28在哪,它与foo1的地址大概差个4KB(一个虚拟页大小)。

我是通过查进程map来确定的,得到虚拟页的分配关系

$ cat /proc/<pid>/maps
000055dcb5414000 8K r-x-- /home/ron/C++_Exercise/vtable
000055dcb5615000 4K r---- /home/ron/C++_Exercise/vtable
000055dcb5416000 4K rw— /home/ron/C++_Exercise/vtable

第一列可以确定地址空间范围,第二列可以确定权限。
我们知道程序运行主要有三个Segment(段),分别对应三种不同的权限:
r-x:可读可执行。主要包括.text .init .fini,和一些辅助(与运行库有关)的代码段。
rw-:可读可写。主要包括.data .bss .got .got.plt等数据段。
r–:可读。主要包括.rodata等,只能读不能更改的section。

最后,我发现0x55dcb5615d28在权限位r–的Segment。
所以,虚函数表的内存空间在运行时环境的只读数据段。

虚函数表可以修改么?

由上可证,虚函数表不可更改内容。

虚函数表所指向的内存空间由谁创建的?编译器还是运行时库?

先确定虚函数表在ELF文件中的section是哪个吧。
相比运行时,通过printf和内存map可以很轻松定位,ELF文件中的section是更加精细的操作。

对于可执行文件,可以通过objdump -ds查看代码段的反汇编,foo1的位置一目了然。
然后我通过运行时foo1地址和虚函数表地址的差值,在ELF文件中推导出虚函数所在位置是.data.rel.ro段

反汇编

0000000000000dda <_ZN1A4foo1Ev>:
 dda:	55                   	push   %rbp
 ddb:	48 89 e5             	mov    %rsp,%rbp
 dde:	48 83 ec 10          	sub    $0x10,%rsp
 de2:	48 89 7d f8          	mov    %rdi,-0x8(%rbp)
 de6:	48 8d 3d e8 00 00 00 	lea    0xe8(%rip),%rdi        # ed5 <_ZStL19piecewise_construct+0x1>
 ded:	e8 ee fb ff ff       	callq  9e0 <puts@plt>
 df2:	90                   	nop
 df3:	c9                   	leaveq 
 df4:	c3                   	retq   
 df5:	90                   	nop

.text中的16进制显示,和汇编指令对应上了。

 0dd0 000000e8 a4ffffff 5dc35548 89e54883  ........].UH..H.

这是个新的示例,打印如下,0xdda + 0x55d552a20d28 - 0x55d55281fdda = 0x201d28

address of vtable: 0x55d552a20d28
address of 1st func: 0x55d55281fdda

Contents of section .data.rel.ro:
 201d18 00000000 00000000 401d2000 00000000  ........@. .....
 201d28 da0d0000 00000000 f60d0000 00000000  ................
 201d38 120e0000 00000000 00000000 00000000  ................
 201d48 d80f0000 00000000 00000000 01000000  ................
 201d58 681d2000 00000000 02080000 00000000  h. .............
 201d68 00000000 00000000 db0f0000 00000000  ................

从这个地址开始,每8个字节就是个函数地址。从数据上来看,和打印值是不对应的,这也好理解:
虚函数表在编译时就开辟空间了。但是其内容(虚函数指针)需要通过运行时装载过程的符号解析和重定向来填充。
如果要深究,还能看到在链接之前(目标文件)每个虚函数都对应一个.text段。每个类有对应的.rodata段。

虚函数表的结构。父子类虚函数表在内存中是连续的么?

准备两个类的实例,打印得到

address of vtable: 0x559591590d10
address of 1st func: 0x55959138ff78
address of vtable: 0x559591590ce8
address of 1st func: 0x55959138ffcc

彼此相隔0x28个内存单元,也就是5个指针

然后查看ELF可执行文件,计算section的位置
父类 0xf78 + 0x559591590d10 - 0x55959138ff78 = 0x201d10
子类 0xfcc + 0x559591590ce8 - 0x55959138ffcc = 0x201ce8

0000000000000f78 <_ZN1A4foo1Ev>:
     f78:	55                   	push   %rbp
     f79:	48 89 e5             	mov    %rsp,%rbp
     f7c:	48 83 ec 10          	sub    $0x10,%rsp
     f80:	48 89 7d f8          	mov    %rdi,-0x8(%rbp)
     f84:	48 8d 3d 2a 01 00 00 	lea    0x12a(%rip),%rdi        # 10b5 <_ZStL19piecewise_construct+0x1>
     f8b:	e8 30 fb ff ff       	callq  ac0 <puts@plt>
     f90:	90                   	nop
     f91:	c9                   	leaveq 
     f92:	c3                   	retq   
     f93:	90                   	nop

0000000000000fcc <_ZN1C4foo1Ev>:
     fcc:	55                   	push   %rbp
     fcd:	48 89 e5             	mov    %rsp,%rbp
     fd0:	48 83 ec 10          	sub    $0x10,%rsp
     fd4:	48 89 7d f8          	mov    %rdi,-0x8(%rbp)
     fd8:	48 8d 3d e5 00 00 00 	lea    0xe5(%rip),%rdi        # 10c4 <_ZStL19piecewise_construct+0x10>
     fdf:	e8 dc fa ff ff       	callq  ac0 <puts@plt>
     fe4:	90                   	nop
     fe5:	c9                   	leaveq 
     fe6:	c3                   	retq   
     fe7:	90                   	nop

因为只定义了3个虚函数,在.data.rel.ro表中标记出两个类的虚函数表的内存空间。
子类的虚函数表在低地址,父类的虚函数表在高地址。

Contents of section .data.rel.ro:
 201cd8 00000000 00000000 281d2000 00000000  ........(. .....
 201ce8 cc0f0000 00000000 940f0000 00000000  ................
 201cf8 b00f0000 00000000 00000000 00000000  ................
 201d08 401d2000 00000000 780f0000 00000000  @. .....x.......
 201d18 940f0000 00000000 b00f0000 00000000  ................
 201d28 00000000 00000000 e8110000 00000000  ................
 201d38 401d2000 00000000 00000000 00000000  @. .............
 201d48 eb110000 00000000 00000000 01000000  ................
 201d58 681d2000 00000000 02080000 00000000  h. .............
 201d68 00000000 00000000 ee110000 00000000  ................

如果虚函数表以NULL结尾的话,跟在两张表后面的8个字节的0就不足为奇了。
{ void *pfunc[, void *pfunc], NULL }

再往前看8个字节,以小端字节序读出
子类虚函数表前8个字节:00000000 00201d28
父类虚函数表前8个字节:00000000 00201d40

Contents of section .data.rel.ro:
 201cd8 00000000 00000000 281d2000 00000000  ........(. .....
 201ce8 cc0f0000 00000000 940f0000 00000000  ................
 201cf8 b00f0000 00000000 00000000 00000000  ................
 201d08 401d2000 00000000 780f0000 00000000  @. .....x.......
 201d18 940f0000 00000000 b00f0000 00000000  ................
 201d28 00000000 00000000 e8110000 00000000  ................
 201d38 401d2000 00000000 00000000 00000000  @. .............
 201d48 eb110000 00000000 00000000 01000000  ................
 201d58 681d2000 00000000 02080000 00000000  h. .............
 201d68 00000000 00000000 ee110000 00000000  ................

这样一看,并没有觉得这个表是NULL结尾的惯例。
所以,父子类虚函数表在内存中不一定连续,虚函数表就是个函数指针的数组。

测试代码:

#include <iostream>
using namespace std;

class B {
public:
    int m_1;
};

class A : public B{
public:
    virtual void foo1(void) {
        printf("foo1\n");
    }
    virtual void foo2(void) {
        printf("foo2\n");
    }
    virtual void foo3(void) {
        printf("foo3\n");
    }
    static void foo4(void) {
        printf("foo4\n");
    }
};

class C: public A{
public:
    virtual void foo1(void) {
        printf("C::foo1\n");
    }
};

void replacefunc(void){
    printf("replacefunc\n");
}

typedef void (vfunc)(void);             // 虚函数签名
typedef vfunc* pvfunc;                  // 指向函数的指针        

// 虚函数表
typedef pvfunc *vtable;
//同义的定义
//typedef vfunc **vtable;
//typedef void (**vtable)(void);

//typedef pvfunc vtable[];
//typedef func *vtable[];
//typedef void (*vtable[])(void);
//数组格式的定义在运行时会crash

// 指向虚函数表的指针
typedef vtable* pvtable;
//typedef vpfunc **pvtable;
//typedef vfunc ***pvtable;
//typedef void(***pvtable)(void);

int main(int argc, char **argv)
{
    A a, a1;
    B b;
    C c;

    printf("address of b: %p 0x%lx\n", &b, (long)&b);                         // 对象所在地址,对指针%p和%lx打印是等价的
    printf("address of b.m_1: %p 0x%lx\n", &(b.m_1), (long)&(b.m_1));         // 对象第1个非静态成员的地址

    pvtable vptr = (pvtable)(&a);
    printf("address of a: %p\n", &a);                   // 对象所在地址
    printf("address of a.m_1: %p\n", &(a.m_1));         // 对象第1个非静态成员的地址
    printf("address of pvtable: %p\n", vptr);           // 虚函数表指针的地址

    printf("address of vtable: %p\n", (*vptr));         // 虚函数表地址
    printf("address of 1st func: %p\n", (*vptr)[0]);    // 虚函数表第1项函数指针(函数地址)

    // 两种函数指针的调用方式
    (*vptr)[0]();       // 执行第1个虚函数,对函数指针直接调用
    (*(*vptr)[0])();    // 执行第1个虚函数,对函数指针解引用后调用

    vptr = (pvtable)(&a1);
    printf("address of a1: %p\n", &a1);                 // 对象所在地址
    printf("address of a1.m_1: %p\n", &(a1.m_1));       // 对象第1个非静态成员的地址
    printf("address of pvtable: %p\n", vptr);           // 虚函数表指针的地址

    printf("address of vtable: %p\n", (*vptr));         // 虚函数表地址
    printf("address of 1st func: %p\n", (*vptr)[0]);    // 虚函数表第1项函数指针(函数地址)

    printf("sizeof a1: %ld %ld\n", sizeof(a1), sizeof(char));               // 对象占用内存,一个虚函数表(指针,8),一个int(4),却得到16字节的大小
    a1.m_1 = 0x12345678;                                                    // 打印对象空间的每个字节,看看内存对齐是咋回事
    char *bytes = (char *)(&a1);
    long count = sizeof(a1)/sizeof(char);
    while(count){
        --count;
        printf("%02x ", (bytes[count] & 0xFF));
    }
    cout << endl;

    //vfunc vfunc1 = replacefunc;                       // error!
    //两种取函数指针进行调用的方法,加不加&都可以
    vfunc *vfunc2 = replacefunc;
    vfunc *vfunc3 = &replacefunc;
    vfunc2();
    vfunc3();
    (*vfunc2)();
    (*vfunc3)();

    vptr = (pvtable)(&c);
    printf("address of c: %p\n", &c);                   // 对象所在地址
    printf("address of c.m_1: %p\n", &(c.m_1));         // 对象第1个非静态成员的地址
    printf("address of pvtable: %p\n", vptr);           // 虚函数表指针的地址

    printf("address of vtable: %p\n", (*vptr));         // 虚函数表地址
    printf("address of 1st func: %p\n", (*vptr)[0]);    // 虚函数表第1项函数指针(函数地址)

    (*vptr)[0]();       // 执行第1个虚函数
    (*(*vptr)[0])();    // 执行第1个虚函数


    //(*vptr)[0] = &replacefunc;                        // 虚函数表所在虚拟页权限为r--,不可修改
    //a1.foo1();

    getchar();          // 暂停程序查看内存pmap
    return 0;
}


  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值