淺聊Rust程序內存布局
內存布局看似是底層和距離應用程序開發(fā)比較遙遠的概念集合,但其對前端應用的功能實現頗具現實意義。從WASM業(yè)務模塊至Nodejs N-API插件,無處不涉及到FFI跨語言互操作。甚至,做個文本數據的字符集轉換也得FFI調用操作系統(tǒng)鏈接庫libiconv,因為這意味著更小的.exe/.node發(fā)布文件。而C ABI與內存布局正是跨(計算機)語言數據結構的基礎。
大約兩個月前,在封裝FFI閉包(不是函數指針)過程中,我重新梳理了Rust內存布局知識點。然后,就有沖動寫這么一篇長文。今恰逢國慶八天長假,匯總我之所知與大家分享。開始正文...
存儲寬度size與對齊位數alignment— 內存布局的核心參數
變量值在內存中的存儲信息包含兩個重要屬性
首字節(jié)地址address
存儲寬度size
而這兩個值都不是在分配內存那一刻的“即興選擇”。而是,遵循著一組規(guī)則:
address與size都必須是【對齊位數alignment】的自然數倍。比如說,
對齊位數alignment等于1字節(jié)的變量值可保存于任意內存地址address上。
對齊位數alignment等于2字節(jié)且有效數據長度等于3字節(jié)的變量值
存儲寬度size等于0字節(jié)的變量值可接受任意正整數作為其對齊位數alignment— 慣例是1字節(jié)。
僅能保存于偶數位的內存地址address上。
存儲寬度size也得是4字節(jié) — 從有效長度3字節(jié)到存儲寬度4字節(jié)的擴容過程被稱作“對齊”。
對齊位數alignment必須是2的自然數次冪。即,alignment = 2 ^ N且N是? 29的自然數。
存儲寬度size是有效數據長度加對齊填充位數的總和字節(jié)數 — 這一點可能有點兒反直覺。
address,size與alignment的計量單位都是“字節(jié)”。
正是因為address,size與alignment之間存在倍數關系,所以程序對內存空間的利用一定會出現冗余與浪費。這些被浪費掉的“邊角料”則被稱作【對齊填充alignment padding】。對齊填充的計量單位也是字節(jié)。根據“邊角料”出現的位置不同,對齊填充alignment padding又分為
小端填充Little-Endian padding—0填充位出現在有效數據右側的低位
大端填充Big-Endian padding—0填充位出現在有效數據左側的高位
文字抽象,圖直觀。一圖抵千詞,請看圖
延伸理解:借助于對齊位數,物理上一維的線性內存被重構為了邏輯上N維的存儲空間。不嚴謹地講,一個數據類型 ? 對應一個對齊位數值 ? 按一個【單位一】將內存空間均分一遍 ? 形成一個僅存儲該數據類型值(且只存在于算法與邏輯中)的維度空間。然后,在保存該數據類型的新值時,只要
選擇進入正確的維度空間
跳過已被占用的【單位一】(這些【單位一】是在哪一個維度空間被占用、是被誰占用和怎么占用并不重要)
尋找連續(xù)出現且數量足夠的【單位一】
就行了。
如果【對齊位數alignment】與【存儲寬度size】在編譯時已知,那么該類型就是【靜態(tài)分派】Fixed Sized Type。于是,
類型的對齊位數可由std::()讀取
類型的存儲寬度可由std::()讀取
若【對齊位數alignment】與【存儲寬度size】在運行時才可計算知曉,那么該類型就是【動態(tài)分派】Dynamic Sized Type。于是,
值的對齊位數可由std::(&T)讀取
值的存儲寬度可由std::(&T)讀取
存儲寬度size的對齊計算
若變量值的有效數據長度payload_size正好是該變量類型【對齊位數alignment】的自然數倍,那么該變量的【存儲寬度size】就是它的【有效數據長度payload_size】。即,size = payload_size;。 否則,變量的【存儲寬度size】就是既要大于等于【有效數據長度payload_size】,又是【對齊位數alignment】自然數倍的最小數值。
這個計算過程的偽碼描述是
variable.size = variable.payload_size.next_multiple_of(variable.alignment);
這個計算被稱作“(自然數倍)對齊”。
簡單內存布局
基本數據類型
基本數據類型包括bool,u8,i8,u16,i16,u32,i32,u64,i64,u128,i128,usize,isize,f32,f64和char。它們的內存布局在不同型號的設備上略有差異
在非x86設備上,存儲寬度size= 對齊位數alignment(即,倍數N = 1)
在x86設備上,因為設備允許的最大對齊位數不能超過4字節(jié),所以alignment ? 4 Byte
u64與f64的size = alignment * 2(即,N = 2)。
u128與i128的size = alignment * 4(即,N = 4)。
其它基本數據類型依舊size = alignment(即,倍數N = 1)。
FST瘦指針
瘦指針的內存布局與usize類型是一致的。因此,在不同設備和不同架構上,其性能表現略有不同
在非x86的
32位架構上,size = alignment = 4 Byte(N = 1)
64位架構上,size = alignment = 8 Byte(N = 1)
在x86的
size = 8 Byte
alignment = 4 Byte—x86設備最大對齊位數不能超過4字節(jié)
N = 2
32位架構上,size = alignment = 4 Byte(N = 1)
64位設備上,
DST胖指針
胖指針的存儲寬度size是usize類型的兩倍,對齊位數卻與usize相同。就依賴于設備/架構的性能表現而言,其與瘦指針行為一致:
在非x86的
size = 16 Byte
alignment = 8 Byte
N = 2
size = 8 Byte
alignment = 4 Byte
N = 2
32位架構上,
64位架構上,
在x86的
size = 16 Byte
alignment = 4 Byte—x86設備最大對齊位數不能超過4字節(jié)
N = 4
size = 8 Byte
alignment = 4 Byte
N = 2
32位架構上,
64位設備上,
數組[T; N],切片[T]和str
str就是滿足UTF-8編碼規(guī)范的增強版[u8]切片。 存儲寬度size是全部元素存儲寬度之和
array.size = std::() * array.len(); 對齊位數alignment與單個元素的對齊位數一致。
array.alignment = std::();
()單位類型
存儲寬度size=0 Byte 對齊位數alignment=1 Byte 所有零寬度數據類型都是這樣的內存布局配置。 來自【標準庫】的零寬度數據類型包括但不限于:
()單位類型 — 模擬“空”。
std::PhantomData— 繞過“泛型類型形參必須被使用”的編譯規(guī)則。進而,成就類型狀態(tài)設計模式中的Phantom Type。
std::PhantomPinned— 禁止變量值在內存中“被挪來挪去”。進而,成就異步編程中的“自引用結構體self-referential struct”。
自定義數據結構的內存布局
復合數據結構的內存布局描繪了該數據結構(緊內一層)字段的內存位置“擺放”關系(比如,間隙與次序等)。在層疊嵌套的數據結構中,內存布局都是就某一層數據結構而言的。它既承接不了來自外層父數據結構的內存布局,也決定不了更內層子數據結構的內存布局,更代表不了整個數據結構內存布局的總覽。舉個例子,
#[repr(C)] struct Data { id: u32, name: String } #[repr(C)]僅只代表最外層結構體Data的兩個字段id和name是按C內存布局規(guī)格“擺放”在內存中的。但,#[repr(C)]并不意味著整個數據結構都是C內存布局的,更改變不了name字段的String類型是Rust內存布局的事實。若你的代碼意圖是定義完全C ABI的結構體,那么【原始指針】才是該用的類型。
use ::{c_char, c_uint}; #[repr(C)] struct Data { id: c_uint, name: *const c_char // 注意對比 }
內存布局核心參數
自定義數據結構的內存布局包含如下五個屬性
alignment
定義:數據結構自身的對齊位數
規(guī)則:
alignment=2的n次冪(n是? 29的自然數)
不同于基本數據類型alignment = size,自定義數據結構alignment的算法隨不同的數據結構而相異。
size
定義:數據結構自身的寬度
規(guī)則:size必須是alignment自然數倍。若有效數據長度payload_size不足size,就添補空白【對齊填充位】湊足寬度。
field.alignment
定義:每個字段的對齊位數
規(guī)則:field.alignment=2的n次冪(n是? 29的自然數)
field.size
定義:每個字段的寬度
規(guī)則:field.size必須是field.alignment自然數倍。若有效數據長度field.payload_size不足field.size,就添補空白【對齊填充位】湊足寬度。
field.offset
定義:每個字段首字節(jié)地址相對于上一層數據結構首字節(jié)地址的偏移字節(jié)數
規(guī)則:
field.offset必須是field.alignment自然數倍。若不足,就墊入空白【對齊填充位】和向后推移當前字段的起始位置。
前一個字段的field.offset + field.size ?后一個字段的field.offset
自定義枚舉類enum的內存布局一般與枚舉類分辨因子discriminant的內存布局一致。更復雜的情況,請見下文章節(jié)。
預置內存布局方案
編譯器內置了四款內存布局方案,分別是
默認Rust內存布局 — 沒有元屬性注釋
C內存布局#[repr(C)]
數字類型·內存布局#[repr(u8 / u16 / u32 / u64 / u128 / usize / i8 / i16 / i32 / i64 / i128 / isize)]
僅適用于枚舉類。
支持與C內存布局混搭使用。比如,#[repr(C, u8)]。
透明·內存布局#[repr(transparent)]
僅適用于單字段數據結構。
預置內存布局方案對比
相較于C內存布局,Rust內存布局面向內存空間利用率做了優(yōu)化— 省內存。具體的技術手段包括Rust編譯器
重排了字段的存儲順序,以盡可能多地消減掉“邊角料”(對齊填充)占用的字節(jié)位數。于是,在源程序中字段聲明的詞法次序經常不同于【運行時】它們在內存里的實際存儲順序。
允許多個零寬度字段共用一個內存地址。甚至,零寬度字段也被允許與普通(有數據)字段共享內存地址。
以C ABI中間格式為橋的C內存布局雖然實現了Rust跨語言數據結構,但它卻更費內存。這主要出于兩個方面原因:
C內存布局未對字段存儲順序做優(yōu)化處理,所以字段在源碼中的詞法順序就是它們在內存條里的存儲順序。于是,若 @程序員 沒有拿著算草紙和數著比特位“人肉地”優(yōu)化每個數據結構定義,那么由對齊填充位冗余造成的內存浪費不可避免。
C內存布局不支持零寬度數據類型。零寬度數據類型是Rust語言設計的重要創(chuàng)新。相比之下,
(參見C17規(guī)范的第6.7.2.1節(jié))無字段結構體會導致標準C程序出現U.B.,除非安裝與開啟GNU的C擴展。
Cpp編譯器會強制給無字段結構體安排一個字節(jié)寬度,除非該數據結構被顯式地標記為[[no_unique_address]]。
以費內存為代價,C內存布局賦予Rust數據結構的另一個“超能力”就是:“僅通過變換【指針類型】就可將內存上的一段數據重新解讀為另一個數據類型的值”。比如,void * / std::c_void被允許指向任意數據類型的變量值例程。但在Rust內存布局下,需要調用專門的標準庫函數std::transmute()才能達到相同的目的。 除了上述鮮明的差別之外,C與Rust內存布局都允許【對齊位數alignment】參數被微調,而不一定總是全部字段alignment中的最大值。這包括但不限于:
修飾符align(x)增加alignment至指定值。例如,#[repr(C, align(8))]將C內存布局中的【對齊位數】上調至8字節(jié)
修飾符packed(x)減小alignment至指定值。例如,#[repr(packed)]將默認Rust內存布局中的【對齊位數】下調至1字節(jié)
結構體struct的C內存布局
結構體算是最“中規(guī)中矩”的數據結構。無論是否對結構體的字段重新排序,只要將它們一個不落地鋪到內存上就完成一多半功能了。所以,結構體存儲寬度struct.size是全部字段size之和再(自然數倍)對齊于【結構體對齊位數struct.alignment】的結果。有點抽象上偽碼
struct.size = struct.fields().map(|field| field.size).sum() // 第一步,求全部字段寬度值之和 .next_multiple_of(struct.alignment); // 第二步,求既大于等于【寬度值之和】,又是`struct.alignment`自然數倍的最小數值 相較于Rust內存布局優(yōu)化算法的錯綜復雜,我好似只能講清楚C內存布局的始末: 首先,結構體自身的對齊位數struct.alignment就是全部字段對齊位數field.alignment中的最大值。
struct.alignment = struct.fields().map(|field| field.alignment).max(); 其次,聲明一個(可修改的)游標變量offset_cursor以實時跟蹤(參照于結構體首字節(jié)地址的)字節(jié)偏移量。游標變量的初始值為0表示該游標與結構體的內存起始位置重合。
let mut offset_cursor = 0; 接著,沿著源碼中字段的聲明次序,逐一處理各個字段:
【對齊】若游標變量值offset_cursor不是當前字段對齊位數field.alignment的自然數倍(即,未對齊),就計算既大于等于offset_cursor又是field.alignment自然數倍的最小數值。并將計算結果更新入游標變量offset_cursor,以插入填充位對齊和向后推移字段在內存中的”擺放“位置。
if offset_cursor.rem_euclid(field.alignment) > 0 { offset_cursor = offset_cursor.next_multiple_of(field.alignment); }
【定位】當前游標的位置就是該字段的首字節(jié)偏移量
field.offset = offset_cursor;
跳過當前字段寬度field.size— 遞歸算法求值子數據結構的存儲寬度。字段子數據結構的內存布局對上一層父數據結構是黑盒的。
offset_cursor += field.size
繼續(xù)處理下一個字段。
然后,在結構體內全部字段都被如上處理之后,
【對齊】若游標變量值offset_cursor不是結構體對齊位數struct.alignment的自然數倍(即,未對齊),就計算既大于等于offset_cursor又是struct.alignment自然數倍的最小數值。并將計算結果更新入游標變量offset_cursor,以增補填充位對齊和擴容有效數據長度至結構體存儲寬度。
if offset_cursor.rem_euclid(struct.alignment) > 0 { offset_cursor = offset_cursor.next_multiple_of(struct.alignment); }
【定位】當前游標值就是整個結構體的寬度(含全部對齊填充位)
struct.size = offset_cursor;
至此,結構體的C內存布局結束。然后,std::GlobalAlloc就能夠拿著這套“策劃案”向操作系統(tǒng)申請內存空間去了。由此可見,每次【對齊】處理都會在有效數據周圍“埋入”大量空白“邊角料”(學名:對齊填充位alignment padding)。但出于歷史原因,為了完成與其它計算機語言的FFI互操作,這些浪費還是必須的。下面附以完整的偽碼輔助理解
// 1. 結構體的【對齊位數】就是它的全部字段【對齊位數】中的最大值。 struct.alignment = struct.fields().map(|field| field.alignment).max(); // 2. 聲明一個游標變量,以實時跟蹤(相對于結構體首字節(jié)地址)的偏移量。 let mut offset_cursor = 0; // 3. 按照字段在源代碼中的詞法聲明次序,逐一遍歷每個字段。 for field in struct.fields_in_declaration_order() { if offset_cursor.rem_euclid(field.alignment) > 0 { // 4. 需要【對齊】當前字段 offset_cursor = offset_cursor.next_multiple_of(field.alignment); } // 5. 【定位】字段的偏移量就是游標變量的最新值。 field.offset = offset_cursor; // 6. 在跳過當前字段寬度的字節(jié)長度(含對齊填充字節(jié)數) offset_cursor += field.size; } if offset_cursor.rem_euclid(struct.alignment) > 0 { // 7. 需要【對齊】結構體自身 offset_cursor = offset_cursor.next_multiple_of(struct.alignment); } // 8. 【定位】結構體的寬度(含對齊填充字節(jié)數)就是游標變量的最新值。 struct.size = offset_cursor;
聯合體union的C內存布局
形象地講,聯合體是給內存中同一段字節(jié)序列準備了多套“數據視圖”,而每套“數據視圖”都嘗試將該段字節(jié)序列解釋為不同數據類型的值。所以,無論在聯合體內聲明了幾個字段,都僅有一個字段值會被保存于物理存儲之上。從原則上講,聯合體union的內存布局一定與占用內存最多的字段一致,以確保任何字段值都能被容納。從實踐上講,有一些細節(jié)處理需要斟酌:
聯合體的對齊位數union.alignment等于全部字段對齊位數中的最大值(同結構體)。
union.alignment = union.fields().map(|field| field.alignment).max();
聯合體的存儲寬度union.size是最長字段寬度值longest_field.size(自然數倍)對齊于聯合體自身對齊位數union.alignment的結果。有點抽象上偽碼
union.size = union.fields().map(|field| field.size).max() // 第一步,求最長字段的寬度值 .next_multiple_of(union.alignment); // 第二步,求既大于等于【最長字段寬度值】,又是`union.alignment`自然數倍的最小數值
舉個例子,聯合體Example0內包含了u8與u16類型的兩個字段,那么Example0的內存布局就一定與u16的內存布局一致。再舉個例子,
use ::mem; #[repr(C)] union Example1 { f1: u16, f2: [u8; 4], } println!("alignment = {1}; size = {0}", mem::(), mem::()) 看答案之前,不防先心算一下,程序向標準輸出打印的結果是多少。演算過程如下:
字段f1的
存儲寬度size是2字節(jié)。
對齊位數alignment也是2字節(jié),因為基本數據類型的【對齊位數alignment】就是它的【存儲寬度size】。
字段f2的
存儲寬度size是4字節(jié),因為數組的【存儲寬度size】就是全部元素存儲寬度之和。
對齊位數alignment是1字節(jié),因為數組的【對齊位數alignment】就是元素的【對齊位數alignment】。
聯合體Example1的
對齊位數alignment就是2字節(jié),因為取最大值
存儲寬度size是4字節(jié),因為得取最大值
再來一個更復雜點兒的例子,
use ::mem; #[repr(C)] union Example2 { f1: u32, f2: [u16; 3], } println!("alignment = {1}; size = {0}", mem::(), mem::()) 同樣,在看答案之前,不防先心算一下,程序向標準輸出打印的結果是多少。演算過程如下:
字段f1的存儲寬度與對齊位數都是4字節(jié)。
字段f2的
對齊位數是2字節(jié)。
存儲寬度是6字節(jié)。
聯合體Example2的
對齊位數alignment是4字節(jié) — 取最大值,沒毛病。
存儲寬度size是8字節(jié),因為不僅得取最大值6字節(jié),還得向Example2.alignment自然數倍對齊。于是,才有了額外2字節(jié)的【對齊填充】和擴容【聯合體】有效長度6字節(jié)至存儲寬度8字節(jié)。你猜對了嗎?
不經意的巧合
思維敏銳的讀者可以已經注意到:單字段【結構體】與單字段【聯合體】的內存布局是相同的,因為數據結構自身的內存布局就是唯一字段的內存布局。不信的話,執(zhí)行下面的例程試試
use ::mem; #[repr(C)] struct Example3 { f1: u16 } #[repr(C)] union Example4 { f1: u16 } // struct 內存布局 等同于 union 的內存布局 assert_eq!(mem::(), mem::()); assert_eq!(mem::(), mem::()); // struct 內存布局 等同于 u16 的內存布局 assert_eq!(mem::(), mem::()); assert_eq!(mem::(), mem::());
枚舉類enum的C內存布局
突破“枚舉”字面含義的束縛,Rust的創(chuàng)新使Rust enum與傳統(tǒng)計算機語言中的同類項都不同。Rust枚舉類
既包括:C風格的“輕裝”枚舉 — 僅標記狀態(tài),卻不記錄細節(jié)數據。
也支持:Rust風格的“重裝”枚舉 — 標記狀態(tài)的同時也記錄細節(jié)數據。
在Rust References一書中,
“輕裝”枚舉被稱為“無字段·枚舉類 field-less enum”或“僅單位類型·枚舉類 unit-only enum”。
“重裝”枚舉被別名為“伴字段·枚舉類enum with fields”。
在Cpp程序中,需要借助【標準庫】的Tagged Union數據結構才能模擬出同類的功能來。欲了解更多技術細節(jié),推薦讀我的另一篇文章。
禁忌:C內存布局的枚舉類必須至少包含一個枚舉值。否則,編譯器就會報怨:error[E0084]: unsupported representation for zero-variant enum。
“輕裝”枚舉類的內存布局
因為“輕裝”枚舉值的唯一有效數據就是“記錄了哪個枚舉項被選中的”分辨因子discriminant,所以枚舉類的內存布局就是枚舉類【整數類型】分辨因子的內存布局。即,
LightEnum.alignment = discriminant.alignment; // 對齊位數 LightEnum.size = discriminant.size; // 存儲寬度 別慶幸!故事遠沒有看起來這么簡單,因為【整數類】是一組數字類型的總稱(餒餒的“集合名詞”)。所以,它包含但不限于
Rust | C | 存儲寬度 |
---|---|---|
u8 / i8 | unsigned char / char | 單字節(jié) |
u16 / i16 | unsigned short / short | 雙字節(jié) |
u32 / i32 | unsigned int / int | 四字節(jié) |
u64 / i64 | unsigned long / long | 八字節(jié) |
usize / isize | 沒有概念對等項,可能得元編程了 | 等長于目標架構“瘦指針”寬度 |
維系FFI兩端Rust和C枚舉類分辨因子都采用相同的整數類型才是最“坑”的,因為
C / Cpp enum實例可存儲任意類型的整數值(比如,char,short,int和long)— 部分原因或許是C系語法靈活的定義形式:“typedef enum塊 + 具名常量”。所以,C / Cpp enum非常適合被做成“比特開關”。但在Rust程序中,就不得不引入外部軟件包bitflags了。
C內存布局Rust枚舉類分辨因子discriminant只能是i32類型— 【存儲寬度size】是固定的4字節(jié)。
Rust內存布局·枚舉類·分辨因子discriminant的整數類型是編譯時由rustc決定的,但最寬支持到isize類型。
這就對FFI - C端的程序設計提出了額外的限制條件:至少,由ABI接口導出的枚舉值得用int類型定義。否則,Rust端FFI函數調用就會觸發(fā)U.B.。FFI門檻稍有上升。 扼要歸納:
FFI - Rust端C內存布局的枚舉類對FFI - C端枚舉值的【整數類型】提出了“確定性假設invariant”:枚舉值的整數類型是int且存儲寬度等于4字節(jié)。
C端 @程序員 必須硬編碼所有枚舉值的數據類型,以滿足該假設。
FFI跨語言互操作才能成功“落地”,而不是發(fā)生U.B.。
來自C端的遷就固然令人心情愉悅,但新應用程序難免要對接兼容遺留系統(tǒng)與舊鏈接庫。此時,再給FFI - C端提要求就不那么現實了 — 深度改動“屎山”代碼風險巨大,甚至你可能都沒有源碼?!?strong>數字類型·內存布局】正是解決此棘手問題的技術方案:
以【元屬性】#[repr(整數類型名)]注釋枚舉類定義
明確指示Rust編譯器采用給定【整數類型】的內存布局,組織【分辨因子discriminant】的數據存儲,而不總是遵循i32內存布局。
從C / Cpp整數類型至Rust內存布局元屬性的映射關系包括但不限于
C | Rust 元屬性 |
---|---|
unsigned char / char | #[repr(u8)] / #[repr(i8)] |
unsigned short / short | #[repr(u16)] / #[repr(i16)] |
unsigned int / int | #[repr(u32)] / #[repr(i32)] |
unsigned long / long | #[repr(u64)] / #[repr(i64)] |
舉個例子,
use ::mem; #[repr(C)] enum Example5 { // ”輕裝“枚舉類,因為 A(), // field-less variant B {}, // field-less variant C // unit variant } println!("alignment = {1}; size = {0}", mem::(), mem::()); 上面代碼定義的是C內存布局的“輕裝”枚舉類Example5,因為它的每個枚舉值不是“無字段”,就是“單位類型”。于是,Example5的內存布局就是i32類型的alignment = size = 4 Byte。 再舉個例子,
use ::mem; #[repr(u8)] enum Example6 { // ”輕裝“枚舉類,因為 A(), // field-less variant B {}, // field-less variant C // unit variant } println!("alignment = {1}; size = {0}", mem::(), mem::()); 上面代碼定義的是【數字類型·內存布局】的“輕裝”枚舉類Example6。它的內存布局是u8類型的alignment = size = 1 Byte。
“重裝”枚舉類的內存布局
【“重裝”枚舉類】絕對是Rust語言設計的一大創(chuàng)新,但同時也給FFI跨語言互操作帶來了嚴重挑戰(zhàn),因為在其它計算機語言中沒有概念對等的核心語言元素“接得住它”。對此,在做C內存布局時,編譯器rustc會將【“重裝”枚舉類】“降維”成一個雙字段結構體:
第一個字段是:剝去了所有字段的【“輕裝”枚舉】,也稱【分辨因子枚舉類Discriminant enum】。
第二個字段是:由枚舉值variant內字段fields拼湊成的【結構體struct】組成的【聯合體union】。
前者記錄選中項的“索引值” — 誰被選中;后者記憶選中項內的值:根據索引值,以對應的數據類型,讀/寫聯合體實例的字段值。 文字描述著實有些晦澀與抽象。邊看下圖,邊再體會。一圖抵千詞?。P鍵還是對union數據類型的理解)
上圖中有三個很細節(jié)的知識點容易被讀者略過,所以在這里特意強調一下:
保存枚舉值字段的結構體struct A / B / C都既派生了trait Copy,又派生了trait Clone,因為
union數據結構要求它的每個字段都是可復制的
同時,trait Copy又是trait Clone的subtrait
降維后結構體struct Example7內的字段名不重要,但字段排列次序很重要。因為在C ABI中,結構體字段的存儲次序就是它們在源碼中的聲明次序,所以Cpp標準庫中的Tagged Union數據結構總是,根據約定的字段次序,
將第一個字段解釋為“選中項的索引號”,
將第二個字段解讀為“選中項的數據值”。
C內存布局的分辨因子枚舉類enum Discriminant的分辨因子discriminant依舊是i32類型值,所以FFI - C端的枚舉值仍舊被要求采用int整數類型。
舉個例子,
use ::mem; #[repr(C)] enum Example8 { Variant0(u8), Variant1, } println!("alignment = {1}; size = {0}", mem::(), mem::()) 看答案之前,不防先心算一下,程序向標準輸出打印的結果是多少。演算過程如下:
enum被“降維”成struct
就C內存布局而言,struct的alignment是全部字段alignment中的最大值。
字段union.Variant0是單字段元組結構體,且字段類型是基本數據類型。所以,union.Variant0.alignment = union.Variant0.size = 1 Byte
字段union.Variant1是單位類型。所以,union.Variant1.alignment = 1 Byte和union.Variant1.size = 0 Byte
于是,union.alignment = 1 Byte
字段tag是C內存布局的“輕裝”枚舉類。所以,tag.alignment = tag.size = 4 Byte
字段union是union數據結構。所以,union的alignment也是全部字段alignment中的最大值。
于是,struct.alignment = 4 Byte
struct的size是全部字段size之和。
union.Variant0.size = 1 Byte
union.Variant1.size = 0 Byte
于是,union.size = 1 Byte
字段tag是C內存布局的“輕裝”枚舉類。所以,tag.size = 4 Byte
字段union是union數據結構。union的size是全部字段size中的最大值。
于是,不精準地struct.size ≈ 5 Byte(約等)
此刻struct.size并不是struct.alignment的自然數倍。所以,需要給struct增補“對齊填充位”和向struct.alignment自然數倍對齊
于是,struct.size = 8 Byte(直等)
哎!看見沒,C內存布局還是比較費內存的,一少半都是空白“邊角料”。 【“重裝”枚舉類】同樣會遇到FFI - ABI兩端【Rust枚舉類分辨因子discriminant】與【C枚舉值】整數類型一致約定的難點。為了遷就C端遺留系統(tǒng)和舊鏈接庫對枚舉值【整數類型】的選擇,Rust編譯器依舊選擇“降維”處理enum。但,這次不是將enum變形成struct,而是跳過struct封裝和直接以union為“話事人”。同時,將【分辨因子·枚舉值】作為union字段子數據結構的首個字段:
對元組枚舉值,分辨因子就是子數據結構第0個元素
對結構體枚舉值,分辨因子就子數據結構第一個字段。注:字段名不重要,字段次序更重要。
文字描述著實有些晦澀與抽象。邊看下圖,邊對比上圖,邊體會。一圖抵千詞!
由上圖可見,C與【數字類型】的混合內存布局
既保證了降級后union與struct數據結構繼續(xù)滿足C ABI的存儲格式要求。
又確保了【Rust端枚舉類分辨因子】與【C端枚舉值】之間整數類型的一致性。
舉個例子,假設目標架構是32位系統(tǒng),
use ::mem; #[repr(C, u16)] enum Example10 { Variant0(u8), Variant1, } println!("alignment = {1}; size = {0}", mem::(), mem::()) 看答案之前,不防先心算一下,程序向標準輸出打印的結果是多少。演算過程如下:
enum被“降維”成union
union的alignment是全部字段alignment中的最大值。
第一個字段是u16類型的分辨因子枚舉值。所以,Variant0.0.alignment = Variant0.0.size = 2 Byte
第二個字段是u8類型數字。所以,Variant0.1.alignment = Variant0.1.size = 1 Byte
于是,union.Variant0.alignment = 2 Byte
字段union.Variant0是雙字段元組結構體。所以,struct的alignment是全部字段alignment中的最大值。
字段union.Variant1是單字段元組結構體且唯一字段就是u16分辨因子枚舉值。所以,union.Variant1.alignment = union.Variant1.size = 2 Byte
于是,union.alignment = 2 Byte
union的size是全部字段size中的最大值。
第一個字段是u16類型的分辨因子枚舉值。所以,Variant0.0.size = 2 Byte
第二個字段是u8類型數字。所以,Variant0.1.size = 1 byte
于是,不精準地union.Variant0.size ≈ 3 Byte(約等)
此刻union.Variant0.size不是union.Variant0.alignment的自然數倍。所以,需要對union.Variant0增補“對齊填充位”和向union.Variant0.alignment自然數倍對齊
于是,union.Variant0.size = 4 Byte(直等)
字段union.Variant0是雙字段元組結構體。所以,struct的size是全部字段size之和。
字段union.Variant1是單字段元組結構體且唯一字段就是u16分辨因子枚舉值。所以,union.Variant1.size = 2 Byte
于是,union.size = 4 Byte
哎!看見沒,C 內存布局還是比較費內存的,一少半的“邊角料”。
新設計方案好智慧
優(yōu)化掉了一層struct封裝。即,從enum ? struct ? union縮編至enum ? union
將被優(yōu)化掉的struct的職能(— 記錄選中項的“索引值”)合并入了union字段的子數據結構中。于是,聯合體的每個字段
既要,保存枚舉值的字段數據 — 舊職能
還要,記錄枚舉值的“索引號” — 新職能
但有趣的是,比較上一版數據存儲設計方案,C內存布局卻沒有發(fā)生變化。邏輯描述精簡了但物理實質未變,這太智慧了!因此,由Cpp標準庫提供的Tagged Union數據結構依舊“接得住”Rust端【“重裝”枚舉值】。
僅【數字類型·內存布局】的“重裝”枚舉類
若不以C加【數字類型】的混合內存布局來組織枚舉類enum Example9的數據存儲,而僅保留【數字類型】內存布局,那么上例中被降維后的【聯合體】與【結構體】就都會缺省采用Rust內存布局。參見下圖:
補充于最后,思維活躍的讀者這次千萬別想太多了。沒有#[repr(transparent, u16)]的內存布局組合,因為【透明·內存布局】向來都是“孤來孤往”的。
數字類型·內存布局
僅【枚舉類】支持【數字類型·內存布局】。而且,將無枚舉值的枚舉類注釋為【數字類型·內存布局】會導致編譯失敗。舉個例子
#[repr(u16)] enum Example12 { // 沒有任何枚舉值 } 會導致編譯失敗error[E0084]: unsupported representation for zero-variant enum。
透明·內存布局
“透明”不是指“沒有”,而是意味著:在層疊嵌套數據結構中,外層數據結構的【對齊位數】與【存儲寬度】等于(緊)內層數據結構的【對齊位數】和【存儲寬度】。因此,它僅適用于
單字段的結構體 — 結構體的【對齊位數】與【存儲寬度】等于唯一字段的【對齊位數】和【存儲寬度】。
struct.alignment = struct.field.alignment; struct.size = struct.field.size;
單枚舉值且單字段的“重裝”枚舉類 — 枚舉類的【對齊位數】與【存儲寬度】等于唯一枚舉值內唯一字段的【對齊位數】和【存儲寬度】。
HeavyEnum.alignment = HeavyEnum::variant.field.alignment; HeavyEnum.size = HeavyEnum::variant.field.size;
單枚舉值的“輕裝”枚舉類 — 枚舉類的【對齊位數】與【存儲寬度】等于單位類型的【對齊位數】和【存儲寬度】。
LightEnum.alignment = 1; LightEnum.size = 0;
原則上,數據結構中的唯一字段必須是非零寬度的。但是,若【透明·內存布局】數據結構涉及到了
類型狀態(tài)設計模式
異步多線程
,那么Rust內存布局的靈活性也允許:結構體和“重裝”枚舉值額外包含任意數量的零寬度字段。比如,
std::PhantomData為類型狀態(tài)設計模式,提供Phantom Type支持。
std::PhantomPinned為自引用數據結構,提供!Unpin支持。
舉個例子,
use ::{marker::PhantomData, mem}; #[repr(transparent)] enum Example13 { // 含`Phantom Type`的“重裝”枚舉類 Variant0 ( f32, // 普通有數據字段 PhantomData // 零寬度字段。泛型類型形參未落實到有效數據上。 ) } println!("alignment = {1}; size = {0}", mem::>(), mem::>()) 看答案之前,不防先心算一下,程序向標準輸出打印的結果是多少。演算過程如下:
因為Example14.Variant0.1字段是零寬度數據類型PhantomData,所以它的 和不參與內存布局計算。
alignment = 1 Byte
size = 0 Byte
首字節(jié)地址address與Example10.Variant0.0字段重疊。
因為【透明·內存布局】,所以 外層枚舉類的
【對齊位數】Example14.alignment = Example10::Variant0.0.alignment = 4 Byte
【存儲寬度】Example14.size = Example10::Variant0.0.size = 4 Byte
不同于【數字類型·內存布局】,【透明·內存布局】不被允許與其它內存布局混合使用。比如,
#[repr(C, u16)]是合法的
#[repr(C, transparent)]和#[repr(transparent, u16)]就會導致語編譯失敗
其它類型的內存布局
trait Object與由胖指針&dyn Trait/Box引用的變量值的【內存布局】相同。
閉包Closure沒有固定的【內存布局】。
微調內存布局
只有Rust與C內存布局具備微調能力,且只能修改【對齊位數alignment】參數值。另外,不同數據結構可做的微調操作也略有不同:
struct,union,enum數據結構可上調對齊位數
僅struct,union被允許下調對齊位數
數據結構【對齊位數alignment】值的增加與減少需要使用不同的元屬性修飾符
#[repr(align(新·對齊位數))]增加對齊位數至新值。將小于等于數據結構原本對齊位數的值輸入align(x)修飾符是無效的。
#[repr(packed(新·對齊位數))]減少對齊位數至新值。將大于等于數據結構原本對齊位數的值輸入packed(x)修飾符也是無效的。
align(x)與packed(x)修飾符的實參是【目標】字節(jié)數,而不是【增量】字節(jié)數。所以,#[repr(align(8))]指示編譯器增加對齊數至8字節(jié),而不是增加8字節(jié)。另外,新對齊位數必須是2的自然數次冪。
禁忌
同一個數據類型不被允許既增加又減少對齊位數。即,align(x)與packed(x)修飾符不能共同注釋一個數據類型定義。
減小對齊位數的外層數據結構禁止包含增加對齊位數的子數據結構。即,#[repr(packed(x))]數據結構不允許嵌套包含#[repr(align(y))]子數據結構。
枚舉類內存布局的微調
首先,枚舉類不允許下調對齊位數。 其次,上調枚舉類的對齊位數也會觸發(fā)“內存布局重構”的負作用。編譯器會效仿Newtypes 設計模式重構#[repr(align(x))] enum枚舉類為嵌套包含了enum的#[repr(align(x))] struct元組結構體。一圖抵千詞,請參閱下圖。
由上圖可見,在內存布局重構之后,C內存布局繼續(xù)保留在枚舉類上,而align(16)修飾符僅對外層的結構體有效。所以,從底層實現來講,枚舉類是不支持內存布局微調的,僅能借助外層的Newtypes數據結構間接限定。 以上面的數據結構為例,
use ::mem; #[repr(C, align(16))] enum Example15 { A, B, C } println!("alignment = {1}; size = {0}", mem::(), mem::()) 看答案之前,不防先心算一下,程序向標準輸出打印的結果是多少。演算過程如下:
因為C內存布局,所以枚舉類的分辨因子是i32類型和枚舉類的存儲寬度size = 4 Byte。
但,align(16)將內存空間占用強制地從alignment = size = 4 Byte提升到alignment = size = 16 Byte。
結束語
這次分享的內容比較多,感謝您耐心地讀到文章結束。文章中問答式例程的輸出結果,您猜對了幾個呀? 內存布局是一個非常宏大技術主題,這篇文章僅是拋磚引玉,講的粒度比較粗,涉及的具體數據結構也都很基礎。更多FFI和內存布局的實踐經驗沉淀與知識點匯總,我將在相關技術線的后續(xù)文章中陸續(xù)分享。
-
計算機
+關注
關注
19文章
7360瀏覽量
87633 -
內存
+關注
關注
8文章
2966瀏覽量
73814 -
程序
+關注
關注
116文章
3756瀏覽量
80754 -
函數指針
+關注
關注
2文章
56瀏覽量
3770 -
Rust
+關注
關注
1文章
228瀏覽量
6542
原文標題:程序內存布局
文章出處:【微信號:Rust語言中文社區(qū),微信公眾號:Rust語言中文社區(qū)】歡迎添加關注!文章轉載請注明出處。
發(fā)布評論請先 登錄
相關推薦
評論