归去来兮 2020-01-29
在 Rust 中使用类型级编程可以使硬件抽象更加安全。
Rust 是一种日益流行的编程语言,被视为硬件接口的最佳选择。通常会将其与 C 的抽象级别相比较。本文介绍了 Rust 如何通过多种方式处理按位运算,并提供了既安全又易于使用的解决方案。
< 如显示不全,请左右滑动 >
语言 | 诞生于 | 官方描述 | 总览 |
---|---|---|---|
C | 1972 年 | C 是一种通用编程语言,具有表达式简约、现代的控制流和数据结构,以及丰富的运算符集等特点。(来源:CS 基础知识) | C 是(一种)命令式语言,旨在以相对简单的方式进行编译,从而提供对内存的低级访问。(来源:W3schools.in) |
Rust | 2010 年 | 一种赋予所有人构建可靠、高效的软件的能力的语言(来源:Rust 网站) | Rust 是一种专注于安全性(尤其是安全并发性)的多范式系统编程语言。(来源:维基百科) |
在系统编程领域,你可能经常需要编写硬件驱动程序或直接与内存映射设备进行交互,而这些交互几乎总是通过硬件提供的内存映射寄存器来完成的。通常,你通过对某些固定宽度的数字类型进行按位运算来与这些寄存器进行交互。
例如,假设一个 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 内核中的使用方式。
有没有更好的办法?如果能够基于对现代编程语言研究得出新的类型系统,就可能能够获得安全性和可表达性的好处。也就是说,如何使用更丰富、更具表现力的类型系统来使此过程更安全、更持久?
继续用上面的寄存器作为例子:
+----------+------+-----------+---------+ | (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 重写的第一个版本很好,但是并不理想。你必须记住要带上掩码和偏移量,并且要手工进行临时计算,这容易出错。人类不擅长精确且重复的任务 —— 我们往往会感到疲劳或失去专注力,这会导致错误。一次一个寄存器地手动记录掩码和偏移量几乎可以肯定会以糟糕的结局而告终。这是最好留给机器的任务。
其次,从结构上进行思考:如果有一种方法可以让字段的类型携带掩码和偏移信息呢?如果可以在编译时就发现硬件寄存器的访问和交互的实现代码中存在错误,而不是在运行时才发现,该怎么办?也许你可以依靠一种在编译时解决问题的常用策略,例如类型。
你可以使用 typenum 来修改前面的示例,该库在类型级别提供数字和算术。在这里,你将使用掩码和偏移量对 Field
类型进行参数化,使其可用于任何 Field
实例,而无需将其包括在调用处:
#[macro_use] extern crate typenum; use core::marker::PhantomData; use typenum::*; // Now we'll add Mask and Offset to Field's type struct Field<Mask: Unsigned, Offset: Unsigned> { value: u8, _mask: PhantomData<Mask>, _offset: PhantomData<Offset>, } // We can use type aliases to give meaningful names to // our fields (and not have to remember their offsets and masks). type RegEnabled = Field<U1, U0>; type RegInterrupt = Field<U2, U1>; type RegKind = Field<op!(U7 << U2), U2>;
现在,当重新访问 Field
的构造函数时,你可以忽略掩码和偏移量参数,因为类型中包含该信息:
impl<Mask: Unsigned, Offset: Unsigned> Field<Mask, Offset> { 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未定义的行为(哈)。
如何以一般方式检查字段的值是否适合其规定的位置?需要更多类型级别的数字!
你可以在 Field
中添加 Width
参数,并使用它来验证给定的值是否适合该字段:
struct Field<Width: Unsigned, Mask: Unsigned, Offset: Unsigned> { value: u8, _mask: PhantomData<Mask>, _offset: PhantomData<Offset>, _width: PhantomData<Width>, } type RegEnabled = Field<U1,U1, U0>; type RegInterrupt = Field<U1, U2, U1>; type RegKind = Field<U3, op!(U7 << U2), U2>; impl<Width: Unsigned, Mask: Unsigned, Offset: Unsigned> Field<Width, Mask, Offset> { fn new(val: u8) -> Option<Self> { 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<Width: Unsigned, Mask: Unsigned, Offset: Unsigned> { value: u8, _mask: PhantomData<Mask>, _offset: PhantomData<Offset>, _width: PhantomData<Width>, } type RegEnabled = Field<U1, U1, U0>; type RegInterrupt = Field<U1, U2, U1>; type RegKind = Field<U3, op!(U7 << U2), U2>; impl<Width: Unsigned, Mask: Unsigned, Offset: Unsigned> Field<Width, Mask, Offset> { const fn new_checked<V: Unsigned>() -> Self where V: IsLessOrEqual<op!((U1 << Width) - U1), Output = True>, { Field { value: (V::U8 << Offset::U8) & Mask::U8, _mask: PhantomData, _offset: PhantomData, _width: PhantomData, } } }
只有拥有此属性的数字才实现此特征,因此,如果使用不适合的数字,它将无法编译。让我们看一看!
fn enable_register(&mut reg) { reg.update(RegEnabled::new_checked::<U10>()); } 12 | reg.update(RegEnabled::new_checked::<U10>()); | ^^^^^^^^^^^^^^^^ expected struct `typenum::B0`, found struct `typenum::B1` | = note: expected type `typenum::B0` found type `typenum::B1`
new_checked
将无法生成一个程序,因为该字段的值有错误的高位。你的输入错误不会在运行时环境中才爆炸,因为你永远无法获得一个可以运行的工件。
就使内存映射的硬件进行交互的安全性而言,你已经接近 Rust 的极致。但是,你在 C 的第一个示例中所写的内容比最终得到的一锅粥的类型参数更简洁。当你谈论潜在可能有数百甚至数千个寄存器时,这样做是否容易处理?
早些时候,我认为手工计算掩码有问题,但我又做了同样有问题的事情 —— 尽管是在类型级别。虽然使用这种方法很不错,但要达到编写任何代码的地步,则需要大量样板和手动转录(我在这里谈论的是类型的同义词)。
我们的团队想要像 TockOS mmio 寄存器之类的东西,而以最少的手动转录生成类型安全的实现。我们得出的结果是一个宏,该宏生成必要的样板以获得类似 Tock 的 API 以及基于类型的边界检查。要使用它,请写下一些有关寄存器的信息,其字段、宽度和偏移量以及可选的枚举类的值(你应该为字段可能具有的值赋予“含义”):
register! { // The register's name Status, // The type which represents the whole register. u8, // The register's mode, ReadOnly, ReadWrite, or WriteOnly. RW, // And the fields in this register. Fields [ On WIDTH(U1) OFFSET(U0), Dead WIDTH(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::<U1>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
,但是在一个输入未知的真实程序中,你可能想检查一下从新调用中返回的某些东西: 12
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 `<typenum::UInt<typenum::UInt<typenum::UInt<typenum::UInt<typenum::UInt<typenum::UTerm, typenum::B1>, typenum::B0>, typenum::B1>, typenum::B0>, typenum::B0> as typenum::IsLessOrEqual<typenum::UInt<typenum::UInt<typenum::UInt<typenum::UInt<typenum::UTerm, typenum::B1>, typenum::B0>, typenum::B1>, typenum::B0>>>::Output == typenum::B1` --> src/main.rs:12:5 | 12 | less_than_ten::<U20>(); | ^^^^^^^^^^^^^^^^^^^^ 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<typenum::UInt, typenum::UInt...
到底是什么呢?好吧,typenum
将数字表示为二进制 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 `<U20 as typenum::IsLessOrEqual<U10>>::Output == typenum::B1`
现在这才有意义!
当在软件与硬件进行交互时,普遍使用内存映射寄存器,并且有无数种方法来描述这些交互,每种方法在易用性和安全性上都有不同的权衡。我们发现使用类型级编程来取得内存映射寄存器交互的编译时检查可以为我们提供制作更安全软件的必要信息。该代码可在 bounded-registers crate(Rust 包)中找到。
我们的团队从安全性较高的一面开始,然后尝试找出如何将易用性滑块移近易用端。从这些雄心壮志中,“边界寄存器”就诞生了,我们在 Auxon 公司的冒险中遇到内存映射设备的任何时候都可以使用它。
此内容最初发布在 Auxon Engineering 博客上,并经许可进行编辑和重新发布。