引言

本材料的内容

这里是 Ferrous Systems 的 Embedded Rust on Espressif 培训材料,分为两个部分:入门和进阶。入门部分将向你介绍嵌入式开发的基础知识,以及如何使开发板与外界交互——对命令做出反应,并发送传感器数据。

进阶部分将深入探讨中断处理、低级外设访问和编写你自己的驱动程序等主题。

你可以加入 Matrix 上的 esp-rs 社区 来探讨任何技术问题!社区对所有人开放。

翻译

本书已由慷慨的志愿者参与翻译。如果你希望在此处列出你的译本,请(向英文原版仓库)提交 PR。

开发板

本书要求使用 Rust ESP 开发板1——不支持像 QEMU 这样的模拟器。

电路板的设计、图片、引脚布局和原理图也可以在此仓库中找到。

如果你订阅了其中一项培训,乐鑫将直接为你提供一块开发板。

我们的重点主要在 ESP32-C3 平台,一个基于 RISC-V 的,具有强大物联网功能的微控制器,集成 Wi-Fi 和 Bluetooth 5 (LE) 功能,以及适用于复杂应用的大容量 RAM 和 Flash。 本教程的大部分内容也适用于 Espressif 使用的其他架构(Xtensa),特别是 ESP32-S3。 对于底层访问,原理上是一样的,但实际的硬件访问会各有不同——请根据需要参阅技术参考手册(C3S3)或其他可用的技术文档

Rust 知识

  • 基本的 Rust 知识:The Rust Book 第 1 到第 6 章,第4章“所有权”不需要完全理解。
  • The Rust on ESP Book 不是必须的,但是强烈推荐。因为它能帮助你理解 Rust on ESP 生态系统和许多将在本教程中讨论到的概念。
1

也可以使用 ESP32-C3-DevKitC-02 学习入门部分,但并不推荐。使用本书要求的硬件学起来更简单。

准备工作

本章包含了有关教程材料、所需硬件的信息,以及一个安装指南。

本书使用的图标和格式

本书使用图标来标记书中不同种类的信息:

  • ✅ 需要动手尝试。
  • ⚠️ 警告和需要特别关注的细节。
  • 🔎 深入某个主题的知识,但不需要了解这些知识即可继续阅读。
  • 💡 在练习中可能对你有帮助的提示

注释示例:像这样的注释包含了有用的信息

代码注释

在某些 Rust 源文件里,有类似这样的 anchor 注释:

// ANCHOR: test
let foo = 1;
...
// ANCHOR_END: test

你可以忽略 Anchor 注释,它们只是用于把包含的代码导入到本书中。详见 mdBook 文档

需要的硬件

不需要额外的调试器硬件。

项目仿真

某些项目可以用 Wokwi 仿真。寻找书中的指示来确认可以仿真的项目。仿真有两种方法实现:

  • 使用 wokwi.com:直接在浏览器中执行构建、编辑代码。
  • 使用 Wokwi VS Code 扩展:用 VS Code 来编辑项目、执行构建。用 Wokwi VS Code 扩展对生成的二进制文件进行仿真。
    • 这种方法需要一些安装
    • 这种方法假定项目是在 debug 模式下构建的
    • 这种方法允许调试项目

确保有一个可用的开发环境

⚠️ 如果你正在参加由 Ferrous Systems 开展的培训,我们强烈建议你至少提前一个工作日按照本章中的说明为培训做好准备。如果你遇到任何问题或需要任何类型的支持,请联系我们

⚠️ 如果你正在使用 ESP32-C3-DevKitC-02,一些引脚和从机地址会有所不同,因为两块板子不完全相同。这与 advanced/i2c-sensor-reading/advanced/i2c-driver/ 中的解答有关,其中用于 ESP32-C3-DevKitC-02 的引脚和从机地址已被注释。

配套材料

检查硬件

将 Espressif Rust 开发板连接到你的电脑。确认一个红色的小 LED 被点亮了。

开发板应当通过 USB 提供了一个 UART 串口:

Windows:一个 USB 串行设备(COM 端口),在设备管理器的“端口”部分。

Linuxlsusb 下的一个 USB 设备。 这个设备的 VID(Vendor ID)为 303a,PID(Product ID)为 1001——lsusb 的输出中会省略 0x 前缀:

$ lsusb | grep USB
Bus 006 Device 035: ID 303a:1001 Espressif USB JTAG/serial debug unit

另一个查看设备,以及相关权限和端口的方法是检查 /by-id 目录:

$ ls -l /dev/serial/by-id
lrwxrwxrwx 1 root root .... usb-Espressif_USB_JTAG_serial_debug_unit_60:55:F9:C0:27:18-if00 -> ../../ttyACM0

如果你在使用 ESP32-C3-DevKitC-02,使用 $ ls /dev/ttyUSB* 命令

macOS:此设备将显示为 system_profiler 中 USB 树的一部分:

$ system_profiler SPUSBDataType | grep -A 11 "USB JTAG"

USB JTAG/serial debug unit:

  Product ID: 0x1001
  Vendor ID: 0x303a
  (...)

此设备还将作为 tty.usbmodem 设备显示在 /dev 目录中:

$ ls /dev/tty.usbmodem*
/dev/tty.usbmodem0

软件

按照以下步骤完成 ESP32-C3 平台工具的默认安装。

🔎 如果想要自定义安装(例如,从源码构建组件,或者添加对 Xtensa 目标的支持),请参阅 Rust on ESP 一书的 Rust on ESP targets 章节。

Rust 工具链

✅ 如果你的电脑上还没有安装 Rust,从 https://rustup.rs/ 获取它

此外,对于 ESP32-C3,目前需要 Rust 工具链的 nightly 版本。本教程中我们将使用 nightly-2024-06-30 版本。

✅ 用以下命令安装 nightly Rust,并添加对目标架构的支持:

rustup toolchain install nightly-2024-06-30 --component rust-src

🔎 Rust 能够交叉编译到任何支持的目标架构(参见 rustup 目标列表)。默认情况下,仅会安装本机的架构。 从 2022 年 1 月起,如果要编译到 Xtensa 架构(不是本材料的一部分),需要一个 Rust 编译器的分支。

Espressif 工具链

需要几个工具:

  • cargo-espflash - 上传固件到微控制器,打开串口监视器,Cargo 集成
  • espflash - 上传固件到微控制器,打开串口监视器
  • ldproxy - Espressif 构建工具链的依赖

✅ 使用下面的指令安装他们:

cargo install cargo-espflash espflash ldproxy

⚠️ 本书中列出的 espflashcargo-espflash 命令假定版本 >= 2

工具链依赖项

Debian/Ubuntu

sudo apt install llvm-dev libclang-dev clang libuv-dev libuv1-dev pkgconf python3-venv python-is-python3

macOS

当使用 Homebrew 包管理器时,这也是我们推荐的方式:

brew install llvm libuv

Troubleshooting

  • Python 3 是必需的依赖项,它预装在 macOS 和大部分桌面 Linux 发行版上。Python 2 和指向它的 virtualenv 附加组件可能导致构建出现问题。

  • 报错 failed to run custom build command for libudev-sys vX.X.Xesp-idf-sys vX.X.X

    在撰写本文时,可以通过以下方法解决:

    1. 运行这一行命令:

    apt-get update \ && apt-get install -y vim nano git curl gcc ninja-build cmake libudev-dev python3 python3-pip libusb-1.0-0 libssl-dev \ pkg-config libtinfo5

    1. 重启终端。

    2. 如果不起作用,尝试 cargo clean,删除 ~/.espressif 目录(对于 Windows,是 %USERPROFILE%\.espressif),然后重新构建你的项目。

    3. 在 Ubuntu 上,可能需要将内核更改为 5.19。运行 uname -r 以获取你的内核版本。

Docker

另一种可选的环境是使用 Docker。本仓库包含一个 Dockerfile, 其中包含用于安装 Rust 工具链(和所有依赖的包)的指令。此虚拟化环境旨在 为 Espressif 目标编译二进制文件,在容器内烧录二进制文件是不可行的。因此,有两种选择:

  • 在主机系统上执行烧写命令,例如 cargo-espflash。 如果采用这个选项,建议开启两个终端:
    • 一个在容器内,用于编译项目
    • 一个在主机上,用 cargo-espflash 子命令来烧写程序
  • 在容器内使用 web-flash crate 来烧写程序。容器已经包含了 web-flash。烧写 hardware-check 项目 的命令是:
    web-flash --chip esp32c3 target/riscv32imc-esp-espidf/debug/hardware-check
    

✅ 为你的操作系统安装 Docker

✅ 获取 Docker 镜像: 有两种方法来获取 Docker 镜像:

  • Dockerfile 构建镜像:
    docker image build --tag rust-std-training --file .devcontainer/Dockerfile .
    
    构建镜像需要一段时间,具体取决于操作系统和硬件(20-30 分钟)。
  • Dockerhub 下载:
    docker pull espressif/rust-std-training
    

✅ 启动新的 Docker 容器:

  • 对于本地 Docker 镜像:
    docker run --mount type=bind,source="$(pwd)",target=/workspace,consistency=cached -it rust-std-training /bin/bash
    
  • 对于从 Docker Hub 下载的:
    docker run --mount type=bind,source="$(pwd)",target=/workspace,consistency=cached -it espressif/rust-std-training:latest /bin/bash
    

这将在 Docker 容器中启动一个交互式 shell。 它还将本地存储库挂载到容器内名为 /workspace 的文件夹中。对主机系统上项目的更改会反映在容器内,反之亦然。

附加软件

VS Code

VS Code 是一个具有良好 Rust 支持的编辑器,在大多数平台上可用。 使用 VS Code 时,我们推荐安装以下扩展:

  • Rust Analyzer 提供代码补全和跳转等
  • Even Better TOML 用于编辑基于 TOML 的配置文件

还有一些适用于高级用法的扩展

  • lldb 基于 LLDB 的本机调试器扩展
  • crates 帮助管理 Rust 依赖项

VS Code 和 Dev container

有助于在 Docker 容器内开发的一个 VS Code 扩展是 Remote Containers。 它使用与 Docker 配置相同的 Dockerfile,构建镜像并从 VS Code 中建立连接。 安装扩展后,VS Code 会识别 .devcontainer 文件夹中的配置。使用 Remote Containers - Reopen in Container 命令将 VS Code 连接到容器。

教程仓库

完整的材料可以在 https://github.com/esp-rs/std-training 找到。

✅ 克隆并进入教程仓库:

git clone "https://github.com/esp-rs/std-training.git"
cd std-training

❗ Windows 用户可能会遇到长路径名问题

仓库内容

  • advanced/ - 进阶教程的代码示例和练习
  • book/ - 本书的 markdown 源码
  • common/ - 入门和进阶教程共用的代码
  • common/lib/ - 基础 crates
  • intro/ - 入门教程的代码示例和练习

关于配置的说明

比起将证书或其他敏感信息直接放在源代码中,在本教程中,我们会使用 toml-cfg 作为一种更方便、更安全的替代方法。配置信息会存储在相应包的根目录中名为 cfg.toml 的文件中

该配置中只包含一个与包同名(Cargo.toml 中的 name = "your-package")的 section 标题,具体配置因项目而异:

[your-package]
user = "example"
password = "h4ckm3"

❗ 如果你把 cfg.toml 复制到了另一个项目,记得将标题改为 [另一个包的 name]

Hello, Board!

现在我们已准备好进行一致性检查了!

✅ 将开发板的 USB-C 口连接到电脑,进入项目仓库中的 hardware-check 目录:

cd intro/hardware-check

为了测试 Wi-Fi 连接,你需要提供你的网络名称(SSID)和密码(PSK)。这些凭据存储在专用的 cfg.toml 文件中(已被 .gitignore 忽略),以防因共享源代码或执行 pull request 而意外泄露。项目里已经提供了一个例子。

✅ 将 cfg.toml.example 复制到 cfg.toml(在同一目录中),将实际的 SSID 和 PSK 写入其中:

⚠️ ESP32-C3 不支持 5 GHz 频段,你需要确保你使用的 Wi-Fi 具有可用的 2.4 GHz 频段。

$ cp cfg.toml.example cfg.toml
$ $EDITOR cfg.toml
$ cat cfg.toml

[hardware-check]
wifi_ssid = "Your Wifi name"
wifi_psk = "Your Wifi password"

✅ 构建、烧写并监视(monitor)这个项目:

$ cargo run

Serial port: /dev/SERIAL_DEVICE
Connecting...

Chip type:         ESP32-C3 (revision 3)
(...)
Compiling hardware-check v0.1.0
Finished release [optimized] target(s) in 1.78s

[00:00:45] ########################################     418/418     segment 0x10000

Flashing has completed!
(...)
rst:0x1 (POWERON),boot:0xc (SPI_FAST_FLASH_BOOT)
(...)
(...)
(...)
I (4427) wifi::wifi: Wifi connected!

🔎 如果成功运行了 cargo run,你可以通过 ctrl+C 退出。

🔎 cargo run配置为使用 espflash 作为自定义 runner。以下方法也会得到相同的输出:

  • 使用 cargo-espflashcargo espflash flash --release --monitor
  • espflash 构建项目并烧写:cargo build --release && espflash target/riscv32imc-esp-espidf/release/hardware-check 为方便起见,这个改动已经应用于本教程的所有项目。

板上的 LED 应在启动时变为黄色,然后根据是否成功建立 Wi-fi 连接,变为红色(错误),或交替闪烁绿色和蓝色(成功)。如果出现 Wi-fi 错误,诊断消息也会显示在下面,例如:

Error: could not connect to Wi-Fi network: ESP_ERR_TIMEOUT

⚠️ 如果你的网络名或密码不正确,也会得到 ESP_ERR_TIMEOUT。所以请仔细检查它们。

关于构建、烧写和监视的额外信息

如果想尝试在不烧写的情况下构建,可以运行:

cargo build

也可以使用以下命令监视设备而不重新烧写程序:

espflash monitor

Simulation

This project is available for simulation through two methods:

  • Wokwi project
  • Wokwi VS Code extension:
    1. Press F1, select Wokwi: Select Config File, and choose intro/hardware-check/wokwi.toml.
    2. Build your project.
    3. Press F1 again and select Wokwi: Start Simulator.

Troubleshooting

构建错误

error[E0463]: can't find crate for `core`
= note: the `riscv32imc-esp-espidf` target may not be installed

这说明你在尝试用 stable Rust 构建——你需要使用 nightly。 这个错误信息有一些误导性——这个目标无法安装。它需要使用 build-std 从源码构建,这是一个仅在 nightly 版本可用的特性。


error: cannot find macro `llvm_asm` in this scope

你使用的 nightly 版本不兼容——用 rust-toolchain.tomlcargo override 配置一个合适的。


CMake Error at .../Modules/CMakeDetermineSystem.cmake:129 (message):

你的 Espressif 工具链可能被损坏了。删除它,然后重新构建来触发新的下载:

rm -rf ~/.espressif

在 Windows 上,删除 %USERPROFILE%\.espressif 文件夹。


Serial port: /dev/tty.usbserial-110
Connecting...

Unable to connect, retrying with extra delay...
Unable to connect, retrying with default delay...
Unable to connect, retrying with extra delay...
Error: espflash::connection_failed

× Error while connecting to device
╰─▶ Failed to connect to the device
help: Ensure that the device is connected and the reset and boot pins are not being held down

无法通过 USB-C 线缆连接到开发板。典型的连接错误如上面所示。

解决方法:

  1. 按住板子上的 boot 按钮,启动烧写命令,开始烧写后松开按钮
  2. 使用集线器(hub)

来源

入门教程

入门教程包含基础的嵌入式开发教学。 在此教程的结尾,我们将能够与外界环境交互,包括与板上的传感器通讯。入门教程的内容包括:

  • 项目概览
  • cargo-generate 生成一个项目。
  • 编写一个 HTTP 客户端。
  • 编写一个 HTTP 服务器。
  • 编写一个 MQTT 客户端,它能够:
    • 发布传感器数据
    • 通过订阅的 topic 接收命令。

准备工作

请阅读准备工作章节,为本教程做好准备。

参考资料

如果你不熟悉嵌入式编程,请阅读我们的参考资料,我们在那里以简单易懂的方式解释了一些术语。

项目结构

esp-rs Crate

不像大多数其他嵌入式平台,Espressif 支持 Rust 标准库。其中最值得关注的是,你可以任意使用大小可变的集合,例如 VecHashMap,以及基于 Box 的通用堆存储。你还可以自由地创建新线程,并使用 ArcMutex 等同步原语在它们之间安全地共享数据。 尽管如此,内存在嵌入式系统上仍然是一种稀缺资源,因此需要注意不要耗尽它——尤其是,使用线程的代价可能会很高。

Espressif 的开源物联网开发框架 ESP-IDF 提供了 Wi-Fi、HTTP 客户端/服务器、MQTT、OTA 更新、日志记录等服务。esp-idf 主要是用 C 编写的,因此将它以规范的、分离的 crate 的形式提供给 Rust:

  • 一个 sys crate 提供了实际的 unsafe 绑定(esp-idf-sys
  • 一个高级的 crate 提供了安全易用的 Rust 抽象(esp-idf-svc

最后一部分是底层硬件访问,仍以分离的形式提供:

  • esp-idf-hal 实现了硬件无关的 embedded-hal traits,例如模数转换、数字 I/O 引脚、SPI 通信。正如它的名字所暗示的,它依赖于 ESP-IDF。
  • 如果需要直接操作寄存器,esp32c3 提供由 svd2rust 生成的外设访问 crate。

Rust on ESP Book 的 ecosystem 章节 提供了更多信息。

构建工具链

🔎 作为项目构建的一部分,esp-idf-sys 会下载基于 C 的 Espressif 工具链 ESP-IDF。下载位置是可配置的,为了节省硬盘空间和下载时间,本教程中的所有示例和练习都被设置为使用一个单一的全局工具链,安装在 ~/.espressif 中(对于 Windows,是%USERPROFILE%\.espressif)。 关于其他可选的配置,请参阅 esp-idf-sysREADME 中的 ESP_IDF_TOOLS_INSTALL_DIR 参数。

Package 布局

与使用 cargo new 创建的常规 Rust 项目相比,我们还需要一些额外的文件和参数。本教程中的示例和练习都已经配置好,要创建新项目,建议使用基于 cargo-generate 向导的方法。

🔎 本页的其余部分是可选知识,在你希望更改项目的某些方面时可以派上用场。

必须设置一些构建依赖项

[build-dependencies]
embuild = "=0.31.2"
anyhow = "=1.0.71"

额外的配置文件

  • build.rs - Cargo 构建脚本。这里设置构建所需的环境变量。
  • .cargo/config.toml - 设置目标架构、自定义 runner 来烧写和监视设备、控制构建细节。如果有需要的话,可以在此处覆盖 ESP_IDF_TOOLS_INSTALL_DIR
  • sdkconfig.defaults - 覆盖 ESP-IDF 的特定参数,例如堆栈大小、日志级别等。

创建新项目

现在让我们用 cargo-generate (一个通用的项目生成向导)来配置我们的第一个项目。

More information on generating projects can be found in the Writing Your Own Application chapter of The Rust on ESP Book.

本教程中的其他大多数练习都已经提供了项目框架,不需要使用 cargo-generate

✅ 安装 cargo-generate

cargo install cargo-generate

✅ 进入 intro 目录并运行 cargo generate,使用 esp-idf 模板

cd intro
cargo generate esp-rs/esp-idf-template cargo

cargo-generate 将提示有关新项目的详细信息。当在多个选项中进行选择时,可以使用光标向上/向下,并使用回车键确定。

你看到的第一条消息会是: ⚠️Unable to load config file: /home/$USER/.cargo/cargo-generate.toml。出现这个错误是因为没有偏好的配置文件。但这不是必须的,你可以忽略这个警告。

🔎 你可以创建一个 偏好的配置文件,放在 $CARGO_HOME/cargo-generate。可以使用 -c, --config <config-file> 覆盖它。

如果误操作了,按下 Ctrl+C 然后重新开始。

✅ 配置你的项目:

(这些选项可能以不同的顺序出现)

  • Project Name: hello-world
  • MCU: esp32c3
  • Configure advanced template options?: false

🔎 .cargo/config.toml 包含你的 package 的本地设置(全部设置列表)。 Cargo.toml 包含依赖项,Cargo.lock导入所有依赖项

可选,但是推荐:为了节省硬盘空间和下载时间,把工具链路径设置为全局(global)。否则每一个新项目/工作空间都会安装一个自己的工具链实例:

✅ 打开 hello-world/.cargo/config.toml 并添加下面几行到 [env] section 的底部。保持其他内容不变。

[env]
# ...
ESP_IDF_TOOLS_INSTALL_DIR = { value = "global" } # 添加这一行

✅ 打开 hello-world/rust-toolchain.toml 并将文件修改为如下所示:

[toolchain]
channel = "nightly-2024-06-30" # 修改这一行

✅ 在 hello-world 目录中用下面的命令来运行项目:

cd hello-world
cargo run

✅ 输出的最后几行应当如下所示:

(...)
I (268) cpu_start: Starting scheduler.
Hello, world!

额外的任务

  • 如果 main 函数退出了,你只能通过复位微控制器来再次启动它。如果在其末尾放置一个死循环会怎么样?下载一个死循环程序来验证你的猜想。
  • 你能想出一种办法来避免你看到的现象吗?(提示1

Troubleshooting

  • 如果 cargo run 卡在了 Connecting... 上,可能是因为有另一个监视进程在运行(例如,在刚刚的 hardware-check 中打开的)。尝试找到并终止它。如果还是不行,尝试重新连接板子的 USB 线缆。
  • ⛔ Git Error: authentication required:你的 git 可能配置为将 https Github URL 替换成 ssh。检查全局 ~/.git/config 中的 insteadOf 部分并禁用它们。
1

通过在循环中休眠而不是忙等待,将控制权交还给底层操作系统。(使用 std::thread::sleep

HTTP 和 HTTPS 客户端

在本练习中,我们将编写一个小型客户端,通过 HTTP 连接到互联网以获取数据。然后我们将其升级为 HTTPS 客户端。

HTTP 客户端

本练习的目标是编写一个能够连接网站的小型 HTTP 客户端。

配置

✅ 进入 intro/http-client 目录。

✅ 打开 intro/http-client 中已准备好的项目框架。

✅ 将你的网络凭据加到 cfg.toml 中,就像在硬件测试中做的那样。

✅ 用下面的命令打开此项目的文档:

cargo doc --open

intro/http-client/examples/http_client.rs 包含解答。你可以用下面的命令运行它:

cargo run --example http_client

建立连接

默认只能使用未加密的 HTTP,这限制了我们能连接到的主机。因此我们将使用 http://neverssl.com/

在 ESP-IDF 中,HTTP 客户端连接由 esp-idf-svc crate 中的 http::client::EspHttpClient 管理。它实现了 embedded-svc 中的 http::client::Client trait,定义了 HTTP 请求方法(如 GETPOST)使用的函数。现在正是查看你用 cargo doc --open 打开的文档的好时机,查看其中 esp_idf_svc::http::client::EspHttpConnectionembedded_svc::http::client::Client 相关的内容,以及可以使用的实例化方法。

✅ 用默认配置创建一个 EspHttpConnection。到文档里找一个合适的构造方法。

✅ 从刚刚创建的 connection 里获取一个 client。

在 client 上调用 HTTP 函数(例如 get(url))会返回一个 embedded_svc::http::client::Request。你需要提交(submit)它来表示 client 在发送请求附带的选项。

get 函数使用 as_ref()。这意味着该函数可以接受任何实现 AsRef<str> trait 的类型,即任何可以调用 .as_ref() 产生 &str 的类型,而不是仅限于某种特定类型,例如 String&str。这适用于 String&str,也适用于包含前两种类型的 Cow<str> 枚举类型。


#![allow(unused)]
fn main() {
    let request = client.request(Method::Get, url.as_ref(), &headers)?;
}

成功的响应具有 2xx 范围内的状态码。紧随其后的是网站的原始 html。

✅ 检验连接是否成功。

✅ 如果状态码不在 2xx 范围内,返回一个错误。


#![allow(unused)]
fn main() {
match status {
        200..=299 => {
        }
        _ => bail!("Unexpected response code: {}", status),
    }
}

状态码错误可以用 Anyhow crate 返回。Anyhow 常被用于简化应用程序中的错误处理,它提供了一个通用的 anyhow::Result<T>,将成功(Ok)情况包装在 T 中,而且无需指定 Err 类型,只要求你返回的每个错误都实现了 std::error::Error

✅ 使用 Read::read(&mut reader,&mut buf) 将接收到的数据逐块地读取到 u8 缓冲区中。Read::read 会返回读取的字节数——当这个值为 0 时就完成了读取。

✅ 报告读取的总字节数。

✅ 把接收到的数据记录到控制台上。 💡 响应数据以字节的形式存储在缓冲区内,所以你可能需要一个方法来把字节转换为 &str

额外的任务

✅ 在 match 分支里分别处理 3xx、4xx 和 5xx 状态码

✅ 编写一个自定义的 Error 枚举来表示这些错误。为这个错误实现 std::error::Error trait。

Simulation

This project is available for simulation through two methods:

  • Wokwi projects:
  • Wokwi files are also present in the project folder to simulate it with Wokwi VS Code extension:
    1. Press F1, select Wokwi: Select Config File and choose intro/http-client/wokwi.toml
      • Edit the wokwi.toml file to select between exercise and solution simulation
    2. Build you project
    3. Press F1 again and select Wokwi: Start Simulator

Troubleshooting

  • missing WiFi name/password:确保你已根据 cfg.toml.example 配置了 cfg.toml。一个常见的问题是包名和配置中的 section 名称不匹配。
# Cargo.toml
#...
[package]
name = "http-client"
#...

# cfg.toml
[http-client]
wifi_ssid = "..."
wifi_psk = "..."
  • Guru Meditation Error: Core 0 panic'ed (Load access fault). Exception was unhandled. 这可能是由你的代码中的 .unwrap() 引起的。试试用问号运算符替代它们。

HTTPS 客户端

现在让我们更改 HTTP 客户端源代码,使它也适用于加密连接。

intro/http-client/examples/http_client.rs 包含解答。你可以用下面的命令运行它:

cargo run --example https_client

创建一个自定义的客户端配置,由此使用启用了证书的esp_idf_svc::http::client::EspHttpConnection,而其他值保持默认。


#![allow(unused)]
fn main() {
    let connection = EspHttpConnection::new(&Configuration {
        use_global_ca_store: true,
        crt_bundle_attach: Some(esp_idf_svc::sys::esp_crt_bundle_attach),
        ..Default::default()
    })?;
}

✅ 用新的配置初始化 HTTP 客户端,通过下载一些 https 资源来验证 HTTPS 是否正常工作,例如 https://espressif.com/。下载的内容会在控制台中以原始 HTML 的形式显示出来。

Troubleshooting(与上一节相同)

  • missing WiFi name/password:确保你已根据 cfg.toml.example 配置了 cfg.toml。一个常见的问题是包名和配置中的 section 名称不匹配。
# Cargo.toml
#...
[package]
name = "http-client"
#...

# cfg.toml
[http-client]
wifi_ssid = "..."
wifi_psk = "..."

简单的 HTTP 服务器

现在让我们把开发板变成一个微型网络服务器,在收到 GET 请求后,它会提供来自内部温度传感器的数据。

配置

intro/http-server/ 中有已准备好的项目框架。它会建立 Wi-Fi 连接,但你需要将其配置为使用 cfg.toml 中的网络凭据。

intro/http-server/examples/https-server.rs 包含一个解答。你可以用下面的命令运行它:

cargo run --example http_server

处理请求

为了用浏览器访问开发板,你需要知道板子的 IP 地址。

✅ 运行 intro/http-server 中的框架代码。输出应该包含板子的 IP 地址,类似这样:

I (3862) esp_netif_handlers: sta ip: 192.168.178.54, mask: ...
...
Server awaiting connection

sta ip 指的是 Wi-Fi 术语站点(station),代表连接到接入点(access point)的接口。这就是你需要输入浏览器的地址(或其他 HTTP 客户端,如 curl)。

🔎 ESP-IDF 会尝试在本地网络中注册主机名 espressif,因此使用 http://espressif/ 代替 http://<sta ip>/ 通常也可以。

你可以通过设置 sdkconfig.defaults 中的 CONFIG_LWIP_LOCAL_HOSTNAME 来更改主机名,例如 CONFIG_LWIP_LOCAL_HOSTNAME="esp32c3"

向客户端发送 HTTP 数据包括:

  • 创建一个 EspHttpServer 实例
  • 在主函数中循环,这样它就不会终止——终止会导致服务器离开作用域然后关闭
  • 为需要提供内容的每个路径设置单独的请求处理(handler)函数。任何未配置的路径都会产生 404 错误。这些处理函数以 Rust 闭包的形式内联实现,如下所示:

#![allow(unused)]
fn main() {
server.fn_handler(path, Method::Get, |request| {
    // ...
    // 构造一个响应
    let mut response = request.into_ok_response()?;
    // 写入期望的数据
    response.write_all(&some_buf)?;
    // 如果完成了处理,处理函数期望一个 `Completion` 作为结果
    // 这是通过它实现的:
    Ok(())
});

}

✅ 使用默认的 esp_idf_svc::http::server::Configuration 创建一个 EspHttpServer 实例。默认配置将使它自动监听 80 端口。

✅ 验证与 http://<sta ip>/ 的连接是否会产生 404(not found)错误,表明 This URI does not exist

✅ 为根路径("/")编写请求处理函数。处理函数会在 http://<sta ip>/ 上发送问候消息,使用已提供的 index_html() 函数来生成 HTML 字符串。

动态数据

我们还可以向客户端发送动态信息。该框架包含一个已配置好的 temp_sensor,用于测量开发板的内部温度。

✅ 在 http://<sta ip>/temperature 上编写第二个请求处理函数,用于报告芯片的温度。使用已提供的 temperature(val: f32) 函数来生成 HTML 字符串。 💡 如果要发送响应字符串,需要通过 a_string.as_bytes() 将其转换为 &[u8] slice。 💡 温度传感器需要独占(可变)访问。将它作为有所有权的值传递给请求处理函数是行不通的(因为它会在第一次调用后被丢弃)——你可以通过把处理函数变成 move || 闭包来解决这个问题。将传感器包裹在 Arc<Mutex<_>> 中,将此 Arc 的一个 clone() 保留在主函数中,并将另一个移动到闭包中。

Troubleshooting

  • httpd_txrx: httpd_resp_send_err 可以通过重启解决。如果不起作用,可以使用 cargo clean
  • 确保你的电脑和开发板使用的是相同的 Wi-Fi 网络。

基于 MQTT 的 IoT

在本节练习中,我们将学习 MQTT 的工作原理,然后编写一个能够通过 MQTT 发送和接收数据的应用。

MQTT 是如何工作的

⚠️ 本节练习需要一个 MQTT 服务器。如果你参加了 Ferrous Systems 的培训,培训中将会提供一个登录凭证,用于访问 Espressif 运营的服务器。否则,你可以使用 https://test.mosquitto.org/ 中列出的 MQTT 服务器,或者在本地安装一个。

作为入门教程的收尾,让我们向开发板添加一些 IoT 功能。 我们的目标是让板子发送实时更新的传感器值,而无需像使用 HTTP 服务器时那样反复查询。此外,还可以让板子接收命令,更改 LED 的颜色。

这些内容可以使用发布-订阅架构进行建模。多个客户端在特定的频道/主题上发布消息,同时可以订阅这些主题,来接收其他设备发布的消息。这些消息的分发由消息代理(broker)协调——在本例中,就是 MQTT 服务器。

MQTT 消息

MQTT 消息由两部分组成——主题(topic)和 payload。

主题的作用与电子邮件中的主题,或文件柜上的标签相同。而 payload 包含实际的数据。payload 数据的格式没有规定,最常见的是 JSON。

🔎 最新版本的 MQTT 标准(MQTT 5)支持内容的类型元数据。 发送 MQTT 消息时,需要指定一个服务质量(QoS),表示这个消息会被传输:

  • 最多一次。
  • 至少一次。
  • 恰好一次。

对于本练习,选择哪种服务质量并不重要。

MQTT 主题

MQTT 主题是表示层次结构的 UTF-8 字符串,各个层次由斜杠 / 分隔。支持前导斜杠,但不推荐。这里是一些例子:

home/garage/temperature
beacons/bicycle/position
home/alarm/enable
home/front door/lock

在这里,一个传感器会定期发布车库温度(home/garage/temperature),并广播给每个订阅者。自行车信标发布 GPS 坐标也是一样(beacons/bicycle/position)。alarmlock 主题用于向特定设备发送命令。不过,其他订阅者也可以监听这些命令,这对于审计可能会很有用。

🔎 以 $ 开头的主题是保留的,用于消息代理内部的统计功能。通常,这种主题将以 $SYS 开头。客户端不能向这些主题发布消息。

⚠️ 由于所有本教程的参与者会共享同一个 MQTT 服务器,因此需要采取一些措施来防止串扰。本练习的框架会为每一个签出的仓库,生成一个唯一且随机的 ID(采用 UUID v4 格式)。你也可以在线手动生成一个。在电脑和开发板之间传输消息时,你的 UUID 应该用作主题的前导部分。大致上类似于这种模式:

6188eec9-6d3a-4eac-996f-ac4ab13f312d/sensor_data/temperature
6188eec9-6d3a-4eac-996f-ac4ab13f312d/command/board_led

订阅主题

客户端发送订阅消息以表示他们希望接收某些主题下的消息。通配符的支持是可选的。通配符可以用于匹配单个或多个层次。

  • home/garage/temperature - 只订阅这个特定的主题
  • home/# - 井号是多级通配符,因此它订阅了以 home/ 开头的所有主题。home/garage/temperaturehome/front door/lockhome/alarm/enable 都会匹配上,但 beacons/bicycle/position 不会。多级通配符必须放在订阅字符串的末尾。
  • home/+/temperature - 加号是单级通配符,这里订阅了 home/garage/temperature, home/cellar/temperature 等。

MQTT 练习:发送消息

配置

✅ 进入 intro/mqtt/exercise 目录。

✅ 打开 intro/mqtt/exercise 中准备好的项目框架。

intro/mqtt/host_client 中有一个在主机上运行的程序,它可以模拟第二个客户端。用 cargo run 在单独的终端中运行它。下面是有关主机客户端的更多信息。

这个客户端也会生成随机的 RGB 颜色,并把它们发布到一个主题下。 这只与练习的第二部分相关

⚠️ 与 HTTP 练习类似,你需要在 cfg.toml 中为两个程序配置连接凭证。除了 Wi-Fi 凭证,还需要添加 MQTT 服务器的信息。查看 cfg.toml.example 来了解需要的设置。请记住 cfg.toml 文件中括号里的名称就是 Cargo.toml 中的包名。

练习的结构如下图所示。在这一部分中,我们将重点关注温度主题。

example_client_broker_board

intro/mqtt/exercise/solution/solution_publ.rs 包含解答。你可以用下面的命令运行它:

cargo run --example solution_publ

任务

✅ 用默认配置和空的处理程序(handler)闭包创建一个 EspMqttClient

✅ 在 hello_topic 主题下,给消息代理发送一个空的消息。 使用 hello_topic(uuid) 函数生成适当范围的主题。

✅ 将一个客户端连接上消息代理,让它输出收到的消息,以此来验证是否发布成功。host_client 已经实现了这个操作。在 ESP Rust 开发板上运行程序之前,在另一个终端里启动 host_clienthost_client 应当输出类似这样的信息:

Setting new color: rgb(1,196,156)
Setting new color: rgb(182,190,128)
Board says hi!

✅ 在主函数末尾的循环里,在 temperature_data_topic(uuid) 主题下每秒发布板子的温度。用 host_client 来进行验证:

Setting new color: rgb(218,157,124)
Board temperature: 33.29°C
Setting new color: rgb(45,88,22)
Board temperature: 33.32°C

建立连接

连接由 esp_idf_svc::mqtt::client::EspMqttClient 实例管理。 使用以下内容构造它:

  • 消息代理的 URL,如果需要的话,还包含连接凭据
  • esp_idf_svc::mqtt::client::MqttClientConfiguration 类型的配置信息
  • 与 HTTP 服务器练习类似的处理程序闭包

#![allow(unused)]

fn main() {
let mut client = EspMqttClient::new(broker_url,
    &mqtt_config,
    move |message_event| {
        // ... 你的处理程序代码,暂时留空
        // 我们会在本章的后面添加功能
    })?;

}

相关工具 & crates

为了记录板子发送的传感器值,intro/mqtt/host_client 下提供了一个辅助客户端,它会订阅温度主题。

mqtt_messages crate(在 common/lib)支持处理消息、订阅和主题:

用于生成主题字符串的函数

  • color_topic(uuid) - 创建一个用于给板子发送颜色的主题。
  • hello_topic(uuid) - 用于初步验证连接成功的主题
  • temperature_data_topic(uuid) - 创建完整的温度主题字符串

编码和解码消息 payload

板子的温度 f32temp.to_be_bytes() 转换成“大端序”的 4 个字节。


#![allow(unused)]
fn main() {
// 温度
let temperature_data = &temp.to_be_bytes() as &[u8]; // 板子上
let decoded_temperature = f32::from_be_bytes(temperature_data); // 电脑上
}

发布 & 订阅

EspMqttClient 也负责在指定主题下发布消息。 发布函数 publish 包含一个 retain 参数,指示此消息是否需要发送给在发布之后才连接上的客户端。


#![allow(unused)]
fn main() {
let publish_topic = /* ... */;
let payload: &[u8] = /* ... */ ;
client.publish(publish_topic, QoS::AtLeastOnce, false, payload)?;
}

Troubleshooting

  • 构建示例客户端时出现 error: expected expression, found .:将你的 stable Rust 更新到 1.58 或更新的版本
  • 没有显示 MQTT 消息?确保所有客户端(板子和电脑)使用的是相同的 UUID(你可以在日志输出中看见它)
  • 确保 cfg.toml 文件被正确配置。example-client 在程序的开始处有一个 dbg!() 输出,显示 mqtt 配置。它应当会输出你的 cfg.toml 文件内容。
  • 运行主机客户端时出现 error: expected expression, found .:用 rustup update 就可以解决

MQTT 练习:接收 LED 命令

✅ 订阅 color_topic(uuid) 主题

✅ 在单独的终端里运行 host_clienthost_client 大约每秒会发布一个开发板 LED 的颜色 color

✅ 通过记录从这个主题收到的信息,来验证订阅是否有效。

✅ 对 LED 命令作出响应:用 led.set_pixel(/* 收到的颜色 */) 函数把新收到的颜色设置到板子上。

intro/mqtt/exercise/solution/solution_publ_rcv.rs 包含解答。你可以用下面的命令运行它:

cargo run --example solution_publ_rcv

编码和解码消息 payload

开发板 LED 命令包含三个字节,分别表示红、绿、蓝。

  • enum ColorData 包含一个主题 color_topic(uuid)BoardLed
  • 可以使用 try_from() 来转换 EspMqttMessagedata() 字段。首先需要用 let message_data: &[u8] = &message.data(); 将消息强制转换为 slice

#![allow(unused)]
fn main() {
// RGB LED 命令

if let Ok(ColorData::BoardLed(color)) = ColorData::try_from(message_data) { /* 在这里设置新的颜色 */ }
}

发布 & 订阅

EspMqttClient 不止负责发布消息,也用于订阅主题。


#![allow(unused)]
fn main() {
let subscribe_topic = /* ... */;
client.subscribe(subscribe_topic, QoS::AtLeastOnce)
}

处理收到的消息

处理函数闭包里的 message_event 参数的类型是 EspMqttEvent,它有一个 payload() 方法,用于访问 EventPayload。 由于我们只对接收成功的消息感兴趣:


#![allow(unused)]
fn main() {
    let mut client =
        EspMqttClient::new_cb(
            &broker_url,
            &mqtt_config,
            move |message_event| match message_event.payload() {
                Received { data, details, .. } => process_message(data, details, &mut led),
                Error(e) => warn!("Received error from MQTT: {:?}", e),
                _ => info!("Received from MQTT: {:?}", message_event.payload()),
            },
        )?;
}

在处理函数中,我们将会处理 Complete 消息。

💡 使用 Rust Analyzer 来生成缺失的 match 分支,或者匹配所有其他类型,输出一个 info!()


#![allow(unused)]
fn main() {
fn process_message(data: &[u8], details: Details, led: &mut WS2812RMT) {
    match details {
        Complete => {
            info!("{:?}", data);
            let message_data: &[u8] = data;
            if let Ok(ColorData::BoardLed(color)) = ColorData::try_from(message_data) {
                info!("{}", color);
                if let Err(e) = led.set_pixel(color) {
                    error!("Could not set board LED: {:?}", e)
                };
            }
        }
        _ => {}
    }
}
}

💡 用 logger 来查看接收到的东西,例如:info!("{}", color);dbg!(color)

额外的任务

实现具有分层主题的 MQTT

✅ 如果你已经完成了所有其他工作,可以考虑实现这个任务。我们不提供完整的解答,因为这是用于测试你自己能走多远。

检查 common/lib/mqtt-messages

✅ 使用分层主题的 MQTT 实现相同的功能。订阅所有的“命令”消息,在 cmd_topic_fragment(uuid) 后面加一个 # 通配符。

✅ 用 enum Command 代替 enum ColorDataenum Command 表示所有可能的命令(这里仅有 BoardLed)。

RawCommandData 存储了消息主题的最后一部分(例如 a-uuid/command/board_led 中的 board_led)。可以用 try_from 将其转换为 Command


#![allow(unused)]
fn main() {
// RGB LED 命令
let raw = RawCommandData {
    path: command,
    data: message.data(),
};
}

检查 host-client:

✅ 你需要将 color 替换成 command。例如:


#![allow(unused)]
fn main() {
let command = Command::BoardLed(color)
}

其他任务

✅ 利用 serde_json 将消息数据编码/解码为 JSON。

✅ 从主机客户端上发送一些带有大量 payload 的消息,并在微控制器上处理它们。大体积的消息将会分部分传递,而不是使用 Details::Complete


#![allow(unused)]
fn main() {
InitialChunk(chunk_info) => { /* 第一块 */},
SubsequentChunk(chunk_data) => { /* 所有后续块 */ }
}

💡 不需要根据消息 ID 来区分收到的块,因为在任意时刻,最多只有一条消息正在传输。

Troubleshooting

  • 构建示例客户端时出现 error: expected expression, found .:将你的 stable Rust 更新到 1.58 或更新的版本
  • 没有显示 MQTT 消息?确保所有客户端(板子和电脑)使用的是相同的 UUID(你可以在日志输出中看见它)

进阶教程

在进阶教程中,我们将深入探讨嵌入式和/或贴近硬件的主题,尤其是关注较底层的 I/O。与入门部分不同,我们不会只使用较高级别的抽象,它们隐藏了引脚配置之类的东西。相反,我们将学习如何自己配置它们。我们还将学习如何直接写入寄存器,以及,如何先找出要用哪些寄存器。我们将在练习中讨论所有权问题和内存安全问题。

这部分包含三个练习:

在第一个练习中,你将学习如何处理按键中断。在第二个练习中,你将通过 I²C 总线从传感器读取数据。在使用了我们准备的驱动程序之后,你将学习如何编写你自己的驱动程序。这是一项必要的技能,因为制造商通常不提供 Rust 驱动程序。

准备工作

请阅读准备工作章节,为本教程做好准备。

参考资料

如果你不熟悉嵌入式编程,请阅读我们的参考资料,我们在那里以简单易懂的方式解释了一些术语。

底层 I/O:如何操作寄存器

两种方法为 ESP32-C3 编写固件

  • 一种是裸机编程,仅使用 [no_std] Rust。
  • 另一种是使用 [std] Rust 以及 ESP-IDF 的 C 绑定。

[no_std] Rust 是指不使用标准库的 Rust——仅使用核心库,它是标准库的子集,不依赖于操作系统。

生态系统是什么样的?

[std] Rust 和 ESP-IDF

这种方式依赖于 ESP-IDF 的 C 绑定。通过这种方式,我们可以使用 Rust 的标准库,因为我们可以使用操作系统:ESP-IDF 基于 FreeRTOS。能够使用标准库带来了很多好处:我们可以使用所有类型,无论它们是在栈上分配的还是在堆上分配的。我们可以使用线程、互斥量和其他同步原语。

ESP-IDF 主要是用 C 编写的,因此将它以规范的、分离的 crate 的形式提供给 Rust:

  • 一个 sys crate 提供了实际的 unsafe 绑定(esp-idf-sys
  • 一个高级的 crate 提供了安全易用的 Rust 抽象(esp-idf-svc

最后一部分是底层硬件访问,仍以分离的形式提供:

  • esp-idf-hal 实现了硬件无关的 embedded-hal traits,例如模数转换、数字 I/O 引脚、SPI 通信。正如它的名字所暗示的,它依赖于 ESP-IDF。

The Rust on ESP Bookecosystem 章节 提供了更多信息。

如果你想使用 Rust,这就是目前在 Espressif 芯片上提供了最大可能性的开发方式。本教程中的所有内容都基于这种方法。

我们将在中断练习中研究,在此生态系统中如何直接将值写入寄存器。

[no_std] 的 Rust 裸机编程

顾名思义,裸机就是不使用操作系统。正因为如此,我们无法使用依赖于操作系统的语言特性。核心库是标准库的一个子集,它不包括堆分配类型和线程等功能。仅使用核心库的代码标有 #[no_std]#[no_std] 代码总能在 std 环境下运行,反之则不然。 在 Rust 中,从寄存器到代码的映射是这样工作的:

设备上的寄存器及其字段由系统视图描述(System View Description,SVD)文件提供。svd2rust 用于从这些 SVD 文件生成外设访问 crate(Peripheral Access Crate,PAC)。PAC 为特定型号微控制器中的各个内存映射寄存器提供了一个很薄的封装。

虽然可以单独使用 PAC 编写固件,但这可能不安全或不太方便,因为它只提供了对微控制器外设的最基本的访问。所以还有另一层封装,即硬件抽象层(Hardware Abstraction Layer,HAL)。HAL 为芯片提供了更加用户友好的 API,并且通常实现了 embedded-hal 中定义的通用 trait。

微控制器通常焊接到一些 PCB 板上,这决定了每个引脚的连接情况。因此可以为给定的电路板编写板级支持 crate(Board Support Crate,BSC,也称为板级支持包或 BSP)。这提供了另一个抽象层,例如,可以为板上的各种传感器和 LED 提供 API——用户无需知道微控制器上的哪些引脚连接到这些传感器或 LED。

我们将用这种方法编写部分传感器的驱动程序,因为驱动程序应该与平台无关。

I²C

简介

集成电路总线(Inter-Integrated Circuit)是一种串行协议(通常缩写为 I²C 或 I2C),它允许多个外围芯片(slave)与一个或多个控制器芯片(master)进行通信。多个设备可以连接到同一条 I²C 总线,并且可以通过指定其 I²C 地址将消息发送到特定设备。该协议需要两根信号线,只能用于设备内的短距离通信。

其中一根信号线用于数据(SDA),另一根用于时钟信号(SCL)。默认情况下,线路被总线上某处的电阻拉高。总线上的任何设备(甚至同时有多个设备)可以“拉低”一条或两条信号线。这意味着如果两个设备同时尝试在总线上通信,电路并不会发生损坏——只有发送的消息会损坏(并且可以检测到)。

I²C 事务由一条或多条消息组成。每条消息都包含一个起始信号、一些,最后是一个结束信号(如果有后续消息,则为另一个起始信号)。每个字都是八位,后面跟着一个 ACK(0)或 NACK(1)位,由接收方发送,以指示是否正确接收和理解该字。第一个字指示此消息的目标设备的 7 位地址,以及表示要从设备读取还是写入的位。如果总线上没有具有此地址的设备,第一个字后面自然会得到一个 NACK(因为没有设备将 SDA 线驱动为低电平以生成 ACK 位),于是你就可以知道此设备不存在。

SCL 上的时钟频率通常为 400 kHz,但也支持更慢和更快的速度(标准速度为 100 kHz-400 kHz-1 MHz)。在我们的练习中,将配置为 400 kHz(<MasterConfig as Default>::default().baudrate(400.kHz().into()))。

要从 EEPROM 设备读取三个字节,通信序列将类似于:

步骤控制器发送外设发送
1.起始信号
2.设备地址 + 写
3.ACK
4.高位 EEPROM 地址字节
5.ACK
6.低位 EEPROM 地址字节
7.ACK
8.起始信号
9.设备地址 + 读
10.ACK
11.EEPROM 地址上的数据字节
12.ACK
13.EEPROM 地址 +1 上的数据字节
14.ACK
15.EEPROM 地址 +2 上的数据字节
16.NAK(即结束读取)
17.结束信号

I²C 信号图

I²C 总线上的数据传输时序图:

  • S - 起始条件
  • P - 结束条件
  • B1 到 BN - 传输一位数据
  • 当 SCL 为低电平(蓝色)时允许 SDA 电平变化,否则将生成起始或结束条件。

来源和更多细节:Wikipedia

I²C 传感器读取练习

在本练习中,我们将学习如何读取 I²C 总线上的传感器。

Rust ESP 开发板上有两个可以通过 I²C 总线读取的传感器

外设型号参考资料Crate地址
IMUICM-42670-PDatasheetLink0x68
温湿度SHTC3DatasheetLink0x70

任务是使用 crates.io 的现有驱动程序通过 I²C 读取温湿度传感器。之后,使用 shared-bus 通过同一 I²C 总线读取第二个传感器。

第一部分:读取温湿度

创建温湿度传感器 SHTC3 的实例,每 600 毫秒读取并打印湿度和温度值。

i2c-sensor-reading/examples/part_1.rs 包含第一部分的解答。要运行第一部分的解答:

cargo run --example part_1

i2c-sensor-reading/src/main.rs 包含代码框架,其中已经包含了第一部分所需的导入语句。

步骤:

✅ 进入 i2c-sensor-reading/ 目录,使用以下命令打开相关文档:

cargo doc --open

✅ 定义两个引脚,一个作为 SDA,一个作为 SCL。

信号GPIO
SDAGPIO10
SCLGPIO8

✅ 借助刚刚生成的文档,创建一个 I²C 外设的实例。频率使用 400 kHz。

✅ 使用驱动 crate shtcx,创建一个 SHTC3 传感器实例,将 I²C 实例传递给它们。查看文档以获取指导。

✅ 要检查传感器是否被正确寻址,可以读取它的设备 ID 并打印该值。

期望的输出:

Device ID SHTC3: 71

✅ 进行测量,读取传感器值并打印出来。查看文档以获取有关传感器的方法的指导。

期望的输出:

TEMP: [当地温度] °C
HUM: [当地湿度] %

❗ 一些传感器在测量和读取结果之间需要一点时间。 ❗ 注意数值单位!

💡 有一些方法可以将传感器值转换为所需的单位。

第二部分:读取加速度计数据

使用总线管理器,驱动第二个传感器。读出它的值并打印两个传感器的值。

从第一部分你自己的解答开始。或者也可以从第一部分提供的部分解答开始:i2c-sensor-reading/examples/part_1.rs

i2c-sensor-reading/examples/part_2.rs 包含第二部分的解答。如果你需要帮助,可以参考它。要运行它,使用:

cargo run --example part_2

步骤

✅ 导入 ICM42670p 的驱动 crate。


#![allow(unused)]
fn main() {
use icm42670::{Address, Icm42670, PowerMode as imuPowerMode};
}

✅ 创建传感器的实例。

✅ 为什么将同一个 I²C 实例传递给两个传感器不管用,尽管它们都在同一个 I²C 总线上?

解答

这是一个所有权问题。内存中的每个位置都需要归某物所有。如果我们将 I²C 总线传递给 SHTC3,则该传感器拥有 I²C 总线。且它不能再由另一个传感器拥有,借用也是不可能的,因为 I²C 总线需要可变,两个传感器都需要能够改变它。我们通过引入总线管理器来解决这个问题,该管理器创建多个 I²C 总线的代理。这些代理可以由相应的传感器拥有。

✅ 导入总线管理器 crate。


#![allow(unused)]
fn main() {
use shared_bus::BusManagerSimple;
}

✅ 创建一个简单的总线管理器的实例。创建两个代理,并用它们代替原来的 I²C 实例传递给传感器。

✅ 从两个传感器读取并打印设备 ID。

期望的输出:

Device ID SHTC3: 71
Device ID ICM42670p: 96

✅ 在低噪声模式下启动 ICM42670p。

✅ 读取陀螺仪传感器值,并将它们与温度和湿度值一起打印,精确到小数点后两位。

期望的输出:

GYRO: X: 0.00 Y: 0.00 Z: 0:00
TEMP: [当地温度] °C
HUM: [当地湿度] %

Simulation

This project is available for simulation through two methods:

  • Wokwi projects
  • Wokwi files are also present in the project folder to simulate it with Wokwi VS Code extension:
    1. Press F1, select Wokwi: Select Config File and choose advanced/i2c-sensor-reading/wokwi.toml
      • Edit the wokwi.toml file to select between exercise and solutions simulation
    2. Build you project
    3. Press F1 again and select Wokwi: Start Simulator

When simulating this project, expect the following hardcoded values: TEMP: 24.61 °C | HUM: 36.65 % | GYRO: X= 0.00 Y= 0.00 Z= 0.00

I²C 驱动练习 - 简单版

我们将不会编写整个驱动程序,只会做第一步:驱动程序编写的 hello world,即读取传感器的设备 ID。这个版本被标记为简单,因为我们解释了代码片段,你只需将它们复制粘贴到正确的位置即可。如果你缺少 Rust 或嵌入式领域的经验,或者如果你觉得困难版本太难,请使用此版本。两个版本使用的是相同的文件。

i2c-driver/src/icm42670p.rs 是一个非常基础的 I²C IMU 传感器驱动的填空版本。任务是补全这个文件,使得运行 main.rs 可以记录驱动的设备 ID。

i2c-driver/src/icm42670p_solution.rs 提供本练习的解答。如果要运行它,需要更改 main.rslib.rs 中的导入语句。导入语句已经存在,你只需要注释掉当前的导入语句,并取消注释标记为解答的几行。

驱动

传感器实例

要使用外设传感器,首先要获取它的一个实例。传感器被表示成一个结构体,包含其地址和 I²C 总线对象。这是使用 embedded-hal crate 中定义的 trait 来实现的。该结构体是公有的,因为我们需要从这个 crate 外访问它,但它的字段是私有的。


#![allow(unused)]
fn main() {
#[derive(Debug)]
pub struct ICM42670P<I2C> {
    // The concrete I²C device implementation.
    i2c: I2C,

    // Device address
    address: DeviceAddr,
}
}

我们添加一个 impl 块,包含可以在传感器实例上使用的所有方法。它还定义了错误处理。在这个块中,我们还实现了一个实例化方法。(与结构体类似)方法也可以是公有的或私有的。这个方法需要从外部访问,所以它被标记为 pub。请注意,以这种方式编写的传感器实例会获取 I²C 总线的所有权。


#![allow(unused)]
fn main() {
impl<I2C, E> ICM42670P<I2C>
where
    I2C: i2c::WriteRead<Error = E> + i2c::Write<Error = E>,
{
    /// Creates a new instance of the sensor, taking ownership of the i2c peripheral.
    pub fn new(i2c: I2C, address: DeviceAddr) -> Result<Self, E> {
        Ok(Self { i2c, address })
    }
// ...
}

设备地址

  • 设备的地址在代码中可用:

#![allow(unused)]
fn main() {
pub enum DeviceAddr {
    /// 0x68
    AD0 = 0b110_1000,
    /// 0x69
    AD1 = 0b110_1001,
}
}
  • 这个 I²C 设备有两个可能的地址——0x680x69。 我们通过向设备上的 AP_AD0 引脚施加 0V3.3V 来告诉设备我们希望它使用哪一个地址。如果我们施加 0V,它会监听地址 0x68。如果我们施加 3.3V,它会监听地址 0x69。因此,可以将引脚 AD_AD0 视为一位输入,用于设置设备地址的最低位。 数据手册的 9.3 节提供了更多信息

寄存器的表示

传感器的寄存器表示为枚举。每个变体都将寄存器的地址作为值。Register 类型实现了一种提供变体地址的方法。


#![allow(unused)]
fn main() {
#[derive(Clone, Copy)]
pub enum Register {
    WhoAmI = 0x75,
}

impl Register {
    fn address(&self) -> u8 {
        *self as u8
    }
}

}

read_register()write_register()

基于 embedded-hal crate 提供的方法,我们定义了 读取写入 的方法。它们将作为更具体的方法的基础,并作为一个抽象层,用于适配具有 8 位寄存器的传感器。请注意 read_register() 方法是基于 write_read() 方法实现的。其原因在于 I²C 协议的特点:我们首先需要在 I²C 总线上写一个命令来指定我们要读取哪个寄存器。这些辅助方法可以保持私有,因为我们不需要从这个 crate 外访问它们。


#![allow(unused)]
fn main() {
impl<I2C, E> ICM42670P<I2C>
where
    I2C: i2c::WriteRead<Error = E> + i2c::Write<Error = E>,
{
    /// Creates a new instance of the sensor, taking ownership of the i2c peripheral.
    pub fn new(i2c: I2C, address: DeviceAddr) -> Result<Self, E> {
        Ok(Self { i2c, address })
    }
    // ...
    /// Writes into a register
    // This method is not public as it is only needed inside this file.
    #[allow(unused)]
    fn write_register(&mut self, register: Register, value: u8) -> Result<(), E> {
        let byte = value;
        self.i2c
            .write(self.address as u8, &[register.address(), byte])
    }

    /// Reads a register using a `write_read` method.
    // This method is not public as it is only needed inside this file.
    fn read_register(&mut self, register: Register) -> Result<u8, E> {
        let mut data = [0];
        self.i2c
            .write_read(self.address as u8, &[register.address()], &mut data)?;
        Ok(u8::from_le_bytes(data))
    }
}

✅ 实现一个公有方法来读取地址为 0x75WhoAmI 寄存器。使用上面的 read_register() 方法。

✅ 可选:实现更多方法来向驱动程序添加功能。在文档中查阅相应寄存器及其地址。💡 一些点子:

  • 启用陀螺仪传感器或加速度计
  • 启动测量
  • 读取测得数据

🔎 有关外设寄存器的一般信息

寄存器可以有不同的含义,本质上,它们是一个可以存储值的位置

在这个特定的上下文中,我们使用的是一个外部设备(因为它是一个传感器,即使与主控芯片在同一块 PCB 上)。它可通过 I2C 寻址,我们在读取和写入其寄存器的地址。每个地址都标识了唯一的一个位置,其中包含了一些信息。在这种情况下,我们想要的是包含当前温度的位置的地址。

如果你想尝试从这个传感器获取其他有趣的数据,可以在第 14 节中找到 ICM-42670 的寄存器表。

Simulation

This project is available for simulation through two methods:

  • Wokwi projects
  • Wokwi files are also present in the project folder to simulate it with Wokwi VS Code extension:
    1. Press F1, select Wokwi: Select Config File and choose advanced/i2c-driver/wokwi.toml
    2. Build you project
    3. Press F1 again and select Wokwi: Start Simulator

I²C 驱动练习 - 困难版

我们将不会编写整个驱动程序,只会做第一步:驱动程序编写的 hello world,即读取传感器的设备 ID。这个版本被标记为困难,因为你需要自己编写方法的内容,并在 embedded-hal 和数据手册里自己查找信息。两个版本使用的是相同的文件。

i2c-driver/src/icm42670p.rs 是一个非常基础的 I²C IMU 传感器驱动的填空版本。任务是补全这个文件,使得运行 main.rs 可以记录驱动的设备 ID。

i2c-driver/src/icm42670p_solution.rs 提供本练习的解答。如果要运行它,需要更改 main.rslib.rs 中的导入语句。导入语句已经存在,你只需要注释掉当前的导入语句,并取消注释标记为解答的几行。

驱动 API

传感器实例

✅ 创建一个结构体来表示传感器。它有两个字段,一个表示传感器的设备地址,另一个表示 I²C 总线。这是使用 embedded-hal crate 中定义的 trait 来实现的。该结构体是公有的,因为我们需要从这个 crate 外访问它,但它的字段是私有的。

✅ 在 impl 块里实现一个实例化方法。这个方法需要从外部访问,所以它被标记为 pub。这个方法获取 I²C 总线的所有权,然后创建前面定义的结构体的实例。

设备地址

✅ 这个 I²C 设备有两个可能的地址,在数据手册的 9.3 节里找到它们。

🔎 我们通过向设备上的 AP_AD0 引脚施加 0V3.3V 来告诉设备我们希望它使用哪一个地址。如果我们施加 0V,它会监听地址 0x68。如果我们施加 3.3V,它会监听地址 0x69。因此,可以将引脚 AD_AD0 视为一位输入,用于设置设备地址的最低位。

✅ 创建一个枚举来表示两种地址。变体的值需要用二进制表示。

寄存器的表示

✅ 创建一个枚举来表示传感器的寄存器。每个变体都将寄存器的地址作为它的值。目前,我们只需要 WhoAmI 寄存器。到数据手册里查找它的地址。

✅ 实现一个方法,用于将变体的地址以 u8 的形式提供出来。

read_register()write_register()

✅ 查看 embedded-hal 中的 writewrite_read 函数。为什么是 write_read 而不是简单的 read

解答 原因在于 I²C 协议的特性。我们需要先在 I²C 总线上写一个命令,来指定我们想要读取哪个寄存器。

✅ 给传感器实例定义 read_registerwrite_register 方法。使用 embedded-hal crate 提供的方法。它们将作为更具体的方法的基础,并作为一个抽象层,用于适配具有 8 位寄存器的传感器。这意味着,读取和写入的数据都是无符号8位的整数。这些辅助方法可以保持私有,因为我们不需要从这个 crate 外访问它们。

✅ 实现一个公有方法来读取地址为 0x75WhoAmI 寄存器。使用上面的 read_register() 方法。

✅ 可选:实现更多方法来向驱动程序添加功能。在文档中查阅相应寄存器及其地址。💡 一些点子:

  • 启用陀螺仪传感器或加速度计
  • 启动测量
  • 读取测得数据

🔎 有关外设寄存器的一般信息

  • 寄存器事实上就是少量的存储空间,可由处理器直接访问。这个传感器上的寄存器是 8 位的。
  • 可以通过地址访问这些寄存器
  • 在数据手册的第 14 节,有寄存器表
  • 为了得到一个由 MSB(最高有效位)和 LSB(最低有效位)组合而成的 16 位数,可以将 MSB 值移位,然后或上 LSB 值。

#![allow(unused)]
fn main() {
let GYRO_DATA_X: i16 = ((GYRO_DATA_X1 as i16) << 8) | GYRO_DATA_X0 as i16;
}

中断

中断就是请求处理器中断当前执行的代码,以便及时处理某些事件。如果中断请求被接受,处理器就会暂停当前的活动,保存其状态,然后执行一个称作中断处理程序(interrupt handler)的函数,来处理某事件。中断常被硬件设备用于指示需要及时关注的电气或物理状态,例如,按钮被按下。

中断处理程序可能随时被调用,这为嵌入式 Rust 带来了一些挑战:需要有静态分配的、可变的内存,中断处理程序和主程序都可以引用它,而且这段内存必须随时是可用的。

unsafe {} 块:

此代码包含许多的 unsafe {}。一般来说,unsafe 并不意味着所包含的代码不是内存安全的,而是意味着 Rust 无法在这个地方做出安全保证,并且程序员有责任确保内存安全。例如,调用 C 绑定本身就是不安全的,因为 Rust 无法为底层的 C 代码提供任何安全保证。

编写中断处理程序

本练习的目标是处理按下 BOOT 按钮时触发的中断。

advanced/button-interrupt/src/main.rs 中包含本练习的代码框架。

advanced/button-interrupt/examples/solution.rs 包含本练习的解答。可以用以下命令运行:

cargo run --example solution

✅ 任务

  1. PinDriver 结构体和以下设置来配置 BOOT 按钮(GPIO9):
    • 输入模式
    • 上拉
    • 上升沿触发中断
  2. 实例化一个新的通知(notification)和 notifier
    • 查看 hal::task::notification 文档
  3. unsafe 块中,创建一个订阅(subscription)及其回调函数
    • 查看 PinDriver::subscribetask::notify_and_yield
    • unsafe 的原因是:
      • 回调函数会运行在 ISR(中断服务函数)中,所以我们需要避免调用任何可能阻塞的函数,包括 STD, libc 或 FreeRTOS API(少数允许的除外)。
      • 回调闭包会从环境中捕获东西,你可以使用其中的静态变量。捕获的变量需要具有比订阅(subscription)更长的生命周期。你也可以使用非静态变量,但这需要额外小心,更多细节请参阅 esp_idf_hal::gpio::PinDriver::subscribe_nonstatic 文档。
  4. 在循环中,使能中断,并等待通知(notification)
    • 应在每次收到通知后,使能中断(在非 ISR 上下文中)
    • esp_idf_svc::hal::delay::BLOCK 可以用于等待
  5. 运行程序,按下 BOOT 按钮,看看效果如何!

🔎 在本练习中,我们使用通知(notification),它只会提供最新的值。 因此如果在读取通知的值之前,中断被多次触发,你只能得到最新的值。 另一方面,队列允许接收多个值。更多详细信息请参阅 esp_idf_hal::task::queue::Queue

Simulation

This project is available for simulation through two methods:

  • Wokwi projects
  • Wokwi files are also present in the project folder to simulate it with Wokwi VS Code extension:
    1. Press F1, select Wokwi: Select Config File and choose advanced/button-interrupt/wokwi.toml
      • Edit the wokwi.toml file to select between exercise and solution simulation
    2. Build you project
    3. Press F1 again and select Wokwi: Start Simulator

按钮随机设置 LED 颜色

✅ 修改代码,使 RGB LED 灯在每次按下按钮时变为随机颜色。如果一段时间内未按下按钮,LED 不应熄灭或改变颜色。

你可以在先前的代码上继续修改,或者从 advanced/button-interrupt/src/main.rs 开始着手。

advanced/button-interrupt/examples/solution.rs 包含本练习的解答。可以用以下命令运行:

cargo run --example solution_led

💡 帮助信息

  • 必要的 crate 都已经导入,你可以用 cargo --doc --open 查看 LED 的帮助文档。
  • LED 的型号是 WS2812RMT。
  • 这是一个可编程的 RGB LED。这意味着不存在单独的,用于设置红、绿、蓝的引脚。我们需要实例化它,然后才能发送 RGB8 类型的值给它。
  • 这个板子有硬件随机数生成器,可以用 esp_random() 调用它。
  • 从 Rust 的角度来看,调用 esp-idf-svc::sys 中的一些函数是 unsafe 的,并且需要 unsafe() 块。不过你可以假设这些功能可以安全使用,不需要其他保护措施。

分步解答

  1. 初始化 LED 外设并以任意颜色值启动它,看看它是否正常工作。

    
    #![allow(unused)]
    fn main() {
     let mut led = WS2812RMT::new(peripherals.pins.gpio2, peripherals.rmt.channel0)?;
    
     led.set_pixel(RGB8::new(20, 0, 20)).unwrap(); // Remove this line after you tried it once
    }
    
  2. 只在按钮按下后点亮 LED。可以在按钮按下信息后添加这行代码来实现:

    
    #![allow(unused)]
    fn main() {
    led.set_pixel(arbitrary_color)?;
    }
    
  3. 调用 esp_random() 来生成随机 RGB 颜色值。

    • 这个函数是 unsafe 的。
    • 它会生成 u32,因此需要将它转换成 u8
    
    #![allow(unused)]
    fn main() {
    unsafe {
    //...
    1 => {
        let r = esp_random() as u8;
        let g = esp_random() as u8;
        let b = esp_random() as u8;
    
        let color = RGB8::new(r, g, b);
        led.set_pixel(color)?;
    
        },
    _ => {},
    }
    
  4. 可选:如果你想在其他地方重用这些代码,可以考虑将其放入一个函数中。这也允许我们确认具体哪些代码需要用 unsafe 块包裹。


#![allow(unused)]
fn main() {
// ...
    loop {
        // Enable interrupt and wait for new notificaton
        button.enable_interrupt()?;
        notification.wait(esp_idf_svc::hal::delay::BLOCK);
        println!("Button pressed!");
        // Generates random rgb values and sets them in the led.
        random_light(&mut led);
    }

// ...
fn random_light(led: &mut WS2812RMT) {
    let mut color = RGB8::new(0, 0, 0);
    unsafe {
        let r = esp_random() as u8;
        let g = esp_random() as u8;
        let b = esp_random() as u8;

        color = RGB8::new(r, g, b);
    }

    led.set_pixel(color).unwrap();
}

}

参考资料

GPIO

GPIO 是通用输入输出(General Purpose Input Output)的缩写。 GPIO 是数字(有时也是模拟)信号引脚,可用作其他系统或设备的接口。每个引脚可以处于多种状态,且在上电或系统复位时进入默认状态(通常是无害的状态,例如数字输入)。然后我们可以编写软件,将它们更改为我们需要的状态。

下面将介绍几个与 GPIO 相关的概念:

引脚配置

GPIO 可以通过多种方式进行配置。可用的选项可能会根据芯片的设计而有所不同,但通常包括:

浮空:浮空引脚既不连接 VCC,也不连接地。它的电平只取决于外部施加的电压。需要注意的是,引脚应从外部拉低或拉高,因为如果引脚电平高于“低电压阈值”(Vtl),但低于“高电压阈值”(Vth),持续超过几微秒,可能会导致 CMOS 硅器件(例如微控制器)无法正常工作。

推挽输出:配置为推挽输出的引脚,可以将其驱动为高电平(即将其连接到 VCC),或将其驱动为低电平(即将其接地)。这对于 LED、蜂鸣器或其他耗电量较小的设备很有用。

开漏输出:开漏输出的引脚可以在“断路”和“接地”之间切换。通常会外接电阻将线路弱上拉至 VCC。这种类型的输出旨在允许多个设备连接在一起——如果连接到这条线路的任一设备将其驱动为低电平,则整条线路为低电平。如果两个或多个设备同时将其驱动为低电平,也不会发生损坏(地与地连接是安全的)。如果所有设备都没有将其驱动为低电平,则默认情况下电阻会将其拉高。

浮空输入:引脚上施加外部电压,可以在软件中读取为 1(如果电压高于某个阈值)或 0(如果低于阈值)。前述“浮空”状态的注意事项也适用于这个状态。

上拉输入:与浮空输入类似,不同之处在于存在一个内部的上拉电阻,它会在没有外部驱动器将线路下拉至地时,将线路弱上拉至 VCC。这对于读取按钮和其他开关的状态很有用,可以节省一个外部电阻。

高有效/低有效

数字信号有两种状态:“高”和“低”。这通常由信号与地之间的电压差来表示。哪种电平代表哪种状态是可以任意选定的,因此“高”和“低”都可以被定义为有效状态。

例如:一个高有效的引脚,在逻辑有效时应当是高电平。一个低有效的引脚,在逻辑无效时才是高电平。

在嵌入式 Rust 的抽象中,我们看到的是逻辑状态,而不是电平。所以如果有一个连接 LED 的低有效的引脚,你需要将其设置为无效状态才能点亮 LED。

片选

片选是发送给一个设备的二进制信号,可以部分或全部地,打开或关闭该设备。它通常是连接到 GPIO 的一条信号线,常用于允许多个设备连接到同一 SPI 总线上 —— 每个设备仅在其片选线处于有效状态时监听总线。

Bit Banging

对于 I2C 或 SPI 等协议,我们通常使用 MCU 内的外设将我们想要传输的数据转换为信号。在某些情况下,例如,如果 MCU 不支持该协议,或者想要使用非标准形式的协议,则需要编写一个程序来手动将数据转换为信号。这称为 Bit Banging。