VC++2013类内存布局变化,造成空间浪费

VC++编译器通常在编译器版本之间保持高度的二进制兼容性,允许对象在使用不同编译器版本的DLL之间传递。但是VS2013对64位版本中某些类的布局进行了更改。幸运的是,这只影响到一小部分类,如果你真的遇到了这个问题,有一个简单的解决方案。这个简单的解决方案还可以用于减少32位和64位构建中的类的大小。如果类具有虚函数,而不是从具有虚函数的类派生,包含需要16字节对齐或更高的成员(不是第一个数据成员),并且正在为64位编译,则会发生布局更改。
说明:VC++编译器保持了高级别的二进制兼容性,允许用一个版本生成的DLL与用另一个版本生成的DLL共存。C++运行时和STL不维护版本间的二进制兼容性。
这个类布局的改变对我来说很明显,因为我想不出还有什么时候VC++升级改变了类的布局(除了通过类定义的改变,CRT和STL就是这样)。如果你知道布局发生了变化,请告诉我。

VS2010是怎么做的?

为了使讨论具体化,下面是一个类定义:

class VirtualVecOne
{
public:
    virtual ~VirtualVecOne() {}
    void* p;
    __m128 v;
};

这个类将由一个v-table指针、指针成员变量p和向量成员变量v组成。VC++会将结构和类中的内置类型对齐为其大小的倍数,但如果将此类中的项压缩在一起,则在64位构建中,它们会自动自然对齐,所以可以这样布置VirtualCone:

 

在VC++2010和VC++2012中使用64位构建,这正是这个类的布局方式。短,甜,没有填充物。但看起来这是个bug。

VC++里应该发生什么?

由于历史原因,VC++布局这些类的规则是,v-table指针之后的类的第一个成员应该在任何类成员的最高对齐要求处对齐。也就是说,如果类有一个需要16字节对齐的__m128成员,那么第一个成员应该是16字节对齐的。这意味着编译器会在vtable之后插入填充字节,也许还会在类的其他地方插入。这并不理想,但它已经成为VC++布局规则几十年了,这个布局就是VC++2013所做的。

注意:所谓“VC++”,是指VC++规则,而不是C++标准规则。标准将结构/类布局的填充留给实现。

换言之,根据VC++规则,64位编译器应该如下布局类:

 

注意两个8块中的16个字节的填充。在VC++2013中,64位编译器从布局A更改为布局B。p和v的偏移量以及类的大小都发生了变化。我发现这一点是因为有一个类(在我移植的大约600个项目中有数千个)的布局与VirtualCone相似。当我们把它从一个VC++2010 DLL传递到一个VC++2013动态链接库时,事情发生了变化。
为了完整起见,以下是该类在32位编译器中的外观,所有版本都有24个字节的填充:

权变措施

布局B可能是VC++的标准,但它过于臃肿,并且与VC++2010不兼容,这两个都是潜在的问题。我想要的是让VC++2013使用旧的布局样式。我找到答案是因为几天前一条被伪随机发给我的微博。这条推文链接到一个bug,该bug讨论了VC++x64编译器中的布局错误。该bug链接到一个页面,该页面解释了布局规则,并展示了如何更改它们。很酷。我的超能力是及时学习,这次效果很好。
解决方法是从具有虚函数的类继承。一个带有虚析构函数的空类是理想的选择。所以我把类改成这样:

 class LayoutFixer
    {
    public:
        virtual ~LayoutFixer() {}
    };

    class VirtualVecOne : public LayoutFixer
    {
    public:
        virtual ~VirtualVecOne() {}
        void* p;
        __m128 v;
    };

就这样。问题已修复。类现在被高效地布局(布局A),并且布局现在在VC++2010、2012和2013之间是一致的。在32位版本中,该结构也将变得更小。喝杯咖啡早点回家。
请注意,虽然这个问题的兼容性方面在32位编译器中不会发生,但是浪费空间的问题却会发生。因此,空基类技术对32位程序有效,并且可以在具有虚函数和double、int64或Um128成员的类中节省空间。

反思

在调查这类事情时,有一些事情要知道。一是观察调试器。当我从一个函数移到另一个函数时,我意识到发生了什么,我看到地址越过边界的对象从有效变为无效。我可以在堆栈上下查看这个对象(它的地址不变)将正确显示,然后错误显示,这取决于堆栈上包含活动函数的DLL。其实挺酷的。这里的教训是调试信息与特定的源文件相关联,并且在调试会话中可能会有所不同。
在Windows世界中,在dll之间更改类的布局实际上是完全合法的。一个定义规则适用于每个DLL。这意味着我可以有一个名为Vector的类,你可以有一个名为Vector的类,我们的dll可以在一个进程中共存。如果我们将一个向量从一个DLL传递到另一个DLL,那么它们的定义最好匹配,但是我们的工作是确保!
通过使用未记录的/d1 reportSingleClassLayoutVirtualCone或/d1 reportAllClassLayout标志,可以让VC++转储特定类或所有类的布局。输出有点奇怪-它不能可靠地指示填充-但它仍然可以非常有用。以下是使用LayoutFixer的VC++2013结果–与使用VC++2010获得的结果类似:

class VirtualVecOne    size(32):
    +—
    | +— (base class LayoutFixer)
0  | | {vfptr}
    | +—
8  | p
16  | __m128 v
    +—

以下是不带LayoutFixer的VC++2013结果-请注意,它明确表示“p”之后的填充,而不是前面的填充:

class VirtualVecOne    size(48):
    +—
0  | {vfptr}
16  | p
    | <alignment member> (size=8)
32  | __m128 v
    +—

 

最后,在研究类布局问题时,可以始终将offsetof与printf或static_assert一起使用,如下所示:

printf(“Offset of VirtualVecOne::p is %u\n”, (unsigned)offsetof(VirtualVecOne, p));
printf(“Offset of VirtualVecOne::v is %u\n”, (unsigned)offsetof(VirtualVecOne, v));
printf(“sizeof(VirtualVecOne is %u\n”, (unsigned)sizeof(VirtualVecOne));

使用VS2010的C4370警告可用于检测此问题。

posted on 2020-07-23 08:30  活着的虫子  阅读(219)  评论(0编辑  收藏  举报

导航