VC++里4、8、12和16字节的成员指针

背景信息简单提一下……大概就是有两次被这事情坑了,我一直以为它只是一个普通指针的大小,32位下是4字节。

第一次,是在前一家公司的时候,遇到一个应用场景,一个头文件需要同时在C和C++里用,里面存有一个成员函数的指针,和void*一起放在union里,是专门给C++用的;C的话这个地方就用条件编译语句换成单个void*。结果是我离开项目组之后,后面用到这段代码的人发现这个成员指针有的时候比void*大。但是那个时候已经不在组里了,也不知道具体那边应用场景是啥样,最后简单把void*替换成char[32]了事,更多的也没在意。

第二次,是在现在这家公司的时候,有个同事在某个头文件里定义的类里面包含了另一个类的成员函数指针,结果这个头文件在两个不同的cpp文件里包含,这个类的大小是不一样的……访问成员变量的时候偏移也不同,数据就坏了。但是cpp里头文件包含的顺序调换一下就能好……同事说是那个成员函数指针的大小会不同,不过那会儿工作有点忙,结果是没放在心上,后来就忘了这事儿了。

最后一次是网友Advance在代码开了最高警告级别的时候,有一个成员指针被报没有对齐到16字节边界,然后就怀疑这个指针的大小。我突然想起之前同事的代码在不同文件访问变量所用的偏移不同的事情,就打算简单调查一下。

最开始是想起了同事说的成员函数指针大小会比普通指针大的问题,到搜索引擎上搜了个微软msdn社区的帖子,有人提到什么样的情况成员函数指针是什么样的大小,帖子链接是:social.msdn.microsoft.com,里面大概是说,一个最基本的类,它的成员函数指针是4字节;如果这个类有多个基类,那么它的成员函数指针会变成8个字节,如果这个类有虚基类,那么它的成员函数指针是12字节。这个类如果都不知道是啥样的,只有声明没有定义,那么就是16字节。

看完了帖子,就说到这里。至于这8、12、16字节里面是啥样的,没有说。那就自己试一下吧……

4字节的情况

4字节的情况我觉得没啥好说的,和普通的函数指针一样。一定要说有区别的话,就是多送了一个参数:this指针。

8字节的情况

说是有多个基类的情况会变成8个字节,于是用下面这样的代码来试验,看看编译以后会成什么样

#include <stdio.h>

struct A { int a; void af() { printf("a=%d\n", a); } };
struct B { int b; void bf() { printf("b=%d\n", b); } };

struct C : public A, public B
{
    int c;
    void cf() { printf("c=%d\n", c); }
};

int main()
{
    C x;
    x.a = 1;
    x.b = 2;
    x.c = 3;
    void (C::*p1)() = &C::af;
    void (C::*p2)() = &C::bf;
    void (C::*p3)() = &C::cf;
    printf("%d\n", sizeof(p1));
    (x.*p1)();
    (x.*p2)();
    (x.*p3)();
}

看了一下编译后生成的代码,这8个字节里面,前4个字节是函数的地址,就和前一种情况——没有多个基类——的时候是一样的。至于后4个字节,是调用函数的时候送入的this指针的偏移。至于为什么要偏移,可以参考这篇博客

上面的p1、p2、p3变量里,偏移分别是0、4、0。

大概就是这种感觉:

int main()
{
    C x;
    x.a = 1;
    x.b = 2;
    x.c = 3;
    union MemPtr {
        struct {
            void(*funcPtr)();
            int thisOffset;
        };
        void (C::*val)();
    } p;
    p.val = &C::bf;
    __asm {
        lea eax, p
        mov edx, MemPtr::thisOffset
        mov ecx, dword ptr [eax+edx]
        lea eax, x
        add ecx, eax
        lea eax, p
        mov edx, MemPtr::funcPtr
        mov eax, dword ptr [eax+edx]
        call eax
    }
}

简单解释一下,就是调用的时候,this=&x + p.thisOffset。

上面程序运行结果是b=2。

12字节的情况

多的不说,直接上测试代码

#include <stdio.h>

struct D {
    virtual ~D(){}
    int d;
    void df() { printf("d=%d\n", d); }
};

struct A : virtual public D { int a; void af() { printf("a=%d\n", a); } };
struct B : virtual public D { int b; void bf() { printf("b=%d\n", b); } };

struct C : public A, public B
{
    int c;
    void cf() { printf("c=%d\n", c); }
};

int main()
{
    C x;
    x.a = 1;
    x.b = 2;
    x.c = 3;
    x.d = 4;

    void (C::*p)() = &C::df;
    printf("%d\n", sizeof(p));
    (x.*p)();
}

因为对虚基类情况下对象在内存中的表示不熟,但是现在又太迟了,这个就以后再研究……先看它这个指针在干嘛。

这个12字节的指针,①最前面4字节放的是函数的地址,和4字节、8字节的情况一样;②中间4字节塞的东西是“虚表中的偏移”:有虚基类的情况,编译器会在虚表里面塞上这个虚基类的对象在当前对象中的偏移。因为可能会有多个这种虚基类,在调用成员函数指针的时候,this指针应该指向谁(换句话说也就是this指针要如何调整),是靠中间这4字节决定的。在上面例子中,就是从C*转换到D*的过程;③最后4字节存放的是this指针在这样调整之后,还要如何调整:在上面例子中因为D已经是最基本的类了,所以这里是0;但如果指向的是D的多个基类中其中一个的成员函数的指针,那么这里就不是0。

简单说就是这12字节,有8字节是说明this指针要怎么转换。

把上面的代码再弄复杂一点,比如

#include <stdio.h>
struct X { int x; void xf() { printf("x=%d\n", x); } };
struct Y { int y; void yf() { printf("y=%d\n", y); } };

struct D : public X, public Y { virtual ~D(){} int d; void df() { printf("d=%d\n", d); } };

struct A : virtual public D { int a; void af() { printf("a=%d\n", a); } };
struct B : virtual public D { int b; void bf() { printf("b=%d\n", b); } };

struct C : public A, public B
{
    int c;
    void cf() { printf("c=%d\n", c); }
};

int main()
{
    C x;
    x.x = -1;
    x.y = -2;
    x.a = 1;
    x.b = 2;
    x.c = 3;
    x.d = 4;

    void (C::*p)() = &C::xf;
    (x.*p)();
}

观察生成的代码就可以发现,这个p的后8个字节的数据是4和4。前一个数据代表取虚表中第4字节的偏移对this进行第一次调整,后一个数据代表在这之后继续像8个字节那种情况对this进行第二次调整。

16字节的情况

说实话,16字节的情况我自己已经看不出名堂来了。代码最终修改到

#include <stdio.h>

struct C;
void (C::*p)();

struct X { int x; void xf() { printf("x=%d\n", x); } };
struct Y { int y; void yf() { printf("y=%d\n", y); } };

struct D : public X, public Y { virtual ~D(){} int d; void df() { printf("d=%d\n", d); } };

struct A : virtual public D { int a; void af() { printf("a=%d\n", a); } };
struct B : virtual public D { int b; void bf() { printf("b=%d\n", b); } };

struct C : public A, public B
{
    int c;
    void cf() { printf("c=%d\n", c); }
};

int main()
{
    printf("%d\n", sizeof(p));
    C x;
    x.x = -1;
    x.y = -2;
    x.a = 1;
    x.b = 2;
    x.c = 3;
    x.d = 4;

    p = &C::xf;
    (x.*p)();
}

这种程度,比起前面可以看出就是多了个前置声明而已。和前面12字节的时候相比,生成的代码在前面12字节的基础上,往中间4字节和最后4字节的数据之间插入了一个4字节的整数0。但是在下面的代码里却没有用到这个0。

调用的地方代码变得复杂了,因为前置声明的缘故,这个指针不知道最后会被用到什么样的地方上面。于是调用这个指针的时候,代码会先判断这个指针的最后4个字节是不是0,如果是,就直接拿第二个4字节整数来对this做偏移然后调用函数;如果不是,就取虚表中这个位置上的数据去对this做偏移,然后再取成员函数指针里第二个4字节数据直接加到this上做第二次偏移,再然后调用函数。

至于指针中第三个4字节整数是干嘛用的,确实还没调查出来。凌晨2:40时间太晚了,明天还要上班,就不作大死了,下次有时间有精力再调查。

补充:生成的代码里没有用到这个0是因为在调用的地方,已经知道类的结构是什么样的、不需要这个0了。不然它4个4字节数据分别是函数地址、第一次this指针偏移调整、虚基类、虚基类之后的再一次this指针偏移调整。

同一个头文件里的类在不同CPP包含大小会不同的原因

说穿了其实很简单,一个类只有前置声明的时候,它的成员函数指针是16字节;但是如果某个CPP已经包含过这个“只有前置声明的类”的完整定义的时候,它的成员函数指针就是4或者8或者12字节。有的CPP包含了这个有类的完整定义的头文件,有的CPP却没有、只能看到前置声明。况且包含了完整定义头文件的CPP,可能是在声明这样的指针之前包含,也可能是在声明这样的指针之后包含,所以调换一下头文件顺序结果也会不一样。

那么这样的问题如何解决?网友featherwit给出了一个靠谱的方案:使用编译参数/vmg(其实之前那个MSDN社区的帖子里,那人似乎也提到过了,说加了这个参数就一定是12字节)。不过不知道为什么我试出来是加了这个参数以后就都是16字节,总之是统一大小了。

 

果然还是在意16字节的情况下第3个4字节整数存的到底是什么。
←已经补充了

 

发表评论