Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

給嵌入式 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_volatilecore::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_volatileunsafe 函式,我們需要使用 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) 會把對齊設為 1repr(packed) 型別也不得包含 repr(align(n)) 型別。

關於型別配置的更多細節,請參考 Rust 參考文件的 type layout 章節。

其他資源