• 文章
  • 虚方法表和事故预防
作者:
2014年10月10日 (最后更新:2014年10月10日)

虚方法表和事故预防

得分:3.9/5 (98 票)
*****

在文章开始前,我想先做一个小小的热身,请读者问问自己:一个摄影师需要了解相机的工作原理才能拍出高质量的照片吗?那么,他至少需要知道“光圈”这个术语吗?“信噪比”?“景深”?实践表明,即使了解这些高深术语,那些最“有天赋”的人拍出的照片,可能也只比用手机0.3MP“针孔”摄像头拍出的照片好一点点。反过来说,凭借出色的经验和直觉,也可能拍出高质量的照片,而无需任何专业知识(但这通常是例外)。尽管如此,我相信没有人会反对这样一个事实:那些希望从相机中挖掘出每一种可能性(而不仅仅是图像传感器上每平方毫米的像素数)的专业人士,必须了解这些术语,否则他们根本不能被称为专业人士。这不仅在数码摄影领域如此,在几乎所有其他行业也是如此。

这一点对于编程同样适用,而对于C++编程来说,更是加倍适用。在本文中,我将解释一个重要的语言特性,即虚函数表指针,它几乎包含在每个非平凡的类中,以及它如何可能被意外损坏。损坏的虚函数表指针可能导致非常难以修复的错误。首先,我将回顾一下什么是虚函数表指针,然后我将分享我的想法,即它哪里以及如何可能被破坏。

很遗憾,本文将有大量与底层相关的论述。然而,没有其他方法可以说明这个问题。此外,我应该说明,本文是为64位模式下的Visual C++编译器编写的——使用其他编译器和其他目标系统可能会有不同的结果。

虚函数表指针


理论上讲,每个至少有一个虚方法的类中都存储着一个vptr指针,即虚函数表指针或vpointer。让我们来搞清楚这到底是个什么东西。为此,让我们用C++编写一个简单的演示程序。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <iostream>
#include <iomanip>
using namespace std;
int nop() {
  static int nop_x; return ++nop_x; // Don't remove me, compiler!
};

class A
{
public:
  unsigned long long content_A;
  A(void) : content_A(0xAAAAAAAAAAAAAAAAull)
      { cout << "++ A has been constructed" << endl;};
  ~A(void) 
      { cout << "-- A has been destructed" << endl;};

  void function(void) { nop(); };
};

void PrintMemory(const unsigned char memory[],
                 const char label[] = "contents")
{
  cout << "Memory " << label << ": " << endl;
  for (size_t i = 0; i < 4; i++) 
  {
    for (size_t j = 0; j < 8; j++)
      cout << setw(2) << setfill('0') << uppercase << hex
           << static_cast<int> (memory[i * 8 + j]) << " ";
    cout << endl;
  }
}

int main()
{
  unsigned char memory[32];
  memset(memory, 0x11, 32 * sizeof(unsigned char));
  PrintMemory(memory, "before placement new");

  new (memory) A;
  PrintMemory(memory, "after placement new");
  reinterpret_cast<A *>(memory)->~A();

  system("pause");
  return 0;
};

尽管代码量相对较大,但其逻辑应该很清晰:首先,它在栈上分配了32个字节,然后用0x11填充(0x11值将表示内存中的“垃圾”,即未初始化的内存)。其次,它使用placement new运算符创建了一个简单的类A的对象。最后,它打印内存内容,之后析构A对象并正常终止。下面是该程序的输出(Microsoft Visual Studio 2012, x64)。

Memory before placement new:
11 11 11 11 11 11 11 11
11 11 11 11 11 11 11 11
11 11 11 11 11 11 11 11
11 11 11 11 11 11 11 11
++ A has been constructed
Memory after placement new:
AA AA AA AA AA AA AA AA
11 11 11 11 11 11 11 11
11 11 11 11 11 11 11 11
11 11 11 11 11 11 11 11
-- A has been destructed
Press any key to continue . . .
很容易注意到,该类在内存中的大小是8个字节,等于其唯一成员“unsigned long long content_A”的大小。

让我们将程序稍微复杂化一点,在void function(void)的声明中添加“virtual”关键字。
 
virtual void function(void) {nop();};

程序输出(此后只显示部分输出,“Memory before placement new”和“Press any key...”将被省略)

++ A has been constructed
Memory after placement new:
F8 D1 C4 3F 01 00 00 00
AA AA AA AA AA AA AA AA
11 11 11 11 11 11 11 11
11 11 11 11 11 11 11 11
-- A has been destructed
同样,很容易注意到,现在类的大小是16个字节。前八个字节现在包含一个指向虚方法表的指针。在这次运行中,它等于0x000000013FC4D1F8(由于Intel64的小端字节序,指针和content_A在内存中是“反向”的;不过,对于content_A来说,这一点不太容易注意到)。

虚方法表是内存中一种自动生成的特殊结构,它包含指向该类中列出的所有虚方法的指针。当代码中某个地方在指向A类的指针上下文中调用function()方法时,将不会直接调用A::function(),而是会调用位于虚方法表中某个偏移量处的函数——这种行为实现了多态性。虚方法表如下所示(通过使用/FAs选项编译获得;另外请注意汇编代码中那个有点奇怪的函数名——它经过了“名字修饰”)

1
2
3
4
CONST SEGMENT
??_7A@@6B@ DQ  FLAT:??_R4A@@6B@   ; A::'vftable'
 DQ FLAT:?function@A@@UEAAXXZ
CONST ENDS


__declspec(novtable)


有时会出现根本不需要虚函数表指针的情况。假设我们永远不会实例化A类的对象,即使要实例化,也只在周末和节假日,并小心翼翼地控制着不调用任何虚函数。这种情况在抽象类中很常见——众所周知,抽象类无论如何都不能被实例化。实际上,如果function()在A类中被声明为抽象方法,虚方法表会是这样的:

1
2
3
4
CONST SEGMENT
??_7A@@6B@ DQ FLAT:??_R4A@@6B@ ; A::'vftable'
 DQ FLAT:_purecall
CONST ENDS


很明显,试图调用这个函数会导致搬起石头砸自己的脚。

在此之后,问题就来了:如果一个类永远不会被实例化,那么还有理由去初始化虚函数表指针吗?为了防止编译器生成多余的代码,程序员可以给它一个__declspec(novtable)属性(注意:这是Microsoft特有的!)。让我们用__declspec(novtable)重写我们的虚函数示例。
 
class __declspec(novtable) A { .... }

程序输出

++ A has been constructed
Memory after placement new:
11 11 11 11 11 11 11 11
AA AA AA AA AA AA AA AA
11 11 11 11 11 11 11 11
11 11 11 11 11 11 11 11
-- A has been destructed
注意,对象的大小没有改变:它仍然是16个字节。在加入了__declspec(novtable)属性后,只有两个区别:第一,虚函数表指针的位置上是未初始化的内存;第二,汇编代码中根本没有A类的虚方法表。然而,虚函数表指针仍然存在,并且大小为八个字节!这一点要记住,因为……

继承


让我们重写我们的示例,以实现从带有虚函数表指针的抽象类进行最简单的继承。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class __declspec(novtable) A // I never instantiate
{
public:
  unsigned long long content_A;
  A(void) : content_A(0xAAAAAAAAAAAAAAAAull)
      { cout << "++ A has been constructed" << endl;};
  ~A(void) 
      { cout << "-- A has been destructed" << endl;};

  virtual void function(void) = 0;
};

class B : public A // I always instantiate instead of A
{
public:
  unsigned long long content_B;
  B(void) : content_B(0xBBBBBBBBBBBBBBBBull)
      { cout << "++ B has been constructed" << endl;};
  ~B(void) 
      { cout << "-- B has been destructed" << endl;};

  virtual void function(void) { nop(); };
};

此外,我们需要让主程序构造(和析构)一个B类的对象,而不是实例化A类。
1
2
3
4
5
....
new (memory) B;
PrintMemory(memory, "after placement new");
reinterpret_cast<B *>(memory)->~B();
....

程序输出将是这样的

++ A has been constructed
++ B has been constructed
Memory after placement new:
D8 CA 2C 3F 01 00 00 00
AA AA AA AA AA AA AA AA
BB BB BB BB BB BB BB BB
11 11 11 11 11 11 11 11
-- B has been destructed
-- A has been destructed
让我们试着弄清楚发生了什么。B::B()构造函数被调用。这个构造函数在执行其函数体之前,调用了基类的构造函数A::A()。如果没有__declspec(novtable)属性,A::A()会初始化虚函数表指针;在我们的例子中,虚函数表指针没有被初始化。然后构造函数将content_A的值设置为0xAAAAAAAAAAAAAAAAull(内存中的第二个字段),并将执行流返回给B::B()。

由于没有__declspec(novtable)属性,构造函数将虚函数表指针(内存中的第一个字段)设置为B类的虚方法表,将content_B的值设置为0xBBBBBBBBBBBBBBBBull(内存中的第三个字段),然后将执行流返回给主程序。考虑到内存内容,很容易发现B类的对象被正确地构造了,并且程序逻辑清楚地表明,一个不必要的操作被跳过了。如果你感到困惑:这里的不必要操作指的是在基类构造函数中初始化虚函数表指针。

看起来似乎只跳过了一个操作。去掉它有什么意义呢?但是,如果程序有成千上万个从一个抽象类派生的类,去掉一个自动生成的指令可以显著影响程序性能。而且,它确实会。你相信我吗?

memset 函数


memset()函数的主要思想在于用某个常量值(最常见的是零)填充一块内存区域。在C语言中,它曾被用来快速初始化所有结构体字段。就内存布局而言,一个没有虚函数表指针的简单C++类和一个C结构体有什么区别?嗯,没有区别,C的原始数据和C++的原始数据是一样的。要初始化真正简单的C++类(用C++11的术语来说——标准布局类型),可以使用memset()函数。当然,也可以用memset()函数来初始化任何类。然而,这样做的后果是什么呢?不正确的memset()调用可能会损坏虚函数表指针。这就提出了一个问题:或许当类带有__declspec(novtable)属性时,这是可以的?

答案是:可以,但要小心。

让我们换一种方式重写我们的类:添加一个wipe()方法,用于将A的所有内容初始化为0xAA。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class __declspec(novtable) A // I never instantiate
{
public:
  unsigned long long content_A;
  A(void)
    {
      cout << "++ A has been constructed" << endl;
      wipe();
    };
    // { cout << "++ A has been constructed" << endl; };
  ~A(void) 
    { cout << "-- A has been destructed" << endl;};

  virtual void function(void) = 0;
  void wipe(void)
  {
    memset(this, 0xAA, sizeof(*this));
    cout << "++ A has been wiped" << endl;
  };
};

class B : public A // I always instantiate instead of A
{
public:
  unsigned long long content_B;
  B(void) : content_B(0xBBBBBBBBBBBBBBBBull)
      { cout << "++ B has been constructed" << endl;};
      // {
      //   cout << "++ B has been constructed" << endl;
      //   A::wipe();
      // };

  ~B(void) 
      { cout << "-- B has been destructed" << endl;};

  virtual void function(void) {nop();};
};

在这种情况下,输出将如预期所示

++ A has been constructed
++ A has been wiped
++ B has been constructed
Memory after placement new:
E8 CA E8 3F 01 00 00 00
AA AA AA AA AA AA AA AA
BB BB BB BB BB BB BB BB
11 11 11 11 11 11 11 11
-- B has been destructed
-- A has been destructed
到目前为止,一切顺利。

然而,如果我们改变wipe()函数的调用方式,注释掉构造函数那几行,并取消注释它们旁边的几行,就会发现出问题了。对虚方法function()的第一次调用将因虚函数表指针损坏而导致运行时错误。

++ A has been constructed
++ B has been constructed
++ A has been wiped
Memory after placement new:
AA AA AA AA AA AA AA AA
AA AA AA AA AA AA AA AA
BB BB BB BB BB BB BB BB
11 11 11 11 11 11 11 11
-- B has been destructed
-- A has been destructed
为什么会发生这种情况?Wipe()函数是在B的构造函数初始化虚函数表指针之后被调用的。结果,wipe()损坏了这个指针。换句话说——不建议对带有虚函数表指针的类进行清零,即使它声明了__declspec(novtable)属性。完全清零只适用于一个永远不会被实例化的类的构造函数中,但即使这样也应非常谨慎。

memcpy 函数


以上所有的话也同样适用于memcpy()函数。同样,它的目的是复制标准布局类型。然而,从实践来看,一些程序员喜欢在需要和不需要的时候都使用它。对于非标准布局类型,使用memcpy()就像在尼亚加拉大瀑布上走钢丝:一个错误就可能致命,而这个致命的错误又出奇地容易犯下。举个例子:
1
2
3
4
5
6
7
8
class __declspec(novtable) A
{
  ....
  A(const A &source) { memcpy(this, &source, sizeof(*this)); }
  virtual void foo() { }
  ....
};
class B : public A { .... };

拷贝构造函数可以将任何它的数字灵魂想要的东西写入抽象类的虚函数表指针:派生类的构造函数无论如何都会用正确的值来初始化它。然而,在赋值运算符的函数体中,使用memcpy()是禁止的。
1
2
3
4
5
6
7
8
9
10
11
12
class __declspec(novtable) A
{
  ....
  A &operator =(const A &source)
  {
    memcpy(this, &source, sizeof(*this)); 
    return *this;
  }
  virtual void foo() { }
  ....
};
class B : public A { .... };

为了让你看清全貌,请记住,几乎每个拷贝构造函数和赋值运算符都有几乎相同的函数体。不,这并不像初看时那么糟糕:在实践中,赋值运算符可能按预期工作,不是因为代码的正确性,而是因为星星的意愿。这段代码从另一个类复制了虚函数表指针,其结果是高度不可预测的。

PVS-Studio


本文是对这个神秘的__declspec(novtable)属性、何时可以在高级代码中使用memset()和memcpy()函数、以及何时不可以的详细研究的结果。开发者们时常向我们询问,为什么PVS-Studio会显示太多关于虚函数表指针的警告。开发者们经常就虚函数表指针给我们发邮件。程序员们认为,如果存在__declspec(novtable),那么类就没有虚方法表,也没有虚函数表指针。我们开始仔细研究这个问题,然后我们明白了,事情并不像看上去那么简单。

这一点应该牢记。如果在类声明中使用了__declspec(novtable)属性,这并不意味着这个类不包含虚函数表指针!这个类是否初始化它呢?这是另一个问题。

未来我们打算让我们的分析器抑制关于使用memset()/memcpy()的警告,但仅限于带有__declspec(novtable)的基类的情况。

结论


不幸的是,本文没有涵盖太多关于继承的材料(例如,我们完全没有涉及多重继承)。尽管如此,我希望这些信息能让大家明白“事情并不像看上去那么简单”,并且在使用底层函数与高层对象结合时,最好三思而后行。而且,这样做值得吗?