Rust 是一种越来越流行的编程语言,定位为硬件接口的最佳选择。它通常与 C 语言进行比较,因为它们的抽象级别相似。本文解释了 Rust 如何以多种方式处理位运算,并提供了一种既安全又易于使用的解决方案。
语言 | 起源 | 官方描述 | 概述 |
---|---|---|---|
C | 1972 | C 是一种通用的编程语言,其特点是表达简洁、现代控制流和数据结构,以及丰富的运算符。(来源:CS Fundamentals) | C 是一种命令式语言,旨在以相对直接的方式进行编译,从而提供对内存的底层访问。(来源:W3schools.in) |
Rust | 2010 | 一种赋予每个人构建可靠且高效软件的语言(来源:Rust 网站) | Rust 是一种多范式系统编程语言,专注于安全性,尤其是安全的并发。(来源:Wikipedia) |
C 语言中寄存器值的位运算
在系统编程领域,你可能会发现自己正在编写硬件驱动程序或直接与内存映射设备交互,交互几乎总是通过硬件提供的内存映射寄存器完成的。你通常通过对一些固定宽度的数字类型进行位运算来与这些东西交互。
例如,想象一个具有三个字段的 8 位寄存器
+----------+------+-----------+---------+
| (unused) | Kind | Interrupt | Enabled |
+----------+------+-----------+---------+
5-7 2-4 1 0
字段名称下面的数字规定了该字段在寄存器中使用的位。要启用此寄存器,你需要写入值 1,在二进制中表示为 0000_0001,以设置 enabled 字段的位。但是,通常你还拥有寄存器中现有的配置,你不希望干扰它。假设你想启用设备上的中断,但也要确保设备保持启用状态。为此,你必须将中断字段的值与启用字段的值组合起来。你需要使用位运算来做到这一点
1 | (1 << 1)
通过将 1 与 2 进行 或 运算,你可以得到二进制值 0000_0011,而 2 是通过将 1 左移 1 位得到的。你可以将其写入寄存器,使其保持启用状态,同时启用中断。
这需要你在头脑中记住很多东西,尤其是在处理完整系统的数百个寄存器时。在实践中,你可以使用助记符来跟踪字段在寄存器中的位置以及字段的宽度——即它的上限是什么?
这是一个助记符的例子。它们是 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;
接下来,你将声明一个字段类型,并执行操作以将给定的值转换为其位置相关的值,以供在寄存器内部使用
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 修改之前的示例,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。这很不幸;最好知道你尝试写入字段的值是否会在尝试写入之前适合该字段。事实上,我会认为砍掉错误字段值的高位是未定义行为(惊叹号)。
以安全为中心的 Rust
你如何以通用的方式检查字段的值是否适合其规定的位置?更多类型级别的数字!
你可以向 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,这表明发生了错误,而不是砍掉该值的高位并静默地写入意外的值。
请注意,尽管这会在运行时引发错误。但是,我们事先知道我们要写入的值,还记得吗?鉴于此,我们可以教编译器完全拒绝具有无效字段值的程序——我们不必等到运行它!
这一次,你将添加一个trait 约束(where 子句)到一个名为 new_checked 的新实现中,该实现要求传入的值小于或等于具有给定 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,
}
}
}
只有具有此属性的数字才具有此 trait 的实现,因此如果你使用不适合的数字,则编译将失败。看一看!
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 将无法生成对于字段具有错误的过高值的程序。你的拼写错误不会在运行时崩溃,因为你永远无法获得运行的工件。
在你可以使内存映射硬件交互变得多么安全方面,你正接近 Peak Rust。但是,你在 C 中的第一个示例中编写的内容比你最终得到的类型参数沙拉要简洁得多。当你谈论可能数百甚至数千个寄存器时,做这样的事情甚至可行吗?
恰到好处的 Rust:既安全又易于访问
早些时候,我提到手动计算掩码是有问题的,但我刚刚做了同样有问题的事情——尽管是在类型级别上。虽然使用这种方法很好,但要编写任何代码都需要大量的样板和手动转录(我说的是类型同义词)。
我们的团队想要类似于 TockOS mmio 寄存器 的东西,但它将生成类型安全的实现,并尽可能减少手动转录。我们提出的结果是一个宏,它生成必要的样板以获得类似 Tock 的 API 加上基于类型的边界检查。要使用它,请记下有关寄存器、其字段、它们的宽度和偏移量以及可选的 enum-like 值(如果你想给字段的可能值赋予“意义”)的一些信息
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 部分中输入的值。另外,请注意,所有这些数字都是 typenums;它们将直接进入你的 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);
}
当我们处理运行时值时,我们使用 Option,就像我们之前看到的那样。这里我使用了 unwrap,但在一个具有未知输入的真实程序中,你可能需要检查你是否从新的调用中得到一个 Some 返回值: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 `<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 typenum::B0 found typenum::B1 部分有点道理,但是 typenum::UInt<typenum::UInt, typenum::UInt… 到底是什么胡说八道?嗯,typenum 将数字表示为二进制 cons 单元!像这样的错误使得很难,尤其是当你有几个这样的类型级别数字被限制在狭窄的空间中时,知道它说的是哪个数字。除非,当然,对你来说将巴洛克式二进制表示转换为十进制表示是第二天性。
在第 U100 次尝试从这种混乱中破译出任何意义之后,一位队友怒不可遏,决定不再忍受,并制作了一个小工具 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 包)中找到。
我们的团队一开始就站在安全频谱中更安全的一侧的边缘,然后试图弄清楚如何将易用性滑块向更容易的一端移动。从这些雄心壮志中,bounded-registers 诞生了,我们在 Auxon 的冒险中,每当遇到内存映射设备时,我们都会使用它。
-
从技术上讲,根据定义,从寄存器字段读取只会给出规定范围内的值,但我们没有人生活在一个纯粹的世界中,你永远不知道当外部系统发挥作用时会发生什么。你在这里受硬件之神的支配,因此,与其强迫你进入“可能崩溃”的情况,不如给你 Option 来处理“这不应该发生”的情况。
-
get_field 看起来有点奇怪。我主要关注 Field::Read 部分。Field 是一种类型,你需要该类型的实例才能传递给 get_field。更简洁的 API 可能是这样的
regs.rx.get_field::<UartRx::Data::Field>();
但请记住,Field 是一个类型同义词,它具有宽度、偏移量等的固定索引。为了能够像这样参数化 get_field,你需要更高阶的类型。
这篇文章最初出现在 Auxon Engineering blog 上,经过编辑并经许可重新发布。
2 条评论