C 還是 Rust:選擇哪個用於硬件抽象編程

在 Rust 中使用類型級編程可以使硬件抽象更加安全。-- Dan Pittman(作者)

C 還是 Rust:選擇哪個用於硬件抽象編程

Rust 是一種日益流行的編程語言,被視為硬件接口的最佳選擇。通常會將其與 C 的抽象級別相比較。本文介紹了 Rust 如何通過多種方式處理按位運算,並提供了既安全又易於使用的解決方案。


C 還是 Rust:選擇哪個用於硬件抽象編程


在 C 語言中對寄存器值進行按位運算

在系統編程領域,你可能經常需要編寫硬件驅動程序或直接與內存映射設備進行交互,而這些交互幾乎總是通過硬件提供的內存映射寄存器來完成的。通常,你通過對某些固定寬度的數字類型進行按位運算來與這些寄存器進行交互。

例如,假設一個 8 位寄存器具有三個字段:

+----------+------+-----------+---------+| (unused) | Kind | Interrupt | Enabled |+----------+------+-----------+---------+ 5-7    2-4    1     0

字段名稱下方的數字規定了該字段在寄存器中使用的位。要啟用該寄存器,你將寫入值 1(以二進制表示為 0000_0001)來設置 Enabled 字段的位。但是,通常情況下,你也不想幹擾寄存器中的現有配置。假設你要在設備上啟用中斷功能,但也要確保設備保持啟用狀態。為此,必須將 Interrupt 字段的值與 Enabled 字段的值結合起來。你可以通過按位操作來做到這一點:

1 | (1 << 1)

通過將 1 和 2(1 左移一位得到)進行“或”(|)運算得到二進制值 0000_0011 。你可以將其寫入寄存器,使其保持啟用狀態,但也啟用中斷功能。

你的頭腦中要記住很多事情,特別是當你要在一個完整的系統上和可能有數百個之多的寄存器打交道時。在實踐上,你可以使用助記符來執行此操作,助記符可跟蹤字段在寄存器中的位置以及字段的寬度(即它的上邊界是什麼)

下面是這些助記符之一的示例。它們是 C 語言的宏,用右側的代碼替換它們的出現的地方。這是上面列出的寄存器的簡寫。& 的左側是該字段的起始位置,而右側則限制該字段所佔的位:

#define REG_ENABLED_FIELD(x) (x << 0) & 1#define REG_INTERRUPT_FIELD(x) (x << 1) & 2#define REG_KIND_FIELD(x) (x << 2) & (7 << 2)

然後,你可以使用這些來抽象化寄存器值的操作,如下所示:

void set_reg_val(reg* u8, val u8);fn enable_reg_with_interrupt(reg* u8) {  set_reg_val(reg, REG_ENABLED_FIELD(1) | REG_INTERRUPT_FIELD(1));}

這就是現在的做法。實際上,這就是大多數驅動程序在 Linux 內核中的使用方式。

有沒有更好的辦法?如果能夠基於對現代編程語言研究得出新的類型系統,就可能能夠獲得安全性和可表達性的好處。也就是說,如何使用更豐富、更具表現力的類型系統來使此過程更安全、更持久?

在 Rust 語言中對寄存器值進行按位運算

繼續用上面的寄存器作為例子:

+----------+------+-----------+---------+| (unused) | Kind | Interrupt | Enabled |+----------+------+-----------+---------+ 5-7    2-4    1     0

你想如何用 Rust 類型來表示它呢?

你將以類似的方式開始,為每個字段的偏移定義常量(即,距最低有效位有多遠)及其掩碼。掩碼是一個值,其二進制表示形式可用於更新或讀取寄存器內部的字段:

const ENABLED_MASK: u8 = 1;const ENABLED_OFFSET: u8 = 0;const INTERRUPT_MASK: u8 = 2;const INTERRUPT_OFFSET: u8 = 1;const KIND_MASK: u8 = 7 << 2;const KIND_OFFSET: u8 = 2;

接下來,你將聲明一個 Field 類型並進行操作,將給定值轉換為與其位置相關的值,以供在寄存器內使用:

struct Field {value: u8,}impl Field {fn new(mask: u8, offset: u8, val: u8) -> Self {Field {value: (val << offset) & mask,}}}

最後,你將使用一個 Register 類型,該類型會封裝一個與你的寄存器寬度匹配的數字類型。 Register 具有 update 函數,可使用給定字段來更新寄存器:

struct Register(u8);impl Register {fn update(&mut self, val: Field) {self.0 = self.0 | field.value;}}fn enable_register(&mut reg) {reg.update(Field::new(ENABLED_MASK, ENABLED_OFFSET, 1));}

使用 Rust,你可以使用數據結構來表示字段,將它們與特定的寄存器聯繫起來,並在與硬件交互時提供簡潔明瞭的工效。這個例子使用了 Rust 提供的最基本的功能。無論如何,添加的結構都會減輕上述 C 示例中的某些晦澀的地方。現在,字段是個帶有名字的事物,而不是從模糊的按位運算符派生而來的數字,並且寄存器是具有狀態的類型 —— 這在硬件上多了一層抽象。

一個易用的 Rust 實現

用 Rust 重寫的第一個版本很好,但是並不理想。你必須記住要帶上掩碼和偏移量,並且要手工進行臨時計算,這容易出錯。人類不擅長精確且重複的任務 —— 我們往往會感到疲勞或失去專注力,這會導致錯誤。一次一個寄存器地手動記錄掩碼和偏移量幾乎可以肯定會以糟糕的結局而告終。這是最好留給機器的任務。

其次,從結構上進行思考:如果有一種方法可以讓字段的類型攜帶掩碼和偏移信息呢?如果可以在編譯時就發現硬件寄存器的訪問和交互的實現代碼中存在錯誤,而不是在運行時才發現,該怎麼辦?也許你可以依靠一種在編譯時解決問題的常用策略,例如類型。

你可以使用 typenum 來修改前面的示例,該庫在類型級別提供數字和算術。在這裡,你將使用掩碼和偏移量對 Field 類型進行參數化,使其可用於任何 Field 實例,而無需將其包括在調用處:

#[macro_use]extern crate typenum;use core::marker::PhantomData;use typenum::*;// Now we'll add Mask and Offset to Field's typestruct Field {value: u8,_mask: PhantomData,_offset: PhantomData,}// We can use type aliases to give meaningful names to// our fields (and not have to remember their offsets and masks).type RegEnabled = Field;type RegInterrupt = Field;type RegKind = Field;

現在,當重新訪問 Field 的構造函數時,你可以忽略掩碼和偏移量參數,因為類型中包含該信息:

impl Field {fn new(val: u8) -> Self {Field {value: (val << Offset::U8) & Mask::U8,_mask: PhantomData,_offset: PhantomData,}}}// And to enable our register...fn enable_register(&mut reg) {reg.update(RegEnabled::new(1));}

看起來不錯,但是……如果你在給定的值是否適合該字段方面犯了錯誤,會發生什麼?考慮一個簡單的輸入錯誤,你在其中放置了 10 而不是 1:

fn enable_register(&mut reg) {  reg.update(RegEnabled::new(10));}

在上面的代碼中,預期結果是什麼?好吧,代碼會將啟用位設置為 0,因為 10&1 = 0。那真不幸;最好在嘗試寫入之前知道你要寫入字段的值是否適合該字段。事實上,我認為截掉錯誤字段值的高位是一種 1未定義的行為(哈)。

出於安全考慮使用 Rust

如何以一般方式檢查字段的值是否適合其規定的位置?需要更多類型級別的數字!

你可以在 Field 中添加 Width 參數,並使用它來驗證給定的值是否適合該字段:

struct Field {value: u8,_mask: PhantomData,_offset: PhantomData,_width: PhantomData,}type RegEnabled = Field;type RegInterrupt = Field;type RegKind = Field;impl Field {fn new(val: u8) -> Option {if val <= (1 << Width::U8) - 1 {Some(Field {value: (val << Offset::U8) & Mask::U8,_mask: PhantomData,_offset: PhantomData,_width: PhantomData,})} else {None}}}

現在,只有給定值適合時,你才能構造一個 Field !否則,你將得到 None 信號,該信號指示發生了錯誤,而不是截掉該值的高位並靜默寫入意外的值。

但是請注意,這將在運行時環境中引發錯誤。但是,我們事先知道我們想寫入的值,還記得嗎?鑑於此,我們可以教編譯器完全拒絕具有無效字段值的程序 —— 我們不必等到運行它!

這次,你將向 new 的新實現 new_checked 中添加一個特徵綁定(where 子句),該函數要求輸入值小於或等於給定字段用 Width 所能容納的最大可能值:

struct Field {value: u8,_mask: PhantomData,_offset: PhantomData,_width: PhantomData,}type RegEnabled = Field;type RegInterrupt = Field;type RegKind = Field;impl Field {const fn new_checked() -> SelfwhereV: IsLessOrEqual,{Field {value: (V::U8 << Offset::U8) & Mask::U8,_mask: PhantomData,_offset: PhantomData,_width: PhantomData,}}}

只有擁有此屬性的數字才實現此特徵,因此,如果使用不適合的數字,它將無法編譯。讓我們看一看!

fn enable_register(&mut reg) {reg.update(RegEnabled::new_checked::());}12 | reg.update(RegEnabled::new_checked::()); | ^^^^^^^^^^^^^^^^ expected struct `typenum::B0`, found struct `typenum::B1` | = note: expected type `typenum::B0` found type `typenum::B1`

new_checked 將無法生成一個程序,因為該字段的值有錯誤的高位。你的輸入錯誤不會在運行時環境中才爆炸,因為你永遠無法獲得一個可以運行的工件。

就使內存映射的硬件進行交互的安全性而言,你已經接近 Rust 的極致。但是,你在 C 的第一個示例中所寫的內容比最終得到的一鍋粥的類型參數更簡潔。當你談論潛在可能有數百甚至數千個寄存器時,這樣做是否容易處理?

讓 Rust 恰到好處:既安全又方便使用

早些時候,我認為手工計算掩碼有問題,但我又做了同樣有問題的事情 —— 儘管是在類型級別。雖然使用這種方法很不錯,但要達到編寫任何代碼的地步,則需要大量樣板和手動轉錄(我在這裡談論的是類型的同義詞)。

我們的團隊想要像 TockOS mmio 寄存器 之類的東西,而以最少的手動轉錄生成類型安全的實現。我們得出的結果是一個宏,該宏生成必要的樣板以獲得類似 Tock 的 API 以及基於類型的邊界檢查。要使用它,請寫下一些有關寄存器的信息,其字段、寬度和偏移量以及可選的 枚舉 類的值(你應該為字段可能具有的值賦予“含義”):

register! {// The register's nameStatus,// The type which represents the whole register.u8,// The register's mode, ReadOnly, ReadWrite, or WriteOnly.RW,// And the fields in this register.Fields [OnWIDTH(U1) OFFSET(U0),DeadWIDTH(U1) OFFSET(U1),Color WIDTH(U3) OFFSET(U2) [Red= U1,Blue = U2,Green= U3,Yellow = U4]]}

由此,你可以生成寄存器和字段類型,如上例所示,其中索引:Width、Mask 和 Offset 是從一個字段定義的 WIDTH 和 OFFSET 部分的輸入值派生的。另外,請注意,所有這些數字都是 “類型數字”;它們將直接進入你的 Field 定義!

生成的代碼通過為寄存器及字段指定名稱來為寄存器及其相關字段提供名稱空間。這很繞口,看起來是這樣的:

mod Status {struct Register(u8);mod On {struct Field; // There is of course more to this definition}mod Dead {struct Field;}mod Color {struct Field;pub const Red: Field = Field::new();// &c.}}

生成的 API 包含名義上期望的讀取和寫入的原語,以獲取原始寄存器的值,但它也有辦法獲取單個字段的值、執行集合操作以及確定是否設置了任何(或全部)位集合的方法。你可以閱讀 完整生成的 API 上的文檔。

粗略檢查

將這些定義用於實際設備會是什麼樣?代碼中是否會充斥著類型參數,從而掩蓋了視圖中的實際邏輯?

不會!通過使用類型同義詞和類型推斷,你實際上根本不必考慮程序的類型層面部分。你可以直接與硬件交互,並自動獲得與邊界相關的保證。

這是一個 UART 寄存器塊的示例。我會跳過寄存器本身的聲明,因為包括在這裡就太多了。而是從寄存器“塊”開始,然後幫助編譯器知道如何從指向該塊開頭的指針中查找寄存器。我們通過實現 Deref 和 DerefMut 來做到這一點:

#[repr(C)]pub struct UartBlock {rx: UartRX::Register,_padding1: [u32; 15],tx: UartTX::Register,_padding2: [u32; 15],control1: UartControl1::Register,}pub struct Regs {addr: usize,}impl Deref for Regs {type Target = UartBlock;fn deref(&self) -> &UartBlock {unsafe { &*(self.addr as *const UartBlock) }}}impl DerefMut for Regs {fn deref_mut(&mut self) -> &mut UartBlock {unsafe { &mut *(self.addr as *mut UartBlock) }}}

一旦到位,使用這些寄存器就像 read() 和 modify() 一樣簡單:

fn main() {// A pretend register block.let mut x = [0_u32; 33];let mut regs = Regs {// Some shenanigans to get at `x` as though it were a// pointer. Normally you'd be given some address like// `0xDEADBEEF` over which you'd instantiate a `Regs`.addr: &mut x as *mut [u32; 33] as usize,};assert_eq!(regs.rx.read(), 0);regs.control1.modify(UartControl1::Enable::Set + UartControl1::RecvReadyInterrupt::Set);// The first bit and the 10th bit should be set.assert_eq!(regs.control1.read(), 0b_10_0000_0001);}

當我們使用運行時值時,我們使用如前所述的選項。這裡我使用的是 unwrap,但是在一個輸入未知的真實程序中,你可能想檢查一下從新調用中返回的某些東西: 1 2

fn main() {  // A pretend register block.  let mut x = [0_u32; 33];  let mut regs = Regs {    // Some shenanigans to get at `x` as though it were a    // pointer. Normally you'd be given some address like    // `0xDEADBEEF` over which you'd instantiate a `Regs`.    addr: &mut x as *mut [u32; 33] as usize,  };  let input = regs.rx.get_field(UartRX::Data::Field::Read).unwrap();  regs.tx.modify(UartTX::Data::Field::new(input).unwrap());}

解碼失敗條件

根據你的個人痛苦忍耐程度,你可能已經注意到這些錯誤幾乎是無法理解的。看一下我所說的不那麼微妙的提醒:

error[E0271]: type mismatch resolving `<:uint typenum::b1="">, typenum::B0>, typenum::B1>, typenum::B0>, typenum::B0> as typenum::IsLessOrEqual<:uint typenum::b1="">, typenum::B0>, typenum::B1>, typenum::B0>>>::Output == typenum::B1`--> src/main.rs:12:5 |12 | less_than_ten::(); | ^^^^^^^^^^^^^^^^^^^^ expected struct `typenum::B0`, found struct `typenum::B1` | = note: expected type `typenum::B0` found type `typenum::B1`

expected struct typenum::B0, found struct typenum::B1 部分是有意義的,但是 typenum::UInt

<:uint typenum::uint...="" cons="">

在第 U100 次試圖從這個混亂中破譯出某些含義之後,我們的一個隊友簡直《 瘋了,地獄了,不要再忍受了(Mad As Hell And Wasn’t Going To Take It Anymore)》,並做了一個小工具 tnfilt,從這種命名空間的二進制 cons 單元的痛苦中解脫出來。tnfilt 將 cons 單元格式的表示法替換為可讓人看懂的十進制數字。我們認為其他人也會遇到類似的困難,所以我們分享了 tnfilt 。你可以像這樣使用它:

$ cargo build 2>&1 | tnfilt

它將上面的輸出轉換為如下所示:

error[E0271]: type mismatch resolving `>::Output == typenum::B1`

現在這才有意義!

結論

當在軟件與硬件進行交互時,普遍使用內存映射寄存器,並且有無數種方法來描述這些交互,每種方法在易用性和安全性上都有不同的權衡。我們發現使用類型級編程來取得內存映射寄存器交互的編譯時檢查可以為我們提供製作更安全軟件的必要信息。該代碼可在 bounded-registers crate(Rust 包)中找到。

我們的團隊從安全性較高的一面開始,然後嘗試找出如何將易用性滑塊移近易用端。從這些雄心壯志中,“邊界寄存器”就誕生了,我們在 Auxon 公司的冒險中遇到內存映射設備的任何時候都可以使用它。


此內容最初發布在 Auxon Engineering 博客 上,並經許可進行編輯和重新發布。


  1. 從技術上講,從定義上看,從寄存器字段讀取的值只能在規定的範圍內,但是我們當中沒有一個人生活在一個純淨的世界中,而且你永遠都不知道外部系統發揮作用時會發生什麼。你是在這裡接受硬件之神的命令,因此與其強迫你進入“可能的恐慌”狀態,還不如給你提供處理“這將永遠不會發生”的機會。 ↩
  2. get_field 看起來有點奇怪。我正在專門查看 Field::Read 部分。Field 是一種類型,你需要該類型的實例才能傳遞給 get_field。更乾淨的 API 可能類似於:regs.rx.get_field::<:data::field>(); 但是請記住,Field 是一種具有固定的寬度、偏移量等索引的類型的同義詞。要像這樣對 get_field 進行參數化,你需要使用更高級的類型。 ↩

via: https://opensource.com/article/20/1/c-vs-rust-abstractions

作者: Dan Pittman 選題: lujun9972 譯者: wxy 校對: wxy

本文由 LCTT 原創編譯, Linux中國 榮譽推出

"


分享到:


相關文章: