09-给嵌入式 C 开发者的提示
8 分钟阅读
译文 · 基于 The Embedded Rust Book
给嵌入式 C 开发者的提示
原文链接: https://doc.rust-lang.org/stable/embedded-book/c-tips/
本章汇总了一系列提示,可能对希望开始编写 Rust 的资深嵌入式 C 开发者有用。它会特别强调:你在 C 中已经习惯的做法,在 Rust 中有何不同。
预处理器
在嵌入式 C 中,预处理器常用于多种目的,例如:
- 用
#ifdef在编译期选择代码块 - 编译期数组大小与计算
- 用宏简化常见模式(以避免函数调用开销)
Rust 没有预处理器,因此许多此类用途会用其它方式解决。本节其余部分介绍预处理器的各种替代方案。
编译期代码选择
在 Rust 中,与 #ifdef ... #endif 最接近的是 Cargo features。它们比 C 预处理器更规范:每个 crate 都会显式列出所有可能的 feature,且只能是开或关。当你把某个 crate 列为依赖时可以开启 feature,并且 feature 是可叠加的:只要依赖树中任意 crate 为另一个 crate 启用了某个 feature,该 feature 就会对该 crate 的所有用户生效。
例如,你可能有一个提供信号处理原语库的 crate。每个组件可能需要额外编译时间,或声明很大的常量表而你希望避免。你可以在 Cargo.toml 里为每个组件声明一个 Cargo feature:
| |
然后在代码中用 #[cfg(feature="FIR")] 控制包含哪些内容。
| |
同样,也可以仅在某个 feature 未启用时包含代码块,或按任意 feature 组合是否启用来包含。
此外,Rust 还提供许多自动设置的条件,例如用 target_arch 按架构选择不同代码。有关条件编译支持的完整细节,请参阅 Rust 参考手册的 条件编译 章节。
条件编译只作用于紧随其后的语句或代码块。若某个块在当前作用域中无法使用,则需要多次使用 cfg 属性。值得注意的是,多数情况下更好的做法是直接包含全部代码,让编译器在优化时删除死代码:这对你和用户都更简单,而且编译器通常能很好地去掉未使用的代码。
编译期大小与计算
Rust 支持 const fn:保证可在编译期求值的函数,因此可用于需要常量的地方,例如数组大小。它可以与上文提到的 feature 一起使用,例如:
| |
这些功能自 Rust 1.31 起才进入稳定版,因此文档仍较稀疏。撰写本文时,const fn 可用的功能也非常有限;在未来的 Rust 版本中,预期会扩大 const fn 中允许的内容。
宏
Rust 提供极其强大的 宏系统。C 预处理器几乎直接作用于源代码文本,而 Rust 宏系统在更高层级上工作。Rust 宏有两种:示例宏(macros by example) 与 过程宏(procedural macros)。前者更简单且更常见;它们看起来像函数调用,可以展开为完整的表达式、语句、项或模式。过程宏更复杂,但能为 Rust 语言带来极强的扩展能力:它们可以把任意 Rust 语法变换成新的 Rust 语法。
一般来说,凡是你可能使用过 C 预处理器宏的地方,多半可以看看示例宏能否胜任。它们可以在你的 crate 中定义,并轻松供本 crate 使用或导出给其他用户。请注意,由于它们必须展开为完整的表达式、语句、项或模式,C 预处理器宏的某些用法行不通,例如展开为变量名的一部分,或列表中不完整的一组项。
与 Cargo feature 一样,也值得考虑你是否真的需要宏。很多情况下普通函数更易理解,并且会内联成与宏相同的代码。#[inline] 与 #[inline(always)] 属性 可进一步控制这一过程,但此处同样需要谨慎——编译器会在适当时自动内联同一 crate 中的函数,不当强制内联实际上可能导致性能下降。
完整解释 Rust 宏系统超出了本提示页的范围,建议查阅 Rust 文档以了解全部细节。
构建系统
多数 Rust crate 使用 Cargo 构建(尽管并非必须)。它能处理传统构建系统中许多棘手问题。不过,你可能希望自定义构建过程。Cargo 为此提供了 build.rs 脚本。它们是可按需与 Cargo 构建系统交互的 Rust 脚本。
构建脚本的常见用途包括:
- 提供构建期信息,例如将构建日期或 Git 提交哈希静态嵌入可执行文件
- 根据所选 feature 或其它逻辑在构建期生成链接脚本
- 更改 Cargo 构建配置
- 添加额外的静态库以供链接
目前尚不支持构建后脚本;传统上你可能用它自动从构建产物生成二进制文件,或打印构建信息。
交叉编译
使用 Cargo 作为构建系统也简化了交叉编译。多数情况下,只需告诉 Cargo --target thumbv6m-none-eabi,即可在 target/thumbv6m-none-eabi/debug/myapp 找到合适的可执行文件。
对于 Rust 原生不支持的平台,你需要自行为该目标构建 libcore。在这类平台上,可以用 Xargo 作为 Cargo 的替代,它会自动为你构建 libcore。
迭代器与数组访问
在 C 中,你可能习惯按索引直接访问数组:
| |
在 Rust 中这是反模式:按索引访问可能更慢(因为需要边界检查),并可能妨碍多种编译器优化。这一点很重要,值得重申:Rust 会对手动数组索引检查越界访问以保证内存安全,而 C 则会愉快地索引到数组之外。
请改用迭代器:
let arr = [0u16; 16];
for element in arr.iter() {
process(*element);
}
迭代器提供了你在 C 中需要手动实现的强大功能,例如链式组合、zip、enumerate、求最小/最大值、求和等。迭代器方法也可以链式调用,从而写出非常易读的数据处理代码。
更多细节见 《Rust 程序设计语言》中的迭代器 与 Iterator 文档。
引用与指针
在 Rust 中,指针(称为 裸指针(raw pointers))确实存在,但只在特定情况下使用,因为解引用它们始终被视为 unsafe——Rust 无法提供其通常对指针背后内容的保证。
多数情况下,我们改用由 & 表示的 引用(references),或由 &mut 表示的 可变引用(mutable references)。引用在行为上类似指针,可以解引用以访问底层值,但它们是 Rust 所有权系统的关键部分:Rust 会严格强制,在任意时刻对同一值只能有一个可变引用,或者多个不可变引用。
实践中这意味着你必须更仔细地考虑是否需要可变访问数据:在 C 中默认是可变的,你必须显式写 const;在 Rust 中则相反。
你可能仍会使用裸指针的一种情形是直接与硬件交互(例如,把缓冲区指针写入 DMA 外设寄存器);此外,所有外设访问 crate 底层也使用它们,以便你读写内存映射寄存器。
易失访问
在 C 中,个别变量可标记为 volatile,向编译器表明变量的值可能在两次访问之间发生变化。在嵌入式上下文中,易失变量常用于内存映射寄存器。
在 Rust 中,我们不是把变量标为 volatile,而是用特定方法执行易失访问:core::ptr::read_volatile 与 core::ptr::write_volatile。这些方法接受 *const T 或 *mut T(上文讨论的 裸指针),并执行易失读或写。
例如,在 C 中你可能这样写:
| |
在 Rust 中的等价写法会在每次访问时使用易失方法:
static mut SIGNALLED: bool = false;
#[interrupt]
fn ISR() {
// 发出中断已发生的信号
// (在真实代码中,应考虑更高层的原语,
// 例如原子类型)。
unsafe { core::ptr::write_volatile(&mut SIGNALLED, true) };
}
fn driver() {
loop {
// 睡眠直到被信号唤醒
while unsafe { !core::ptr::read_volatile(&SIGNALLED) } {}
// 复位信号指示
unsafe { core::ptr::write_volatile(&mut SIGNALLED, false) };
// 执行等待该中断的某些任务
run_task();
}
}
代码示例中有几点值得注意:
- 我们可以把
&mut SIGNALLED传给需要*mut T的函数,因为&mut T会自动转换为*mut T(*const T同理) read_volatile/write_volatile方法需要unsafe块,因为它们是unsafe函数。确保安全使用是程序员的责任:更多细节见这些方法的文档。
在你的代码中直接需要这些函数的情况很少见,因为更高层的库通常会替你处理。对于内存映射外设,外设访问 crate 会自动实现易失访问;而对于并发原语,则有更好的抽象可用(见 并发章节)。
打包与对齐类型
在嵌入式 C 中,常告诉编译器某个变量必须有特定对齐,或某个结构体必须打包而非对齐,通常是为了满足特定硬件或协议要求。
在 Rust 中,这由结构体或联合体上的 repr 属性控制。默认表示不保证布局,因此不应用于与硬件或 C 互操作的代码。编译器可能重排结构体成员或插入填充,且行为可能随未来的 Rust 版本变化。
| |
为确保与 C 可互操作的布局,请使用 repr(C):
| |
为确保打包表示,请使用 repr(packed):
| |
注意,使用 repr(packed) 也会将该类型的对齐设为 1。
最后,要指定特定对齐,请使用 repr(align(n)),其中 n 是要对齐到的字节数(且必须是 2 的幂):
| |
注意我们可以把 repr(C) 与 repr(align(n)) 组合,以获得对齐且与 C 兼容的布局。不允许把 repr(align(n)) 与 repr(packed) 组合,因为 repr(packed) 将对齐设为 1。也不允许 repr(packed) 类型包含 repr(align(n)) 类型。
有关类型布局的更多细节,请参阅 Rust 参考手册的 类型布局 章节。