結(jié)合實(shí)例深入理解C++對象的內(nèi)存布局
作者 | daemonzhao
通過實(shí)例來深入理解 C++ 對象的內(nèi)存布局,包括基礎(chǔ)數(shù)據(jù)類、帶方法的類、私有成員、靜態(tài)成員、類繼承等。通過 GDB 查看對象的內(nèi)存布局,探討成員變量、成員方法、虛函數(shù)表等在內(nèi)存中的存儲位置和實(shí)現(xiàn)細(xì)節(jié),幫助大家對 C++ 類成員變量和函數(shù)在內(nèi)存布局有個直觀的理解。
因?yàn)槎M(jìn)制使用了不同版本的 proto 對象,對象的內(nèi)存布局不一致導(dǎo)致讀、寫成員的內(nèi)存地址錯亂,進(jìn)而導(dǎo)致進(jìn)程 crash 掉。這之中會出現(xiàn)下面的問題:
- 對象在內(nèi)存中是怎么布局的?
- 成員方法是如何拿到成員變量的地址?
這些其實(shí)涉及 C++ 的對象模型,《深度探索 C++對象模型:Inside the C++ Object Model》這本書全面聊了這個問題,非常值得一讀。不過這本書讀起來并不容易,有的內(nèi)容讀過后如果沒有加以實(shí)踐,也很難完全理解。本篇文章試著從實(shí)際的例子出發(fā),幫助大家對 C++ 類成員變量和函數(shù)在內(nèi)存布局有個直觀的理解,后面再讀這本書也會容易理解些。
簡單對象內(nèi)存分布
首先以一個最簡單的 Basic 類為例,來看看只含有基本數(shù)據(jù)類型的對象是怎么分配內(nèi)存的。
#include <iostream>
using namespace std;
class Basic {
public:
int a;
double b;
};
int main() {
Basic temp;
temp.a = 10;
return 0;
}
編譯運(yùn)行后,可以用 GDB 來查看對象的內(nèi)存分布。如下圖:
對象 temp 的起始地址是 0x7fffffffe3b0,這是整個對象在內(nèi)存中的位置。成員變量 a 的地址也是 0x7fffffffe3b0,表明 int a 是對象 temp 中的第一個成員,位于對象的起始位置。成員變量 b 的類型為 double,其地址是 0x7fffffffe3b8(a 的地址+8),內(nèi)存布局如下圖:
這里 int 類型在當(dāng)前平臺上占用 4 個字節(jié)(可以用 sizeof(int)驗(yàn)證),而這里 double 成員的起始地址與 int 成員的起始地址之間相差 8 個字節(jié),說明在 a 之后存在內(nèi)存對齊填充(具體取決于編譯器的實(shí)現(xiàn)細(xì)節(jié)和平臺的對齊要求)。內(nèi)存對齊要求數(shù)據(jù)的起始地址在某個特定大小(比如 4、8)的倍數(shù)上,這樣可以優(yōu)化硬件和操作系統(tǒng)訪問內(nèi)存的效率。這是因?yàn)樵S多處理器訪問對齊的內(nèi)存地址比訪問非對齊地址更快。
另外在不進(jìn)行內(nèi)存對齊的情況下,較大的數(shù)據(jù)結(jié)構(gòu)可能會跨越多個緩存行或內(nèi)存頁邊界,這會導(dǎo)致額外的緩存行或頁的加載,降低內(nèi)存訪問效率。不過大多時候我們不需要手動管理內(nèi)存對齊,編譯器和操作系統(tǒng)會自動處理這些問題。
帶方法的對象內(nèi)存分布
帶有方法的類又是什么樣呢?接著上面的例子,在類中增加一個方法 setB,用來設(shè)置其中成員 b 的值。
#include <iostream>
class Basic {
public:
int a;
double b;
void setB(double value) {
b = value; // 直接訪問成員變量b
}
};
int main() {
Basic temp;
temp.a = 10;
temp.setB(3.14);
return 0;
}
用 GDB 打印 temp 對象以及成員變量的地址,發(fā)現(xiàn)內(nèi)存布局和前面不帶方法的完全一樣。整個對象 size 依然是 16,a 和 b 的內(nèi)存地址分布也是一致的。那么新增加的成員方法存儲在什么位置?成員方法中又是如何拿到成員變量的地址呢?
1.成員方法內(nèi)存布局
可以在 GDB 里面打印下成員方法的地址,如下圖所示。
回憶下 Linux 中進(jìn)程的內(nèi)存布局,其中文本段(也叫代碼段)是存儲程序執(zhí)行代碼的內(nèi)存區(qū)域,通常是只讀的,以防止程序在運(yùn)行時意外或惡意修改其執(zhí)行代碼。這里 setB 方法地址 0x5555555551d2 就是位于程序的文本段內(nèi),可以在 GDB 中用 info target 驗(yàn)證一下:
其中 .text 段的地址范圍是 0x0000555555555060 - 0x0000555555555251,setB 剛好在這個范圍內(nèi)。至此前面第一個問題有了答案,成員方法存儲在進(jìn)程的文本段,添加成員方法不會改變類實(shí)例對象的內(nèi)存布局大小,它們也不占用對象實(shí)例的內(nèi)存空間。
2.成員變量尋址
那么成員方法中又是如何拿到成員變量的地址呢?在解決這個疑問前,先來仔細(xì)看下 setB 的函數(shù)原型(void (*)(Basic * const, double)),這里函數(shù)的第一個參數(shù)是Basic* 指針,而在代碼中的調(diào)用是這樣:temp.setB(3.14)。這種用法其實(shí)是一種語法糖,編譯器在調(diào)用成員函數(shù)時自動將當(dāng)前對象的地址作為 this 指針傳遞給了函數(shù)的。
(gdb) p &Basic::setB(double)
$7 = (void (*)(Basic * const, double)) 0x5555555551d2 <Basic::setB(double)>
這里參數(shù)傳遞了對象的地址,但是在函數(shù)里面是怎么拿到成員變量 b 的地址呢?我們在調(diào)用 setB 的地方打斷點(diǎn),執(zhí)行到斷點(diǎn)后,用 step 進(jìn)入到函數(shù),然后查看相應(yīng)寄存器的值和匯編代碼。整個過程如下圖:
這里的匯編代碼展示了如何通過 this 指針和偏移量訪問 b??梢苑譃閮刹糠郑谝徊糠质翘幚?this 指針和參數(shù),第二部分是找到成員 b 的內(nèi)存位置然后進(jìn)行賦值。
- 參數(shù)傳遞部分。這里mov %rdi,-0x8(%rbp)將 this 指針(通過 rdi 寄存器傳入)保存到棧上。將 double 類型的參數(shù) value 通過 xmm0 寄存器傳入保存到棧上。這是 x86_64 機(jī)器下 GCC 編譯器的傳參規(guī)定,我們可以通過打印 $rdi 保存的地址來驗(yàn)證確實(shí)是 temp 對象的開始地址。
- 對象賦值部分。mov -0x8(%rbp),%rax 將 this 指針從棧上加載到 rax 寄存器中。類似的,movsd -0x10(%rbp),%xmm0 將參數(shù) value 從棧上重新加載到 xmm0 寄存器中。movsd %xmm0,0x8(%rax) 將 value 寫入到 this 對象的 b 成員。這里 0x8(%rax) 表示 rax(即 this 指針)加上 8 字節(jié)的偏移,這個偏移正是成員變量 b 在 Basic 對象中的位置。
這個偏移是什么時候,怎么算出來的呢?其實(shí)成員變量的地址相對于對象地址是固定的,對象的地址加上成員變量在對象內(nèi)的偏移量就是成員變量的實(shí)際地址。編譯器在編譯時,基于類定義中成員變量的聲明順序和編譯器的內(nèi)存布局規(guī)則,計(jì)算每個成員變量相對于對象起始地址的偏移量。然后在運(yùn)行時,通過基地址(即對象的地址)加上偏移量,就能夠計(jì)算出每個成員變量的準(zhǔn)確地址。這個過程對于程序員來說是透明的,由編譯器和運(yùn)行時系統(tǒng)自動處理。
3.函數(shù)調(diào)用約定與優(yōu)化
上面的匯編代碼中,setB 的兩個參數(shù),都是從寄存器先放到棧上,接著又從棧上放到寄存器進(jìn)行操作,為什么要移來移去多此一舉呢?要回答這個問題,需要先了解函數(shù)的調(diào)用約定和寄存器使用。在 x86_64 架構(gòu)的系統(tǒng)調(diào)用約定中,前幾個整數(shù)或指針參數(shù)通常通過寄存器(如 rdi, rsi, rdx, 等)傳遞,而浮點(diǎn)參數(shù)通過 xmm0 到 xmm7 寄存器傳遞。這種約定目的是為了提高函數(shù)調(diào)用的效率,因?yàn)槭褂眉拇嫫鱾鬟f參數(shù)比使用棧更快。
而將寄存器上的參數(shù)又移動到棧上,是為了保證寄存器中的值不被覆蓋。因?yàn)榧拇嫫魇怯邢薜馁Y源,在函數(shù)中可能會被多次用于不同的目的。將值保存到棧上可以讓函數(shù)內(nèi)部自由地使用寄存器,而不必?fù)?dān)心覆蓋調(diào)用者的數(shù)據(jù)。
接著又將-0x8(%rbp) 放到 rax 寄存器,然后再通過movsd %xmm0,0x8(%rax)寫入成員變量 b 的值,為啥不直接從xmm0寄存器寫到基于 rbp 的偏移地址呢?這是因?yàn)?x86_64 的指令集和其操作模式通常支持使用寄存器間接尋址方式訪問數(shù)據(jù)。使用rax等通用寄存器作為中間步驟,是一種更通用和兼容的方法。
當(dāng)然上面編譯過程沒有開啟編譯優(yōu)化,所以編譯器采用了直接但效率不高的代碼生成策略,包括將參數(shù)和局部變量頻繁地在棧與寄存器間移動。而編譯器的優(yōu)化策略可能會影響參數(shù)的處理方式。如果我們開啟編譯優(yōu)化,如下:
$ g++ basic_method.cpp -o basic_method_O2 -O2 -g -std=c++11
生成的 main 函數(shù)匯編部分如下:
(gdb) disassemble /m main
=> 0x0000555555555060 <+0>: xor %eax,%eax
0x0000555555555062 <+2>: ret
0x0000555555555063: data16 nopw %cs:0x0(%rax,%rax,1)
0x000055555555506e: xchg %ax,%ax
在 O2 優(yōu)化級別下,編譯器認(rèn)定 main 函數(shù)中的所有操作(包括創(chuàng)建 Basic 對象和對其成員變量的賦值操作)對程序的最終結(jié)果沒有影響,因此它們都被優(yōu)化掉了。這是編譯器的“死代碼消除”,直接移除那些不影響程序輸出的代碼部分。
特殊成員內(nèi)存分布
上面的成員都是 public 的,如果是 private(私有) 變量,私有方法呢?另外,靜態(tài)成員變量或者靜態(tài)成員方法,在內(nèi)存中又是怎么布局呢?
1.私有成員
先來看私有成員,接著上面的例子,增加私有成員變量和方法。整體代碼如下:
#include <iostream>
class Basic {
public:
int a;
double b;
void setB(double value) {
b = value; // 直接訪問成員變量b
secret(b);
}
private:
int c;
double d;
void secret(int temp) {
d = temp + c;
}
};
int main() {
Basic temp;
temp.a = 10;
temp.setB(3.14);
return 0;
}
編譯之后,通過 GDB,可以打印出所有成員變量的地址,發(fā)現(xiàn)這里私有變量的內(nèi)存布局并沒有什么特殊地方,也是依次順序存儲在對象中。私有的方法也沒有特殊地方,一樣存儲在文本段。整體布局如下如:
那么 private 怎么進(jìn)行可見性控制的呢?首先編譯期肯定是有保護(hù)的,這個很容易驗(yàn)證,我們無法直接訪問 temp.c ,或者調(diào)用 secret 方法,因?yàn)橹苯訒幾g出錯。
那么運(yùn)行期是否有保護(hù)呢?我們來驗(yàn)證下。前面已經(jīng)驗(yàn)證 private 成員變量也是根據(jù)偏移來找到內(nèi)存位置的,我們可以在代碼中直接根據(jù)偏移找到內(nèi)存位置并更改里面的值。
int* pC = reinterpret_cast<int*>(reinterpret_cast<char*>(&temp) + 16);
*pC = 12; // 直接修改c的值
這里修改后,可以增加一個 show 方法打印所有成員的值,發(fā)現(xiàn)這里 temp.c 確實(shí)被改為了 12??梢姵蓡T變量在運(yùn)行期并沒有做限制,知道地址就可以繞過編譯器的限制進(jìn)行讀寫了。那么私有的方法呢?
私有方法和普通成員方法一樣存儲在文本段,我們拿到其地址后,可以通過這個地址調(diào)用嗎?這里需要一些騷操作,我們在類定義中添加額外的接口來暴露私有成員方法的地址,然后通過成員函數(shù)指針來調(diào)用私有成員函數(shù)。整體代碼如下:
class Basic {
...
public:
// 暴露私有成員方法的地址
static void (Basic::*getSecretPtr())(int) {
return &Basic::secret;
}
...
}
int main() {
// ...
void (Basic::*funcPtr)(int) = Basic::getSecretPtr();
// 調(diào)用私有成員函數(shù)
(temp.*funcPtr)(10);
// ...
}
上面代碼正常運(yùn)行,你可以通過 print 打印調(diào)用前后成員變量的值來驗(yàn)證。看來對于成員函數(shù)來說,只是編譯期不讓直接調(diào)用,運(yùn)行期并沒有保護(hù),我們可以繞過編譯限制在對象外部調(diào)用。
當(dāng)然實(shí)際開發(fā)中,千萬不要直接通過地址偏移來訪問私有成員變量,也不要通過各種騷操作來訪問私有成員方法,這樣不僅破壞了類的封裝性,而且是不安全的。
2.靜態(tài)成員
每個熟悉 c++ 類靜態(tài)成員的人都知道,靜態(tài)成員變量在類的所有實(shí)例之間共享,不管你創(chuàng)建了多少個類的對象,靜態(tài)成員變量只有一份數(shù)據(jù)。靜態(tài)成員變量的生命周期從它們被定義的時刻開始,直到程序結(jié)束。靜態(tài)成員方法不依賴于類的任何實(shí)例來執(zhí)行,主要用在工廠方法、單例模式的實(shí)例獲取方法、或其他與類的特定實(shí)例無關(guān)的工具函數(shù)。
下面以一個具體的例子,來看看靜態(tài)成員變量和靜態(tài)成員方法的內(nèi)存布局以及實(shí)現(xiàn)特點(diǎn)。繼續(xù)接著前面代碼例子,這里省略掉其他無關(guān)代碼了。
#include <iostream>
class Basic {
// ...
public:
static float alias;
static void show() {
std::cout << alias << std::endl;
}
};
float Basic::alias = 0.233;
int main() {
// ...
temp.show();
return 0;
}
簡單的打印 temp 和 alias 地址,發(fā)現(xiàn)兩者之間差異挺大。temp 地址是 0x7fffffffe380,Basic::alias 是 0x555555558048,用 info target 可以看到 alias 在程序的 .data 內(nèi)存空間范圍 0x0000555555558038 - 0x000055555555804c 內(nèi)。進(jìn)一步驗(yàn)證了下,.data段用于存儲已初始化的全局變量和靜態(tài)變量,注意這里需要是非零初始值。
對于沒有初始化,或者初始化為零的全局變量或者靜態(tài)變量,是存儲在 .bss 段內(nèi)的。這個也很好驗(yàn)證,把上面 alias 的值設(shè)為 0,重新查看內(nèi)存位置,就能看到確實(shí)在 .bss 段內(nèi)了。對于全局變量或者靜態(tài)變量,為啥需要分為這兩個段來存儲,而不是合并為一個段來存儲呢?
這里主要是考慮到二進(jìn)制文件磁盤空間大小以及加載效率。在磁盤上,.data 占用實(shí)際的磁盤空間,因?yàn)樗枰鎯唧w的初始值數(shù)據(jù)。.bss段不占用實(shí)際的存儲空間,只需要在程序加載時由操作系統(tǒng)分配并清零相應(yīng)的內(nèi)存即可,這樣可以減少可執(zhí)行文件的大小。在程序啟動時,操作系統(tǒng)可以快速地為.bss段分配內(nèi)存并將其初始化為零,而無需從磁盤讀取大量的零值數(shù)據(jù),可以提高程序的加載速度。這里詳細(xì)的解釋也可以參考 Why is the .bss segment required?。
靜態(tài)方法又是怎么實(shí)現(xiàn)呢?我們先輸出內(nèi)存地址,發(fā)現(xiàn)在 .text 代碼段,這點(diǎn)和其他成員方法是一樣的。不過和成員方法不同的是,第一個參數(shù)并不是 this 指針了。在實(shí)現(xiàn)上它與普通的全局函數(shù)類似,主要區(qū)別在于它們的作用域是限定在其所屬的類中。
類繼承的內(nèi)存布局
當(dāng)然,既然是在聊面向?qū)ο蟮念?,那就少不了繼承了。我們還是從具體例子來看看,在繼承情況下,類的內(nèi)存布局情況。
1.不帶虛函數(shù)的繼承
先來看看不帶虛函數(shù)的繼承,示例代碼如下:
#include <iostream>
class Basic {
public:
int a;
double b;
void setB(double value) {
b = value; // 直接訪問成員變量b
}
};
class Derived : public Basic {
public:
int c;
void setC(int value) {
c = value; // 直接訪問成員變量c
}
};
int main() {
Derived temp;
temp.a = 10;
temp.setB(3.14);
temp.c = 1;
temp.setC(2);
return 0;
}
編譯運(yùn)行后,用 GDB 打印成員變量的內(nèi)存分布,發(fā)現(xiàn) Derived 類的對象在內(nèi)存中的布局首先包含其基類Basic的所有成員變量,緊接著是 Derived 類自己的成員變量。整體布局如下圖:
其實(shí) C++ 標(biāo)準(zhǔn)并沒有規(guī)定在繼承中,基類和派生類的成員變量之間的排列順序,編譯器可以自由發(fā)揮的。但是大部分編譯器在實(shí)現(xiàn)中,都是基類的成員變量在派生類的成員變量之前,為什么這么做呢?因?yàn)檫@樣實(shí)現(xiàn),使對象模型變得更簡單和直觀。不論是基類還是派生類,對象的內(nèi)存布局都是連續(xù)的,簡化了對象創(chuàng)建、復(fù)制和銷毀等操作的實(shí)現(xiàn)。我們通過派生類對象訪問基類成員與直接使用基類對象訪問時完全一致,一個派生類對象的前半部分就是一個完整的基類對象。
對于成員函數(shù)(包括普通函數(shù)和靜態(tài)函數(shù)),它們不占用對象實(shí)例的內(nèi)存空間。不論是基類的成員函數(shù)還是派生類的成員函數(shù),它們都存儲在程序的代碼段中(.text 段)。
2.帶有虛函數(shù)的繼承
帶有虛函數(shù)的繼承,稍微有點(diǎn)復(fù)雜了。在前面繼承例子基礎(chǔ)上,增加一個虛函數(shù),然后在 main 中用多態(tài)的方式調(diào)用。
#include <iostream>
class Basic {
public:
int a;
double b;
virtual void printInfo() {
std::cout << "Basic: a = " << a << ", b = " << b << std::endl;
}
virtual void printB() {
std::cout << "Basic in B" << std::endl;
}
void setB(double value) {
b = value; // 直接訪問成員變量b
}
};
class Derived : public Basic {
public:
int c;
void printInfo() override {
std::cout << "Derived: a = " << a << ", b = " << b << ", c = " << c << std::endl;
}
void setC(int value) {
c = value; // 直接訪問成員變量c
}
};
int main() {
Derived derivedObj;
derivedObj.a = 10;
derivedObj.setB(3.14);
derivedObj.c = 1;
derivedObj.setC(2);
Basic* ptr = &derivedObj; // 基類指針指向派生類對象
ptr->printInfo(); // 多態(tài)調(diào)用
ptr->printB(); // 調(diào)用
Basic basicObj;
basicObj.a = 10;
basicObj.setB(3.14);
Basic* anotherPtr = &basicObj;
anotherPtr->printInfo();
anotherPtr->printB();
return 0;
}
上面代碼中,Basic* ptr = &derivedObj; 這一行用一個基類指針指向派生類對象,當(dāng)通過基類指針調(diào)用虛函數(shù) ptr->printInfo();時,將在運(yùn)行時解析為 Derived::printInfo() 方法,這是就是運(yùn)行時多態(tài)。對于 ptr->printB(); 調(diào)用,由于派生類中沒有定義 printB() 方法,所以會調(diào)用基類的 printB() 方法。
那么在有虛函數(shù)繼承的情況下,對象的內(nèi)存布局是什么樣?虛函數(shù)的多態(tài)調(diào)用又是怎么實(shí)現(xiàn)的呢?實(shí)踐出真知,我們可以通過 GDB 來查看對象的內(nèi)存布局,在此基礎(chǔ)上可以驗(yàn)證虛函數(shù)表指針,虛函數(shù)表以及多態(tài)調(diào)用的實(shí)現(xiàn)細(xì)節(jié)。這里先看下 Derived 類對象的內(nèi)存布局,如下圖:
可以看到派生類對象的開始部分(地址 0x7fffffffe370 處)有一個 8 字節(jié)的虛函數(shù)表指針 vptr(指針地址 0x555555557d80),這個指針指向一個虛函數(shù)表(vtable),虛函數(shù)表中存儲了虛函數(shù)的地址,一共有兩個地址 0x55555555538c 和 0x555555555336,分別對應(yīng)Derived 類中的兩個虛函數(shù) printInfo 和 printB?;惖那闆r類似,下面畫一個圖來描述更清晰些:
現(xiàn)在搞清楚了虛函數(shù)在類對象中的內(nèi)存布局。在編譯器實(shí)現(xiàn)中,虛函數(shù)表指針是每個對象實(shí)例的一部分,占用對象實(shí)例的內(nèi)存空間。對于一個實(shí)例對象,通過其地址就能找到對應(yīng)的虛函數(shù)表,然后通過虛函數(shù)表找到具體的虛函數(shù)地址,實(shí)現(xiàn)多態(tài)調(diào)用。那么為什么必須通過引用或者指針才能實(shí)現(xiàn)多態(tài)調(diào)用呢?看下面 3 個調(diào)用,最后一個沒法多態(tài)調(diào)用。
Basic& ref = derivedObj;
Basic* ptr = &derivedObj;
Basic dup = derivedObj; // 沒法實(shí)現(xiàn)多態(tài)調(diào)用
我們用 GDB 來看下這三種對象的內(nèi)存布局,如下圖:
指針和引用在編譯器底層沒有區(qū)別,ref 和 ptr 的地址一樣,就是原來派生類 derivedObj 的地址0x7fffffffe360,里面的虛函數(shù)表指針指向派生類的虛函數(shù)表,所以可以調(diào)用到派生類的 printInfo。而這里的 dup 是通過拷貝構(gòu)造函數(shù)生成的,編譯器執(zhí)行了隱式類型轉(zhuǎn)換,從派生類截?cái)嗔嘶惒糠郑闪艘粋€基類對象。dup 中的虛函數(shù)表指針指向的是基類的虛函數(shù)表,所以調(diào)用的是基類的 printInfo。
從上面 dup 虛函數(shù)表指針的輸出也可以看到,虛函數(shù)表不用每個實(shí)例一份,所有對象實(shí)例共享同一個虛函數(shù)表即可。虛函數(shù)表是每個多態(tài)類一份,由編譯器在編譯時創(chuàng)建。
當(dāng)然,這里是 Mac 平臺下 Clang 編譯器對于多態(tài)的實(shí)現(xiàn)。C++ 標(biāo)準(zhǔn)本身沒有規(guī)定多態(tài)的實(shí)現(xiàn)細(xì)節(jié),沒有說一定要有虛函數(shù)表(vtable)和虛函數(shù)表指針(vptr)來實(shí)現(xiàn)。這是因?yàn)?C++標(biāo)準(zhǔn)關(guān)注的是行為和語義,確保我們使用多態(tài)特性時能夠得到正確的行為,但它不規(guī)定底層的內(nèi)存布局或具體的實(shí)現(xiàn)機(jī)制,這些細(xì)節(jié)通常由編譯器的實(shí)現(xiàn)來決定。
不同編譯器的實(shí)現(xiàn)也可能不一樣,許多編譯器為了訪問效率,將虛函數(shù)表指針放在對象內(nèi)存布局的開始位置。這樣,虛函數(shù)的調(diào)用可以快速定位到虛函數(shù)表,然后找到對應(yīng)的函數(shù)指針。如果類有多重繼承,情況可能更復(fù)雜,某些編譯器可能會采取不同的策略來安排虛函數(shù)表指針的位置,或者一個對象可能有多個虛函數(shù)表指針。
地址空間布局隨機(jī)化
前面的例子中,如果用 GDB 多次運(yùn)行程序,對象的虛擬內(nèi)存地址每次都一樣,這是為什么呢?
我們知道現(xiàn)代操作系統(tǒng)中,每個運(yùn)行的程序都使用虛擬內(nèi)存地址空間,通過操作系統(tǒng)的內(nèi)存管理單元(MMU)映射到物理內(nèi)存的。虛擬內(nèi)存有很多優(yōu)勢,包括提高安全性、允許更靈活的內(nèi)存管理等。為了防止緩沖區(qū)溢出攻擊等安全漏洞,操作系統(tǒng)還會在每次程序啟動時隨機(jī)化進(jìn)程的地址空間布局,這就是地址空間布局隨機(jī)化(ASLR,Address Space Layout Randomization)。
在 Linux 操作系統(tǒng)上,可以通過 cat /proc/sys/kernel/randomize_va_space 查看當(dāng)前系統(tǒng)的 ASLR 是否啟用,基本上默認(rèn)都是開啟狀態(tài)(值為 2),如果是 0,則是禁用狀態(tài)。
前面使用 GDB 進(jìn)行調(diào)試時,之所以觀察到內(nèi)存地址是固定不變的,這是因?yàn)?GDB 默認(rèn)禁用了 ASLR,以便于調(diào)試過程中更容易重現(xiàn)問題。可以在使用 GDB 時啟用 ASLR,從而讓調(diào)試環(huán)境更貼近實(shí)際運(yùn)行環(huán)境。啟動 GDB 后,可以通過下面命令開啟地址空間的隨機(jī)化。
(gdb) set disable-randomization off
之后再多次運(yùn)行,這里的地址就會變化了。
總結(jié)
C++ 的對象模型是一個復(fù)雜的話題,涉及到類的內(nèi)存布局、成員變量和成員函數(shù)的訪問、繼承、多態(tài)等多個方面。本文從實(shí)際例子出發(fā),幫助大家對 C++ 對象的內(nèi)存布局有了一個直觀的認(rèn)識。
簡單總結(jié)下本文的核心結(jié)論:
- 對象的內(nèi)存布局是連續(xù)的,成員變量按照聲明的順序存儲在對象中,編譯器會根據(jù)類定義計(jì)算每個成員變量相對于對象起始地址的偏移量。
- 成員方法存儲在進(jìn)程的文本段,不占用對象實(shí)例的內(nèi)存空間,通過 this 指針和偏移量訪問成員變量。
- 私有成員變量和方法在運(yùn)行期并沒有保護(hù),可以通過地址偏移繞過編譯器的限制進(jìn)行讀寫,但是不推薦這樣做。
- 靜態(tài)成員變量和靜態(tài)成員方法存儲在程序的數(shù)據(jù)段和代碼段,不占用對象實(shí)例的內(nèi)存空間。
- 繼承類的內(nèi)存布局,編譯器一般會把基類的成員變量放在派生類的成員變量之前,使對象模型變得更簡單和直觀。
- 帶有虛函數(shù)的繼承,對象的內(nèi)存布局中包含虛函數(shù)表指針,多態(tài)調(diào)用通過虛函數(shù)表實(shí)現(xiàn)。虛函數(shù)實(shí)現(xiàn)比較復(fù)雜,這里只考慮簡單的單繼承。
- 地址空間布局隨機(jī)化(ASLR)是現(xiàn)代操作系統(tǒng)的安全特性,可以有效防止緩沖區(qū)溢出攻擊等安全漏洞。GDB 默認(rèn)禁用 ASLR,可以通過 set disable-randomization off 命令開啟地址空間的隨機(jī)化。