本帖最后由 monow 于 2023-1-24 10:31 编辑

参与了 eet-china.com 的开发板测评活动, 申请的板子是 "STM32WLE5 易智联 Lora 评估板(LM401-Pro-Kit)(https://mbb.eet-china.com/evaluating/product-106.html)".
本文主要介绍如何使用 Rust 语言的 Embassy 嵌入式框架实现 STM32WL LoRa 数据传输.
过年回老家, 随身带的东西不多, 只有一个迷你 BMP280 (大气压温度)传感器模块, 所以本文使用 BMP280 传感器数据作为例子.
门槛率高, 还是从点灯开始搞起.
最终相关代码位于 Github: andelf/lm401-pro-kit (https://github.com/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
  1. $ probe-run --list-chips | grep STM32WL
  2. STM32WL Series
  3.         STM32WLE5J8Ix
  4.         STM32WLE5JBIx
  5.         STM32WLE5JCIx
  6.         STM32WL55JCIx

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

安装任意串口调试工具, 这里我使用 `picocom`. 其他可以使用的替代有 PuTTy(https://www.putty.org/),
Teraterm(https://ttssh2.osdn.jp/index.html.en) 等等.

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

测试连接
  1. > lsusb
  2. Bus 001 Device 008: ID 0483:374b STMicroelectronics STM32 STLink  Serial: xxxx

## 从 Blinky 开始 Embassy 应用开发

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

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

我们直接依赖 embassy 的 master 分支进行开发, 为方便调试, 直接 clone 到本地用相对路径引入依赖:
  1. git clone [email]git@github.com[/email]:embassy-rs/embassy.git
  2. # or
  3. git clone https://github.com/embassy-rs/embassy.git
  4. # 在同层目录直接创建我们的项目, 起板子名就可以. 相当于一个 BSP 模板可以扩充
  5. cargo new --lib lm401-pro-kit
  6. # 进入项目目录, 以下命令均在此执行
  7. cd lm401-pro-kit

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

- <font face="Courier">.cargo/config.toml</font>
  - 设置编译器 target 到 `thumbv7em-none-eabi`
  - 设置 `cargo run` 的执行方式为调用 `probe-run ...`
  -
  1.     [target.'cfg(all(target_arch = "arm", target_os = "none"))']
  2.     runner = "probe-run --chip STM32WLE5JCIx"
  3.     [build]
  4.     target = "thumbv7em-none-eabi"
  5.    


- `build.rs`
  - 设置 `link.x`/`memory.x` 链接过程中所用配置, 编译过程中由 embassy 自动按照芯片选择生成
  - 添加 `defmt` 链接参数支持
- `Cargo.toml`
  - 添加 `embassy` 相关依赖, 并通过 `features` 设置相关参数
  - 添加项目依赖, defmt, cortex-m 相关等
  - 设置编译参数 `opt-level = "z"`, 最小化编译二进制大小
  -
  1.     # part of Cargo.toml
  2.     [dependencies]
  3.     # ...
  4.     embassy-stm32 = { version = "0.1.0", path = "../embassy/embassy-stm32", features = [
  5.         "nightly",
  6.         "defmt",
  7.         "stm32wle5cb",
  8.         "time-driver-any",
  9.         "memory-x",
  10.         "subghz",
  11.         "unstable-pac",
  12.         "exti",
  13.     ] }
  14.     # ...
  15.     [profile.dev]
  16.     opt-level = "z" # Optimize for size.
  17.     [profile.release]
  18.     lto = true
  19.     opt-level = "z" # Optimize for size.
  20.    
  - 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`.
  1. // blinky.rs
  2. #![no_std]
  3. #![no_main]
  4. #![feature(type_alias_impl_trait)]
  5. use defmt::*;
  6. use embassy_executor::Spawner;
  7. use embassy_stm32::gpio::{Level, Output, Speed};
  8. use embassy_time::{Duration, Timer};
  9. use {defmt_rtt as _, panic_probe as _};
  10. #[embassy_executor::main]
  11. async fn main(_spawner: Spawner) {
  12.     let p = embassy_stm32::init(Default::default());
  13.     info!("Hello World!");
  14.     let mut led = Output::new(p.PB4, Level::High, Speed::Low);
  15.     loop {
  16.         info!("high");
  17.         led.set_high();
  18.         Timer::after(Duration::from_millis(1000)).await;
  19.         info!("low");
  20.         led.set_low();
  21.         Timer::after(Duration::from_millis(1000)).await;
  22.     }
  23. }

`#![no_main]` 用于告诉 Rust 编译器, 我们不使用 Rust 提供的 `main` 函数做程序入口.
`#[embassy_executor::main]` 是一个宏, 用于包装 `async fn main()` 函数,
由 embassy-executor 提供了一个 futures runtime, 所以可以使用 `async` 和 `await` 语法.
底层实现中, `.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_high` 和 `set_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`.

确保板子连接正常, 直接运行:
  1. > cargo run --bin blinky
  2.     Finished dev [optimized + debuginfo] target(s) in 0.32s
  3.      Running `probe-run --chip STM32WLE5JCIx target/thumbv7em-none-eabi/debug/blinky`
  4. (HOST) INFO  flashing program (14 pages / 14.00 KiB)
  5. (HOST) INFO  success!
  6. ────────────────────────────────────────────────────────────────────────────────
  7. 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) }
  8. └─ embassy_stm32::rcc::set_freqs @ ./embassy/embassy-stm32/src/fmt.rs:125
  9. 0.000113 INFO  Hello World!
  10. └─ blinky::____embassy_main_task::{async_fn#0} @ src/bin/blinky.rs:14
  11. 0.000552 INFO  high
  12. └─ blinky::____embassy_main_task::{async_fn#0} @ src/bin/blinky.rs:19
  13. 1.001157 INFO  low
  14. └─ blinky::____embassy_main_task::{async_fn#0} @ src/bin/blinky.rs:23
  15. 2.001811 INFO  high

- 二进制编译成功后, 由 `probe-run` 烧录到 MCU 并执行, 持续获取 defmt 打印信息
- `rcc: Clocks` 调试时钟信息由 `embassy-stm32` 库 `embassy_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()` 方法设置时钟参数:
  1. // sys clk init, with LSI support
  2. let mut config = embassy_stm32::Config::default();
  3. config.rcc.enable_lsi = true;
  4. config.rcc.mux = embassy_stm32::rcc::ClockSrc::MSI(embassy_stm32::rcc::MSIRange::Range11); // 48MHz
  5. let p = embassy_stm32::init(config);

Embassy UART 使用非常简单, 可以单独用 UartTx/UartRx 只初始发送/接收部分.
这里是一个发送 Hello world 和 MCU 内部 "时间" 的简单示例:
  1. // USART2 tx
  2. use embassy_stm32::dma::NoDma;
  3. use embassy_stm32::usart::UartTx;
  4. use embassy_time::Instant;
  5. use heapless::String;
  6. // Default: 115200 8N1
  7. let mut usart = UartTx::new(p.USART2, p.PA2, NoDma, Default::default());
  8. let mut msg: String<64> = String::new();
  9. let i = Instant::now();
  10. core::write!(msg, "Hello world, device time: {}\r\n", i.as_millis()).unwrap();
  11. usart.blocking_write(msg.as_bytes()).unwrap();
  12. msg.clear();

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

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

完整代码请参考 代码仓库(https://github.com/andelf/lm401-pro-kit).

执行代码确认, 可以看到系统时钟被正确设置为 48MHz.
  1. > cargo run --bin uart
  2. 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) }
  3. └─ embassy_stm32::rcc::set_freqs @ /Users/mono/Elec/embassy/embassy-stm32/src/fmt.rs:125
  4. 0.000011 INFO  Hello World!
  5. └─ uart::____embassy_main_task::{async_fn#0} @ src/bin/uart.rs:21
  6. 0.000064 INFO  tick

在另一命令行打开串口监视工具, 查看串口输出:
  1. > picocom -b 115200 /dev/tty.usbmodem11103
  2. Hello world, device time: 1000
  3. Hello world, device time: 3002
  4. Hello world, device time: 5005
  5. Hello world, device time: 7008
  6. Hello world, device time: 9011
  7. Hello world, device time: 11013
  8. ....

### I2C 访问 BMP280
#### 硬件准备

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

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

接线:

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

#### BMP280 访问

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

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

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

修改 `src/lib.rs` 增加:
  1. pub mod bme280;

#### 代码实现

创建 BMP280 传感器项目 `src/bin/i2c-bmp280.rs`. 完整代码请参考 [代码仓库](https://github.com/andelf/lm401-pro-kit), 以下只选择关键部分介绍.
  1. // BMP280 init
  2. use embassy_stm32::i2c::I2c;
  3. use embassy_stm32::interrupt;
  4. use embassy_stm32::time::Hertz;
  5. use embassy_time::Delay;
  6. use lm401_pro_kit::bme280::BME280;
  7. let irq = interrupt::take!(I2C2_EV);
  8. let i2c = I2c::new(
  9.     p.I2C2,
  10.     p.PA12,
  11.     p.PA11,
  12.     irq,
  13.     NoDma,
  14.     NoDma,
  15.     Hertz(100_000),
  16.     Default::default(),
  17. );
  18. let mut delay = Delay;
  19. let mut bmp280 = BME280::new_primary(i2c);
  20. 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 打印信息.

完成设备初始化后, 可以访问传感器信息:
  1. let raw = unwrap!(bmp280.measure(&mut delay));
  2. info!("BMP280: {:?}", raw);

传感器执行测量时候, 按照手册, 依然需要延时, 所以也同样需要传递 `Delay` 对象.
`BME280::measure` 方法返回 `Measurements` 类型, 为了方便调试使用, 用 derive macro 增加了 defmt 支持,
可以直接做格式化参数:
  1. #[derive(Debug, defmt::Format)]
  2. pub struct Measurements {
  3.     /// temperature in degrees celsius
  4.     pub temperature: f32,
  5.     /// pressure in pascals
  6.     pub pressure: f32,
  7.     /// percent relative humidity (`0` with BMP280)
  8.     pub humidity: f32,
  9. }

执行代码:
  1. > cargo run --bin i2c-bmp280
  2. 0.000011 INFO  I2C BMP280 demo!
  3. └─ i2c_bmp280::____embassy_main_task::{async_fn#0} @ src/bin/i2c-bmp280.rs:23
  4. 0.009314 INFO  measure tick
  5. └─ i2c_bmp280::____embassy_main_task::{async_fn#0} @ src/bin/i2c-bmp280.rs:45
  6. 0.051652 INFO  BMP280: Measurements { temperature: 23.689554, pressure: 88391.13, humidity: 0.0 }
  7. └─ i2c_bmp280::____embassy_main_task::{async_fn#0} @ src/bin/i2c-bmp280.rs:48

输出 `<font face="Courier">temperature: 23.689554, pressure: 88391.13</font>` 传感器数据正常.

## LoRa 传感器数据传输

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

详细实现请参考 代码仓库(https://github.com/andelf/lm401-pro-kit) 里的 `src/bin/subghz-bmp280-tx.rs` 和 `src/bin/subghz-bmp280-rx.rs`.

### 硬件准备

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

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

### 射频开关 RadioSwitch

使用开发板射频功能, 需要处理射频开关逻辑. 相关逻辑从 BSP C 代码获得. 可以直接作为 BSP 的工具类型, 写入到 `src/lib.rs` 中:
  1. use embassy_stm32::{
  2.     gpio::{AnyPin, Level, Output, Pin, Speed},
  3.     peripherals::{PA15, PA8, PB0},
  4. };
  5. pub struct RadioSwitch<'a> {
  6.     ctrl1: Output<'a, AnyPin>,
  7.     ctrl2: Output<'a, AnyPin>,
  8.     ctrl3: Output<'a, AnyPin>,
  9. }
  10. impl<'a> RadioSwitch<'a> {
  11.     pub fn new_from_pins(ctrl1: PB0, ctrl2: PA15, ctrl3: PA8) -> Self {
  12.         Self {
  13.             ctrl1: Output::new(ctrl1.degrade(), Level::Low, Speed::VeryHigh),
  14.             ctrl2: Output::new(ctrl2.degrade(), Level::Low, Speed::VeryHigh),
  15.             ctrl3: Output::new(ctrl3.degrade(), Level::Low, Speed::VeryHigh),
  16.         }
  17.     }
  18.     pub fn new(
  19.         ctrl1: Output<'a, AnyPin>,
  20.         ctrl2: Output<'a, AnyPin>,
  21.         ctrl3: Output<'a, AnyPin>,
  22.     ) -> Self {
  23.         Self {
  24.             ctrl1,
  25.             ctrl2,
  26.             ctrl3,
  27.         }
  28.     }
  29.     pub fn set_off(&mut self) {
  30.         self.ctrl3.set_low();
  31.         self.ctrl1.set_low();
  32.         self.ctrl2.set_low();
  33.     }
  34. }
  35. impl<'a> embassy_lora::stm32wl::RadioSwitch for RadioSwitch<'a> {
  36.     fn set_rx(&mut self) {
  37.         self.ctrl3.set_low();
  38.         self.ctrl1.set_high();
  39.         self.ctrl2.set_low();
  40.     }
  41.     fn set_tx(&mut self) {
  42.         self.ctrl3.set_high();
  43.         self.ctrl1.set_low();
  44.         self.ctrl2.set_low();
  45.     }
  46. }

非常简单的 GPIO 操作, GPIO 的强类型 `PAn`/`PBn`/.. 可以通过 `.degrade()` 方法转换为 `AnyPin` 类型,
方便使用:
  1. let mut rfs = lm401_pro_kit::RadioSwitch::new_from_pins(p.PB0, p.PA15, p.PA8);

### LoRa 数据报文定义

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

报文格式为 24 字节:
  1. | 头    | 设备地址 | 设备时间戳 | 温度 | 大气压 | checksum |
  2. | b"MM" | u32    |  u64      | f32 |  f32  |   u16    |

其中设备地址使用 STM32 系列的 chip id 实现, 保证一定的唯一性:
  1. // Device ID in STM32L4/STM32WL microcontrollers
  2. pub fn chip_id() -> [u32; 3] {
  3.     unsafe {
  4.         [
  5.             core::ptr::read_volatile(0x1FFF7590 as *const u32),
  6.             core::ptr::read_volatile(0x1FFF7594 as *const u32),
  7.             core::ptr::read_volatile(0x1FFF7598 as *const u32),
  8.         ]
  9.     }
  10. }
  11. let chip_id = chip_id();
  12. 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 字节数据长度.
接收端和发送端设置一致.

参数定义:
  1. use embassy_stm32::subghz::*;
  2. const DATA_LEN: u8 = 24_u8;
  3. const PREAMBLE_LEN: u16 = 0x8 * 4;
  4. const RF_FREQ: RfFreq = RfFreq::from_frequency(490_500_000);
  5. const TX_BUF_OFFSET: u8 = 128;
  6. const RX_BUF_OFFSET: u8 = 0;
  7. const LORA_PACKET_PARAMS: LoRaPacketParams = LoRaPacketParams::new()
  8.     .set_crc_en(true)
  9.     .set_preamble_len(PREAMBLE_LEN)
  10.     .set_payload_len(DATA_LEN)
  11.     .set_invert_iq(false)
  12.     .set_header_type(HeaderType::Fixed);
  13. // SF7, Bandwidth 125 kHz, 4/5 coding rate, low data rate optimization
  14. const LORA_MOD_PARAMS: LoRaModParams = LoRaModParams::new()
  15.     .set_bw(LoRaBandwidth::Bw125)
  16.     .set_cr(CodingRate::Cr45)
  17.     .set_ldro_en(true)
  18.     .set_sf(SpreadingFactor::Sf7);
  19. // see table 35 "PA optimal setting and operating modes"
  20. const PA_CONFIG: PaConfig = PaConfig::new()
  21.     .set_pa_duty_cycle(0x4)
  22.     .set_hp_max(0x7)
  23.     .set_pa(PaSel::Hp);
  24. const TX_PARAMS: TxParams = TxParams::new()
  25.     .set_power(0x16) // +22dB
  26.     .set_ramp_time(RampTime::Micros200);

设备初始化, 部分内容从 BSP C 代码转换得到:
  1. let mut radio = SubGhz::new(p.SUBGHZSPI, NoDma, NoDma);
  2. // from demo code: Radio_SMPS_Set
  3. unwrap!(radio.set_smps_clock_det_en(true));
  4. unwrap!(radio.set_smps_drv(SmpsDrv::Milli40));
  5. unwrap!(radio.set_standby(StandbyClk::Rc));
  6. // in XO mode, set internal capacitor (from 0x00 to 0x2F starting 11.2pF with 0.47pF steps)
  7. unwrap!(radio.set_hse_in_trim(HseTrim::from_raw(0x20)));
  8. unwrap!(radio.set_hse_out_trim(HseTrim::from_raw(0x20)));
  9. unwrap!(radio.set_regulator_mode(RegMode::Smps)); // Use DCDC
  10. unwrap!(radio.set_buffer_base_address(TX_BUF_OFFSET, RX_BUF_OFFSET));
  11. unwrap!(radio.set_pa_config(&PA_CONFIG));
  12. unwrap!(radio.set_pa_ocp(Ocp::Max60m)); // current max
  13. unwrap!(radio.set_tx_params(&TX_PARAMS));
  14. unwrap!(radio.set_packet_type(PacketType::LoRa));
  15. unwrap!(radio.set_lora_sync_word(LoRaSyncWord::Public));
  16. unwrap!(radio.set_lora_mod_params(&LORA_MOD_PARAMS));
  17. unwrap!(radio.set_lora_packet_params(&LORA_PACKET_PARAMS));
  18. unwrap!(radio.calibrate_image(CalibrateImage::ISM_470_510));
  19. unwrap!(radio.set_rf_frequency(&RF_FREQ));

中断信号量处理, 由于发送接收循环需要涉及到中断处理, 这里直接用 `Signal` 类型的信号量处理中断, 保证收到 SubGhz 中断后, 主程序逻辑能够获得通知:
  1. use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
  2. use embassy_sync::signal::Signal;
  3. static IRQ_SIGNAL: Signal<CriticalSectionRawMutex, ()> = Signal::new();
  4. let radio_irq = interrupt::take!(SUBGHZ_RADIO);
  5. radio_irq.set_handler(|_| {
  6.     IRQ_SIGNAL.signal(());
  7.     unsafe { interrupt::SUBGHZ_RADIO::steal() }.disable();
  8. });

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

### SubGhz 发送端

首先拼接报文, 这里直接手动拼接组合字节:
  1. let mut payload = [0u8; 24];
  2. let now = Instant::now();
  3. let measurements = unwrap!(bmp280.measure(&mut delay));
  4. payload[0] = b'M';
  5. payload[1] = b'M';
  6. payload[2..6].copy_from_slice(dev_addr.to_be_bytes().as_slice());
  7. payload[6..14].copy_from_slice(now.as_millis().to_be_bytes().as_slice());
  8. payload[14..18].copy_from_slice(measurements.temperature.to_be_bytes().as_slice());
  9. payload[18..22].copy_from_slice(measurements.pressure.to_be_bytes().as_slice());
  10. let checksum = payload[2..22]
  11.     .iter()
  12.     .fold(0u16, |acc, x| acc.wrapping_add(*x as u16));
  13. info!("checksum: {:04x}", checksum);
  14. payload[22..24].copy_from_slice(checksum.to_be_bytes().as_slice());

然后开始发送:
  1. rfs.set_tx();
  2. unwrap!(radio.set_irq_cfg(&CfgIrq::new().irq_enable_all(Irq::TxDone)));
  3. unwrap!(radio.write_buffer(TX_BUF_OFFSET, &payload[..]));
  4. unwrap!(radio.set_tx(Timeout::DISABLED));
  5. radio_irq.enable();
  6. IRQ_SIGNAL.wait().await;
  7. rfs.set_off();
  8. let (_, irq_status) = unwrap!(radio.irq_status());
  9. if irq_status & Irq::TxDone.mask() != 0 {
  10.     defmt::info!("TX done");
  11. }
  12. unwrap!(radio.clear_irq_status(irq_status));


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

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

### SubGhz 接收端

这里是接收端逻辑, `src/bin/subghz-bmp280-rx.rs`, 其中配置部分和发送端相同:
  1. let mut buf = [0u8; 256];
  2. rfs.set_rx();
  3. unwrap!(radio.set_irq_cfg(
  4.     &CfgIrq::new()
  5.         .irq_enable_all(Irq::RxDone)
  6.         .irq_enable_all(Irq::Timeout)
  7.         .irq_enable_all(Irq::Err)
  8. ));
  9. unwrap!(radio.read_buffer(RX_BUF_OFFSET, &mut buf));
  10. unwrap!(radio.set_rx(Timeout::from_duration_sat(Duration::from_millis(5000))));
  11. radio_irq.unpend();
  12. radio_irq.enable();
  13. IRQ_SIGNAL.wait().await;
  14. led_rx.set_low();
  15. let (_, irq_status) = unwrap!(radio.irq_status());
  16. unwrap!(radio.clear_irq_status(irq_status));
  17. if irq_status & Irq::RxDone.mask() != 0 {
  18.     let (_st, len, offset) = unwrap!(radio.rx_buffer_status());
  19.     let packet_status = unwrap!(radio.lora_packet_status());
  20.     let rssi = packet_status.rssi_pkt().to_integer();
  21.     let snr = packet_status.snr_pkt().to_integer();
  22.     info!(
  23.         "RX done: rssi={}dBm snr={}dB len={} offset={}",
  24.         rssi, snr, len, offset
  25.     );
  26.     let payload = &buf[offset as usize..offset as usize + len as usize];
  27.     // Parse payload here
  28. }

发送步骤如下:

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

这里略去了解析报文和串口打印代码. 相关逻辑比较简单.

### 运行结果

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

接收端上电之后, 持续接收数据并同时打印在 defmt 调试和串口输出.
  1. > cargo run --bin subghz-bmp280-rx --release
  2. 1.226162 INFO  begin rx...
  3. 3.292868 INFO  RX done: rssi=-42dBm snr=14dB len=24 offset=0
  4. 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]
  5. 3.293173 INFO  dev addr=722e6728 dev tick=22586 temp=21.633121'C pressure=883.4433hPa
  6. 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 }
  7. 3.299622 INFO  begin rx...
串口输出:
  1. addr=722e6728,rssi=-44,snr=14,temperature=16.304043,pressure=87621.96
  2. addr=722e6728,rssi=-44,snr=14,temperature=16.306524,pressure=87621.96
## 总结
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` 不工作. 目前类型系统还不能保证 `Delay` 和 `Pwm` 不会使用同一个 `TIM` 设备. 最终的解决方法是使用 `cortex_m::delay::Delay`, 这是一个基于 SYSTICK 的实现.
## 参考资料
- Github: Embassy(https://github.com/embassy-rs/embassy)
- Embassy Documentation(https://embassy.dev/book/dev/index.html)
- Embedded Rust & Embassy 系列教程(https://apollolabsblog.hashnode.dev/embedded-rust-embassy-gpio-button-controlled-blinking)
- STM32WLE5.pdf(https://www.st.com/resource/en/datasheet/STM32WLE5.pdf)