嵌入式 C 开发人员的技巧

本章收集了各种技巧,这些技巧可能对希望开始编写 Rust 的经验丰富的嵌入式 C 开发人员很有用。它将特别强调您可能已经在 C 中习惯的事情在 Rust 中是如何不同的。

预处理器

在嵌入式 C 中,使用预处理器来实现各种目的是非常常见的,例如:

  • 使用 #ifdef 进行代码块的编译时选择
  • 编译时数组大小和计算
  • 使用宏来简化常见模式(以避免函数调用开销)

在 Rust 中,没有预处理器,因此许多这些用例的处理方式都不同。在本节的其余部分,我们将介绍使用预处理器的各种替代方法。

编译时代码选择

在 Rust 中,与 #ifdef ... #endif 最匹配的是 Cargo 功能。这些比 C 预处理器更正式一些:每个 crate 都明确列出了所有可能的功能,并且只能打开或关闭。当您将 crate 列为依赖项时,功能就会打开,并且是累加的:如果依赖树中的任何 crate 为另一个 crate 启用了一个功能,则该功能将为该 crate 的所有用户启用。

例如,您可能有一个 crate 提供信号处理原语的库。每个原语可能都需要一些额外的编译时间,或者声明一些您想避免的大型常量表。您可以在您的 Cargo.toml 中为每个组件声明一个 Cargo 功能。

[features]
FIR = []
IIR = []

然后,在您的代码中使用 #[cfg(feature="FIR")] 来控制包含的内容。

#![allow(unused)]
fn main() {
/// In your top-level lib.rs

#[cfg(feature="FIR")]
pub mod fir;

#[cfg(feature="IIR")]
pub mod iir;
}

类似地,您也可以仅在未启用功能或启用或未启用任何功能组合时才包含代码块。

此外,Rust 还提供了许多自动设置的条件,您可以用来选择不同的代码,例如 target_arch,以基于体系结构选择不同的代码。有关条件编译支持的完整详细信息,请参阅 Rust 参考的 条件编译 章节。

条件编译将仅应用于下一个语句或块。如果一个块在当前作用域中无法使用,则需要多次使用 cfg 属性。值得注意的是,大多数情况下,最好只包含所有代码,并允许编译器在优化时删除死代码:这对您和您的用户来说更简单,并且通常编译器在删除未使用的代码方面会做得很好。

编译时大小和计算

Rust 支持 const fn,保证在编译时可求值的函数,因此可以在需要常量的地方使用,例如在数组大小中。这可以与上面提到的功能一起使用,例如:

#![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()];
}

这些是自 1.31 起稳定 Rust 的新功能,因此文档仍然很少。在编写本文时,const fn 可用的功能也非常有限;在未来的 Rust 版本中,预计将扩展在 const fn 中允许的内容。

Rust 提供了一个非常强大的 宏系统。虽然 C 预处理器几乎直接在您的源代码文本上操作,但 Rust 宏系统在更高的级别上操作。Rust 宏有两种类型:示例宏过程宏。前者更简单,更常见;它们看起来像函数调用,并且可以扩展为完整的表达式、语句、项或模式。过程宏更复杂,但允许对 Rust 语言进行非常强大的添加:它们可以将任意 Rust 语法转换为新的 Rust 语法。

一般来说,您可能已经使用了 C 预处理器宏的地方,您可能需要看看示例宏是否可以完成这项工作。它们可以在您的 crate 中定义,并且可以轻松地被您自己的 crate 使用或导出供其他用户使用。请注意,由于它们必须扩展到完整的表达式、语句、项或模式,因此 C 预处理器宏的某些用例将不起作用,例如,扩展为变量名称的一部分或列表中不完整的一组项的宏。

与 Cargo 功能一样,值得考虑您是否真的需要宏。在许多情况下,常规函数更容易理解,并且将被内联到与宏相同的代码中。 #[inline]#[inline(always)] 属性 可以让您进一步控制此过程,尽管这里也应该谨慎 - 编译器会自动内联同一 crate 中的函数,因此强制它不适当地这样做实际上可能会导致性能下降。

解释整个 Rust 宏系统超出了本技巧页面的范围,因此建议您查阅 Rust 文档以获取完整详细信息。

构建系统

大多数 Rust crate 都使用 Cargo 构建(尽管这不是必需的)。这解决了传统构建系统的许多难题。但是,您可能希望自定义构建过程。Cargo 为此目的提供了 build.rs 脚本。它们是 Rust 脚本,可以根据需要与 Cargo 构建系统交互。

构建脚本的常见用例包括:

  • 提供构建时信息,例如将构建日期或 Git 提交哈希静态嵌入到您的可执行文件中
  • 在构建时根据选定的功能或其他逻辑生成链接器脚本
  • 更改 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 中手动实现的强大功能数组,例如链接、压缩、枚举、查找最小值或最大值、求和等等。迭代器方法也可以链接,从而提供非常可读的数据处理代码。

有关更多详细信息,请参阅 Book 中的迭代器迭代器文档

引用与指针

在 Rust 中,指针(称为 原始指针)存在,但仅在特定情况下使用,因为取消引用它们始终被认为是 unsafe -- Rust 无法提供其通常关于指针后面可能是什么的保证。

在大多数情况下,我们使用 引用,用 & 符号表示,或 可变引用,用 &mut 表示。引用行为类似于指针,因为它们可以被取消引用以访问底层值,但它们是 Rust 所有权系统的关键部分:Rust 将严格强制您在任何给定时间只能对同一个值有一个可变引用 多个不可变引用。

在实践中,这意味着您必须更加小心地确定是否需要对数据进行可变访问:在 C 中,默认值是可变的,您必须明确地使用 const,而在 Rust 中,情况正好相反。

您可能仍然使用原始指针的一种情况是直接与硬件交互(例如,将指向缓冲区的指针写入 DMA 外围寄存器),它们也在底层用于所有外围访问 crate,以便您可以读取和写入内存映射的寄存器。

易失性访问

在 C 中,可以将单个变量标记为 volatile,表示编译器该变量中的值可能会在访问之间发生变化。易失性变量通常在嵌入式环境中用于内存映射的寄存器。

在 Rust 中,我们不将变量标记为 volatile,而是使用特定的方法来执行易失性访问:core::ptr::read_volatilecore::ptr::write_volatile。这些方法采用 *const T*mut T原始指针,如上所述)并执行易失性读取或写入。

例如,在 C 中,您可能会编写:

volatile bool signalled = false;

void ISR() {
    // Signal that the interrupt has occurred
    signalled = true;
}

void driver() {
    while(true) {
        // Sleep until signalled
        while(!signalled) { WFI(); }
        // Reset signalled indicator
        signalled = false;
        // Perform some task that was waiting for the interrupt
        run_task();
    }
}

在 Rust 中,等效的代码将对每次访问使用易失性方法:

static mut SIGNALLED: bool = false;

#[interrupt]
fn ISR() {
    // Signal that the interrupt has occurred
    // (In real code, you should consider a higher level primitive,
    //  such as an atomic type).
    unsafe { core::ptr::write_volatile(&mut SIGNALLED, true) };
}

fn driver() {
    loop {
        // Sleep until signalled
        while unsafe { !core::ptr::read_volatile(&SIGNALLED) } {}
        // Reset signalled indicator
        unsafe { core::ptr::write_volatile(&mut SIGNALLED, false) };
        // Perform some task that was waiting for the interrupt
        run_task();
    }
}

在代码示例中,有几件事值得注意:

  • 我们可以将 &mut SIGNALLED 传递给需要 *mut T 的函数,因为 &mut T 会自动转换为 *mut T(对于 *const T 也一样)
  • 我们需要 unsafe 块用于 read_volatile/write_volatile 方法,因为它们是 unsafe 函数。程序员有责任确保安全使用:有关更多详细信息,请参阅这些方法的文档。

很少需要直接在代码中使用这些函数,因为它们通常由更高级别的库为您处理。对于内存映射的外围设备,外围访问 crate 将自动实现易失性访问,而对于并发原语,有更好的抽象可用(请参阅 并发章节)。

打包和对齐的类型

在嵌入式 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
// Note ordering has been changed to x, z, y to improve packing.

为了确保与 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
// Ordering is preserved and the layout will not change over time.
// `z` is two-byte aligned so a byte of padding exists between `y` and `z`.

为了确保打包表示,请使用 repr(packed)

#[repr(packed)]
struct Foo {
    x: u16,
    y: u8,
    z: u16,
}

fn main() {
    let v = Foo { x: 0, y: 0, z: 0 };
    // References must always be aligned, so to check the addresses of the
    // struct's fields, we use `std::ptr::addr_of!()` to get a raw pointer
    // instead of just printing `&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
// No padding has been inserted between `y` and `z`, so now `z` is unaligned.

请注意,使用 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
// The two instances `u` and `v` have been placed on 4096-byte alignments,
// evidenced by the `000` at the end of their addresses.

请注意,我们可以将 repr(C)repr(align(n)) 结合使用,以获得对齐且与 C 兼容的布局。不允许将 repr(align(n))repr(packed) 组合使用,因为 repr(packed) 将对齐方式设置为 1repr(packed) 类型也不允许包含 repr(align(n)) 类型。

有关类型布局的更多详细信息,请参阅 Rust 参考的类型布局章节。

其他资源