跳转到内容

嵌入式 Rust 入门:单片机、no_std 与 Embassy

嵌入式 Rust 指的是为藏在各种东西里的小型计算机编写 Rust 程序:温控器、键盘、无人机、给植物浇水的传感器。这些计算机叫做微控制器(microcontroller,也就是常说的单片机)。一颗芯片里装着处理器、几百 KB 的内存,以及连接 LED、按钮和传感器的引脚。通常根本没有操作系统,从通电到断电,只有你的程序在运行。

听起来有点吓人,但其实非常好玩:你写上几行代码,桌上就有一盏真实的灯开始闪烁。这一页会告诉你能做什么、该选哪块开发板和哪些 crate,以及怎样把第一个程序烧录到芯片上。一路上,你还会在可以直接在浏览器里运行的示例中,练习“嵌入式的思维方式”。

几十年来,嵌入式代码大多是用 C 写的。Rust 带来了几样在小芯片上非常重要的东西:

  • 没有垃圾回收器。 没有任何东西会暂停你的程序去清理内存,所以时序是可预测的。当你必须在几微秒内响应一个信号时,这一点很重要。
  • 不需要运行时的内存安全。 所有权和借用规则在编译时检查,所以在芯片上没有任何开销。一整类崩溃(悬垂指针、中断和主循环之间的数据竞争)在你烧录之前就被拦下了。
  • 零成本抽象。 有了 HAL,你可以写 led.set_high(),而不用去戳内存地址,它编译出来的结果和你手写的那一次寄存器写入完全一样。
  • 用所有权来建模硬件。 一个引脚就是一个值。它一旦被移动进一个 Output,别人就没法再拿到它,也没法不小心把它当成输入来用。以前在 C 文件里只是一行注释的约定,现在由编译器来保证。
  • 出色的工具链。 cargo、rustup 的编译目标和 probe-rs 可以替代很多厂商 IDE 和 makefile。

实话实说,也有不完善的地方:芯片厂商发布 SDK 和示例时还是优先用 C,所以有些芯片的 Rust 支持比较薄弱,或者只有社区在维护。你仍然需要读数据手册。还有一些 crate 还没到 1.0,版本之间 API 会变,所以一定要参照你所用版本的示例。

  • 传感器和小玩意儿:气象站、土壤湿度报警器、计步器。
  • USB 设备:自制键盘、游戏手柄、MIDI 控制器。
  • 联网(IoT)设备:用 ESP32 做的 Wi-Fi 或蓝牙传感器,把读数发送到服务器。
  • 机器人和电机:循迹小车、步进电机和舵机控制、LED 灯带和显示屏。
  • 严肃的系统:Oxide Computer 完全用 Rust 编写了 Hubris(英文),这是一个面向微控制器的小型操作系统。ESP32 背后的公司乐鑫(Espressif)自己维护着 Rust 支持,并发布了 The Rust on ESP Book(英文)。

下面每一块都是不错的第一块开发板。如果你拿不定主意,就选 Raspberry Pi Pico:便宜,文档非常完善,而且下面的 Embassy 示例就是为它写的。

开发板 芯片 Rust 编译目标 优点 从这里开始
Raspberry Pi Pico / Pico W RP2040(Arm Cortex-M0+) thumbv6m-none-eabi 便宜、流行,Embassy 支持非常好 embassy-rp,或者 rp2040-hal + rp-pico
Raspberry Pi Pico 2 RP2350(Arm Cortex-M33 或 RISC-V) thumbv8m.main-none-eabihf 更新、更快,带浮点运算单元 embassy-rp(feature rp235xa)、rp235x-hal
ESP32-C3 / ESP32-C6 开发板 乐鑫的 RISC-V 芯片 riscv32imc-unknown-none-elf / riscv32imac-unknown-none-elf 内置 Wi-Fi 和蓝牙,有官方 Rust 支持 esp-hal 和 esp-generate 工具
STM32 Nucleo、“Black Pill” STM32(比如 STM32F4) thumbv7em-none-eabihf 工业界广泛使用的庞大芯片家族;Nucleo 开发板内置调试探针 embassy-stm32、stm32f4xx-hal
BBC micro:bit v2 nRF52833(Arm Cortex-M4F) thumbv7em-none-eabihf 板上自带调试探针、LED、按钮和传感器 microbit-v2,再配上 Discovery 教程

官方的 Embedded Rust Discovery 教程(英文)用的就是 micro:bit,这是一门面向新手、免费且循序渐进的课程。最初的 ESP32 和 ESP32-S3 用的是 Xtensa 内核而不是 RISC-V;对于它们,乐鑫的 espup 工具会安装一套特殊的 Rust 工具链。Pico W 的 LED 接在它的 Wi-Fi 芯片上,而不是普通引脚上,所以需要另一种闪灯程序(Embassy 为它准备了一个 wifi_blinky 示例)。

普通的 Rust 程序会使用标准库(std)。std 需要一个操作系统:文件、线程、堆,以及给 println! 用的终端。微控制器上这些都没有。所以嵌入式程序一开头会有两行:

#![no_std] // 不链接标准库,只用 `core`
#![no_main] // 不使用常规的 `fn main()` 入口
  • #![no_std] 表示你只能用 core(英文):整数、Option、Result、迭代器、slice、trait、格式化。没有 Vec、String、Box、文件和线程。(如果有内存分配器,可以通过 alloc crate 重新用上 Vec 这些类型,但很多项目根本用不到。)
  • 需要 #![no_main],是因为芯片上没有操作系统来调用 main。像 cortex-m-rt 这样的运行时 crate 会在复位后设置好内存,然后跳转到你的函数。你要用 #[cortex_m_rt::entry] 这样的属性来标记这个函数,如果用 Embassy,则是 #[embassy_executor::main]。
  • 你还必须说明发生 panic 时该怎么办,因为没有终端可以打印。panic-probe 或 panic-halt 这样的 crate 提供了这个功能;用 use panic_probe as _; 加上其中一个即可。

既然没有 Vec,列表要怎么存?heapless(英文)crate 提供了 Vec、String 和队列,它们的容量是固定的,在编译时就决定好。它们存放在栈上或者 static 里,所以没有堆,也不会突然内存不足。缓冲区满了,它只会把你的值原样还给你:

use core::fmt::Write;
use heapless::{String, Vec};
fn main() {
// 正好能放 4 个读数。内存就在变量本身里:
// 没有堆,没有分配器,大小在编译时就确定了。
let mut readings: Vec<i32, 4> = Vec::new();
for value in [215, 218, 221, 224, 227] {
match readings.push(value) {
Ok(()) => println!("stored {value}"),
Err(rejected) => println!("buffer full, dropped {rejected}"),
}
}
// 一个固定容量为 16 字节的 String
let mut line: String<16> = String::new();
write!(line, "avg={}", readings.iter().sum::<i32>() / readings.len() as i32).unwrap();
println!("{line} (capacity {})", line.capacity());
}

heapless 在电脑上也能用,所以你可以用 cargo add [email protected] 和 cargo run 来试试(Playground 里没有这个 crate):

stored 215
stored 218
stored 221
stored 224
buffer full, dropped 227
avg=219 (capacity 16)

嵌入式 Rust 的 crate 是一层叠一层的。你大部分时间会在 HAL 或框架这一层工作,但了解下面有什么会很有帮助:

层 是什么 例子
PAC(Peripheral Access Crate,外设访问 crate) 根据芯片的寄存器描述文件生成。每个寄存器和字段各有一个还算安全的函数。非常底层。 rp2040-pac、stm32f4、esp32c3
HAL(Hardware Abstraction Layer,硬件抽象层) 面向某一个芯片家族的友好类型:Output、Uart、I2c、定时器。构建在 PAC 之上。 rp2040-hal、esp-hal、stm32f4xx-hal、embassy-rp、embassy-stm32
embedded-hal trait 共享的 trait,比如 OutputPin、I2c、SpiDevice 和 DelayNs。HAL 实现它们,驱动使用它们。1.0 版本已经稳定。 embedded-hal、embedded-hal-async
BSP(Board Support Package,板级支持包) 一个 HAL,再加上某块具体开发板上引脚和元件的名字(“那个 LED”“按钮 A”)。 rp-pico、microbit-v2
RTIC 一个面向中断驱动应用的框架。任务和中断、优先级绑定,编译器在构建时检查共享数据。 rtic
Embassy 一个异步框架:执行器、定时器、网络和 USB,外加自己的 HAL。你编写 async fn 任务,用 .await 等待硬件事件。 embassy-executor、embassy-time、embassy-rp

奥妙在中间那一行。一个基于 embedded-hal 的 I2c trait 编写的温度传感器驱动,不用任何修改就能在 Pico、ESP32 和 STM32 上运行,因为每个 HAL 都实现了同一个 trait。下面你会自己写一个这样的驱动。

你的电脑用的是 x86 或 Arm 的桌面处理器,而 Pico 用的是 Cortex-M0+。所以你要做交叉编译:在一台机器上构建,给另一台机器用。rustup 会为这颗芯片下载预编译好的 core 库:

Terminal window
rustup target add thumbv6m-none-eabi # RP2040 (Pico)
rustup target add thumbv8m.main-none-eabihf # RP2350 (Pico 2)
rustup target add thumbv7em-none-eabihf # micro:bit v2, STM32F4

要把程序复制到芯片上并查看它的日志,你需要 probe-rs(英文)和一个调试探针:一个连接芯片调试引脚的小型 USB 设备。micro:bit 和 STM32 Nucleo 开发板都内置了一个。如果是 Pico,可以用 Raspberry Pi Debug Probe,或者另一块刷了免费 debugprobe 固件的 Pico。用安装脚本安装 probe-rs(Windows 的方法见安装页面(英文)),或者用 cargo 安装:

Terminal window
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/probe-rs/probe-rs/releases/latest/download/probe-rs-tools-installer.sh | sh
# 或者从源码安装(比较慢):
cargo install probe-rs-tools --locked
probe-rs list # 列出它能看到的探针

还没有探针?按住 Pico 的 BOOTSEL 按钮再插上 USB,它就会显示成一个 U 盘。picotool 或 elf2uf2-rs 这样的工具可以用这种方式把程序复制进去。不过没有探针,你就看不到 defmt 日志。

如果是 ESP32-C3 或 C6,通常根本不需要探针:芯片内置了 USB,espflash 工具可以通过普通的 USB 线烧录。运行 cargo install esp-generate espflash --locked,然后运行 esp-generate,选好芯片和选项,再用 cargo run --release 就能构建、烧录并显示日志。

在 Raspberry Pi Pico 上用 Embassy 闪灯

Section titled “在 Raspberry Pi Pico 上用 Embassy 闪灯”

“Blinky”(闪灯)是嵌入式世界的“Hello, world!”:点亮 LED,等一会儿,熄灭,再等一会儿,永远循环下去。下面是官方的 Embassy RP2040 blinky 示例(英文,这里加了注释)。它需要一块真实的开发板,无法在 Playground 里运行。

#![no_std]
#![no_main]
use defmt::*;
use defmt_rtt as _; // 把 defmt 日志发送到探针
use embassy_executor::Spawner;
use embassy_rp::gpio;
use embassy_time::Timer;
use gpio::{Level, Output};
use panic_probe as _; // 发生 panic 时:通过探针打印消息,然后停止
#[embassy_executor::main(executor = "embassy_rp::executor::Executor", entry = "cortex_m_rt::entry")]
async fn main(_spawner: Spawner) {
// 取得芯片上所有外设的所有权,而且只能取一次
let p = embassy_rp::init(Default::default());
// Pico 的 LED 在 25 号引脚上。PIN_25 被移动进 `led`。
let mut led = Output::new(p.PIN_25, Level::Low);
loop {
info!("led on!");
led.set_high();
Timer::after_millis(500).await;
info!("led off!");
led.set_low();
Timer::after_millis(500).await;
}
}

有几点值得注意:

  • main 是一个 async fn。Timer::after_millis(500).await 不会在忙循环里空转;执行器会让芯片休眠,直到定时器触发。如果有更多任务(通过 Spawner 创建),这个任务等待时,其他任务就会运行。如果你还不熟悉 async/await,它的思路和电脑上的 Tokio 一样,只是没有操作系统。
  • p.PIN_25 被移动进了 Output::new。试着第二次使用 p.PIN_25,编译器就会拦住你:这就是用在硬件上的所有权。
  • use defmt_rtt as _; 和 use panic_probe as _; 导入这两个 crate,只是为了它们设置好的东西(日志通道和 panic 处理函数),而不是为了用其中的某个名字。

想最快看到它闪起来,就直接用官方的示例文件夹:克隆 Embassy 仓库,然后在 examples/rp 里运行 cargo run --bin blinky --release。如果是你自己的项目,依赖可以照搬那个示例的 Cargo.toml:

[package]
name = "blinky"
version = "0.1.0"
edition = "2024"
[dependencies]
embassy-executor = { version = "0.10.0", features = ["defmt"] }
embassy-time = { version = "0.5.1", features = ["defmt", "defmt-timestamp-uptime"] }
embassy-rp = { version = "0.10.0", features = ["defmt", "unstable-pac", "time-driver", "critical-section-impl", "rp2040", "executor-thread"] }
defmt = "1.0.1"
defmt-rtt = "1.0.0"
panic-probe = { version = "1.0.0", features = ["print-defmt"] }
cortex-m = { version = "0.7.6", features = ["inline-asm"] }
cortex-m-rt = "0.7.0"
[profile.release]
debug = true # 为 probe-rs 保留调试信息;它不会被烧录到芯片上

还有两个文件告诉 cargo 要构建什么、怎么运行。.cargo/config.toml 设置编译目标,并让 cargo run 通过 probe-rs 烧录:

[target.'cfg(all(target_arch = "arm", target_os = "none"))']
runner = "probe-rs run --chip RP2040"
[build]
target = "thumbv6m-none-eabi"
[env]
DEFMT_LOG = "debug"

最后,从同一个 examples/rp 文件夹里复制 memory.x(说明芯片上闪存和 RAM 的位置)和 build.rs(负责传递链接脚本)。然后插上探针,运行:

Terminal window
cargo run --release

cargo 会为 thumbv6m-none-eabi 构建,probe-rs 烧录芯片,LED 开始闪烁,带时间戳的 led on! / led off! 消息会源源不断地显示在你的终端里。

微控制器没有屏幕,而且用 core::fmt 格式化文本会占用大量闪存空间。defmt(英文,“deferred formatting”,延迟格式化)同时解决了这两个问题。格式字符串保存在你的电脑上,而不是芯片上;芯片只通过探针发送一个很小的 ID 和原始数值,再由 probe-rs 把它们还原成可读的文本。

use defmt::{info, warn, Format};
#[derive(Format)] // 类似 #[derive(Debug)],但是给 defmt 用的
pub struct Reading {
pub sensor_id: u8,
pub milli_celsius: i32,
}
pub fn report(r: &Reading) {
info!("new reading: {}", r);
if r.milli_celsius > 60_000 {
// `=u8` 和 `=i32` 告诉 defmt 确切的类型,能省下更多空间
warn!("sensor {=u8} is too hot: {=i32} mC", r.sensor_id, r.milli_celsius);
}
}

日志级别有 trace!、debug!、info!、warn! 和 error!,和 log crate 一样。.cargo/config.toml 里的 DEFMT_LOG 设置决定哪些级别会被编译进去;低于它的级别一个字节都不占。

在每个 HAL 底下,控制硬件就是往寄存器里写数字:寄存器是一些特殊的内存地址,其中每一位都控制着芯片上的某样东西。在一颗典型的芯片上,32 位的 GPIO 输出寄存器里,每个引脚对应一位。下面用一个普通的 u32 来扮演这个寄存器,让你练习每个嵌入式开发者都会用到的四个基本操作:

const LED: u32 = 25; // Pico 板载 LED 在 25 号引脚上
fn main() {
// 假装这个 u32 就是芯片的 GPIO 输出寄存器。
// 第 0 位控制 0 号引脚,第 1 位控制 1 号引脚,依此类推。
let mut gpio_out: u32 = 0b1000; // 3 号引脚已经是开的
// 置位:和掩码做 OR
gpio_out |= 1 << LED;
println!("LED on: {gpio_out:#034b}");
// 读取一位:和掩码做 AND,再和零比较
let led_is_on = (gpio_out & (1 << LED)) != 0;
let pin2_is_on = (gpio_out & (1 << 2)) != 0;
println!("LED on? {led_is_on}, pin 2 on? {pin2_is_on}");
// 翻转一位:和掩码做 XOR
gpio_out ^= 1 << LED;
println!("toggled: {gpio_out:#034b}");
// 清除一位:和取反后的掩码做 AND
gpio_out |= 1 << LED;
gpio_out &= !(1 << 3);
println!("pin 3 off: {gpio_out:#034b}");
println!("as hex: {gpio_out:#010x}");
}

输出:

LED on: 0b00000010000000000000000000001000
LED on? true, pin 2 on? false
toggled: 0b00000000000000000000000000001000
pin 3 off: 0b00000010000000000000000000000000
as hex: 0x02000000
▶ 动手试一试

在 Rust 里,对整数用 ! 会把每一位都取反(C 用的是 ~)。{:#034b} 会以二进制打印数值,带上 0b 前缀并补齐到 32 位数字,这样你就能看到第 25 位出现又消失。(这里的输出文字保留了英文,因为标签后面的空格是用来对齐二进制数字的。)位运算符的完整列表见运算符。

很多寄存器会把几个字段塞进同一个数字里:几位用来表示速度设置,一位表示“已启用”。要修改其中一个字段,就要做读-改-写(read-modify-write):先清除这个字段的位,再用 OR 放入新值,其他位都保持不变:

// 一个虚构的 UART 控制寄存器:
// 第 0 位 = 启用
// 第 4..=6 位 = 速度设置(一个 3 位的数,0 到 7)
const ENABLE: u32 = 1 << 0;
const SPEED_SHIFT: u32 = 4;
const SPEED_MASK: u32 = 0b111 << SPEED_SHIFT;
fn read_speed(reg: u32) -> u32 {
(reg & SPEED_MASK) >> SPEED_SHIFT
}
fn write_speed(reg: u32, speed: u32) -> u32 {
// 先清除旧的字段,再放入新值。
// 其他每一位都和原来完全一样。
(reg & !SPEED_MASK) | ((speed << SPEED_SHIFT) & SPEED_MASK)
}
fn main() {
let mut ctrl: u32 = ENABLE | (2 << SPEED_SHIFT);
println!("start: {ctrl:#010b}, speed = {}", read_speed(ctrl));
ctrl = write_speed(ctrl, 5);
println!("speed 5: {ctrl:#010b}, speed = {}", read_speed(ctrl));
// 3 位放不下 9。掩码会把它截断,
// 而不是让它溢出到相邻的位里。
ctrl = write_speed(ctrl, 9);
println!("speed 9?: {ctrl:#010b}, speed = {}", read_speed(ctrl));
let still_enabled = (ctrl & ENABLE) != 0;
println!("still enabled: {still_enabled}");
}

输出:

start: 0b00100001, speed = 2
speed 5: 0b01010001, speed = 5
speed 9?: 0b00010001, speed = 1
still enabled: true
▶ 动手试一试

注意,9 悄无声息地变成了 1。这正是 PAC 和 HAL 帮你防范的那类 bug:它们给每个字段提供带名字的方法(通常连取值也有名字),所以你很少需要手写掩码。在真实的硬件上,寄存器是一个固定的内存地址,要在 unsafe 代码里用 core::ptr::write_volatile 写入;这部分 PAC 会替你完成。

你能学到的最好的嵌入式习惯是:基于 trait 来编写逻辑,而不是针对某一颗芯片。这样同一份代码,在开发板上配合真实的 HAL 运行,在电脑上则配合假的(“mock”)部件在普通测试里运行。这里的 StatusLed 只需要“某个能开能关的东西”和“某个能等待的东西”:

use std::cell::RefCell;
use std::rc::Rc;
// 闪灯驱动需要的两种能力。它们对应 embedded-hal 1.0 的
// OutputPin 和 DelayNs trait,做了简化,好让它能在 Playground 里运行。
trait OutputPin {
fn set_high(&mut self);
fn set_low(&mut self);
}
trait Delay {
fn delay_ms(&mut self, ms: u32);
}
// 驱动对任何芯片都一无所知。它只使用这两个 trait。
struct StatusLed<P: OutputPin, D: Delay> {
pin: P,
delay: D,
}
impl<P: OutputPin, D: Delay> StatusLed<P, D> {
// 闪烁 `times` 次:亮 `on_ms` 毫秒,再灭 `off_ms` 毫秒。
fn blink(&mut self, times: u32, on_ms: u32, off_ms: u32) {
for _ in 0..times {
self.pin.set_high();
self.delay.delay_ms(on_ms);
self.pin.set_low();
self.delay.delay_ms(off_ms);
}
}
}
// --- 在电脑上测试用的假部件 ---
// 两个假部件都写入同一份共享日志,这样就能看出先后顺序。
type Log = Rc<RefCell<Vec<String>>>;
struct MockPin {
log: Log,
}
impl OutputPin for MockPin {
fn set_high(&mut self) {
self.log.borrow_mut().push("on".to_string());
}
fn set_low(&mut self) {
self.log.borrow_mut().push("off".to_string());
}
}
struct MockDelay {
log: Log,
}
impl Delay for MockDelay {
fn delay_ms(&mut self, ms: u32) {
self.log.borrow_mut().push(format!("wait {ms}"));
}
}
fn main() {
let log: Log = Rc::new(RefCell::new(Vec::new()));
let pin = MockPin { log: Rc::clone(&log) };
let delay = MockDelay { log: Rc::clone(&log) };
let mut led = StatusLed { pin, delay };
led.blink(2, 100, 400);
let events = log.borrow();
println!("{}", events.join(", "));
assert_eq!(events.len(), 8);
assert_eq!(events[0], "on");
assert_eq!(events[1], "wait 100");
println!("所有检查都通过了");
}

输出:

on, wait 100, off, wait 400, on, wait 100, off, wait 400
所有检查都通过了
▶ 动手试一试

两个 mock 通过 Rc<RefCell<...>> 共享同一份日志(见智能指针),所以你能检查事件的确切顺序。在真实项目里,这些 assert_eq! 会放进一个 #[test] 函数,在电脑上用 cargo test 运行,不需要开发板(见测试和测试进阶)。

在开发板上,你会基于真正的 embedded-hal 1.0 trait 编写同一个驱动。唯一的变化是:引脚方法会返回一个 Result(I/O 扩展芯片上的引脚可能会失败),所以要用 ? 把错误往上传;而且这份代码完全不需要 std:

#![no_std]
use embedded_hal::delay::DelayNs;
use embedded_hal::digital::OutputPin;
pub struct StatusLed<P, D> {
pin: P,
delay: D,
}
impl<P: OutputPin, D: DelayNs> StatusLed<P, D> {
pub fn new(pin: P, delay: D) -> Self {
StatusLed { pin, delay }
}
pub fn blink(&mut self, times: u32, on_ms: u32, off_ms: u32) -> Result<(), P::Error> {
for _ in 0..times {
self.pin.set_high()?;
self.delay.delay_ms(on_ms);
self.pin.set_low()?;
self.delay.delay_ms(off_ms);
}
Ok(())
}
}

Embassy 的 Output 实现了 OutputPin,embassy_time::Delay 实现了 DelayNs,所以 StatusLed::new(led, embassy_time::Delay) 在 Pico 上可以直接用。如果要在电脑上针对这些真正的 trait 做测试,embedded-hal-mock(英文)crate 提供了现成的假引脚、假 I2C 总线和假 SPI 总线。

很多小芯片没有浮点运算单元,比如 RP2040 里的 Cortex-M0+。浮点数照样能用,但要靠软件来算:更慢,而且会让程序变大。所以嵌入式代码经常使用定点数(fixed-point):用更小单位表示的整数。不存 25.25 度,而是存 25250 毫度。

一个 TMP102 这类的温度传感器会发送两个字节。高 12 位是一个有符号数,单位是 1/16 °C:

// 高 12 位是温度,单位是 1/16 °C(0.0625 °C),
// 以有符号数(二进制补码)表示。
fn to_millicelsius(bytes: [u8; 2]) -> i32 {
// i16 保留符号。对有符号数做 `>> 4` 也会保留符号。
let raw = i16::from_be_bytes(bytes) >> 4;
// 1/16 °C = 62.5 m°C,所以先乘以 625 再除以 10。
raw as i32 * 625 / 10
}
// 把 25250 打印成 "25.250",全程不碰浮点数。
fn format_milli(m: i32) -> String {
let sign = if m < 0 { "-" } else { "" };
let m = m.abs();
format!("{sign}{}.{:03}", m / 1000, m % 1000)
}
fn main() {
let samples = [
[0x19, 0x40], // 温暖的房间
[0x00, 0x10], // 零度以上的最小一步
[0xFF, 0x80], // 刚好低于冰点
[0xE7, 0x00], // 冰柜
];
for bytes in samples {
let m = to_millicelsius(bytes);
println!("{bytes:02X?} -> {m:>6} m°C = {} °C", format_milli(m));
}
}

输出:

[19, 40] -> 25250 m°C = 25.250 °C
[00, 10] -> 62 m°C = 0.062 °C
[FF, 80] -> -500 m°C = -0.500 °C
[E7, 00] -> -25000 m°C = -25.000 °C
▶ 动手试一试

这段代码能正确工作,靠的是三个细节。i16::from_be_bytes 把两个字节读成一个有符号数,高位字节在前(“大端序”,big-endian),大多数传感器就是这样发送数据的。对有符号整数做 >> 4 会保留符号,所以负温度也能算对。format_milli 单独处理了符号:在整数运算里 -500 / 1000 是 0,如果没有这一步,-0.5 °C 就会被打印成 0.500。

整数除法还会截断:62.5 m°C 变成了 62。对房间里的传感器来说,半个毫度无关紧要,但你要时刻问问自己,这个单位能提供多少精度。还要留意数值范围:一个很大的读数乘以一个很大的系数,可能会让 i32 溢出(见整数溢出)。在电脑上用 String 没问题;在芯片上,你会用 defmt 打印,或者写进一个 heapless::String。

在 no_std 程序里用 std 的东西。 println!、Vec 和 String 都来自 std,所以在这里根本不存在:

error: cannot find macro `println` in this scope
--> src/main.rs:4:5
|
4 | println!("hello");
| ^^^^^^^

日志用 defmt::info!,集合用 heapless(或者配合分配器使用 alloc crate)。

忘了 panic 处理函数。 no_std 的二进制程序必须说明 panic 时该做什么。没有 panic-probe 这样的 crate,你会看到:

error: `#[panic_handler]` function required, but not found

在 Cargo.toml 里加上 panic-probe(或 panic-halt),并在 main.rs 里写上 use panic_probe as _;。

覆盖了整个寄存器。 reg = 1 << 3 会打开 3 号引脚,同时把其他所有引脚都关掉。只想改一位,就用 reg |= 1 << 3。如果是字段,就用上面讲的读-改-写。

照着旧教程做。 嵌入式 crate 变化很快,几年前的博客文章经常用的是 embedded-hal 0.2 或者旧版的 Embassy API。从你实际依赖的那个 crate 版本的 examples 文件夹开始。

编写 toggle(reg: u32, pin: u32) -> u32,用来翻转一位;再编写 pins_on(reg: u32) -> Vec<u32>,列出所有对应位为 1 的引脚。开始时打开 0、5 和 25 号引脚,打印列表和开着的引脚数量,然后翻转 5 号和 7 号引脚,再打印一次。提示:用掩码逐个检查 0 到 31 号引脚,另外 u32 有一个 count_ones() 方法。

查看答案
fn toggle(reg: u32, pin: u32) -> u32 {
reg ^ (1 << pin)
}
fn pins_on(reg: u32) -> Vec<u32> {
(0..32).filter(|pin| reg & (1 << pin) != 0).collect()
}
fn main() {
let mut reg: u32 = (1 << 0) | (1 << 5) | (1 << 25);
println!("开着的引脚:{:?}(共 {} 个)", pins_on(reg), reg.count_ones());
reg = toggle(reg, 5);
reg = toggle(reg, 7);
println!("开着的引脚:{:?}(共 {} 个)", pins_on(reg), reg.count_ones());
}

输出:

开着的引脚:[0, 5, 25](共 3 个)
开着的引脚:[0, 7, 25](共 3 个)
▶ 动手试一试

不管原来的值是什么,XOR 都会把这一位翻过来,所以翻转 5 号引脚就把它关掉了,翻转 7 号引脚就把它打开了。count_ones() 会替你数出有多少个 1,所以这部分不需要写循环。

一个 12 位的 ADC 给出 0 到 4095 的读数,对应 0 到 3300 mV。电池通过一个分压电路连接,电压被减半,所以真实电压是测量值的两倍。编写 battery_mv(raw: u16) -> i32 和 battery_percent(mv: i32) -> i32,其中 3000 mV 是 0%,4200 mV 是 100%,结果不能低于 0,也不能高于 100。用原始读数 2606、2482、2234 和 1861 试一试。提示:先乘后除,这样才不会损失精度;另外看看 clamp。

查看答案
const EMPTY_MV: i32 = 3000;
const FULL_MV: i32 = 4200;
fn battery_mv(raw: u16) -> i32 {
// 12 位 ADC(0..=4095)测量 0..=3300 mV,前面有一个把电压减半的分压电路
raw as i32 * 3300 * 2 / 4095
}
fn battery_percent(mv: i32) -> i32 {
let percent = (mv - EMPTY_MV) * 100 / (FULL_MV - EMPTY_MV);
percent.clamp(0, 100)
}
fn main() {
for raw in [2606, 2482, 2234, 1861] {
let mv = battery_mv(raw);
println!("原始值 {raw} -> {mv} mV -> {}%", battery_percent(mv));
}
}

输出:

原始值 2606 -> 4200 mV -> 100%
原始值 2482 -> 4000 mV -> 83%
原始值 2234 -> 3600 mV -> 50%
原始值 1861 -> 2999 mV -> 0%
▶ 动手试一试

最后再除以 4095 才能保住精度:如果写成 raw / 4095 * 6600,所有低于 4095 的读数都会得到 0。最大的乘积 4095 * 6600 大约是 2700 万,i32 轻松装得下。

  • 嵌入式 Rust 运行在没有操作系统的微控制器上。没有垃圾回收器、编译时的安全检查和零成本抽象,让 Rust 非常适合这个领域。
  • #![no_std] 让你只剩下 core;#![no_main] 让运行时 crate 来调用你的入口函数;你必须选一个 panic 处理函数。heapless 提供固定容量的集合。
  • 分层结构:PAC(寄存器)、HAL(一个芯片家族)、embedded-hal trait(共享)、BSP(一块开发板),最上面是 RTIC 和 Embassy 这样的框架。
  • 用 rustup target add 添加芯片的编译目标,然后用 probe-rs 和调试探针烧录、查看日志(ESP32 上用 espflash)。defmt 让日志非常小巧。
  • 在电脑上练习:用位掩码操作寄存器,基于 trait 编写驱动并用 mock 部件测试,用定点整数代替浮点数。

小测验

1. #![no_std] 有什么作用?

2. 哪一层能让同一个传感器驱动不做任何修改就在 Pico、ESP32 和 STM32 上运行?

3. 怎样打开 reg 的第 3 位,同时不改变其他任何位?

4. 为什么嵌入式代码经常把 25.25 °C 存成 25250 毫度?

得分: 0 / 4

  • The Embedded Rust Book(英文):官方入门书,讲解 no_std、外设、寄存器和嵌入式生态。
  • Discovery (micro:bit v2)(英文):免费的新手课程,在 BBC micro:bit 上一步步带你从闪烁的 LED 学到传感器。
  • The Embassy Book(英文):Embassy 入门,介绍它的执行器和 HAL 怎样工作,以及怎样运行官方示例。
  • The Rust on ESP Book(英文):乐鑫官方指南,讲解 esp-hal、esp-generate、espflash 和 ESP32 工具链。
  • probe-rs installation(英文):怎样在各个操作系统上安装 probe-rs,以及怎样设置调试探针。