C++對象內存佈局

所有的分析均針對|gcc version 4.3.4 [gcc-4_3-branch revision 152973] (SUSE Linux X86_64)|這一版本。

最簡單的類

先從一個簡單的類開始吧。如下,此簡單類,非常簡單,兩個int成員,通過printf很容易瞭解到它的內存佈局,本質就是一個C結構體,兩個成員依次排列。

對象:|成員1 | 成員2 |  

   1:  #include <cstdio> 
   2:  class Class0 
   3:  { 
   4:  public: 
   5:  int member1; 
   6:  int member2; 
   7:  }; 
   8:  int main() 
   9:  { 
  10:  Class0 c; 
  11:  printf("object addr=0x%lx\nmember1 addr=0x%lx\nmember2 addr=0x%lx\n", 
  12:  &c, &c.member1, &c.member2); 
  13:  return 0; 
  14:  } 
# ./a.out 
object addr=0x7fffea480d70  
member1 addr=0x7fffea480d70 //類成員1 
member2 addr=0x7fffea480d74 //類成員2 

成員函數

那麼我們增加點複雜性,添加一個成員函數。 

   1:  #include <cstdio> 
   2:  class Class1 
   3:  { 
   4:  public: 
   5:     int member1; 
   6:     int member2; 
   7:     void function1() { printf("Class1::function1"); } 
   8:  }; 
   9:  int main() 
  10:  { 
  11:     Class1 c; 
  12:     printf("object    addr=0x%lx\n", &c); 
  13:     printf("member1   addr=0x%lx\n", &c.member1); 
  14:     printf("member1   addr=0x%lx\n", &c.member2); 
  15:     printf("function1 addr=0x%lx\n", &Class1::function1); 
  16:     return 0; 
  17:  } 
./a.out 
object  addr =0x7fff6805bf90 
member1 addr=0x7fff6805bf90 
member1 addr=0x7fff6805bf94 
function1 addr=0x4006a0//成員函數地址在代碼段。----這簡直是廢話,不在代碼段沒法玩啊。 
對象:    | 成員1 | 成員2 |   
代碼段:  |成員函數| 
我們看到,對象數據成員的佈局並沒有變化,但是函數成員的地址跑到十萬八千里之外了。爲什麼?很簡單,因爲函數是代碼,放在了代碼段。這也是我們通過Class1::function1來取值,而不是c.function1的原因。從這裏可以看出,類的函數成員本質就是一個C全局函數,那麼如果函數內訪問類的非靜態數據成員,如何動態的獲取成員地址?編譯器是這樣做的: 
1. 編譯器生成function1()的指令時,如果遇到了訪問對象的數據成員,比如member1,就從一個約定的位置(比如一個寄存器)獲取對象的首地址(其實就是this指針),然後加上偏移(這個是編譯時期可以確定的),也就找到了member1對應的內存位置,就可以訪問member1了。 
2. 編譯器生成c.function1()對應的指令時,把c的地址,放到了上述約定的位置。 
簡單來說,c.function1() 等價於function1(c), c是作爲隱含參數傳遞給function1了。 

虛函數

好了搞清楚了成員函數的工作機制,我們再進一步分析,如下例子,有了繼承,並且基類成員函數是一個虛函數。派生類重載了它。 

   1:  #include <cstdio> 
   2:  class Base 
   3:  { 
   4:  public: 
   5:     virtual void function() { printf("Base::function1\n"); } 
   6:  }; 
   7:  class Derived : public Base 
   8:  { 
   9:  public: 
  10:     void function() { printf("Derived::function1\n"); } 
  11:  }; 
  12:  int main() 
  13:  { 
  14:     printf("Base::function addr    = 0x%lx\n", &Base::function); 
  15:     printf("Derived::function addr = 0x%lx\n", &Derived::function); 
  16:     Base* pb = new Derived(); 
  17:     pb->function(); 
  18:     return 0; 
  19:  } 
./a.out 
Base::function addr    = 0x1 
Derived::function addr = 0x1 
Derived::function1 
我們看看它的輸出,成員函數的地址是0x1,這明顯不是一個合法的地址,更像是一個偏移量,爲什麼?暫且先不管爲什麼地址是0x1,不妨先分析下,下面這兩行代碼是如何工作的。 
   1:  Base* pb = new Derived(); 
   2:  pb->function(); 

派生類指針賦給了基類指針,調用function,但執行還是派生類的function,這就是多態了。那麼對於 pb->function(); 這個語句來說,編譯器是不能夠在編譯時期決定調用哪個function的。因爲它並不知道pb這個指針是通過派生類轉化而來。大家會說,我們上面的語句不是告訴它了嗎?這個肯定不行,編譯器不能做這個上下文關聯,你要是通過函數參數傳遞過來,賦值的地方離這條語句很遠甚至都不在一個源文件裏面怎麼辦?所以這個決定調用哪個function的信息,必須保存在內存裏面,運行期間就可以執行正確的函數。那麼具體保存在哪裏?如何工作的?gcc是這樣做的: 
1. 申請一段內存,存放虛函數的地址。就是一些書上所說的虛表。本質就是一個數組。 
2. 在對象的起始位置,存放虛表首地址,而不是像普通類對象那樣存放第一個非靜態數據成員。 
3.  pb->function(); 這條語句執行時,編譯器知道function是一個虛函數(我們聲明瞭virtual關鍵字),那麼就會採用虛函數的調用方法,首先根據pb找到虛表的首地址,然後加上一個偏移量,因爲是編譯器把function這個函數的地址放到虛表內的,所以它知道偏移量。我們通過下面這段代碼驗證這點: 
   1:  #include <cstdio> 
   2:  class Base 
   3:  { 
   4:  public: 
   5:     virtual void function1() { printf("Base::function1\n"); } 
   6:     virtual void function2() { printf("Base::function2\n"); } 
   7:  }; 
   8:  int main() 
   9:  { 
  10:     printf("Base::function addr    = 0x%lx\n", &Base::function1); 
  11:     printf("Base::function addr    = 0x%lx\n", &Base::function2); 
  12:     Base* pb = new Base; 
  13:     long* vtl = *(long**)pb;  
  14:     printf("0x%lx\n", *(vtl)); 
  15:     printf("0x%lx\n", *(vtl + 1)); 
  16:     return 0; 
  17:  } 
# ./a.out 
Base::function addr    = 0x1 
Base::function addr    = 0x9 
0x40082a 
0x400812 
# nm a.out | grep function 
000000000040082a W _ZN4Base9function1Ev 
0000000000400812 W _ZN4Base9function2Ev 
# c++filt _ZN4Base9function1Ev 
Base::function1() 
# c++filt _ZN4Base9function2Ev 
Base::function2() 
對象:    | 虛表地址|成員1 | 成員2 |   
虛表:    |虛函數1的地址|虛函數2的地址| 
代碼段:  |虛函數1|虛函數2| 
1. 通過long* vtl = *(long**)pb; 獲取pb對象第一個成員的內容,我們拿到了虛表的首地址vtl。  
2. printf("0x%lx\n", *(vtl)); 訪問虛表的第一個元素,打印的是0x40082a,恰好對應我們通過nm查看到的Base::function1的函數地址000000000040082a 。 
3. printf("0x%lx\n", *(vtl + 1)); 訪問虛表的第二個元素,打印的是0x400812,恰好對應我們通過nm查看到的Base::function2的函數地址0000000000400812 。 
那麼&Base::function1是0x1,&Base::function2是0x9,何解?其實怎麼解讀,完全看編譯器心情。。。從我們的實驗結果來看,gcc是把它解讀成了虛表偏移量+1。編譯器也是可以解讀爲函數的真實地址的。 
所謂多態,也就是這麼回事兒,其邏輯並不複雜,只是C++"封裝"了細節,只給我們展示了它的強大形象,讓我們覺得多態好神奇啊,其實丫的,本質就是函數指針,就是地址而已,因爲地址纔是CPU理解的東西。懂得這點,就知道內核裏到處都是多態,同樣是一個read操作,read不同的文件,執行不同的函數。。。內核就是在文件對象(C結構體)裏,保存了函數指針,不同的文件系統註冊不同的函數指針。內核是各種編程技術、思想的集大成者,OO思想隨處可見。 

單繼承

扯遠了,我們還是繼續回到C++,多態背後的內存佈局講完了,我們再進一步分析類繼承的。當派生類繼承了基類,也就擁有了基類的數據成員,那麼這些數據成員如何擺放?其實還能怎麼擺放?無非是順着來就好了。對就這樣,但是誰先誰後?是否順序無所謂?答案是,基類在前面派生類在後邊更合理。爲什麼是這樣?我們考慮下面的代碼: 
   1:  class Base 
   2:  { 
   3:  public: 
   4:  int b; 
   5:  }; 
   6:  class Derived : public Base 
   7:  { 
   8:  public: 
   9:  int d; 
  10:  }; 
  11:  int main() 
  12:  { 
  13:  Derived d; 
  14:  d.b = 2012; 
  15:  Base* b = &d; 
  16:  b->b = 2012; 
  17:  } 
我們把一個派生類對象地址,賦給基類指針,並且通過它訪問基類成員,如果派生類對象的內存佈局是,基類在後,即成員d在前面,然後成員b。    d.b = 2012; 這條語句沒有問題,編譯器知道b的位置,d起始位置加4即可。但是 b->b = 2012; 就沒法玩了,因爲編譯器不知道b是一個Derived對象(原因上面有說),那麼它就按b在Base中的偏移0,來算,而這個偏移,取到的其實是Derived::d的內容。如果反過來放,就沒問題了。通過下面的代碼,我們可以看到,確實是按基類優先的順序存放的。 
   1:  #include <cstdio> 
   2:  class Base 
   3:  { 
   4:  public: 
   5:     int b; 
   6:  }; 
   7:  class Derived : public Base 
   8:  { 
   9:  public: 
  10:     int d; 
  11:  }; 
  12:  int main() 
  13:  { 
  14:     Derived d; 
  15:     printf("Derived  = 0x%lx\n", &d); 
  16:     printf("Derived.b  = 0x%lx\n", &d.b); 
  17:     printf("Derived.d  = 0x%lx\n", &d.d); 
  18:     return 0; 
  19:  } 
# ./a.out 
Derived    = 0x7fffece35bf0 
Derived.b  = 0x7fffece35bf0 
Derived.d  = 0x7fffece35bf4 

對象:    | 基類的成員|派生類的成員|   

多繼承

終於來到了最神奇的地方,那就是多繼承,在討論多繼承的內存佈局之前,我忍不住要吐槽幾句。C++的設計哲學是大而全,實際上很多特性可能一輩子都用不到,我覺得一個好的編程語言,應該提供簡潔的語言特性和強大豐富的功能庫,比如Python。C++太不精簡了。實際上,所有C++程序其實都是C++子集程序員;但所有C程序員都是C全集程序員。C的語言特性基本沒有多餘的,C程序員基本都會用到。多繼承就是最多餘的C++特性之一。可能有些同學說,有些地方用多繼承很方便,不用不太好搞;沒這回事兒,那肯定是類設計出了問題,正是因爲語言支持這種特性,才導致一些糟糕的設計存在。要是C++不支持,編譯器編譯不過,你丫的會想不出來解決方案?好的語言特性可以直接引導程序員好的設計思維。比如Erlang不支持循環、不支持變量二次賦值…… 逼得程序員完全改變思維方式。。。結果就是寫出來的程序,自然支持多核、高併發,還無鎖。另外你看google的C++編程規範就知道,最重要的一部分就是對C++做減法,取子集。吐槽完畢,可能引起一片拍磚。。。(偶爾還是得拋一些觀點,否則只是純技術性的,太冷清了) 
單繼承的內存佈局,是基類成員在前,派生在後,但是多繼承呢?丫的有兩個基類,誰前誰後?誰前誰後不重要,關鍵的是根據上面單繼承分析,如果基類成員在派生類對象的位置不是從頭開始,派生類對像指針轉化爲基類指針之後,就不能正確訪問基類成員了。而多繼承,必然至少有一個基類不是從頭開始的。那麼怎麼辦?還能怎麼辦,涼拌!當你把一個派生類對象地址賦值給一個基類指針,如果這個基類在派生類中的位置,不是從頭開始的,編譯器偷偷的把它改變,加上基類在派生類中的位置偏移量!我們來驗證下: 
   1:  #include <cstdio> 
   2:  class Base1 
   3:  { 
   4:  public: 
   5:     int b1; 
   6:  }; 
   7:  class Base2 
   8:  { 
   9:  public: 
  10:     int b2; 
  11:  }; 
  12:  class Derived : public Base1, public Base2 
  13:  { 
  14:  public: 
  15:     int d; 
  16:  }; 
  17:  int main() 
  18:  { 
  19:     Derived d; 
  20:     printf("Derived  = 0x%lx\n", &d); 
  21:     printf("Derived.b1  = 0x%lx\n", &d.b1); 
  22:     printf("Derived.b2  = 0x%lx\n", &d.b2); 
  23:     printf("Derived.d  = 0x%lx\n", &d.d); 
  24:     Base2* b2p = &d; 
  25:     printf("Base2 pointer = 0x%lx\n", b2p); 
  26:     return 0; 
  27:  } 
 
# ./a.out 
Derived          =    0x7fffedfe10e0 
Derived.b1      =    0x7fffedfe10e0 
Derived.b2      =    0x7fffedfe10e4 
Derived.d        =    0x7fffedfe10e8 
Base2 pointer  =    0x7fffedfe10e4 

可以看到,擺放的順序是Base1,Base2,Derived: 
對象:| 基類1的成員 | 基類2的成員 | 派生類的成員 
而當我們把Derived的地址0x7fffedfe10e0賦給Base2時,變成了0x7fffedfe10e4,即Base2成員的起始位置,這樣我們的b2p->b2; 可以正確的工作。是不是很神奇?=號都是不可信的! 

多繼承+虛函數

如果在多繼承的基礎上有加上了虛函數怎麼辦?也就說多了一個虛表,假設兩個基類,gcc是這樣處理的: 
對象:| 虛表1的地址 | 基類1的成員 | 虛表2的地址 | 基類2的成員 | 派生類的成員 
其中虛表1中存放是派生類重載的虛函數地址,無論來自於基類1還是基類2。虛表2只存放基類2的重載函數地址(實際上GCC幫你生成了一箇中間函數,中間函數再去調用實際的函數)。 
   1:  #include <cstdio> 
   2:  class Base1 
   3:  { 
   4:  public: 
   5:  int b1; 
   6:  virtual void function1() { printf("Base1::function1\n"); } 
   7:  }; 
   8:  class Base2 
   9:  { 
  10:  public: 
  11:  int b2; 
  12:  virtual void function2() { printf("Base2::function2\n"); } 
  13:  }; 
  14:  class Derived : public Base1, public Base2 
  15:  { 
  16:  public: 
  17:  int d; 
  18:  void function1() { printf("Derived::function1\n"); } 
  19:  void function2() { printf("Derived::function2\n"); } 
  20:  }; 
  21:  int main() 
  22:  { 
  23:  Derived d; 
  24:  printf("Derived = 0x%lx\n", &d); 
  25:  printf("Derived.b1 = 0x%lx\n", &d.b1); 
  26:  printf("Derived.b2 = 0x%lx\n", &d.b2); 
  27:  printf("Derived.d = 0x%lx\n", &d.d); 
  28:  Base2* b2p = &d; 
  29:  printf("Base2 pointer = 0x%lx\n", b2p); 
  30:  long* vtl = *(long**)b2p; 
  31:  printf("0x%lx\n", *(vtl)); 
  32:  printf("0x%lx\n", *(vtl + 1)); 
  33:  vtl = *(long**)&d; 
  34:  printf("0x%lx\n", *(vtl)); 
  35:  printf("0x%lx\n", *(vtl + 1)); 
  36:  return 0; 
  37:  } 
# ./a.out 
Derived = 0x7fffa74ae400 
Derived.b1 = 0x7fffa74ae408//b1沒有放在最開始,因爲第一個是虛表地址 
Derived.b2 = 0x7fffa74ae418//b2沒有放在b1後面,因爲前邊還有一個虛表地址 
Derived.d = 0x7fffa74ae41c 
Base2 pointer = 0x7fffa74ae410//base2在派生類中的起始位置, 
0x4008aa//虛表2中存放的函數地址,gcc生成的中間函數 
0x0//虛表2中存放的函數地址&nbsp; 
0x4008c8//虛表1中存放的函數地址,function1 
0x4008b0//虛表1中存放的函數地址,function2 
# nm a.out |grep function 
00000000004008e0 W _ZN5Base19function1Ev 
00000000004008f8 W _ZN5Base29function2Ev 
00000000004008c8 W _ZN7Derived9function1Ev 
00000000004008b0 W _ZN7Derived9function2Ev 
00000000004008aa W _ZThn16_N7Derived9function2Ev 
# c++filt _ZN7Derived9function1Ev _ZN7Derived9function2Ev _ZThn16_N7Derived9function2Ev 
Derived::function1() 
Derived::function2() 
non-virtual thunk to Derived::function2() 
# objdump -d a.out | sed -n '/_ZThn16_N7Derived9function2Ev/,/00000/p' 
00000000004008aa <_ZThn16_N7Derived9function2Ev>: 
4008aa: 48 83 c7 f0 add $0xfffffffffffffff0,%rdi 
4008ae: eb 00 jmp 4008b0 <_ZN7Derived9function2Ev>//中間函數跳轉到了function2 
00000000004008b0 <_ZN7Derived9function2Ev>: 

瞭解C++內存佈局的意義

意義至少有一點,讓我們寫出更好的C++程序。內存佈局越複雜,性能越差,所以你會知道該如何選擇。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章