給嵌入式 C 開發者的提示
本章收錄各種可能對有經驗的嵌入式 C 開發者有用的提示,以便開始撰寫 Rust。其中會特別強調你在 C 中習以為常的事,在 Rust 中有何不同。
預處理器
在嵌入式 C 中,預處理器常被用於各種用途,例如:
- 使用
#ifdef於編譯期選擇程式碼區塊 - 編譯期的陣列大小與計算
- 用巨集簡化常見模式(避免函式呼叫開銷)
Rust 沒有預處理器,因此這些用例多半以不同方式處理。在本節其餘部分,我們會介紹多種預處理器的替代方案。
編譯期程式碼選擇
在 Rust 中,最接近 #ifdef ... #endif 的是 Cargo features。它們比 C 預處理器更正式:每個 crate 會明確列出所有可能的 feature,且只能是開或關。當你把某個 crate 列為相依時會啟用 feature,而且 feature 是可累加的:若相依樹中任何 crate 為另一個 crate 啟用 feature,該 crate 的所有使用者都會啟用該 feature。
例如,你可能有個 crate 提供訊號處理基礎元件。每個元件可能需要額外編譯時間或宣告大型常數表,而你希望避免。你可以在 Cargo.toml 為每個元件宣告一個 Cargo feature:
[features]
FIR = []
IIR = []
然後在程式碼中使用 #[cfg(feature="FIR")] 來控制要包含哪些內容。
#![allow(unused)]
fn main() {
/// 在你的頂層 lib.rs 中
#[cfg(feature="FIR")]
pub mod fir;
#[cfg(feature="IIR")]
pub mod iir;
}
你也可以在 feature _未_啟用時包含程式碼區塊,或在某些 feature 組合啟用或未啟用時包含程式碼。
此外,Rust 提供多種自動設定的條件可用,例如 target_arch 可依架構選擇不同程式碼。關於條件式編譯支援的完整細節,請參考 Rust 參考文件中的條件式編譯 章節。
條件式編譯只會套用到下一個敘述或區塊。若某個區塊在目前作用域無法使用,就需要多次使用 cfg 屬性。值得注意的是,多數情況下更好的作法是直接包含所有程式碼,讓編譯器在最佳化時移除死碼:對你與使用者都更簡單,且通常編譯器能很好地移除未使用的程式碼。
編譯期大小與計算
Rust 支援 const fn,其函式可保證在編譯期求值,因此可用於需要常數的地方,例如陣列大小。它可與上述 feature 一起使用,例如:
#![allow(unused)]
fn main() {
const fn array_size() -> usize {
#[cfg(feature="use_more_ram")]
{ 1024 }
#[cfg(not(feature="use_more_ram"))]
{ 128 }
}
static BUF: [u32; array_size()] = [0u32; array_size()];
}
這些功能自 Rust 1.31 起才進入穩定版,因此文件仍較少。撰寫本書時,const fn 可用的功能也相當有限;未來 Rust 版本預期會擴充 const fn 允許的內容。
巨集
Rust 提供非常強大的巨集系統。C 的預處理器幾乎直接操作原始碼文字,而 Rust 巨集系統在更高層級運作。Rust 的巨集有兩種:巨集範例(macros by example) 與 程序式巨集(procedural macros)。前者較簡單且最常見,看起來像函式呼叫,能展開成完整的表達式、敘述、項目或樣式。程序式巨集更複雜,但可對 Rust 語言進行極其強大的擴展:它們可將任意 Rust語法轉換成新的 Rust 語法。
一般而言,若你原本會用 C 預處理器巨集,應先看看巨集範例是否能完成任務。它們可在你的 crate 中定義,並輕鬆供自己的 crate 使用或匯出給其他使用者。請注意,因為它們必須展開成完整的表達式、敘述、項目或樣式,所以某些 C 預處理器巨集的用法無法使用,例如展開成變數名稱的一部分,或清單中不完整的一組項目。
和 Cargo features 一樣,也值得考慮是否真的需要巨集。許多情況下,一般函式更容易理解,且會被內聯成與巨集相同的程式碼。#[inline] 與 #[inline(always)] 屬性 可提供更多控制,但也需小心——編譯器會在適當時自動內聯同一 crate 的函式,若強制內聯不恰當,反而可能降低效能。
完整說明 Rust 巨集系統超出本提示頁的範圍,建議參考 Rust 文件以了解完整細節。
建置系統
多數 Rust crate 以 Cargo 建置(雖非必須)。這解決了傳統建置系統的許多難題。不過,你可能希望自訂建置流程。Cargo 提供 build.rs 腳本 來達成此目的。這些是可依需求與 Cargo 建置系統互動的 Rust 腳本。
建置腳本的常見用途包括:
- 提供建置時資訊,例如將建置日期或 Git 提交雜湊值靜態嵌入可執行檔
- 依選擇的 feature 或其他邏輯在建置時產生連結器腳本
- 變更 Cargo 建置設定
- 加入額外要連結的靜態函式庫
目前沒有後置建置腳本的支援,而你可能曾用它來自動從建置物件產生二進位檔或輸出建置資訊。
交叉編譯
使用 Cargo 作為建置系統也能簡化交叉編譯。多數情況下只需告訴 Cargo --target thumbv6m-none-eabi,並在 target/thumbv6m-none-eabi/debug/myapp 找到合適的可執行檔。
對於 Rust 尚未原生支援的平台,你需要自行為該目標建置 libcore。在這類平台上,可使用 Xargo 作為 Cargo 的替代,為你自動建置 libcore。
疊代器與陣列存取
在 C 中,你可能習慣以索引直接存取陣列:
int16_t arr[16];
int i;
for(i=0; i<sizeof(arr)/sizeof(arr[0]); i++) {
process(arr[i]);
}
在 Rust 中這是反模式:索引存取可能較慢(因為需要邊界檢查),也可能阻礙各種編譯器最佳化。這點很重要,值得重申:Rust 會對手動索引陣列進行越界檢查以保證記憶體安全,而 C 則會愉快地索引到陣列之外。
改用疊代器:
let arr = [0u16; 16];
for element in arr.iter() {
process(*element);
}
疊代器提供了你在 C 中必須手動實作的一系列強大功能,例如串接、拉鍊化、枚舉、找最小/最大值、加總等。疊代器方法也可串接,讓資料處理程式碼非常易讀。
更多細節請參考手冊中的疊代器與Iterator 文件。
參考與指標
在 Rust 中確實有指標(稱為裸指標),但只在特定情況下使用,因為解參考它們一律被視為 unsafe——Rust 無法對指標背後的內容提供一貫的保證。
在多數情況下,我們會改用_參考_(以 & 表示),或_可變參考_(以 &mut 表示)。參考的行為類似指標,可解參考以存取底層值,但它們是 Rust 所有權系統的關鍵:Rust 嚴格保證在任何時刻對同一個值只能有一個可變參考,或 多個不可變參考。
實務上這代表你必須更謹慎考慮是否真的需要對資料進行可變存取:在 C 中預設可變且需明確標示 const,在 Rust 中則相反。
仍可能使用裸指標的情況之一是直接與硬體互動(例如將緩衝區指標寫入 DMA 周邊暫存器)。它們也被所有周邊存取套件在底層使用,以允許讀寫記憶體對映暫存器。
易變存取
在 C 中,單一變數可標記為 volatile,告訴編譯器該變數的值可能在存取之間改變。易變變數在嵌入式情境中常用於記憶體對映暫存器。
在 Rust 中,不是將變數標記為 volatile,而是使用特定方法進行易變存取:core::ptr::read_volatile 與core::ptr::write_volatile。這些方法接受 *const T 或 *mut T(如上所述的_裸指標_),並執行易變讀寫。
例如,在 C 中你可能會寫:
volatile bool signalled = false;
void ISR() {
// 表示中斷已發生
signalled = true;
}
void driver() {
while(true) {
// 睡眠直到收到訊號
while(!signalled) { WFI(); }
// 重設已通知的指示
signalled = false;
// 執行先前等待中斷的任務
run_task();
}
}
在 Rust 中等效作法是在每次存取時使用易變方法:
static mut SIGNALLED: bool = false;
#[interrupt]
fn ISR() {
// 表示中斷已發生
//(在實際程式碼中,你應考慮更高階的原語,
// 例如原子型別)。
unsafe { core::ptr::write_volatile(&mut SIGNALLED, true) };
}
fn driver() {
loop {
// 睡眠直到收到訊號
while unsafe { !core::ptr::read_volatile(&SIGNALLED) } {}
// 重設已通知的指示
unsafe { core::ptr::write_volatile(&mut SIGNALLED, false) };
// 執行先前等待中斷的任務
run_task();
}
}
程式碼範例中有幾點值得注意:
- 我們可以將
&mut SIGNALLED傳給需要*mut T的函式,因為&mut T會自動轉換為*mut T(*const T亦同) - 由於
read_volatile/write_volatile是unsafe函式,我們需要使用unsafe區塊。確保安全使用是程式設計者的責任:詳情請參考這些方法的文件。
通常不需要在程式中直接呼叫這些函式,因為高階函式庫會替你處理。對記憶體對映周邊而言,周邊存取套件會自動實作易變存取;對並行原語則有更好的抽象可用(參見並行章節)。
打包與對齊型別
在嵌入式 C 中,常見做法是告訴編譯器某變數必須有特定位元組對齊,或某個結構體必須打包而非對齊,通常是為了滿足特定硬體或通訊協定需求。
在 Rust 中,這由結構體或聯合體上的 repr 屬性控制。預設表現形式不保證版面配置,因此不應用於與硬體或 C 互通的程式碼。編譯器可能會重新排序結構體成員或插入填充,且行為可能在未來 Rust 版本中改變。
struct Foo {
x: u16,
y: u8,
z: u16,
}
fn main() {
let v = Foo { x: 0, y: 0, z: 0 };
println!("{:p} {:p} {:p}", &v.x, &v.y, &v.z);
}
// 0x7ffecb3511d0 0x7ffecb3511d4 0x7ffecb3511d2
// 注意為了改善打包,順序已改為 x、z、y。
為了確保與 C 互通的版面配置,使用 repr(C):
#[repr(C)]
struct Foo {
x: u16,
y: u8,
z: u16,
}
fn main() {
let v = Foo { x: 0, y: 0, z: 0 };
println!("{:p} {:p} {:p}", &v.x, &v.y, &v.z);
}
// 0x7fffd0d84c60 0x7fffd0d84c62 0x7fffd0d84c64
// 順序保持不變,且版面配置不會隨時間改變。
// `z` 需 2 位元組對齊,因此 `y` 與 `z` 之間存在 1 位元組填充。
若要確保打包表示,使用 repr(packed):
#[repr(packed)]
struct Foo {
x: u16,
y: u8,
z: u16,
}
fn main() {
let v = Foo { x: 0, y: 0, z: 0 };
// 參考必須永遠對齊,因此要檢查結構體欄位位址時,
// 我們使用 `std::ptr::addr_of!()` 取得裸指標,
// 而不是直接列印 `&v.x`。
let px = std::ptr::addr_of!(v.x);
let py = std::ptr::addr_of!(v.y);
let pz = std::ptr::addr_of!(v.z);
println!("{:p} {:p} {:p}", px, py, pz);
}
// 0x7ffd33598490 0x7ffd33598492 0x7ffd33598493
// `y` 與 `z` 之間未插入填充,因此 `z` 現在未對齊。
注意,使用 repr(packed) 也會把型別對齊設為 1。
最後,若要指定特定對齊方式,使用 repr(align(n)),其中 n 是對齊到的位元組數(且必須為 2 的冪):
#[repr(C)]
#[repr(align(4096))]
struct Foo {
x: u16,
y: u8,
z: u16,
}
fn main() {
let v = Foo { x: 0, y: 0, z: 0 };
let u = Foo { x: 0, y: 0, z: 0 };
println!("{:p} {:p} {:p}", &v.x, &v.y, &v.z);
println!("{:p} {:p} {:p}", &u.x, &u.y, &u.z);
}
// 0x7ffec909a000 0x7ffec909a002 0x7ffec909a004
// 0x7ffec909b000 0x7ffec909b002 0x7ffec909b004
// 兩個實例 `u` 與 `v` 被放在 4096 位元組對齊的位置,
// 從位址末尾的 `000` 可看出。
注意,我們可將 repr(C) 與 repr(align(n)) 結合以取得對齊且相容 C 的配置。不得將 repr(align(n)) 與 repr(packed) 結合,因為 repr(packed) 會把對齊設為 1。repr(packed) 型別也不得包含 repr(align(n)) 型別。
關於型別配置的更多細節,請參考 Rust 參考文件的 type layout 章節。