C++與彙編小結
本文通過C++反編譯,幫助理解C++中的一些概念(指針引用、this指針、虛函數、析構函數、lambda表達式),
希望能在深入理解C++其它一些高級特性(多重繼承、RTTI、異常處理)能起到拋磚引玉的作用吧。
指針和引用
引用類型的存儲方式和指針是一樣的,都是使用內存空間存放地址值。
只是引用類型是通過編譯器實現尋址,而指針需要手動尋址。
void funRef(int &ref){
ref++;
}
int main(){
//定義int類型變量
int var = 0x41;
//int指針變量,初始化爲變量var的地址
int *pnVar = &var;
//取出指針pcVar指向的地址內容並顯示
char *pcVar = (char*)&var;
printf("%s",pcVar);
//引用作爲參數,即把var的地址作爲參數
funRef(var);
return 0;
}
用godbolt查看的效果如圖,C++代碼與對應的彙編代碼用相同的顏色標註,非常方便查看。
switch
在分支數少的情況下可以用if–else if模擬,
但是分支比較大的情況下,需要比較的次數太多,
如果是有序線性的數值,可將每個case語句塊的地址預先保存在數組中,
考察switch語句的參數,並依次查詢case語句塊地址的數組,
從而得到對應case語句塊的首地址,
這樣可以降低比較的次數,提升效率。
int main(){
int nIdx=1;
scanf("%d",&nIdx);
int result = 0;
switch(nIdx){
case 1:
result = 1;
break;
case 2:
result = 2;
break;
case 3:
result = 3;
break;
case 5:
result = 3;
break;
case 7:
result = 3;
break;
}
return result;
}
如下圖,編譯器把switch跳轉表放到了.L4所指向的區域,其中的元素.L2、.L3 … .L8指向case對應代碼地址。
this指針
this指針中保存了所屬對象的首地址。
在調用成員函數的過程中,編譯器利用rdi寄存器保存了對象的首地址,
並以寄存器傳參的方式傳遞到成員函數中。
#include <stdio.h>
class Location{
public:
Location(){
//this指針指向一塊16字節的內存區域
m_x = 1;
//m_x是一個8字節類型,所以mov一個4字
//mov DWORD PTR [rax], 1
m_y = 2;
//mov WORD PTR [rax+4], 2
}
short getY(){
//獲取this指針(對象首地址)偏移4處的數據,即m_y的值
//movzx eax, WORD PTR [rbp-8+4]
return m_y;
}
private:
int m_x; //佔4字節
short m_y; //佔2字節
//由於內存對齊,整個對象佔8字節
};
int main(){
//在棧上分配16字節,其中有8字節分配給是loc
//把棧上loc的內存地址(即this指針)作爲參數調用Location構造函數。
Location loc;
//把棧上loc的內存地址(即this指針)作爲參數調用getY成員函數。
short y = loc.getY();
//y變量位於[rbp-2]處
//mov WORD PTR [rbp-2], ax
return 0;
}
對應的彙編如下:
虛函數和虛表
編譯器會爲每一個包含虛函數的類(或通過繼承得到的子類)生成一個表,其中包含指向類中每一個虛函數的指針。
這樣的表就叫做虛表(vtable)。
此外,每個包含虛函數的類都獲得另外一個數據成員,用於在運行時指向適當的虛表。
這個成員通常叫做虛表指針(vtable pointer),並且是類中的第一個數據成員。
在運行時創建對象時,對象的虛表指針將設置爲指向合適的虛表。
如果該對象調用一個虛函數,則通過在該對象的虛表中進行查詢來選擇正確的函數。
代碼舉例如下,詳細代碼在這裏。
class BaseClass {
public:
BaseClass(){x=1;y=2;};
virtual void vfunc1() = 0;
virtual void vfunc2(){};
virtual void vfunc3();
virtual void vfunc4(){};
void hello(){printf("hello,y=%d",this->y);};
private:
int x;//4字節
int y;
};
class SubClass : public BaseClass {
public:
SubClass(){z=3;};
virtual void vfunc1(){};
virtual void vfunc3();
virtual void vfunc5(){};
private:
int z;
};
虛表佈局
下圖是一個簡化後的內存佈局,它動態分配了一個SubClass類型的對象,編譯器會確保該對象的第一個字段虛表指針指向正確的虛表。虛表指向編譯器爲每個類在只讀段創建的一塊區域,即虛表,類似於數組,其中的大部分元素指向在代碼段中的成員函數地址。C++編譯器會在編譯階段給這些函數名做name mangling,以實現c++中函數重載、namespace等標準。
vtable for SubClass:
.quad 0
.quad typeinfo for SubClass ;RTTI相關
.quad SubClass::vfunc1() ;this指針中的虛表指針一般指向這個偏移處
.quad BaseClass::vfunc2()
.quad SubClass::vfunc3()
.quad BaseClass::vfunc4()
.quad SubClass::vfunc5()
vtable for BaseClass:
.quad 0
.quad typeinfo for BaseClass ;RTTI相關
.quad __cxa_pure_virtual ;vfunc1是純虛函數
.quad BaseClass::vfunc2()
.quad BaseClass::vfunc3()
.quad BaseClass::vfunc4()
SubClass 中包含兩個指向屬於BaseClass的函數( BaseClass::vfunc2 和 BaseClass::vfunc4)的指針。
這是因爲 SubClass 並沒有重寫這2個函數,而是直接繼承自BaseClass 。
由於沒有針對純虛函數BaseClass::vfunc1的實現,因此,在 BaseClass的虛表中並沒有存儲 vfunc1 的地址。
這時,編譯器會插入一個錯誤處理函數的地址,名爲 purecall,萬一被調用,它會令程序終止或者其他編譯器想要發生的行爲。
另外,一般的成員函數不在虛表裏面,因爲不涉及動態調用,如BaseClass中的hello()函數。
創建對象
這裏已在堆上動態創建對象爲例。
調用new操作符,在堆上動態分配一塊SubClass大小的內存,rax指向這塊內存的開始。
SubClass需要的內存大小爲24字節=8(虛表指針)+4*3(3個int類型的成員變量)+4(內存對齊)
對象首地址的值作爲參數調用SubClass構造函數。
BaseClass *a_ptr = new SubClass();
main:
;...
mov edi, 24 ;SubClass需要24字節的內存
call operator new(unsigned long)
mov rbx, rax
mov rdi, rbx ;this指針作爲參數
call SubClass::SubClass()
SubClass的構造函數,在完成自身的任務之前會調用基類的構造函數,然後對this指針的內存的虛表指針修改爲指向SubClass自身的虛表。
SubClass::SubClass():
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR [rbp-8], rdi
mov rax, QWORD PTR [rbp-8]
mov rdi, rax ;this指針
call BaseClass::BaseClass()
mov edx, OFFSET FLAT:vtable for SubClass+16 ;指向SubClass虛表
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], rdx ;this指針的虛表指針字段賦值
mov rax, QWORD PTR [rbp-8]
mov DWORD PTR [rax+16], 3 ;z=3
nop
leave
ret
BaseClass的構造函數:
BaseClass::BaseClass():
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi ;this指針
mov edx, OFFSET FLAT:vtable for BaseClass+16 ;指向BaseClass虛表
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], rdx ;this指針的虛表指針字段賦值
mov rax, QWORD PTR [rbp-8]
mov DWORD PTR [rax+8], 1 ;x=1
mov rax, QWORD PTR [rbp-8]
mov DWORD PTR [rax+12], 2 ;y=2
nop
pop rbp
ret
調用成員函數
1、非虛函數
hello()是類BaseClass中的非虛成員函數,不需要通過虛表查找,編譯器直接生成調用語句call BaseClass::hello()
,並且第一個參數默認爲this指針。
BaseClass *a_ptr = new SubClass();
//一般的成員函數,不在虛表裏
a_ptr->hello();
main:
;...
mov rdi, rax ;參數:this指針
call BaseClass::hello()
.LC0:
.string "hello\357\274\214y=%d"
BaseClass::hello():
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR [rbp-8], rdi ;this指針放到棧上
mov rax, QWORD PTR [rbp-8]
mov eax, DWORD PTR [rax+12] ;this指針偏移12處,即成員變量y的位置
mov esi, eax ;參數:format的數據,即y的值
mov edi, OFFSET FLAT:.LC0 ;參數:format string
mov eax, 0 ;參數:fd,指向stdout
call printf
nop
leave
ret
2、虛函數
a_ptr是BaseClass類型的指針,動態分配的是SubClass類型的內存。
call_vfunc函數的參數是基類BaseClass,再調用vfunc3函數時需要先根據虛表指針定位到虛表,再通過偏移,解引用找到vfunc3的代碼段地址,完成調用。
int main(){
BaseClass *a_ptr = new SubClass();
//對象首地址作爲參數調用函數call_vfunc
call_vfunc(a_ptr);
}
void call_vfunc(BaseClass *a) {
a->vfunc3();
}
main:
;...
mov rax, QWORD PTR [rbp-24]
mov rdi, rax ;rax爲this指針
call call_vfunc(BaseClass*)
call_vfunc(BaseClass*):
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR [rbp-8], rdi ;把this指針放到棧上
mov rax, QWORD PTR [rbp-8]
mov rax, QWORD PTR [rax] ;this指向的內存的開頭8個字節的數據複製給rax,即虛表指針
add rax, 16 ;找到虛表指針偏移16
mov rax, QWORD PTR [rax] ;虛表指針偏移16處解引用,得到函數的SubClass::vfunc3的地址
mov rdx, QWORD PTR [rbp-8]
mov rdi, rdx ;rdi爲this指針,作爲參數
call rax ;調用vfunc3
nop
leave
ret
析構函數
這裏以堆分配的對象析構爲例,完整代碼在這裏。
堆分配的對象的析構函數在分配給對象的內存釋放之前通過 delete 操作符調用。
其過程如下:
1、如果類擁有任何虛函數,則還原對象的虛表指針,使其指向相關類的虛表。如果一個子類在創建過程中覆蓋了虛表指針,就需要這樣做。
2、執行程序員爲析構函數指定的代碼。
3、如果類擁有本身就是對象的數據成員,則執行這些成員的析構函數。
4、如果對象擁有一個超類,則調用超類的析構函數
5、如果是釋放堆的對象,則用一個代理析構函數執行1~4步驟,並在最後調用delete操作符釋放堆上的對象。
class BaseClass {
public:
BaseClass(){x=1;y=2;};
virtual ~BaseClass(){printf("~BaseClass()\n");};
virtual void vfunc1() = 0;
private:
int x;//4字節
int y;
};
class SubClass : public BaseClass {
public:
SubClass(){z=3;};
virtual ~SubClass(){printf("~SubClass()\n");};
virtual void vfunc1(){};
private:
int z;
};
int main() {
BaseClass *a_ptr = new SubClass();
//觸發析構
delete a_ptr;
}
;只讀段中的虛表結構
vtable for SubClass:
.quad 0
.quad typeinfo for SubClass
.quad SubClass::'scalar deleting destructor' ;代理析構函數的地址
.quad SubClass::~SubClass() ;析構函數的地址,這裏godbolt沒有把它們區分出來
.quad SubClass::vfunc1()
main:
mov QWORD PTR [rbp-24], rbx ;rbx爲a_ptr的指針
cmp QWORD PTR [rbp-24], 0 ;判斷a_ptr是否爲null,這是編譯器加的。
je .L9 ;如果爲null直接跳過析構
mov rax, QWORD PTR [rbp-24]
mov rax, QWORD PTR [rax]
add rax, 8 ;this指針偏移8處,即指向代理析構函數
mov rax, QWORD PTR [rax] ;rax爲代理析構函數的地址
mov rdx, QWORD PTR [rbp-24]
mov rdi, rdx ;參數:this指針
call rax ;調用代理析構函數
;SubClass的代理析構函數
SubClass::'scalar deleting destructor':
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR [rbp-8], rdi
mov rax, QWORD PTR [rbp-8]
mov rdi, rax ;參數:this指針
call SubClass::~SubClass() ;調用析構函數
mov rax, QWORD PTR [rbp-8]
mov esi, 24 ;參數:釋放24字節大小的堆空間
mov rdi, rax ;參數:堆空間的首地址
call operator delete(void*, unsigned long) ;釋放堆空間
leave
ret
.LC1:
.string "~SubClass()"
;SubClass的析構函數,執行析構函數中的代碼
SubClass::~SubClass():
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR [rbp-8], rdi
mov edx, OFFSET FLAT:vtable for SubClass+16
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], rdx ;還原對象的虛表指針,使其指向相關類的虛表
mov edi, OFFSET FLAT:.LC1
call puts ;調用puts函數,這裏編譯器把printf調用轉換成puts了。
mov rax, QWORD PTR [rbp-8]
mov rdi, rax ;參數:this指針
call BaseClass::~BaseClass() ;調用基類的析構函數
nop
leave
ret
.LC0:
.string "~BaseClass()"
;BaseClass的析構函數,執行析構函數中的代碼
BaseClass::~BaseClass():
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR [rbp-8], rdi
mov edx, OFFSET FLAT:vtable for BaseClass+16
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], rdx
mov edi, OFFSET FLAT:.LC0
call puts
nop
leave
ret
通過分析C++析構函數的調用過程,我們就知道了爲什麼C++基類的析構函數要聲明爲virtual了。我們希望當調用C++基類BaseClass的析構函數時能夠觸發動態綁定,能夠找到當前對象所屬類的虛函數表中的析構函數。
如果不聲明BaseClass的析構函數爲virtual,那麼在調用delete a_ptr
時,將只會釋放BaseClass大小的內存,給SubClass中成員變量分配的內存將得不到釋放,從而導致內存泄漏。
C++11中的Lambda表達式
lambda表達式表示一個可調用的代碼單元。可以理解爲一個未命名的內聯函數。
lambda表達式具有如下形式:
[capture list](parameter list) -> return type {function body}
下面定義了一個C++函數,其中有一個lambda表達式。v1之前的&符號指出v1是以引用方式捕獲,當lambda返回v1時,它返回的是v1指向對象的值,所以j的值是0,而不是42.
void fcn1(){
int v1 =42;
auto f= [&v1] {return v1;};
v1 = 0;
auto j = f();
}
對應的反彙編代碼如下,可以看到編譯器爲fcn1中的lambda表達式在代碼段中生成了一段指令,當調用這個lambda時就會執行到這段指令,跟普通的函數調用一致。
可以看出傳遞給fcn1()::{lambda()#1}
函數的參數rdi的值其實就是v1變量的地址,所以這個lambda是是採用引用方式捕獲變量的。
.Ltext0:
fcn1()::{lambda()#1}::operator()() const:
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi
mov rax, QWORD PTR [rbp-8]
mov rax, QWORD PTR [rax]
mov eax, DWORD PTR [rax]
pop rbp
ret
fcn1():
push rbp
mov rbp, rsp
sub rsp, 16
mov DWORD PTR [rbp-8], 42
lea rax, [rbp-8]
mov QWORD PTR [rbp-16], rax
mov DWORD PTR [rbp-8], 0
lea rax, [rbp-16]
mov rdi, rax
call fcn1()::{lambda()#1}::operator()() const
mov DWORD PTR [rbp-4], eax
nop
leave
ret
參考
《IDA Pro權威指南》
《C++反彙編與逆向分析技術揭祕》
《C++ Primer(第5版)》