• 文章
  • 虛方法表和事故預防
作者:
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)的基類的情況。

結論


不幸的是,本文沒有涵蓋太多關於繼承的材料(例如,我們完全沒有涉及多重繼承)。儘管如此,我希望這些資訊能讓大家明白“事情並不像看上去那麼簡單”,並且在使用底層函式與高層物件結合時,最好三思而後行。而且,這樣做值得嗎?