之前参与了 eet-china.com 的开发板测评活动, 申请的板子是 STM32WLE5 易智联 Lora 评估板(LM401-Pro-Kit), 正好 Rust Embassy 框架对 STM32WL 系列及其 SubGhz 有不错的支持, 所以打算用这套技术栈进行开发尝试.

本文主要介绍如何使用 Rust 语言的 Embassy 嵌入式框架实现 STM32WL LoRa 数据传输. 过年回老家, 随身带的东西不多, 只有一个迷你 BMP280 (大气压温度)传感器模块, 所以本文使用 BMP280 传感器数据作为例子.

门槛率高, 还是从点灯开始搞起.

最终相关代码位于 Github: andelf/lm401-pro-kit.

介绍

快递于 [[2023-01-08]] 收到, 里面的评估板, 天线, 数据线均是两份, 方便开发使用.

开发板介绍

LM401-Pro-Kit 是基于 STM32WLE5CBU6 的 Lora 评估板. 支持 SubGHz 无线传输. LM401 模组内嵌高性能 MCU 芯片 STM32WLE5CBU6, 芯片内部集成了 SX1262. 开发板板载 ST-Link(上传下载程序, UART 转 USB). ST-Link 通过跳线帽和模块核心部分连接, 方便单独供电使用模块. 开发板提供了若干 LED 状态灯, 复位按钮和一个用户按钮.

日常屯的(吃灰)板子也有大几十上百了, 拿到新板子, 需要查资料, 看手册, 电路图, 读例程, 找到一些核心信息, 其中一些信息可能需要读例程的 C 代码库才能获得, 这里列出整理的部分:

  • MCU: STM32WLE5CBU6
    • 架构: Cortex-M4
    • 主频: 48MHz, 通过 MSI 提供
    • FLASH 128K,RAM 48K
    • 核心外设: SX1262 via SPI3
  • LM401: CN470-510MHZ
  • 板载
    • ST-Link 下载器
    • 用户按钮 PA0
    • LED blue PB5, green PB4, yellow PB3
    • 射频开关:
      • FE_CTRL1 PB0
      • FE_CTRL2 PA15, LM401 未使用
      • FE_CTRL3 PA8
    • UART RX/TX
      • PA2 TXD, USART2_TX, LPUART1_TX
      • PA3 RXD, USART2_RX, LPUART1_RX

预备知识

  • 对 Rust 的基础了解
  • 对 STM32 的基础了解

使用 Rust 嵌入式开发大概大概有如下几层(只是粗略分类, 实际项目使用中, 可能会混合使用):

  • 直接使用 PAC 库操作寄存器, PAC 库通过 svd2rust 工具从 .svd 文件生成
  • 使用 HAL 库, 例如 stm32f4xx-hal, stm32l0xx-hal, stm32wlxx-hal 等, 融合 embedded-hal 生态
  • 使用 Rust 嵌入式框架, 例如 embassy

Embassy 框架是基于 Rust 语言的嵌入式异步框架. 考虑到相关框架还在开发中, 本文的代码仓库使用的是最新的 embassy master 分支. Commit hash 为 f98ba4ebac192e81c46933c0dc1dfb2d907cd532, 通过 Cargo.toml 中设置依赖 path 的方式引入. 其他可选方案还可有 git submodule 或 直接 git 依赖远程版本等.

绕开 C HAL/BSP 库开发, 是需要踩不少坑的, 例如, RCC 时钟初始化, 需要查阅 BSP 代码才能确认, 48MHz 主时钟通过 MSI range11 获得, 而 embassy 对应 MCU 的示例代码使用的是 HSE, 这些都给 Rust 嵌入式开发带来一定的门槛.

软件环境准备

安装 Rust 工具链, 本文使用 rustup nightly. 请参考 https://rustup.rs/ .

安装 Rust thumbv7em-none-eabi target, 对应 Cortex-M4:

rustup target add thumbv7em-none-eabi

安装 Rust 嵌入式开发烧录/运行工具 probe-run, 也可以使用 OpenOCD 或其他烧录工具:

> cargo install probe-run
...(install log)
> probe-run --list-chips | grep STM32WL
STM32WL Series
        STM32WLE5J8Ix
        STM32WLE5JBIx
        STM32WLE5JCIx
        STM32WL55JCIx

检查发现支持列表没有 STM32WLE5CBU6, 不过可以拿 STM32WLE5JCIx 替代, 问题不大.

安装任意串口调试工具, 这里我使用 picocom. 其他可以使用的替代有 PuTTy, Teraterm 等等.

通过 USB 数据线连接开发板, 通过 picocom 连接串口, 通过 probe-run 烧录程序.

测试连接

> lsusb
Bus 001 Device 008: ID 0483:374b STMicroelectronics STM32 STLink  Serial: xxxx

从 Blinky 开始 Embassy 应用开发

考虑到从初识 Rust 嵌入式开发直接跨越到 LoRa 无线传输门槛较高, 我们从简单的点灯例子开始:

创建项目 - 初始化 Rust 嵌入式项目模板

我们直接依赖 embassy 的 master 分支进行开发, 为方便调试, 直接 clone 到本地用相对路径引入依赖:

git clone git@github.com:embassy-rs/embassy.git
# or
git clone https://github.com/embassy-rs/embassy.git

# 在同层目录直接创建我们的项目, 起板子名就可以. 相当于一个 BSP 模板可以扩充

cargo new --lib lm401-pro-kit

# 进入项目目录, 以下命令均在此执行
cd lm401-pro-kit

Rust 嵌入式项目的初始设置需要请参考项目代码

  • .cargo/config.toml
    • 设置编译器 target 到 thumbv7em-none-eabi
    • 设置 cargo run 的执行方式为调用 probe-run ...
    • [target.'cfg(all(target_arch = "arm", target_os = "none"))']
      runner = "probe-run --chip STM32WLE5JCIx"
      
      [build]
      target = "thumbv7em-none-eabi"
      
  • build.rs
    • 设置 link.x/memory.x 链接过程中所用配置, 编译过程中由 embassy 自动按照芯片选择生成
    • 添加 defmt 链接参数支持
  • Cargo.toml
    • 添加 embassy 相关依赖, 并通过 features 设置相关参数
    • 添加项目依赖, defmt, cortex-m 相关等
    • 设置编译参数 opt-level = "z", 最小化编译二进制大小
    • # part of Cargo.toml
      [dependencies]
      # ...
      embassy-stm32 = { version = "0.1.0", path = "../embassy/embassy-stm32", features = [
          "nightly",
          "defmt",
          "stm32wle5cb",
          "time-driver-any",
          "memory-x",
          "subghz",
          "unstable-pac",
          "exti",
      ] }
      # ...
      [profile.dev]
      opt-level = "z" # Optimize for size.
      
      [profile.release]
      lto = true
      opt-level = "z" # Optimize for size.
      
    • defmt 是一个非常好用的 Rust 嵌入式调试打印, 对 STM32(ST-Link) 有很好的支持.
    • stm32wle5cb 用于选择 STM32WLE5CBU6 的芯片配置, subghz 用于选择 SubGHz 驱动.
    • memory-x 自动生成链接所需的 memory.x 文件(FLASH, SRAM 的大小和内存位置).
  • 未避免编译报错, 还需要清空 src/lib.rs 项目初始文件, 用 #![no_std] 替代

几乎所有的 Rust 嵌入式项目都是 no_std 的, 这意味着无法简单地使用所有带内存分配类型. 本例中, 我们使用 heapless crate 中提供的栈分配类型来替代 String.

注意到, 创建项目时候使用了 cargo new --lib, 相当于我们创建的是一个 library 项目. 这不需要担心, cargo run 会自动识别 src/bin/xxx.rs 为 “可执行” 二进制目标. 通过 cargo run --bin xxx 即可运行对应程序. 也可以通过 examples/xxx.rs 的方法管理多个可执行二进制目标.

Blinky 点灯 - 初识 Rust Embassy

我们先通过一个最简单的闪灯例子来熟悉 Rust Embassy 的使用. 创建 src/bin/blinky.rs.

// blinky.rs
#![no_std]
#![no_main]
#![feature(type_alias_impl_trait)]

use defmt::*;
use embassy_executor::Spawner;
use embassy_stm32::gpio::{Level, Output, Speed};
use embassy_time::{Duration, Timer};
use {defmt_rtt as _, panic_probe as _};

#[embassy_executor::main]
async fn main(_spawner: Spawner) {
    let p = embassy_stm32::init(Default::default());
    info!("Hello World!");

    let mut led = Output::new(p.PB4, Level::High, Speed::Low);

    loop {
        info!("high");
        led.set_high();
        Timer::after(Duration::from_millis(1000)).await;

        info!("low");
        led.set_low();
        Timer::after(Duration::from_millis(1000)).await;
    }
}

#![no_main] 用于告诉 Rust 编译器, 我们不使用 Rust 提供的 main 函数做程序入口. #[embassy_executor::main] 是一个宏, 用于包装 async fn main() 函数, 由 embassy-executor 提供了一个 futures runtime, 所以可以使用 asyncawait 语法. 底层实现中, .await 通过 STM32 的 WFE/SEV 等待指令和中断唤醒指令实现, 实现了程序逻辑在等待时候的低功耗. Spawner 还可以用来启动其他 async fn 函数, 实现了多任务的功能.

#![feature(type_alias_impl_trait)] 在 embassy 中被广泛使用, 需要开启. Embassy 中经常能看到形如 irq: impl Peripheral<P = T::Interrupt> + 'd 的类型签名.

let p = embassy_stm32::init(Default::default()); 直接初始化了所有的外设, 并返回一个 Peripherals 对象. 通过 Rust 的 move 语义保证不同外设使用之间不会出现竞争.

let mut led = Output::new(p.PB4, Level::High, Speed::Low); 创建了一个 Output 对象, 用于控制 PB4 引脚. Output 对象是一个 Pin 的 trait, 通过 set_highset_low 方法可以控制引脚电平. 这里会自动完成对 GPIOB PB4 的所有初始化和设置, 包括外设时钟使能, 状态设置等.

info!, warn! 等都是 defmt 的宏, 用于通过 ST-Link 提供的 Debug 通道打印调试信息. 强烈推荐使用, 否则嵌入式开发中, 只能用串口打印信息.

Timer::after(Duration::from_millis(1000)).await 是一个异步等待 1 秒的方法, 通过 embassy-time crate 实现. 在 Cargo.toml 中的 time-driver-any feature 选择了任意可用 timer 实现, 默认是 TIM2, 由 embassy-stm32 提供给 embassy-time.

确保板子连接正常, 直接运行:

> cargo run --bin blinky
    Finished dev [optimized + debuginfo] target(s) in 0.32s
     Running `probe-run --chip STM32WLE5JCIx target/thumbv7em-none-eabi/debug/blinky`
(HOST) INFO  flashing program (14 pages / 14.00 KiB)
(HOST) INFO  success!
────────────────────────────────────────────────────────────────────────────────
0.000000 DEBUG rcc: Clocks { sys: Hertz(4000000), apb1: Hertz(4000000), apb1_tim: Hertz(4000000), apb2: Hertz(4000000), apb2_tim: Hertz(4000000), apb3: Hertz(4000000), ahb1: Hertz(4000000), ahb2: Hertz(4000000), ahb3: Hertz(4000000) }
└─ embassy_stm32::rcc::set_freqs @ ./embassy/embassy-stm32/src/fmt.rs:125
0.000113 INFO  Hello World!
└─ blinky::____embassy_main_task::{async_fn#0} @ src/bin/blinky.rs:14
0.000552 INFO  high
└─ blinky::____embassy_main_task::{async_fn#0} @ src/bin/blinky.rs:19
1.001157 INFO  low
└─ blinky::____embassy_main_task::{async_fn#0} @ src/bin/blinky.rs:23
2.001811 INFO  high
  • 二进制编译成功后, 由 probe-run 烧录到 MCU 并执行, 持续获取 defmt 打印信息
  • rcc: Clocks 调试时钟信息由 embassy-stm32embassy_stm32::rcc::set_freqs 打印
  • 所有 defmt 打印内容在 cargo run dev 模式下均附加了代码行, 非常方便
  • defmt 打印内容均带有时间戳, 该时间戳由 STM32 SYSTICK 提供(所以如果使用了 SYSTICK, 有可能导致时间戳异常)
  • 最终的 main 函数显示为 blinky::____embassy_main_task::{async_fn#0}, 由 #[embassy_executor::main] 宏生成

UART 打印 - 时钟和外设初始化

defmt 固然方便, 但很多时候依然需要用到 UART, 通过串口获取调试信息或收集数据. LM401-Pro-Kit 正好通过 ST-Link 提供了到 USART2 的访问.

Blinky 例子中, 由 defmt 调试信息可知, 我们使用的系统时钟只有 4MHz, 但 STM32WL 的最大时钟频率是 48MHz. 所以需要通过初始化 init() 方法设置时钟参数:

// sys clk init, with LSI support
let mut config = embassy_stm32::Config::default();
config.rcc.enable_lsi = true;
config.rcc.mux = embassy_stm32::rcc::ClockSrc::MSI(embassy_stm32::rcc::MSIRange::Range11); // 48MHz
let p = embassy_stm32::init(config);

Embassy UART 使用非常简单, 可以单独用 UartTx/UartRx 只初始发送/接收部分. 这里是一个发送 Hello world 和 MCU 内部 “时间” 的简单示例:

// USART2 tx
use embassy_stm32::dma::NoDma;
use embassy_stm32::usart::UartTx;
use embassy_time::Instant;
use heapless::String;

// Default: 115200 8N1
let mut usart = UartTx::new(p.USART2, p.PA2, NoDma, Default::default());

let mut msg: String<64> = String::new();
let i = Instant::now();
core::write!(msg, "Hello world, device time: {}\r\n", i.as_millis()).unwrap();
usart.blocking_write(msg.as_bytes()).unwrap();
msg.clear();

UartTx 初始化时需要传入 USART2, PA2, 分别对应 USART2 外设和 TX 引脚, DMA 通道是可选的. 默认串口参数是 115200 8N1. 外设初始化会自动处理对应引脚的 AF 设置.

串口打印需要字符串拼接格式化, 由于 no_std, 标准库的 String 类型不可用, 这里使用 heapless::String, 初始化时候需要指定分配大小. core::write! 即标准库中的 write!, core:: 前缀是为了避免和 defmt::write! 名字冲突.

完整代码请参考 代码仓库.

执行代码确认, 可以看到系统时钟被正确设置为 48MHz.

> cargo run --bin uart
0.000000 DEBUG rcc: Clocks { sys: Hertz(48000000), apb1: Hertz(48000000), apb1_tim: Hertz(48000000), apb2: Hertz(48000000), apb2_tim: Hertz(48000000), apb3: Hertz(48000000), ahb1: Hertz(48000000), ahb2: Hertz(48000000), ahb3: Hertz(48000000) }
└─ embassy_stm32::rcc::set_freqs @ /Users/mono/Elec/embassy/embassy-stm32/src/fmt.rs:125
0.000011 INFO  Hello World!
└─ uart::____embassy_main_task::{async_fn#0} @ src/bin/uart.rs:21
0.000064 INFO  tick

在另一命令行打开串口监视工具, 查看串口输出:

> picocom -b 115200 /dev/tty.usbmodem11103
Hello world, device time: 1000
Hello world, device time: 3002
Hello world, device time: 5005
Hello world, device time: 7008
Hello world, device time: 9011
Hello world, device time: 11013
....

I2C 访问 BMP280

硬件准备

  • BMP280 传感器模块 1 个
  • 杜邦线若干根, 用于连接传感器模块和开发板

BMP280 是来自 Bosch 的气压传感器, 通过 I2C 接口读取气压和温度数据, 所以需要在板子上找到未被占用的 I2C SCL/SDA 引脚资源, 通过查阅芯片手册, 最后选择了空闲的 I2C2, SCL pin PA12, SDA pin PA11. 开发板上一排跳线帽正好提供了 VCC, GND.

接线:

+--------+          VCC GND
| BMP280 |           |   |
|      VCC>----------+   |
|      GND>--------------+
| [.]  SCL>-------------------->PA12
|      SDA>-------------------->PA11
|        |          (LM401-Pro-Kit)
+--------+

BMP280 访问

Rust Embassy 完美兼容 embedded-hal 相关生态, 相关外设类型均支持对应的 embedded-hal trait,

考虑到 BMP280 的使用略微复杂, 需要初始化, 读取校准数据, 测量后还需要通过校准数据计算最终测量结果. 所以 BMP280 直接寻找对应驱动即可. 但 Rust 嵌入式生态有个问题, 弃坑项目太多. 寻找第三方依赖时候需要注意阅读代码, 查看依赖版本, 必要时更新.

这么说, 其实是之前我有个弃坑项目里面有个 BME280 驱动库, BME280 和 BMP280 基本兼容, 只是多了湿度测量. 驱动代码使用 embedded-hal 提供的 trait 类型访问设备, 完成传感器初始化和测量. 稍微改了改, 直接 Copy embedded-drivers: bme280.rs 到项目 src/ 下使用即可.

修改 src/lib.rs 增加:

pub mod bme280;

代码实现

创建 BMP280 传感器项目 src/bin/i2c-bmp280.rs. 完整代码请参考 代码仓库, 以下只选择关键部分介绍.

// BMP280 init
use embassy_stm32::i2c::I2c;
use embassy_stm32::interrupt;
use embassy_stm32::time::Hertz;
use embassy_time::Delay;
use lm401_pro_kit::bme280::BME280;

let irq = interrupt::take!(I2C2_EV);
let i2c = I2c::new(
    p.I2C2,
    p.PA12,
    p.PA11,
    irq,
    NoDma,
    NoDma,
    Hertz(100_000),
    Default::default(),
);

let mut delay = Delay;

let mut bmp280 = BME280::new_primary(i2c);
unwrap!(bmp280.init(&mut delay));

Embassy 中访问设备时, 一般会需要中断, 虽然理论上阻塞访问外设时不需要中断. 但是为了保证接口的一致性, 一般都会要求提供中断参数. interrupt::take! 用于获取对应中断对象.

BME280::new_primary 直接使用设备主地址 0x76 访问 I2C 总线上的 BMP280.

初始化设备时候由于需要软复位, 需要传递 Delay 对象, 用于延时(delay_ms). 默认的 embassy_time::Delay 使用循环比较 “设备当前时间” 的方法实现.

unwrap! 宏由 defmt 提供, 等价于 .unwrap() 调用, 但是会在 panic 时候通过 defmt 打印信息.

完成设备初始化后, 可以访问传感器信息:

let raw = unwrap!(bmp280.measure(&mut delay));
info!("BMP280: {:?}", raw);

传感器执行测量时候, 按照手册, 依然需要延时, 所以也同样需要传递 Delay 对象. BME280::measure 方法返回 Measurements 类型, 为了方便调试使用, 用 derive macro 增加了 defmt 支持, 可以直接做格式化参数:

#[derive(Debug, defmt::Format)]
pub struct Measurements {
    /// temperature in degrees celsius
    pub temperature: f32,
    /// pressure in pascals
    pub pressure: f32,
    /// percent relative humidity (`0` with BMP280)
    pub humidity: f32,
}

执行代码:

> cargo run --bin i2c-bmp280
0.000011 INFO  I2C BMP280 demo!
└─ i2c_bmp280::____embassy_main_task::{async_fn#0} @ src/bin/i2c-bmp280.rs:23
0.009314 INFO  measure tick
└─ i2c_bmp280::____embassy_main_task::{async_fn#0} @ src/bin/i2c-bmp280.rs:45
0.051652 INFO  BMP280: Measurements { temperature: 23.689554, pressure: 88391.13, humidity: 0.0 }
└─ i2c_bmp280::____embassy_main_task::{async_fn#0} @ src/bin/i2c-bmp280.rs:48

temperature: 23.689554, pressure: 88391.13 传感器数据正常.

LoRa 传感器数据传输

LoRa 是一种无线传输协议, 适合长距离(km), 少量数据传输. 尤其适合传感器数据. 因为手头没有 LoRaWAN 基站, 所以暂时没法测试 LoRaWAN. 这里使用 LoRa 调制模式点对点传输 BMP280 传感器数据.

详细实现请参考 代码仓库 里的 src/bin/subghz-bmp280-tx.rssrc/bin/subghz-bmp280-rx.rs.

硬件准备

LM401-Pro-Kit x2, 天线, 数据线.

其中一个开发板作为传感器采集端, 按照上一示例链接到 BMP280 传感器模块, 另一个作为接收端, 两个开发板之间通过 LoRa 无线传输数据. 接收端通过 UART 与电脑连接, 通过串口调试工具查看传感器数据.(实际上也可以直接通过 ST-Link + defmt 获取数据)

射频开关 RadioSwitch

使用开发板射频功能, 需要处理射频开关逻辑. 相关逻辑从 BSP C 代码获得. 可以直接作为 BSP 的工具类型, 写入到 src/lib.rs 中:

use embassy_stm32::{
    gpio::{AnyPin, Level, Output, Pin, Speed},
    peripherals::{PA15, PA8, PB0},
};

pub struct RadioSwitch<'a> {
    ctrl1: Output<'a, AnyPin>,
    ctrl2: Output<'a, AnyPin>,
    ctrl3: Output<'a, AnyPin>,
}
impl<'a> RadioSwitch<'a> {
    pub fn new_from_pins(ctrl1: PB0, ctrl2: PA15, ctrl3: PA8) -> Self {
        Self {
            ctrl1: Output::new(ctrl1.degrade(), Level::Low, Speed::VeryHigh),
            ctrl2: Output::new(ctrl2.degrade(), Level::Low, Speed::VeryHigh),
            ctrl3: Output::new(ctrl3.degrade(), Level::Low, Speed::VeryHigh),
        }
    }
    pub fn new(
        ctrl1: Output<'a, AnyPin>,
        ctrl2: Output<'a, AnyPin>,
        ctrl3: Output<'a, AnyPin>,
    ) -> Self {
        Self {
            ctrl1,
            ctrl2,
            ctrl3,
        }
    }
    pub fn set_off(&mut self) {
        self.ctrl3.set_low();
        self.ctrl1.set_low();
        self.ctrl2.set_low();
    }
}
impl<'a> embassy_lora::stm32wl::RadioSwitch for RadioSwitch<'a> {
    fn set_rx(&mut self) {
        self.ctrl3.set_low();
        self.ctrl1.set_high();
        self.ctrl2.set_low();
    }

    fn set_tx(&mut self) {
        self.ctrl3.set_high();
        self.ctrl1.set_low();
        self.ctrl2.set_low();
    }
}

非常简单的 GPIO 操作, GPIO 的强类型 PAn/PBn/.. 可以通过 .degrade() 方法转换为 AnyPin 类型, 方便使用.

let mut rfs = lm401_pro_kit::RadioSwitch::new_from_pins(p.PB0, p.PA15, p.PA8);

LoRa 数据报文定义

为简单展示, 传感器节点只负责发送, 接受节点只接受 LoRa 报文, 不回传 ACK 信号.

报文格式为 24 字节:

设备地址 设备时间戳 温度 大气压 checksum
b”MM” u32 u64 f32 f32 u16

其中设备地址使用 STM32 系列的 chip id 实现, 保证一定的唯一性:

// Device ID in STM32L4/STM32WL microcontrollers
pub fn chip_id() -> [u32; 3] {
    unsafe {
        [
            core::ptr::read_volatile(0x1FFF7590 as *const u32),
            core::ptr::read_volatile(0x1FFF7594 as *const u32),
            core::ptr::read_volatile(0x1FFF7598 as *const u32),
        ]
    }
}

let chip_id = chip_id();
let dev_addr = chip_id[0] ^ chip_id[1] ^ chip_id[2];

设备时间戳直接读取 Instant::now() 并转为 millis. 保证每个数据报文的差异性. checksum 校验和字段通过计算 [2..22] 所有字节之和得到. 所有数据字段均按照大端序列化(BigEndian).

SubGhz 初始化

LM401 的射频功能由 STM32WLE5 内置的 SX1262 提供, 设备内部通过 SPI3(SUBGHZSPI) 访问. SX1262 初始化需要较多参数, 且发送端接收端若干参数需要一致.

这里选用 490.500MHz, LoRa SF7, 4/5 编码率, 125kHz 带宽, 24 字节数据长度. 接收端和发送端设置一致.

参数定义:

use embassy_stm32::subghz::*;

const DATA_LEN: u8 = 24_u8;
const PREAMBLE_LEN: u16 = 0x8 * 4;

const RF_FREQ: RfFreq = RfFreq::from_frequency(490_500_000);

const TX_BUF_OFFSET: u8 = 128;
const RX_BUF_OFFSET: u8 = 0;
const LORA_PACKET_PARAMS: LoRaPacketParams = LoRaPacketParams::new()
    .set_crc_en(true)
    .set_preamble_len(PREAMBLE_LEN)
    .set_payload_len(DATA_LEN)
    .set_invert_iq(false)
    .set_header_type(HeaderType::Fixed);

// SF7, Bandwidth 125 kHz, 4/5 coding rate, low data rate optimization
const LORA_MOD_PARAMS: LoRaModParams = LoRaModParams::new()
    .set_bw(LoRaBandwidth::Bw125)
    .set_cr(CodingRate::Cr45)
    .set_ldro_en(true)
    .set_sf(SpreadingFactor::Sf7);

// see table 35 "PA optimal setting and operating modes"
const PA_CONFIG: PaConfig = PaConfig::new()
    .set_pa_duty_cycle(0x4)
    .set_hp_max(0x7)
    .set_pa(PaSel::Hp);

const TX_PARAMS: TxParams = TxParams::new()
    .set_power(0x16) // +22dB
    .set_ramp_time(RampTime::Micros200);

设备初始化, 部分内容从 BSP C 代码转换得到:

let mut radio = SubGhz::new(p.SUBGHZSPI, NoDma, NoDma);

// from demo code: Radio_SMPS_Set
unwrap!(radio.set_smps_clock_det_en(true));
unwrap!(radio.set_smps_drv(SmpsDrv::Milli40));

unwrap!(radio.set_standby(StandbyClk::Rc));

// in XO mode, set internal capacitor (from 0x00 to 0x2F starting 11.2pF with 0.47pF steps)
unwrap!(radio.set_hse_in_trim(HseTrim::from_raw(0x20)));
unwrap!(radio.set_hse_out_trim(HseTrim::from_raw(0x20)));

unwrap!(radio.set_regulator_mode(RegMode::Smps)); // Use DCDC

unwrap!(radio.set_buffer_base_address(TX_BUF_OFFSET, RX_BUF_OFFSET));

unwrap!(radio.set_pa_config(&PA_CONFIG));
unwrap!(radio.set_pa_ocp(Ocp::Max60m)); // current max
unwrap!(radio.set_tx_params(&TX_PARAMS));

unwrap!(radio.set_packet_type(PacketType::LoRa));
unwrap!(radio.set_lora_sync_word(LoRaSyncWord::Public));
unwrap!(radio.set_lora_mod_params(&LORA_MOD_PARAMS));
unwrap!(radio.set_lora_packet_params(&LORA_PACKET_PARAMS));
unwrap!(radio.calibrate_image(CalibrateImage::ISM_470_510));
unwrap!(radio.set_rf_frequency(&RF_FREQ));

中断信号量处理, 由于发送接收循环需要涉及到中断处理, 这里直接用 Signal 类型的信号量处理中断:

use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::signal::Signal;

static IRQ_SIGNAL: Signal<CriticalSectionRawMutex, ()> = Signal::new();
let radio_irq = interrupt::take!(SUBGHZ_RADIO);
radio_irq.set_handler(|_| {
    IRQ_SIGNAL.signal(());
    unsafe { interrupt::SUBGHZ_RADIO::steal() }.disable();
});

这样, 在 async fn main() 中使用 IRQ_SIGNAL.wait().await 就可以随时等待中断信号量.

SubGhz 发送端

首先拼接报文, 这里直接手动拼接组合字节:

let mut payload = [0u8; 24];
let now = Instant::now();
let measurements = unwrap!(bmp280.measure(&mut delay));

payload[0] = b'M';
payload[1] = b'M';

payload[2..6].copy_from_slice(dev_addr.to_be_bytes().as_slice());
payload[6..14].copy_from_slice(now.as_millis().to_be_bytes().as_slice());
payload[14..18].copy_from_slice(measurements.temperature.to_be_bytes().as_slice());
payload[18..22].copy_from_slice(measurements.pressure.to_be_bytes().as_slice());
let checksum = payload[2..22]
    .iter()
    .fold(0u16, |acc, x| acc.wrapping_add(*x as u16));
info!("checksum: {:04x}", checksum);
payload[22..24].copy_from_slice(checksum.to_be_bytes().as_slice());

然后开始发送:

rfs.set_tx();
unwrap!(radio.set_irq_cfg(&CfgIrq::new().irq_enable_all(Irq::TxDone)));
unwrap!(radio.write_buffer(TX_BUF_OFFSET, &payload[..]));
unwrap!(radio.set_tx(Timeout::DISABLED));

radio_irq.enable();
IRQ_SIGNAL.wait().await;
rfs.set_off();

let (_, irq_status) = unwrap!(radio.irq_status());
if irq_status & Irq::TxDone.mask() != 0 {
    defmt::info!("TX done");
}
unwrap!(radio.clear_irq_status(irq_status));

总结起来发送过程需要如下步骤:

  • 打开射频发送开关
  • 设置中断, 开启 TxDone
  • 写入数据 buffer
  • 开始发送, 不使用 Timeout
  • 开启中断
  • 等待中断信号量
  • 关闭射频开关
  • 检查中断状态
  • 清理中断状态

SubGhz 接收端

这里是接收端逻辑, src/bin/subghz-bmp280-rx.rs, 其中配置部分和发送端相同:

let mut buf = [0u8; 256];

rfs.set_rx();
unwrap!(radio.set_irq_cfg(
    &CfgIrq::new()
        .irq_enable_all(Irq::RxDone)
        .irq_enable_all(Irq::Timeout)
        .irq_enable_all(Irq::Err)
));
unwrap!(radio.read_buffer(RX_BUF_OFFSET, &mut buf));
unwrap!(radio.set_rx(Timeout::from_duration_sat(Duration::from_millis(5000))));

radio_irq.unpend();
radio_irq.enable();

IRQ_SIGNAL.wait().await;
led_rx.set_low();
let (_, irq_status) = unwrap!(radio.irq_status());
unwrap!(radio.clear_irq_status(irq_status));

if irq_status & Irq::RxDone.mask() != 0 {
    let (_st, len, offset) = unwrap!(radio.rx_buffer_status());
    let packet_status = unwrap!(radio.lora_packet_status());
    let rssi = packet_status.rssi_pkt().to_integer();
    let snr = packet_status.snr_pkt().to_integer();
    info!(
        "RX done: rssi={}dBm snr={}dB len={} offset={}",
        rssi, snr, len, offset
    );
    let payload = &buf[offset as usize..offset as usize + len as usize];
    // Parse payload here
}

发送步骤如下:

  • 打开射频接收开关
  • 设置中断, 开启 RxDone, Timeout, Err
  • 设置读入 buffer
  • 开始接收, 这里使用 Timeout 5 秒
  • 清理未处理中断状态, 否则会有观察到空中断
  • 开启中断
  • 等待中断信号量
  • 检查中断状态, 清理中断状态
  • 通过 rx_buffer_status 获取 buffer 状态
  • 通过 lora_packet_status 获取报文 rssi, snr 信息

运行结果

发送端上电之后, 每2秒采集一次传感器数据并发送.

接收端上电之后, 持续接收数据并同时打印在 defmt 调试和串口输出.

> cargo run --bin subghz-bmp280-rx --release
1.226162 INFO  begin rx...
3.292868 INFO  RX done: rssi=-42dBm snr=14dB len=24 offset=0
3.292969 DEBUG got BMP280 node raw=[0x4d, 0x4d, 0x72, 0x2e, 0x67, 0x28, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x58, 0x3a, 0x41, 0xad, 0x10, 0xa2, 0x47, 0xac, 0x8c, 0x2a, 0x5, 0xa]
3.293173 INFO  dev addr=722e6728 dev tick=22586 temp=21.633121'C pressure=883.4433hPa
3.299479 INFO  stats: Stats { status: Status { mode: Ok(StandbyRc), cmd: Ok(Avaliable) }, pkt_rx: 2, pkt_crc: 0, pkt_len_or_hdr_err: 0, ty: LoRaStats }
3.299622 INFO  begin rx...

串口输出, CSV 格式:

> picocom -b 115200 /dev/tty.usbmodem11203
addr=722e6728,rssi=-44,snr=14,temperature=16.304043,pressure=87621.96
addr=722e6728,rssi=-44,snr=14,temperature=16.306524,pressure=87621.96
addr=722e6728,rssi=-44,snr=13,temperature=16.309006,pressure=87621.83
addr=722e6728,rssi=-45,snr=13,temperature=16.311487,pressure=87621.81
addr=722e6728,rssi=-45,snr=13,temperature=16.313969,pressure=87621.66

总结

Rust Embassy 是一个非常好的嵌入式 Rust 开发框架, 通过它可以快速开发嵌入式应用. Rust Embassy 把 async, await 关键字带到了 Rust 嵌入式开发中, 其还有丰富的多任务支持, 多种同步元语支持. 通过它们, 我们可以很方便的开发多任务应用.

但它依然是一个很早期的框架, 还不够完善, 例如目前在 STM32WL 上缺乏 ADC 支持. 文档不够丰富, 部分库函数会随着开发进度有所变更, 给维护项目带来不小的困难.

在开发过程中, 往往能看到 move 语义, ownership, 类型系统等 Rust 的特性, 虽然这些特性在嵌入式开发中并不是必须的, 但是它们确实能带来更好的开发体验. 例如 move/borrow 保证对设备资源的唯一访问所有权. 通过类型安全的寄存器类型访问避免 C 语言中错误的寄存器访问, 经过 Rust 编译器优化后, 和 C 中的 bit mask 写法是等价的. 通过 “associated types” 保证设备和对应引脚的状态匹配.

Rust Embassy 隐藏了大部分嵌入式设备细节, 开发者不需要过多的关注设备初始化细节, 应用代码短小.

实际使用过程中, 也遇到了一些坑, 例如在写一个 PWM 例子时候, embassy_time::Delay 怎么都不工作, 添加了若干 debug 打印之后才发现, embassy_time::Delay 内部使用 embassy_time::Instant 实现, 默认情况下会使用 TIM2. 而选择的 PWM 输出 pin 正好是 TIM2_CH2, 两者互相干扰, 导致 Delay 不工作. 目前类型系统还不能保证 DelayPwm 不会使用同一个 TIM 设备. 最终的解决方法是使用 cortex_m::delay::Delay, 这是一个基于 SYSTICK 的实现.

本位未介绍 Embassy 的多任务功能, 在代码仓库里有一个简单的按钮控制闪灯频率的例子 src/bin/button-control-blinky.rs. 多任务的时候需要有 .await 调用让出时间片.

参考资料