嵌入式 C 开发人员的技巧

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

预处理器

在嵌入式 C 中,使用预处理器来完成各种目的非常常见,例如

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

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

编译时代码选择

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

例如,您可能有一个板条箱提供信号处理原语库。每个原语可能需要一些额外的编译时间或声明一些您想避免的大型常量表。您可以在您的 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()];
}

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

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

通常,在您可能使用过 C 预处理器宏的地方,您可能想看看示例宏是否可以完成这项工作。它们可以在您的板条箱中定义,并由您自己的板条箱轻松使用,或者导出供其他用户使用。请注意,由于它们必须扩展为完整的表达式、语句、项目或模式,因此 C 预处理器宏的一些用例将无法使用,例如扩展为变量名称的一部分或列表中不完整项目集的宏。

与 Cargo 特性一样,值得考虑您是否真的需要宏。在许多情况下,普通函数更容易理解,并且会内联到与宏相同的代码中。#[inline]#[inline(always)] 属性 为您提供了对该过程的进一步控制,尽管这里也应该注意——编译器会自动内联来自同一板条箱的函数(如果合适),因此强制它不适当地这样做实际上可能会导致性能下降。

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

构建系统

大多数 Rust 板条箱都是使用 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 中手动实现这些功能,例如链接、压缩、枚举、查找最小值或最大值、求和等等。迭代器方法也可以链接,从而提供非常易读的数据处理代码。

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

引用与指针

在 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() {
    // 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 函数。程序员有责任确保安全使用:有关更多详细信息,请参阅方法的文档。

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

打包和对齐类型

在嵌入式 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)` 将对齐方式设置为 `1`。`repr(packed)` 类型也不允许包含 `repr(align(n))` 类型。

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

其他资源