嵌入式 Rust 入门:单片机、no_std 与 Embassy
嵌入式 Rust 指的是为藏在各种东西里的小型计算机编写 Rust 程序:温控器、键盘、无人机、给植物浇水的传感器。这些计算机叫做微控制器(microcontroller,也就是常说的单片机)。一颗芯片里装着处理器、几百 KB 的内存,以及连接 LED、按钮和传感器的引脚。通常根本没有操作系统,从通电到断电,只有你的程序在运行。
听起来有点吓人,但其实非常好玩:你写上几行代码,桌上就有一盏真实的灯开始闪烁。这一页会告诉你能做什么、该选哪块开发板和哪些 crate,以及怎样把第一个程序烧录到芯片上。一路上,你还会在可以直接在浏览器里运行的示例中,练习“嵌入式的思维方式”。
为什么 Rust 这么适合嵌入式
Section titled “为什么 Rust 这么适合嵌入式”几十年来,嵌入式代码大多是用 C 写的。Rust 带来了几样在小芯片上非常重要的东西:
- 没有垃圾回收器。 没有任何东西会暂停你的程序去清理内存,所以时序是可预测的。当你必须在几微秒内响应一个信号时,这一点很重要。
- 不需要运行时的内存安全。 所有权和借用规则在编译时检查,所以在芯片上没有任何开销。一整类崩溃(悬垂指针、中断和主循环之间的数据竞争)在你烧录之前就被拦下了。
- 零成本抽象。 有了 HAL,你可以写
led.set_high(),而不用去戳内存地址,它编译出来的结果和你手写的那一次寄存器写入完全一样。 - 用所有权来建模硬件。 一个引脚就是一个值。它一旦被移动进一个
Output,别人就没法再拿到它,也没法不小心把它当成输入来用。以前在 C 文件里只是一行注释的约定,现在由编译器来保证。 - 出色的工具链。
cargo、rustup的编译目标和probe-rs可以替代很多厂商 IDE 和 makefile。
实话实说,也有不完善的地方:芯片厂商发布 SDK 和示例时还是优先用 C,所以有些芯片的 Rust 支持比较薄弱,或者只有社区在维护。你仍然需要读数据手册。还有一些 crate 还没到 1.0,版本之间 API 会变,所以一定要参照你所用版本的示例。
能用它做什么
Section titled “能用它做什么”- 传感器和小玩意儿:气象站、土壤湿度报警器、计步器。
- USB 设备:自制键盘、游戏手柄、MIDI 控制器。
- 联网(IoT)设备:用 ESP32 做的 Wi-Fi 或蓝牙传感器,把读数发送到服务器。
- 机器人和电机:循迹小车、步进电机和舵机控制、LED 灯带和显示屏。
- 严肃的系统:Oxide Computer 完全用 Rust 编写了 Hubris(英文),这是一个面向微控制器的小型操作系统。ESP32 背后的公司乐鑫(Espressif)自己维护着 Rust 支持,并发布了 The Rust on ESP Book(英文)。
适合入门的开发板
Section titled “适合入门的开发板”下面每一块都是不错的第一块开发板。如果你拿不定主意,就选 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 示例)。
no_std 和 no_main 是什么
Section titled “no_std 和 no_main 是什么”普通的 Rust 程序会使用标准库(std)。std 需要一个操作系统:文件、线程、堆,以及给 println! 用的终端。微控制器上这些都没有。所以嵌入式程序一开头会有两行:
#![no_std] // 不链接标准库,只用 `core`#![no_main] // 不使用常规的 `fn main()` 入口#![no_std]表示你只能用core(英文):整数、Option、Result、迭代器、slice、trait、格式化。没有Vec、String、Box、文件和线程。(如果有内存分配器,可以通过alloccrate 重新用上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 215stored 218stored 221stored 224buffer full, dropped 227avg=219 (capacity 16)分层的生态系统
Section titled “分层的生态系统”嵌入式 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。下面你会自己写一个这样的驱动。
安装编译目标和 probe-rs
Section titled “安装编译目标和 probe-rs”你的电脑用的是 x86 或 Arm 的桌面处理器,而 Pico 用的是 Cortex-M0+。所以你要做交叉编译:在一台机器上构建,给另一台机器用。rustup 会为这颗芯片下载预编译好的 core 库:
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 安装:
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(负责传递链接脚本)。然后插上探针,运行:
cargo run --releasecargo 会为 thumbv6m-none-eabi 构建,probe-rs 烧录芯片,LED 开始闪烁,带时间戳的 led on! / led off! 消息会源源不断地显示在你的终端里。
用 defmt 输出日志
Section titled “用 defmt 输出日志”微控制器没有屏幕,而且用 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 设置决定哪些级别会被编译进去;低于它的级别一个字节都不占。
用寄存器思考:位和掩码
Section titled “用寄存器思考:位和掩码”在每个 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: 0b00000010000000000000000000001000LED on? true, pin 2 on? falsetoggled: 0b00000000000000000000000000001000pin 3 off: 0b00000010000000000000000000000000as 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 = 2speed 5: 0b01010001, speed = 5speed 9?: 0b00010001, speed = 1still enabled: true注意,9 悄无声息地变成了 1。这正是 PAC 和 HAL 帮你防范的那类 bug:它们给每个字段提供带名字的方法(通常连取值也有名字),所以你很少需要手写掩码。在真实的硬件上,寄存器是一个固定的内存地址,要在 unsafe 代码里用 core::ptr::write_volatile 写入;这部分 PAC 会替你完成。
写一个能在电脑上测试的驱动
Section titled “写一个能在电脑上测试的驱动”你能学到的最好的嵌入式习惯是:基于 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 总线。
不用浮点数的传感器运算
Section titled “不用浮点数的传感器运算”很多小芯片没有浮点运算单元,比如 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 文件夹开始。
1. 哪些引脚是开着的?
Section titled “1. 哪些引脚是开着的?”编写 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,所以这部分不需要写循环。
2. 不用浮点数计算电池电量
Section titled “2. 不用浮点数计算电池电量”一个 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-haltrait(共享)、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,以及怎样设置调试探针。