寫在前面的話
自從拙文《從彙編層面深度剖析C++虛函數》
見於csdn首頁後,受到很多讀者的好評。本人甚是高興,並且打算從彙編的角度來分析C++中的語言構造,比如對象的布局,多繼承下的物件版面配置以及它們的虛函數表的結構,我想這些都是C++開發人員所感興趣的。
此外,本人目前從事的是Linux平台下的C語言系統開發,工作中並沒有真正寫過C++程式,因此,文中所使用的術語和分析未必見得準確無誤,還請各位多多指正。
使用哪個彙編文法形式才適合讀者呢?這是很多讀者看不明白上篇文章的原因之一,很多讀者都不瞭解AT&T的彙編語式格式。因此,我打算選用g++編譯器+intel文法形式的彙編來產生相應的彙編代碼,並相應翻譯成C語言代碼。
1. C++基本對象的組成
儘管C++社區最近十年興起元編程範式和模板技術,物件導向編程仍是C++語言最基礎,也是最重要的一種編程範式(paradigms)。C++的對象,將過程式的資料結構,以及操作在它們之上的函數,綁定到一個單一的文法單元,那就是類(class)。C++世界裡,活生生的個體聯絡就是所有類的對象所進行的訊息傳遞,對象協作而構成千變萬化的世界。但對象的內部結構,對象的產生和消亡,資料成員和函數成員的結構,困擾了無數初學者。
C++基本對象由以下幾方面元素組成:
對象資料成員(非待用資料成員)
類資料成員(待用資料成員)
函數成員
靜態函數成員
這裡,我們不考慮類的單繼承,多重繼承以及虛函數等複雜的特性。
下面我們對C++基本對象進行抽絲剝繭,深入分析對象的記憶體布局,與之相關的靜態變數,成員函數。
2. 一個簡單的例子
為簡單起見,本文以class point為例子,它包含建構函式,解構函式,函數成員,物件變數,類變數和靜態成員函數等文法結構。它的代碼如下:
class point<br />{<br /> public:<br /> point(int x, int y)<br /> {<br /> this->x = x;<br /> this->y = y;<br /> ins_cnt++;<br /> }<br /> ~point()<br /> {<br /> ins_cnt--;<br /> }<br /> static int get_instances()<br /> {<br /> return ins_cnt;<br /> }<br /> point & move(int addx, int addy)<br /> {<br /> this->x += addx;<br /> this->y += addy;<br /> return *this;<br /> }<br /> private:<br /> int x, y;<br /> static int ins_cnt;<br />};<br />int point::ins_cnt = 0;<br />int main()<br />{<br /> point x(3, 4);<br /> x.move(10, 10);<br /> int p_ins_cnt = point::get_instances();<br /> return 0;<br />}
3. 編譯產生彙編檔案和可執行檔
g++命令列提供了簡便方式來產生這兩種檔案,我們在下面根據實際需要來對這兩檔案進行分析,從而深入理解point對象的記憶體布局。
g++命令使用如下:
[lyt@t468 ~]$ g++ -g -o object object.cpp
[lyt@t468 ~]$ g++ -g -S -o object.s object.cpp
object.s檔案產生的彙編比較淩亂,因為它裡面的符號還未重定位,只是使用一些符號來表示某些以後要分配記憶體的變數,編譯器使用的變數或符號。因此,我們可以利用它來分析某些C++變數經編譯器處理後,在彙編層面上的符號名稱。
object檔案可用來供gdb調試工具來使用,gdb可以對原始碼以函數為單位,對每一行語句進行反組譯碼。
4. 所有與point類相關的符號
C++原始碼產生可執行檔(linux下稱為ELF格式檔案)後,它專門有一個符號節區來記錄執行檔案中各個符號的類型,地址等相關資訊。為了便於分析,我們使用readelf工具對產生的object檔案,找出與point類相關的所有符號,以及使用c++filt工具,將這些符號轉變成C++語言層級上的語義,如下:
[lyt@t468 ~]$ readelf -s object | grep point
41: 08048530 10 FUNC WEAK DEFAULT 13 _ZN5point13get_instancesE
49: 0804853a 40 FUNC WEAK DEFAULT 13 _ZN5point4moveEii
56: 080484fa 35 FUNC WEAK DEFAULT 13 _ZN5pointC1Eii
58: 0804851e 18 FUNC WEAK DEFAULT 13 _ZN5pointD1Ev
59: 0804a01c 4 OBJECT GLOBAL DEFAULT 25 _ZN5point7ins_cntE
[lyt@t468 ~]$ c++filt _ZN5point13get_instancesE
point::get_instances
[lyt@t468 ~]$ c++filt _ZN5point4moveEii
point::move(int, int)
[lyt@t468 ~]$ c++filt _ZN5pointC1Eii
point::point(int, int)
[lyt@t468 ~]$ c++filt _ZN5pointD1Ev
point::~point()
[lyt@t468 ~]$ c++filt _ZN5point7ins_cntE
point::ins_cnt
從上面的結果可以看出來,point類的建構函式,解構函式,move成員函數,get_instances靜態成員函數都對應一個函數符號。而令我們感到意外的是,point類的靜態變數ins_cnt也對應一個全域變數符號,它的地址是0804a01c;下面對地址0804a01c 的讀寫組合語言,都意味著相應的C++函數讀寫該變數,也即point類的靜態變數。
5. point對象的記憶體布局和建構函式
對象的生命始於建構函式,而在執行建構函式之前,對象還處於混沌狀態。在建構函式裡面,它按對象記憶體所包含的每個成員依次進行初始化,因此我們從對象的建構函式就可以一窺它的記憶體布局。
為了方便大家較對C++原始碼和彙編代碼,使用gdb對point類的建構函式按原始碼行進行反組譯碼。結果如下:
(gdb) disassemble /m _ZN5pointC1Eii<br />Dump of assembler code for function point:<br />5 point(int x, int y)<br />0x080484fa <point+0>: push ebp<br />0x080484fb <point+1>: mov ebp,esp<br />6 {<br />7 this->x = x;<br />0x080484fd <point+3>: mov eax,DWORD PTR [ebp+0x8]<br />0x08048500 <point+6>: mov edx,DWORD PTR [ebp+0xc]<br />0x08048503 <point+9>: mov DWORD PTR [eax],edx<br />8 this->y = y;<br />0x08048505 <point+11>: mov eax,DWORD PTR [ebp+0x8]<br />0x08048508 <point+14>: mov edx,DWORD PTR [ebp+0x10]<br />0x0804850b <point+17>: mov DWORD PTR [eax+0x4],edx<br />9 ins_cnt++;<br />0x0804850e <point+20>: mov eax,ds:0x804a01c<br />0x08048513 <point+25>: add eax,0x1<br />0x08048516 <point+28>: mov ds:0x804a01c,eax<br />10 }<br />0x0804851b <point+33>: pop ebp<br />0x0804851c <point+34>: ret<br />End of assembler dump.
為了讓大家更清楚建構函式到底作了什麼事情,我對上面的彙編語句逐行分析:
7 this->x = x;
0x080484fd <point+3>: mov eax,DWORD PTR [ebp+0x8]
0x08048500 <point+6>: mov edx,DWORD PTR [ebp+0xc]
0x08048503 <point+9>: mov DWORD PTR [eax],edx
mov eax,DWORD PTR [ebp+0x8] 將函數第一個參數的值存放到寄存器eax中
mov edx,DWORD PTR [ebp+0xc] 將函數第二個參數的值存放到寄存器edx中
mov DWORD PTR [eax],edx 將edx寄存器的值寫到eax所指向的記憶體中
結合this->x = x;這個C++代碼,我們可以大膽推測,point建構函式產生彙編後,它對應的函數名(或者符號名)為
_ZN5pointC1Eii。該函數的第一個參數為this,類型為point類記憶體布局的表示類型,我們姑且稱為struct point *類型;第二參數為int類型的x。
接下來的this->y = y;語句的反組譯碼,與上面this->x = x; 語句如同一轍,唯有x和y在point對象的記憶體位移量不同。
從而得出,x成員在point對象記憶體的位移量為0,而y的為4。
比較迷惑的是最後這句:
9 ins_cnt++;<br />0x0804850e <point+20>: mov eax,ds:0x804a01c<br />0x08048513 <point+25>: add eax,0x1<br />0x08048516 <point+28>: mov ds:0x804a01c,eax
第一個mov是將記憶體0x804a01c的值讀到eax中,add指令是將eax加1,最後一個mov是將eax最後的值寫回到記憶體中。還記得0x804a01c是哪個符號的地址嗎?沒錯,它就是point類靜態變數ins_cnt的地址。
由此,我們可以使用point類的對象在記憶體的布局如下:
struct point {<br /> int x;<br /> int y;<br />};<br />// point::ins_cnt 變數,在彙編層面上,它是一個全域變數<br />int point_ins_cnt = 0;
它的建構函式翻譯成如下:
void point::point(struct point *this, int x, int y)<br />{<br /> this->x = x;<br /> this->y = y;<br /> point_ins_cnt++;<br />}
正如你早已知道的秘密,C++編譯器悄悄地將你寫的非靜態
函數
成員(當然包括建構函式的解構函式)加上this指標作為第一個參數,這就是C++資料上所說的this隱藏參數。在彙編的曝光下,這一切都真相大白了。
下面是move成員函數反組譯碼的結果,如有不明白,可以對比分析一下:
(gdb) disassemble /m _ZN5point4moveEii<br />Dump of assembler code for function _ZN5point4moveEii:<br />22 point & move(int addx, int addy)<br />0x0804853a <_ZN5point4moveEii+0>: push ebp<br />0x0804853b <_ZN5point4moveEii+1>: mov ebp,esp<br />23 {<br />24 this->x += addx;<br />0x0804853d <_ZN5point4moveEii+3>: mov eax,DWORD PTR [ebp+0x8]<br />0x08048540 <_ZN5point4moveEii+6>: mov eax,DWORD PTR [eax]<br />0x08048542 <_ZN5point4moveEii+8>: mov edx,eax<br />0x08048544 <_ZN5point4moveEii+10>: add edx,DWORD PTR [ebp+0xc]<br />0x08048547 <_ZN5point4moveEii+13>: mov eax,DWORD PTR [ebp+0x8]<br />0x0804854a <_ZN5point4moveEii+16>: mov DWORD PTR [eax],edx<br />25 this->y += addy;<br />0x0804854c <_ZN5point4moveEii+18>: mov eax,DWORD PTR [ebp+0x8]<br />0x0804854f <_ZN5point4moveEii+21>: mov eax,DWORD PTR [eax+0x4]<br />0x08048552 <_ZN5point4moveEii+24>: mov edx,eax<br />0x08048554 <_ZN5point4moveEii+26>: add edx,DWORD PTR [ebp+0x10]<br />0x08048557 <_ZN5point4moveEii+29>: mov eax,DWORD PTR [ebp+0x8]<br />0x0804855a <_ZN5point4moveEii+32>: mov DWORD PTR [eax+0x4],edx<br />26<br />27 return *this;<br />0x0804855d <_ZN5point4moveEii+35>: mov eax,DWORD PTR [ebp+0x8]<br />28 }<br />0x08048560 <_ZN5point4moveEii+38>: pop ebp<br />0x08048561 <_ZN5point4moveEii+39>: ret<br />End of assembler dump.
6. 靜態成員函數
是否還記得靜態函數成員不能使用非靜態變數成員?為什麼不能使用非靜態變數成員呢?原因很簡單,是因為靜態函數成員沒有this參數。C++的靜態函數成員,和待用資料成員一樣,是屬於類的,而不是屬於對象的,訪問它們時,不需要使用任何現成的對象,直接使用<class-name>::<member>形式即可,所以它的函數不需要this指標。
下面point::get_instances()函數反組譯碼的結果:
(gdb) disassemble /m _ZN5point13get_instancesEv<br />Dump of assembler code for function _ZN5point13get_instancesEv:<br />17 static int get_instances()<br />0x08048530 <_ZN5point13get_instancesEv+0>: push ebp<br />0x08048531 <_ZN5point13get_instancesEv+1>: mov ebp,esp<br />18 {<br />19 return ins_cnt;<br />0x08048533 <_ZN5point13get_instancesEv+3>: mov eax,ds:0x804a01c<br />20 }<br />0x08048538 <_ZN5point13get_instancesEv+8>: pop ebp<br />0x08048539 <_ZN5point13get_instancesEv+9>: ret<br />End of assembler dump.
在函數體內,沒有從堆棧裡面讀取任何參數資訊,我們可以認為該函數是沒有帶參數,即它的參數型類為void。其實我們可以從調用該函數的地方去驗證。下面是main函數反組譯碼的部分結果:
39 x.move(10, 10);<br />0x080484ba <main+38>: mov DWORD PTR [esp+0x8],0xa<br />0x080484c2 <main+46>: mov DWORD PTR [esp+0x4],0xa<br />0x080484ca <main+54>: lea eax,[esp+0x14]<br />0x080484ce <main+58>: mov DWORD PTR [esp],eax<br />0x080484d1 <main+61>: call 0x804853a <_ZN5point4moveEii><br />40<br />41 int p_ins_cnt = point::get_instances();<br />0x080484d6 <main+66>: call 0x8048530 <_ZN5point13get_instancesEv><br />0x080484db <main+71>: mov DWORD PTR [esp+0x1c],eax
在x.move(10, 10);調用時,它使用了兩個mov …, 0xa將常量10壓入堆棧中,作為_ZN5point4moveEii函數的第二和第三個參數,第一個當然是this拉。
而x.move(10, 10) 調用完後,它接著call _ZN5point13get_instancesEv,說明_ZN5point13get_instancesEv函數不帶任何參數。
因此point::get_instances()函數翻譯成C語言代碼相應如下:
int point::get_instances(void)<br />{<br /> return point_ins_cnt;<br />}<br />
7. 總結
不考慮C++虛函數,繼承等文法功能後的C++基本對象記憶體配置模式格外簡單。具有以下特點:
1)class內定義的非待用資料成員,它將佔用對象的記憶體,它的布局類似於一種相應的結構體定義相應的字元。
2) class內定義的待用資料成員,它是類變數,每種類只有唯一的一份,它以全域變數的身份擠身於全域變數列表。當然g++可根據它的初始化值是否為0來安排它放在.bss節區還是.data節區。
3)非靜態函數成員,不佔用對象的記憶體,它經C++編譯器處理後,它是一個全域函數,它的第一個參數為this指標,其餘參數類型和名字,與使用者定義的一致。
4) 靜態函數成員,同樣不佔用對象的記憶體,它經C++編譯器處理後,它是一個全域函數,它沒有this指標,它的參數類型和名字與使用者定義的一致。
8. 給讀者的問題
看到這樣,你是不是明白了C++基本對象記憶體布局的裡裡外外?其實,上面的分析中,還少考慮了一種函數,那就是const成員函數。你能說出成員函數和const成員函數在反組譯碼後的區別嗎?成員函數和const成員函數的重載關係能否轉換成函數參類間類型不同的重載關係。