C 結(jié)構(gòu)體內(nèi)存布局、對齊機(jī)制初探
▲? ?? ▼? ?
在初次接觸C結(jié)構(gòu)體數(shù)據(jù)類型時(shí),為了便于初學(xué)者的學(xué)習(xí)和記憶,暫且將結(jié)構(gòu)體類型與基本數(shù)據(jù)類型視為等同,而不必去理會(huì)結(jié)構(gòu)體作為一類數(shù)據(jù)結(jié)構(gòu)的特性。這類學(xué)習(xí)路線在一定程度上表現(xiàn)出對初學(xué)者的友好,不過也會(huì)在深入學(xué)習(xí)結(jié)構(gòu)體性質(zhì)的過程中,因?yàn)樗季S慣性的緣故造成揮之不去的干擾,因此即便無需在初學(xué)階段深究結(jié)構(gòu)體特性,也需要強(qiáng)調(diào)結(jié)構(gòu)體的一些基礎(chǔ)且重要特性等概念,并積極鼓勵(lì)初學(xué)者親自動(dòng)手實(shí)踐加以驗(yàn)證。
當(dāng)初自己在學(xué)習(xí)C結(jié)構(gòu)體時(shí),并沒有人向我強(qiáng)調(diào)結(jié)構(gòu)體內(nèi)存布局和對齊機(jī)制等概念,更別提其中的具體表現(xiàn)和深層次原理,以至于我在后來的實(shí)踐過程中遇到一些自認(rèn)為是莫名其妙的偏差和錯(cuò)誤時(shí),往往無從下手。在自己的固有印象中,為什么一個(gè)大小理應(yīng)為23字節(jié)的結(jié)構(gòu)體變量,實(shí)際測量發(fā)現(xiàn)卻為40字節(jié)大???多余使用的字節(jié)中到底存儲(chǔ)著怎樣的內(nèi)容?為何需要多余使用這些空間?以及這么做有何意義,這么做是不是在浪費(fèi)寶貴的內(nèi)存空間?即便我在初步了解如文章標(biāo)題中所描述的技術(shù)之后,我依舊會(huì)被突如其來的思維方式轉(zhuǎn)變而無所適從,也就是說理論無法充分地聯(lián)系實(shí)際。因此我希望再次以一位初學(xué)者的視角,初探C結(jié)構(gòu)體內(nèi)存布局與對齊機(jī)制。
?? ?? ?? ?
請注意,這里所討論的內(nèi)存布局是指進(jìn)程在虛擬內(nèi)存系統(tǒng)下的直觀表現(xiàn),現(xiàn)代操作系統(tǒng)為用戶提供了比物理內(nèi)存更為高效、安全的上層抽象,允許我們使用統(tǒng)一的內(nèi)存模型去描述進(jìn)程,不過這都是題外話了。既然已經(jīng)知道C結(jié)構(gòu)體在一般情形下會(huì)遵循內(nèi)存對齊的規(guī)定,那么這樣做到底有何意義?是不是浪費(fèi)內(nèi)存空間?對此網(wǎng)上已有較為詳細(xì)的解釋,一是為了跨平臺(tái)可移植性;二是為了盡可能提升不同處理器對內(nèi)存訪問效率,這里不作過多贅述。一言蔽之,浪費(fèi)內(nèi)存空間了但又沒完全浪費(fèi),為了避免不必要的效能損失,拿空間換時(shí)間是慣用做法。相比之下,在一些特定情形中,由用戶自身造成的內(nèi)存浪費(fèi)往往會(huì)比對齊所舍棄的空間要嚴(yán)重得多。
既然在大多數(shù)情形中,結(jié)構(gòu)體的內(nèi)存對齊機(jī)制不可避免,那么多余使用的字節(jié)中到底存儲(chǔ)著怎樣的內(nèi)容?取決于編譯器具體實(shí)現(xiàn),但無論如何不會(huì)是你所需要的填充內(nèi)容(padding),不過這對我們來說是無關(guān)緊要的,編譯器會(huì)幫忙處理好這一切。需要重點(diǎn)關(guān)注的是:結(jié)構(gòu)體的內(nèi)存布局是依據(jù)何種方式或規(guī)則對齊的?歸納起來無外乎是以下三項(xiàng)原則:
結(jié)構(gòu)體的首地址(同時(shí)也是結(jié)構(gòu)體中首個(gè)成員的起始地址)能夠被其最寬基本類型成員的大小所整除(PS:且只可能是1,2,4,8這類寬度的整數(shù)倍);
結(jié)構(gòu)體中各成員相對結(jié)構(gòu)體首地址的偏移量(offset)是該成員類型寬度和平臺(tái)默認(rèn)對齊寬度中較小值的整數(shù)倍;
結(jié)構(gòu)體的整體大小是其最寬基本類型成員大小的整數(shù)倍。
如果在對齊的過程中遇到不滿足上述原則的情形,則使用字節(jié)填充的方式直至滿足規(guī)則。乍一看這些規(guī)則既繞口又抽象,一時(shí)間讓人無從下手,強(qiáng)行解釋反而適得其反,索性結(jié)合實(shí)例逐條理解也許是個(gè)不錯(cuò)的選擇。不過在此之前有必要描述有關(guān)演示環(huán)境的相關(guān)信息,以x86-64平臺(tái)為例,在64位操作系統(tǒng)上,演示所用到的基本數(shù)據(jù)類型占用字節(jié)大小分別如下:
查閱相關(guān)資料可知,該環(huán)境下默認(rèn)對齊寬度為8byte。于是我們定義兩個(gè)結(jié)構(gòu)體類型用以演示,如下所示,結(jié)構(gòu)體BasicType中只包含有基本數(shù)據(jù)類型的成員,結(jié)構(gòu)體MixedType中包含有更為復(fù)雜的組合數(shù)據(jù)類型:
其中各結(jié)構(gòu)體聲明末尾處的注釋部分是我依據(jù)對齊基本原則計(jì)算得到的結(jié)構(gòu)體大小估值,接下來我們通過更加直觀的方式來觀察內(nèi)存對齊后的效果與布局,首先是BasicType結(jié)構(gòu)體變量:

和預(yù)期中的一樣,對齊后的BasicType結(jié)構(gòu)體變量整體大小為24字節(jié),而不是簡單地將成員變量類型大小累加之后的23字節(jié),究其原因是在uchar成員變量之后填充了1字節(jié),從上圖中uchar與ushort兩者之間的起始地址差值(0x7ffff0b39582 - 0x7ffff0b39580)也能看出來,原理是依據(jù)原則2,與默認(rèn)對齊寬度相比,ushort數(shù)據(jù)類型寬度較小且為2,則要求到結(jié)構(gòu)體首地址的偏移量為2的整數(shù)倍,uchar僅需使用1字節(jié),繼而填充1字節(jié)以滿足條件。同時(shí)我們也能直接觀察得知,結(jié)構(gòu)體首地址與結(jié)構(gòu)體首個(gè)成員的起始地址是一致的(0x7ffff0b39580)。以上的描述可能還是比較籠統(tǒng),所以我用excel表格繪制了一張內(nèi)存對齊布局的示意圖,也僅僅是符合邏輯上的局部,實(shí)際物理布局絕非如此:

這樣看待對齊局部便能做到一目了然,便于我們從頭說起。依據(jù)對齊原則1,讓我們檢查一下結(jié)構(gòu)體變量首地址,0x7ffff0b39580能夠被其包含的最寬基本類型大小所整除,此時(shí)最寬基本類型與平臺(tái)默認(rèn)對齊寬度一致,同為8,最寬基本類型可以是double、unsigned long。成員uchar和ushort的對齊情況上面已經(jīng)說過,此時(shí)uint的偏移量為4,滿足原則2,緊接著占用4字節(jié);隨后ulong的偏移量為8,滿足原則2,緊接著占用8字節(jié),以此類推。最后當(dāng)dbl也完成對齊并占用字節(jié)后,檢查此時(shí)結(jié)構(gòu)體整體的大小為24,符合原則3,無需再填充額外字節(jié)用以對齊。至此,BasicType結(jié)構(gòu)體變量的最終大小為24字節(jié)(可視范圍)。
讓我們繼續(xù)觀察MixedType結(jié)構(gòu)體變量的表現(xiàn),不出所料,對齊后的MixedType結(jié)構(gòu)體變量整體大小為56字節(jié),首地址(0x7ffff0b39540)同樣與其首個(gè)成員的起始地址一致,也能夠被其包含的最寬基本類型大小所整除。

這里需要著重解釋一下,對于像MixedType這類包含組合類型的結(jié)構(gòu)體該如何計(jì)算其最寬基本類型大小,并與平臺(tái)默認(rèn)對齊寬度作比較。對于結(jié)構(gòu)體中所包含的數(shù)組類型成員,其類型寬度就是它自身基本數(shù)據(jù)類型的寬度,例如成員char chs[n],其類型寬度是char基本數(shù)據(jù)類型的寬度1;對于結(jié)構(gòu)體中所包含的結(jié)構(gòu)體類型成員,其類型寬度就是它自身所包含的最寬基本數(shù)據(jù)類型,例如包含在MixedType結(jié)構(gòu)體中的BasicType結(jié)構(gòu)體成員,其類型寬度就是double、unsigned long基本數(shù)據(jù)類型的寬度8,所以當(dāng)我們將結(jié)構(gòu)體成員視為整體時(shí),其類型寬度計(jì)算如上,除了作為判斷最外層結(jié)構(gòu)體的最寬數(shù)據(jù)類型大小的依據(jù)之一以外,對于結(jié)構(gòu)體成員的內(nèi)存對齊填充具有重要的指示作用。這部分概念確實(shí)比較繞口和生澀難懂,但也是解決復(fù)雜結(jié)構(gòu)體內(nèi)存對齊問題的關(guān)鍵所在,值得反復(fù)揣摩。
同樣給出我用excel表繪制的內(nèi)存對齊布局示意圖,便于理解:

如果你能較為清晰地理解我著重解釋的內(nèi)容,那么在你的腦海當(dāng)中描繪出以上這張圖并不會(huì)太難,所要做的無非是搞清楚每一次對齊填充的依據(jù)是什么?盡管成員變量integers是以unsigned數(shù)組的形式存在,其類型寬度與unsigned基本數(shù)據(jù)類型一致,在面對占用7字節(jié)的成員變量chars給自身所造成的偏移量時(shí),毅然選擇了對齊填充1字節(jié),使得自身偏移量為8,滿足是自身類型寬度整數(shù)倍的要求。當(dāng)我們剛剛結(jié)束對成員變量pstr的安排,打算為結(jié)構(gòu)體成員變量S_basic_type計(jì)算其自身偏移量和類型寬度時(shí),請把它當(dāng)作整體來看待,而不是完全展開后單獨(dú)為S_basic_type.uchar量身做嫁衣,只有當(dāng)我們將結(jié)構(gòu)體成員整體安排妥當(dāng)之后再考慮其家族中個(gè)人的事。無論你是否愿意這么做,解決MixedType結(jié)構(gòu)體變量的內(nèi)存對齊問題確實(shí)給我們上了深刻的一節(jié)課。

至此我們得到了兩種結(jié)構(gòu)體在進(jìn)行對齊之后的內(nèi)存布局和最終可視的大小。為何要這么說?原因是在我驗(yàn)證過程中另外觀察到了一些細(xì)微的誤差。其實(shí)以上這兩個(gè)不同類型的結(jié)構(gòu)體變量,是緊挨著進(jìn)行聲明和定義的局部變量,按照C程序的進(jìn)程內(nèi)存模型,這兩個(gè)局部變量是先后相鄰著壓入進(jìn)程的棧區(qū),在邏輯上這兩者的虛擬地址是非常相近的,實(shí)際結(jié)果也是如此:

S_basic_type比S_mixed_type更早聲明和定義,因此兩者的起始地址分別為0x7ffff0b39580,0x7ffff0b39540,非常接近不是嗎?此外,由于進(jìn)程棧區(qū)的增長方向是從高位地址向低位生長,S_basic_type比S_mixed_type更早入棧,所以兩者起始地址的大小順序也是符合條件的,似乎并無異樣。讓我們計(jì)算一下兩個(gè)地址的偏移值,0x7ffff0b39580 -?0x7ffff0b39540 = 0x000000000040,簡單點(diǎn)0x40,對應(yīng)十進(jìn)制是64,也就是說對應(yīng)間隙的大小為64字節(jié),讓我們再繪制一個(gè)棧區(qū)的局部示意圖:

無需我多說,非常直觀。而且可以確定的是,S_mixed_type對齊后的最終大小為56字節(jié),滿足各項(xiàng)對齊原則無需額外的8字節(jié)繼續(xù)對齊。那么這多余的8字節(jié)從何而來?為何而來?我首先懷疑的是,如果S_mixed_type按照自己的實(shí)際大小,向上覆蓋這8字節(jié)緊挨著S_basic_type,那么S_mixed_type的起始地址將變?yōu)?x7ffff0b39548,是否會(huì)違背對齊原則1呢?顯然0x7ffff0b39540是8的整數(shù)倍,0x7ffff0b39540 + 8又何嘗不是8的整數(shù)倍?所以理論上0x7ffff0b39548是可以充當(dāng)S_mixed_type的起始地址,因此這額外的8字節(jié)大概率是故意為之。那么為何要額外保留這8字節(jié)?我個(gè)人的猜想是:
由具體平臺(tái)或編譯器具體實(shí)現(xiàn)所致,需要以某種固定模式來組織、管理進(jìn)程棧區(qū)所保存的數(shù)據(jù);
或是這額外的8字節(jié)中保存有S_basic_type結(jié)構(gòu)信息,我們知道C結(jié)構(gòu)體并不是基本數(shù)據(jù)類型,而更像是一種數(shù)據(jù)結(jié)構(gòu),作為數(shù)據(jù)結(jié)構(gòu)不可避免地需要保留少量的結(jié)構(gòu)信息,這些信息是正確訪問這類數(shù)據(jù)結(jié)構(gòu)的重要組成部分,往往對用戶不可見,因此我才將結(jié)構(gòu)體對齊后的最終大小稱之為可視大小。
但無論是何種猜想都需要更加深入一系列底層實(shí)現(xiàn),這顯然超出了我們的討論范圍,以上這些內(nèi)容無關(guān)文章標(biāo)題,僅僅是作為驗(yàn)證過程的副產(chǎn)物,因此點(diǎn)到為止。

讓我們繼續(xù)探討C結(jié)構(gòu)體對齊和內(nèi)存布局這一話題,還記得上文說過:在某些特定情形中,由用戶自身造成的內(nèi)存浪費(fèi)往往會(huì)比對齊所舍棄的空間要嚴(yán)重得多這句話嗎?準(zhǔn)確來說是因?yàn)橛脩糇陨韮?yōu)化原因顯著放大了對齊所舍棄的空間,從而造成不必要的內(nèi)存浪費(fèi)。下面我用幾個(gè)例子來說明,而這些例子與上面示例中的結(jié)構(gòu)體相比,僅僅是調(diào)整了聲明結(jié)構(gòu)體成員的先后順序,但最終的結(jié)果卻大相徑庭:
在揭曉答案之前可以先自行估算一下,檢驗(yàn)一下自己對結(jié)構(gòu)體對齊規(guī)則的理解程度。這里我在注釋中給出了自己的預(yù)估值,所以就不賣關(guān)子了,下面分別給出實(shí)際結(jié)果和布局示意圖:




上圖給我們的直觀感受是調(diào)整成員順序后的結(jié)構(gòu)體在內(nèi)存中的布局似乎不再像之前那樣緊湊,轉(zhuǎn)而出現(xiàn)了許多因?qū)R填充而導(dǎo)致的內(nèi)存空間的浪費(fèi),更有甚者出現(xiàn)了接近一半的空間浪費(fèi)。希望在你的腦海中已經(jīng)擁有了清晰的思路來解釋這一現(xiàn)象;以及我們在使用結(jié)構(gòu)體的同時(shí),該如何避免出現(xiàn)這樣的情況?我們當(dāng)然知道,出現(xiàn)這種不必要的空間浪費(fèi)直觀感受便是結(jié)構(gòu)體對齊機(jī)制的鍋;當(dāng)我們再往上翻看,只是因?yàn)榻Y(jié)構(gòu)體成員聲明順序發(fā)生了改變,從而導(dǎo)致了這樣的災(zāi)變,順著對齊原則的思路似乎可以得出以下結(jié)論,當(dāng)我們在聲明和定義一個(gè)結(jié)構(gòu)體時(shí):
結(jié)構(gòu)體類型不等同于基本數(shù)據(jù)類型,它有自己的定位;
不要輕易無視結(jié)構(gòu)體對齊機(jī)制的存在,除非你很清楚自己在做什么,不要擅自改變這一現(xiàn)狀;
結(jié)構(gòu)體中各成員的聲明順序?qū)?huì)影響到整體最終的對齊方式、內(nèi)存布局以及大?。?/p>
聲明結(jié)構(gòu)體成員時(shí),順序上,類型寬度較小的成員應(yīng)先于寬度較大的成員,相同類型或類型寬度相同的成員應(yīng)盡可能相鄰。
以上便是本次對C結(jié)構(gòu)體初探的學(xué)習(xí)思路、實(shí)踐過程以及初步結(jié)論。作為一名初學(xué)者從感性認(rèn)知再到理性認(rèn)知的過程必不可少,我也很熱衷于反復(fù)回顧這些基礎(chǔ)理論知識(shí),子曰:溫故而知新可以為師矣。當(dāng)然,講得有不到位、謬誤之處也請批評指正。

參考資料:
https://www.bilibili.com/read/cv12221662????(C語言結(jié)構(gòu)體內(nèi)存布局問題)
https://blog.csdn.net/Carrot_ly/article/details/118242788????(結(jié)構(gòu)體內(nèi)存對齊的意義)
