引言

欢迎阅读嵌入式Rust之书, 本书是使用Rust在如微控制器(MCU)的"裸金属"嵌入式系统上编程的引导

谁应使用Rust进行嵌入式开发

嵌入式Rust为任何想要在嵌入式系统上享受Rust提供的高级功能及安全性的人所提供. (也可以看看Who Rust Is For)

概览

这本书的目标是:

  • 让开发者快速上手Rust嵌入式开发. 例如, 如何建立开发环境

  • 分享当前使用Rust进行嵌入式开发的最佳实践. 例如, 如何最好地使用Rust编写更加正确的嵌入式应用

  • 在某些情况下提供一个开发指南. 例如, 如何在一个项目中混用C与Rust.

本书试着尽可能涵盖各种体系, 但是为了让读者与作者~~还有翻译~~更轻松, 在所有实例中都是用ARM Cortex-M架构. 但是, 本书并不建立在读者熟悉该架构的基础上, 会在需要的地方解释架构的细节.

这本书适合谁

本书面向具有一定嵌入式背景或者对Rust熟悉的人, 但是我们相信每个对嵌入式Rust编程感兴趣的人都可以从本书中学到东西. 对于那些没有任何经验知识的人, 建议您阅读 "先决条件" 部分并且补全缺少的知识, 以便从书中获得更多知识并且提升阅读体验. 你可以查看 "其他资源" 部分来查找你想获得的知识对应资源.

先决条件

  • 你对使用Rust很熟悉, 并且在桌面环境Rust程序写过, 跑过, 捉过虫. 对Rust2018版本熟悉, 应为本书使用Rust 2018

  • 熟悉使用其他语言, 如C, C++, Ada开发调试嵌入式系统, 熟悉如以下概念:

    • 交叉编译
    • 内存映射外设
    • 中断
    • 通用接口, 如I2C, SPI, 串口等

其他资源

如果你对上面提到的东西不熟, 或者你想对本书提到的一个概念有更加深刻的了解, 你可以看看下面这些资源, 会很有用.

TopicResourceDescription
RustRust BookIf you are not yet comfortable with Rust, we highly suggest reading this book.
Rust, EmbeddedDiscovery BookIf you have never done any embedded programming, this book might be a better start
Rust, EmbeddedEmbedded Rust BookshelfHere you can find several other resources provided by Rust's Embedded Working Group.
Rust, EmbeddedEmbedonomiconThe nitty gritty details when doing embedded programming in Rust.
Rust, Embeddedembedded FAQFrequently asked questions about Rust in an embedded context.
InterruptsInterrupt-
Memory-mapped IO/PeripheralsMemory-mapped I/O-
SPI, UART, RS232, USB, I2C, TTLStack Exchange about SPI, UART, and other interfaces-

怎么看这本书

这本书默认你从头看到尾. 后面的章节建立在前面的基础上, 并且前面的章节不会深挖某个细节部分, 在后面会重新探讨这个问题

这本书使用ST公司的STM32F3DISCOVERY开发板作为例子. 这个开发板时ARM Cortex-M架构, 尽管基于该架构的大多数CPU的基本功能都是相似的, 但是不同供应商之间的MCU的外设与其他市县细节是不同的, 并且同意供应商之间的MCU也往往有所不同.

出于这个原因, 我们建议你买一块STM32F3DISCOVERY开发板来跟着学习本书中的例子.

为本书做贡献

本书在this repository一起编写并且主要由resources team编写

如果你跟不住本书或是发现本书中某些部分不够清晰明白或者很难学习, 拿着就是一个BUG并且应该在the issue tracker被汇报

欢迎修改文字错误或是增加内容

中文翻译

本书为作者抽空翻译,可能有语义不通顺,如有不明白的地方也请参考英文原版

如果有勘误, 欢迎提出你的想法

同时也复习考研英语

本书仓库

时刻欢迎批评与建议

重用本书资源

本书在以下LICENSES下发布

太长别看系列: 如果你想在你的作品中使用我们的文字或图片, 你应该:

  • 加个提醒, 像是提一下本书, 再加个链接
  • 提供CC-BY-SA v4.0的链接
  • 说明你是否对内容进行了修改, 并且用相同的协议对进行更改

另外请一定让我们知道这本书帮了你 :gift:

硬件

先让我们熟悉一下陪我们的开发板

STM32F3DISCOVERY ("F3")

这块板子上都有什么?

关于这块板子更进一步的详细信息, 请参阅STMicroelectronics

警告!: 如果你相对板子施加外部信号, 一定要小心! STM32F303VCT6引脚能承受的电压为3.3V. 更多有关信息, 请参阅用户手册中6.2 Absolute maximum ratings section in the manual

一个 no_std 的Rust环境

嵌入式编程一词用于很多不同种类的涵义. 从只有几KB大小RAM与ROM的8位MCU, 到像是树莓派这样有32/64位四核Cortex-A53 cpu与1GB内存的设备. 编写代码时, 对于不同设备会有不同的限制.

有两种通用的嵌入式变成分类:

托管环境

这种环境与正常的PC环境相似. 这意味这你能够使用系统级接口, 类似POSIX这样的能提供给你与系统交互的原语, 像是文件系统, 网络, 内存管理, 线程等等. 你可能还会有些sysroot和RAM/ROM的限制, 可能还会有些特殊的硬件或I/O. 简而言之, 这类似在一台特殊用途的PC环境上编程.

裸金属

在一个裸金属环境中, 在你的程序开始之前不会有任何代码被加载. 没有OS提供我们没法使用标准库. 相反, 程序和它使用的库(Crates)可以只使用硬件(裸金属)来运行. 为了防止rust使用标准库, 我们使用no_std. 标准库中与平台无关的部分可以通过libcore获取. libcore中也排除了在嵌入式环境中并不总是理想的东西. 这其中之一就是用于动态内存分配的内存分配器. 如果你需要这个或是其他功能, 会有库(Crates)提供.

libstd运行时

像前面说的, 使用libstd需要系统支持, 但是这并不只是因为libstd至提供了访问OS的通用的抽象的方法, 而且它还提供了一个运行时. 这个运行时, 除了其他事情外, 还负责设置对战一处保护, 处理命令行参数还有在调用程序的main函数之前创建主线程. 这个运行时在no_std环境中不可用.

总结

#![no_std]是一个声明这个crate不会连接到std-crate二十core-crate的crate级别的属性. libcore是std-crate的一个与平台无关的子集, 对程序将要运行在的系统上没有任何假设(需求). 因此, 它为语言原语,像是float, string和slices等提供api, 和开放的处理器特性, 像是原子操作与SIMD指令. 然而他缺少任何设计平台集成的API. 由于这些属性, no_std与libcore写成的代码能不能够用于任何类型的引导(stage 0)像是加载程序, 固件还有内核.

概述

featureno_stdstd
堆 (动态内存)*
集合 (Vec, HashMap, etc)**
堆栈溢出保护
初始化函数
libstd 可用
libcore 可用
编写 固件, 内核, 引导加载器

* 只有当你使用 alloc crate并且选择一个合适的分配器, 像是alloc-cortex-m才可用.

** 只有当你使用 collections crate 并且配置一个全局默认的分配器才可用

See Also

工具

处理微控制器涉及到使用集中不同的工具, 因为我们要处理一个与你电脑架构不同的架构, 我们必须要在远程设备上来运行和调试程序.

我们会使用下面列出的工具. 没指定最低版本时, 按理说任何最新版本都能用, 但是我们也列出了经过测试的版本.

  • Rust 1.31, 1.31-beta, 或带有 ARM Cortex-M 编译器的更新的工具链
  • cargo-binutils ~0.1.4
  • qemu-system-arm. 测试版本: 3.0.0
  • OpenOCD >=0.8. 测试版本: v0.9.0 and v0.10.0
  • GDB with ARM support. 7.12或更高版本. 测试版本: 7.10, 7.11, 7.12 and 8.1
  • cargo-generategit. 这个工具可选但是会让你学习本书更加轻松.

下面来讲为什么我们需要这些工具. 安装说明会在下一页提及.

cargo-generategit

裸金属应用是不标准(no_std)的Rust程序, 需要对链接过程做出一些调整, 以使程序的内存布局正确. 这需要一些额外的文件(像是链接器脚本)和设置(像是连接器参数). 我们已经把这些打包成了一个模板, 这样你就只需要填写确实的信息就行(就想项目名称和目标硬件型号).

我们的模板与cargo-generate兼容, cargo-generate是Cargo的一个子命令, 用来从模板创建新的Cargo项目. 你也可以使用git, curl, wget或浏览器来下载模板.

cargo-binutils

cargo-binutils是Cargo的一系列子命令, 用来更轻松的配合Rust工具链使用LLVM工具. 这些工具包含LLVM版本的objdump, nmsize, 用来检查二进制产物.

与GNU binmutils相比, 使用这些工具的优势在于, (a) 可以无视系统一键安装LLVM工具(rustup component add llvm-tools-preview), (b) 像objdump这样的工具支持所有rustc支持的所有架构, 从 ARM 到 x86_64 应为他们都使用了相同的LLVM后端.

qemu-system-arm

QEMU是个模拟器. 在本书中, 我们使用能够模拟各种ARM系统的变体. 我们使用QEMU来在电脑上运行嵌入式程序. 多亏这个, 你能在没有硬件的情况下学习本书.

GDB

调试器是嵌入式开发中非常重要的一个组件, 因为你并不总是有足够的空间去把气质打到控制台上. 某些情况下, 你的硬件上甚至都没有LED可以闪(呜呜呜).

通常情况下, 涉及到调试的时候, LLDB和GDB差不多, 但是我们还没找到一个与GDB的load命令相同功能的LLDB指令, 这个命令把程序加载到硬件上, 所以我们建议你使用GDB.

OpenOCD

GDB现在还不能直接通过ST-Link调试器和你的STM32F3DISCOVERY开发板沟通. 他需要一个翻译器, Open On-Chip Debugger 缩写 OpenOCD 就是这个翻译器. OpenOCD是在你电脑上运行的, 可以在GDB基于TCP/IP的远程调试协议和ST-LINK基于USB的协议之间进行转换.

OpenOCD还执行其他工作, 作为翻译的一部分, 用于调试STM32F3DISCOVERY开发板上的ARM Cortex-M处理器.

  • 他知道如何与ARM CoreSight调试外围设备使用的内存映射寄存器沟通.正是这些CoreSight寄存器允许:
    • 断电/观察点操作
    • 读写CPU寄存器
    • 检测CPU何时银调试而暂停
    • 在调试结束后继续CPU执行
    • 更多.
  • 它还知道如何擦除和覆写mcu的flash

安装工具

此页包含与操作系统无关的工具安装说明:

Rust 工具链

按照https://rustup.rs的教程安装rustup.

NOTE 确保你有1.31或以上版本的编译器. rustc -V应该返回一个更新的版本.

$ rustc -V
rustc 1.31.1 (b6c32da9b 2018-12-18)

为了带宽和磁盘的使用情况, 默认安装只支持本机编译. 要想添加ARM Cortex-M的交叉编译支持, 应该选择下面其一的编译目标. 对于STM32F3DISCOVERY开发板, 应该使用thumbv7em-none-eabihf

Cortex-M0, M0+, M1 (ARMv6-M 架构):

$ rustup target add thumbv6m-none-eabi

Cortex-M3 (ARMv7-M 架构):

$ rustup target add thumbv7m-none-eabi

没有硬浮点的Cortex-M4 and M7 (ARMv7E-M 架构):

$ rustup target add thumbv7em-none-eabi

有硬浮点的Cortex-M4F and M7F (ARMv7E-M 架构):

$ rustup target add thumbv7em-none-eabihf

Cortex-M23 (ARMv8-M 架构):

$ rustup target add thumbv8m.base-none-eabi

Cortex-M33 and M35P (ARMv8-M 架构):

$ rustup target add thumbv8m.main-none-eabi

有硬浮点的Cortex-M33F and M35PF (ARMv8-M 架构):

$ rustup target add thumbv8m.main-none-eabihf

cargo-binutils

$ cargo install cargo-binutils

$ rustup component add llvm-tools-preview

cargo-generate

我们后面用这个来生成项目

$ cargo install cargo-generate

OS相关安装

现在跟着这些教程安装:

Linux

如下是对几种发行版的安装命令

  • Ubuntu 18.04 及以上 / Debian stretch 及以上

NOTE gdb-mutliarch 是你debug你的ARM Cortex-M程序的GDB命令

sudo apt install gdb-multiarch openocd qemu-system-arm
  • Ubuntu 14.04 , 16.04

NOTE arm-none-eabi-gdb 是你debug你的ARM Cortex-M程序的GDB命令

sudo apt install gdb-arm-none-eabi openocd qemu-system-arm
  • Fedora 27 及以上

NOTE arm-none-eabi-gdb 是你debug你的ARM Cortex-M程序的GDB命令

sudo dnf install arm-none-eabi-gdb openocd qemu-system-arm
  • Arch Linux

NOTE arm-none-eabi-gdb 是你debug你的ARM Cortex-M程序的GDB命令

sudo pacman -S arm-none-eabi-gdb qemu-arch-extra openocd

udev规则

这条规则让你可以使用OpenOCD而不要root权限.

/etc/udev/rules.d/70-st-link.rules创建文件, 并写入以下内容.

# STM32F3DISCOVERY rev A/B - ST-LINK/V2
ATTRS{idVendor}=="0483", ATTRS{idProduct}=="3748", TAG+="uaccess"

# STM32F3DISCOVERY rev C+ - ST-LINK/V2-1
ATTRS{idVendor}=="0483", ATTRS{idProduct}=="374b", TAG+="uaccess"

然后重新加载udev规则

sudo udevadm control --reload-rules

如果你把板子连接到了电脑, 重新连接.

使用如下命令检查权限:

lsusb

你应该看到如下内容:

(..)
Bus 001 Device 018: ID 0483:374b STMicroelectronics ST-LINK/V2.1
(..)

记一下总线设备号. 用这些数字来创建如下目录/dev/bus/usb/<bus>/<device>. 然后链接目录:

ls -l /dev/bus/usb/001/018
crw-------+ 1 root root 189, 17 Sep 13 12:34 /dev/bus/usb/001/018
getfacl /dev/bus/usb/001/018 | grep user
user::rw-
user:you:rw-

附加在权限后面的+表示存在扩展权限。getfacl命令告诉用户可以使用这个设备。

现在阅读下一部分

macOS

所有工具都可以用Homebrew安装:

$ # GDB
$ brew install armmbed/formulae/arm-none-eabi-gcc

$ # OpenOCD
$ brew install openocd

$ # QEMU
$ brew install qemu

这就是全部了, 下一部分.

Windows

arm-none-eabi-gdb

ARM为Windows提供了.exe安装器. 从这openocd: https://xpack.github.io/openocd/下载here, 然后跟着说i名安装. 在安装结束之前选择"添加到环境变量"选项. 然后验证工具已经在%PATH%中:

$ arm-none-eabi-gdb -v
GNU gdb (GNU Tools for Arm Embedded Processors 7-2018-q2-update) 8.1.0.20180315-git
(..)

OpenOCD

对Windows现在还没有官方的二进制文件, 但是如果你不想自己编译, xPack项目提供了一个二进制文件. here. 跟着安装说明. 然后更新你的%PATH%环境变量. (如果你使用快速安装C:\Users\USERNAME\AppData\Roaming\xPacks\@xpack-dev-tools\openocd\0.10.0-13.1\.content\bin\)

验证OpenOCD已经在%PATH%中:

$ openocd -v
Open On-Chip Debugger 0.10.0
(..)

QEMU

官方网站获取QEMU.

你还需要安装USB驱动, 否则OpenOCD不能工作. 跟着安装说明并且保证你安装了正确的版本(32位或64位).

这就是全部了, 下一部分

验证安装

在这一部分我们检查需要的工具/驱动是否被正确安装.

把Discovery开发板连接到电脑. Discovery有两个USB口, 使用在板子中间标着"USB ST-LINK"的口.

也检查ST-LINK的接口是否被污染. 看如下图片, ST-LINK接口被红线圈中.

现在运行如下命令:

$ openocd -f interface/stlink.cfg -f target/stm32f3x.cfg

你应该会得到如下输出并且命令行被阻塞:

Open On-Chip Debugger 0.10.0
Licensed under GNU GPL v2
For bug reports, read
        http://openocd.org/doc/doxygen/bugs.html
Info : auto-selecting first available session transport "hla_swd". To override use 'transport select <transport>'.
adapter speed: 1000 kHz
adapter_nsrst_delay: 100
Info : The selected transport took over low-level target control. The results might differ compared to plain JTAG/SWD
none separate
Info : Unable to match requested speed 1000 kHz, using 950 kHz
Info : Unable to match requested speed 1000 kHz, using 950 kHz
Info : clock speed 950 kHz
Info : STLINK v2 JTAG v27 API v2 SWIM v15 VID 0x0483 PID 0x374B
Info : using stlink api v2
Info : Target voltage: 2.919881
Info : stm32f3x.cpu: hardware has 6 breakpoints, 4 watchpoints

内容并不会完全一样, 但你应该会看到有关断点和观察点的最后一行. 如果没什么问题那就关掉OpenOCD然后到下一部分.

如果你没看到 "断点" 这一行, 那试试如下命令.

$ openocd -f interface/stlink-v2.cfg -f target/stm32f3x.cfg
$ openocd -f interface/stlink-v2-1.cfg -f target/stm32f3x.cfg

如果有一条命令成功了, 这意味这你手上的是个旧版本Discovery. 那不会有什么问题, 除了内存设置会在后面有些不同, 到下一部分.

如果这些命令都用不了, 那就试试使用root权限(像是sudo openocd ...). 如果命令能够执行, 那么检查一下udev规则是否正确.

如果到这, 你的OpenOCD还是不能用, 那就来发个issue然后我们来帮你!

开始

在这一部分, 我们会带你写代码, 编译, 烧录, 调试嵌入式程序. 我们会教你QEMU的基础, 一个开源的硬件模拟器, 因此你能不用硬件来运行大部分例子. 很自然的, 唯一需要硬件的章节就是硬件, 在这部分我们使用OpenOCD在STM32F3DISCOVERY

QEMU

我们要开始给LM3S6965编程, LM3S6965是一个Cortex-M3微控制器. 我们选择这个作为开始是应为它能被QEMU模拟,所以你不必在本部分玩弄硬件,专心与工具与编程.

Important 在本教程中我们使用"app"作为项目名. 当你看到"app"这个词的时候,你应该把它换成你给你自己的项目起的名. 或者,你就可以把你的项目名设成"app".

创建一个不含标准库的Rust程序

我们会使用cortex-m-quickstart项目模板来生成一个新项目. 新创建的项目会包含一个基础结构: 一个对嵌入式Rust程序的好的开始. 这个项目还额外包括一个有着几个不同例子的example文件夹.

使用 cargo-generate

首先安装 cargo-generate

cargo install cargo-generate

然后生成一个新项目

cargo generate --git https://github.com/rust-embedded/cortex-m-quickstart
 Project Name: app
 Creating project called `app`...
 Done! New project created /tmp/app
cd app

使用git

克隆仓库

git clone https://github.com/rust-embedded/cortex-m-quickstart app
cd app

然后修改Cargo.toml中的占位符

[package]
authors = ["{{authors}}"] # "{{authors}}" -> "John Smith"
edition = "2018"
name = "{{project-name}}" # "{{project-name}}" -> "awesome-app"
version = "0.1.0"

# ..

[[bin]]
name = "{{project-name}}" # "{{project-name}}" -> "awesome-app"
test = false
bench = false

两者都不用

下载最新的cortex-m-quickstart然后解压

curl -LO https://github.com/rust-embedded/cortex-m-quickstart/archive/master.zip
unzip master.zip
mv cortex-m-quickstart-master app
cd app

或者你可以打开cortex-m-quickstart,然后点绿色的"Clone or download",然后选择"Download ZIP".

然后按照第二部分"使用git"中修改占位符.

程序概览

为了方便,src/main.rs中已经有了很重要的部分:

#![no_std]
#![no_main]

use panic_halt as _;

use cortex_m_rt::entry;

#[entry]
fn main() -> ! {
    loop {
        // your code goes here
    }
}

这个和标准的Rust程序不太一样,咱们来凑近一点看看.

#![no_std]声明这个程序不会连接到std标准库. 作为代替会连接到core

#![no_main]声明这个程序不会使用大部分Rust使用的main函数接口. 使用no_main主要原因是在no_std中使用main需要每夜版的Rust.

use panic_halt as _;.这个库提供一个定义panic行为的panic_handler. 我们会在Panicing章节中讨论更多细节.

#[entry]是一个由cortex-m-rt提供的属性,用来标记程序的入口. 当我们不用标准的main入口我们就需要其他的方式声明程序的入口,就是#[entry].

fn main() -> !.我们的程序只会运行在目标硬件上,所以我们不希望他停止! 我们使用一个发散函数 (->!符号)来确保编译期不会出问题.

交叉编译

下一步是为Cortex-M3架构进行交叉编译. 在知道编译目标时($TRIPLE)使用cargo build --target $TRIPLE会很方便. 很幸运,模板中的.cargo/config已经提供了答案.

tail -n6 .cargo/config
[build]
# Pick ONE of these compilation targets
# target = "thumbv6m-none-eabi"    # Cortex-M0 and Cortex-M0+
target = "thumbv7m-none-eabi"    # Cortex-M3
# target = "thumbv7em-none-eabi"   # Cortex-M4 and Cortex-M7 (no FPU)
# target = "thumbv7em-none-eabihf" # Cortex-M4F and Cortex-M7F (with FPU)

为了给Cortex-M3架构交叉编译,我们要使用thumbv7m-none-eabi. 这个编译目标并不是自带的,如果你没有的话,现在就装:

rustup target add thumbv7m-none-eabi

如果thumbv7m-none-eabi已经.cargo/config中设为默认值,那下面这两条命令是一样的

cargo build --target thumbv7m-none-eabi
cargo build

检查

现在我们在target/thumbv7m-none-eabi/debug/app有一个非本机的ELF二进制文件. 我们可以用cargo-binutils来检查它.

使用cargo-readobj来查看ELF头来确认这是个给ARM的二进制文件.

cargo readobj --bin app -- -file-headers

注意:

  • --bin app是个target/$TRIPLE/debug/app的语法糖
  • --bin app如果需要的话会重新编译
ELF Header:
  Magic:   7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF32
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0x0
  Type:                              EXEC (Executable file)
  Machine:                           ARM
  Version:                           0x1
  Entry point address:               0x405
  Start of program headers:          52 (bytes into file)
  Start of section headers:          153204 (bytes into file)
  Flags:                             0x5000200
  Size of this header:               52 (bytes)
  Size of program headers:           32 (bytes)
  Number of program headers:         2
  Size of section headers:           40 (bytes)
  Number of section headers:         19
  Section header string table index: 18

cargo-size可以打印二进制文件中连接器的部分.

cargo size --bin app --release -- -A

我们使用--release来获取优化的版本.

app  :
section             size        addr
.vector_table       1024         0x0
.text                 92       0x400
.rodata                0       0x45c
.data                  0  0x20000000
.bss                   0  0x20000000
.debug_str          2958         0x0
.debug_loc            19         0x0
.debug_abbrev        567         0x0
.debug_info         4929         0x0
.debug_ranges         40         0x0
.debug_macinfo         1         0x0
.debug_pubnames     2035         0x0
.debug_pubtypes     1892         0x0
.ARM.attributes       46         0x0
.debug_frame         100         0x0
.debug_line          867         0x0
Total              14570

A refresher on ELF linker sections

  • .text contains the program instructions
  • .rodata contains constant values like strings
  • .data contains statically allocated variables whose initial values are not zero
  • .bss also contains statically allocated variables whose initial values are zero
  • .vector_table is a non-standard section that we use to store the vector (interrupt) table
  • .ARM.attributes and the .debug_* sections contain metadata and will not be loaded onto the target when flashing the binary.

IMPORTANT: ELF文件包含了类似Debug信息等等元数据,所以他们的在磁盘上的大小不能 准确的反映烧录在硬件上的大小.通常使用cargo-size来检查二进制文件真正的大小

cargo-objdump可用于反汇编二进制文件.

cargo objdump --bin app --release -- --disassemble --no-show-raw-insn --print-imm-hex

NOTE 如果以上命令报错Unknown command line argument, 可以看看这个bug: https://github.com/rust-embedded/book/issues/269

NOTE 这个根据不同系统有所区别.新版本的rustc,LLVM还有库会生成不同的二进制文件. 我们删节了一些说明,意识代码段变小.

app:  file format ELF32-arm-little

Disassembly of section .text:
main:
     400: bl  #0x256
     404: b #-0x4 <main+0x4>

Reset:
     406: bl  #0x24e
     40a: movw  r0, #0x0
     < .. truncated any more instructions .. >

DefaultHandler_:
     656: b #-0x4 <DefaultHandler_>

UsageFault:
     657: strb  r7, [r4, #0x3]

DefaultPreInit:
     658: bx  lr

__pre_init:
     659: strb  r7, [r0, #0x1]

__nop:
     65a: bx  lr

HardFaultTrampoline:
     65c: mrs r0, msp
     660: b #-0x2 <HardFault_>

HardFault_:
     662: b #-0x4 <HardFault_>

HardFault:
     663: <unknown>

运行

下一步我们要在QEMU上运行我们的嵌入式程序! 这次我们使用hello这个例子来搞事.

为了方便,如下是example/hello.rs的源码

//! Prints "Hello, world!" on the host console using semihosting

#![no_main]
#![no_std]

use panic_halt as _;

use cortex_m_rt::entry;
use cortex_m_semihosting::{debug, hprintln};

#[entry]
fn main() -> ! {
    hprintln!("Hello, world!").unwrap();

    // exit QEMU
    // NOTE do not run this on hardware; it can corrupt OpenOCD state
    debug::exit(debug::EXIT_SUCCESS);

    loop {}
}

这个程序使用一个叫semihosting的东西来打印信息到宿主机. 等到了真正的硬件上,就需要一个调试会话才能用.

让我们来开始编译这个例子:

cargo build --example hello

产出的二进制文件在target/thumbv7m-none-eabi/debug/examples/hello.

为了在QEMU上运行这个应使用如下命令:

qemu-system-arm \
  -cpu cortex-m3 \
  -machine lm3s6965evb \
  -nographic \
  -semihosting-config enable=on,target=native \
  -kernel target/thumbv7m-none-eabi/debug/examples/hello
Hello, world!

这条命令应该在输出信息后成功推出(exit code = 0). 在*nix上你可以用如下命令确认:

echo $?
0

让我们来破解QEMU命令:

  • qemu-system-arm.这是QEMU模拟器.有几种不同的QEMU二进制文件;这个能对ARM机器进行完整的系统仿真

  • -cpu cortex-m3.这告诉QEMU去模拟一个Cortex-M3 CPU.指定CPU型号可以让我们捕获一些编译错误: 例如,运行为带有硬件FPU的Cortex-M4F编译的程序回事QEMU执行过程中出错.

  • -machine lm3s6965evb.这告诉QEMU去模拟LM3S6965EVB,一个包含LM3S6965的评估开发板

  • -nographic.这告诉QEMu不要去启动GUI.

  • -semihosting-config (..).这让QEMU启动semihosting. Semihosting允许仿真设备使用主机的stdout, stderr和stdin,并且在主机上创建文件

  • -kernel $file.这告诉QEMU运行哪个二进制文件

输入这么长的命令太麻烦了!我们可以在.cargo/config中配置一个自定义的运行指令. 去掉注释:

head -n3 .cargo/config
[target.thumbv7m-none-eabi]
# uncomment this to make `cargo run` execute programs on QEMU
runner = "qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel"

这个运行器只针对thumbv7m-none-eabi,现在执行cargo run会编译程序并且使用QEMU运行.

cargo run --example hello --release
   Compiling app v0.1.0 (file:///tmp/app)
    Finished release [optimized + debuginfo] target(s) in 0.26s
     Running `qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel target/thumbv7m-none-eabi/release/examples/hello`
Hello, world!

调试

调试对于嵌入式开发至关重要.让我们看看它是如何完成的.

调试嵌入式设备设计远程调试,因为我们要调试的程序不会运行在运行调试器(GDB or LLDB)所在的机器上

远程调试涉及客户端与服务端.在QEMU设置中,客户端是GDB(LLDB)进程,服务端则是运行嵌入式应用的QEMU程序.

在本节中,我们使用已经编译好的hello例子.

调试的第一步是以调试模式启动QEMU:

qemu-system-arm \
  -cpu cortex-m3 \
  -machine lm3s6965evb \
  -nographic \
  -semihosting-config enable=on,target=native \
  -gdb tcp::3333 \
  -S \
  -kernel target/thumbv7m-none-eabi/debug/examples/hello

这条命令不会在控制台上打印任何内容并会阻塞终端.这次我们额外传递两个命令行参数:

  • -gdb tcp::3333.这条命令告诉QEMU在TCP 3333上等待GDB链接

  • -S.这条命令告诉QEMU在开始时冻结机器.如果没有这条命令, 还没等我们打开调试器,程序就已经运行到了末尾.

下一步我们在另一个终端中启动GDB,并让它加载示例的调试符:

gdb-multiarch -q target/thumbv7m-none-eabi/debug/examples/hello

注意取决于你在安装章节安装了哪一个,你可能需要其他版本的gdb而不是gdb-multiarch. 这可能是arm-none-eabi-gdb或就是gdb.

然后在GDB Shell中连接到QEMU,它正在TCP3333上等待连接.

target remote :3333
Remote debugging using :3333
Reset () at $REGISTRY/cortex-m-rt-0.6.1/src/lib.rs:473
473     pub unsafe extern "C" fn Reset() -> ! {

你会看到该过程已暂停,并且程序计数器指向一个名为Reset的函数. 那就是reset handler,MCU在启动时执行的.

注意在某些设置中,gdb可能会提示如下信息,而不是显示Reset () at $REGISTRY/cortex-m-rt-0.6.1/src/lib.rs:473

core::num::bignum::Big32x40::mul_small () at src/libcore/num/bignum.rs:254 src/libcore/num/bignum.rs: No such file or directory.

这是一个已知的故障,你可以放心的忽略它,最有可能出现在Reset()处

这个reset handler最终调用我们的main函数.让我们使用断点与continue跳过.要设置断点的话,首先让我们用list看一下在哪断点.

list main

这会展示example/hello.rs的源码

6       use panic_halt as _;
7
8       use cortex_m_rt::entry;
9       use cortex_m_semihosting::{debug, hprintln};
10
11      #[entry]
12      fn main() -> ! {
13          hprintln!("Hello, world!").unwrap();
14
15          // exit QEMU

我们想要在第13行,"Hello world!"后加一个断点.我们可以使用break命令

break 13

现在,我们可以使用continue命令让GDB运行我们的main函数

continue
Continuing.

Breakpoint 1, hello::__cortex_m_rt_main () at examples\hello.rs:13
13          hprintln!("Hello, world!").unwrap();

我们现在很接近输出"Hello, world!"的那一行代码.让我们用next继续.

next
16          debug::exit(debug::EXIT_SUCCESS);

这时你应该看到"Hello, world!"已经在运行qemu-system-arm的终端上被打印出来了.

$ qemu-system-arm (..)
Hello, world!

继续使用next会结束QEMU进程.

next
[Inferior 1 (Remote target) exited normally]

现在你可以退出GDB会话.

quit

硬件

现在你应该熟悉了工具与开发过程. 在本节我们来试试真正的硬件. 该过程基本不变.让我们开始:

了解你的硬件

在我们开始之前你需要了解硬件的特点以便于配置项目:

  • ARM内核. e.g. Cortex-M3.
  • ARM内核有FPU吗? Cortex-M4F和Cortex-M7F有.
  • 目标设备有多大闪存和RAM? e.g. 256KiB闪存32KiB内存.
  • 闪存和RAM在的地址在多少? e.g. RAM通常位于0x2000_0000.

你可以在用户手册和数据手册中找到这些信息.

在本届我们使用我们的参考硬件STM32F3DISCOVERY. 这块板子有一个STM32F303VCT6.这块MCU有:

  • 一个带有单精度FPU的Cortex-M4F内核
  • 位于0x0800_0000的256KiB闪存
  • 位于0x2000_0000的40KiB内存(还有另一个RAM区域,为了简单我们忽略)

配置

我们从一个新的模板实例开始. 关于如何使用cargo-generate请参考上一章节QEMU

$ cargo generate --git https://github.com/rust-embedded/cortex-m-quickstart
 Project Name: app
 Creating project called `app`...
 Done! New project created /tmp/app

$ cd app

第一步是在.cargo/config中设置默认编译目标.

$ tail -n5 .cargo/config
# Pick ONE of these compilation targets
# target = "thumbv6m-none-eabi"    # Cortex-M0 and Cortex-M0+
# target = "thumbv7m-none-eabi"    # Cortex-M3
# target = "thumbv7em-none-eabi"   # Cortex-M4 and Cortex-M7 (no FPU)
target = "thumbv7em-none-eabihf" # Cortex-M4F and Cortex-M7F (with FPU)

我们使用thumbv7em-none-eabihf,因为它包含Cortex-M4F内核.

第二步是把内存区域信息输入到memory.x中.

$ cat memory.x
/* Linker script for the STM32F303VCT6 */
MEMORY
{
  /* NOTE 1 K = 1 KiBi = 1024 bytes */
  FLASH : ORIGIN = 0x08000000, LENGTH = 256K
  RAM : ORIGIN = 0x20000000, LENGTH = 40K
}

NOTE如果你因为某些原因修改了memory.x,并且之前做过了编译, 那你需要执行cargo clean再执行cargo build,因为cargo build并不会追踪memory.x的变化.

我们还用hello这个例子, 但是首先先做一点小改动.

examples/hello.rs中,确保debug::exit()被注释掉或者删掉.它只是为了运行QEMU而存在的.

#[entry]
fn main() -> ! {
    hprintln!("Hello, world!").unwrap();

    // exit QEMU
    // NOTE do not run this on hardware; it can corrupt OpenOCD state
    // debug::exit(debug::EXIT_SUCCESS);

    loop {}
}

现在你可以用cargo build进行交叉编译,并且像之前一样用cargo-binutils查看信息. cortex-m-rt这个库包含了一切能让你芯片运行的魔法,它很有帮助,因为几乎所有的Cortex-M CPU都可以用相同的方式引导.

$ cargo build --example hello

调试

调试过程看起来有些不同了.事实上,第一步根据目标设备不同也有不同.这一节中我们会展示在STM32DISCOBVERY上debug的步骤.这仅供参考,有关设备的调试请参考the Debugonomicon.

和以前一样,我们进行远程调试,客户端是GDB.但是这次服务端则是OpenOCD.

像之前在验证安装中所做的一样,将板子连接到电脑,然后检查ST-LINK.

在终端上运行OpenOCD以连接到ST-LINK.从模板的根目录运行此命令;OpenOCD会使用openocd.cfg,这里面声明了使用什么接口,连接什么设备.

$ cat openocd.cfg
# Sample OpenOCD configuration for the STM32F3DISCOVERY development board

# Depending on the hardware revision you got you'll have to pick ONE of these
# interfaces. At any time only one interface should be commented out.

# Revision C (newer revision)
source [find interface/stlink.cfg]

# Revision A and B (older revisions)
# source [find interface/stlink-v2.cfg]

source [find target/stm32f3x.cfg]

NOTE 如果你在用旧版本的DISCOVERY板子,你应该修改一下openocd.cfg来使用interface/stlink-v2.cfg

$ openocd
Open On-Chip Debugger 0.10.0
Licensed under GNU GPL v2
For bug reports, read
        http://openocd.org/doc/doxygen/bugs.html
Info : auto-selecting first available session transport "hla_swd". To override use 'transport select <transport>'.
adapter speed: 1000 kHz
adapter_nsrst_delay: 100
Info : The selected transport took over low-level target control. The results might differ compared to plain JTAG/SWD
none separate
Info : Unable to match requested speed 1000 kHz, using 950 kHz
Info : Unable to match requested speed 1000 kHz, using 950 kHz
Info : clock speed 950 kHz
Info : STLINK v2 JTAG v27 API v2 SWIM v15 VID 0x0483 PID 0x374B
Info : using stlink api v2
Info : Target voltage: 2.913879
Info : stm32f3x.cpu: hardware has 6 breakpoints, 4 watchpoints

同样在根目录下另起一个终端运行GDB.

$ <gdb> -q target/thumbv7em-none-eabihf/debug/examples/hello

先一步连接GDB到OpenOCD.

(gdb) target remote :3333
Remote debugging using :3333
0x00000000 in ?? ()

现在使用load命令烧录程序到mcu.

(gdb) load
Loading section .vector_table, size 0x400 lma 0x8000000
Loading section .text, size 0x1e70 lma 0x8000400
Loading section .rodata, size 0x61c lma 0x8002270
Start address 0x800144e, load size 10380
Transfer rate: 17 KB/sec, 3460 bytes/write.

现在程序被加载了.之前程序使用semihosting,因此在我们进行任何semihosting操作时,应该先告诉OpenOCD启用semihosting.可以使用monitor命令.

(gdb) monitor arm semihosting enable
semihosting is enabled

你也可以使用monitor help查看所用OpenOCD命令.

前之前那样给main加断点并执行continue

(gdb) break main
Breakpoint 1 at 0x8000d18: file examples/hello.rs, line 15.

(gdb) continue
Continuing.
Note: automatically using hardware breakpoints for read-only addresses.

Breakpoint 1, main () at examples/hello.rs:15
15          let mut stdout = hio::hstdout().unwrap();

NOTE 如果在发出上面的continue命令后GDB阻塞了终端而不是到达断点,则你可能要仔细检查一下是否已为您的设备正确设置了memory.x文件中的存储区域信息(起始位置和长度).

使用next继续程序,应该会有和之前相同的结果.

(gdb) next
16          writeln!(stdout, "Hello, world!").unwrap();

(gdb) next
19          debug::exit(debug::EXIT_SUCCESS);

在这我们应该看到在OpenOCD的控制台上出现了"Hello, world!"

$ openocd
(..)
Info : halted: PC: 0x08000e6c
Hello, world!
Info : halted: PC: 0x08000d62
Info : halted: PC: 0x08000d64
Info : halted: PC: 0x08000d66
Info : halted: PC: 0x08000d6a
Info : halted: PC: 0x08000a0c
Info : halted: PC: 0x08000d70
Info : halted: PC: 0x08000d72

发出另一个next命令会使程序执行debug::exit().这会触发断点并终止程序:

(gdb) next

Program received signal SIGTRAP, Trace/breakpoint trap.
0x0800141a in __syscall ()

这也会使以下内容出现在OpenOCD控制台上:

$ openocd
(..)
Info : halted: PC: 0x08001188
semihosting: *** application exited ***
Warn : target not halted
Warn : target not halted
target halted due to breakpoint, current mode: Thread
xPSR: 0x21000000 pc: 0x08000d76 msp: 0x20009fc0, semihosting

但是,mcu上的进程不没有终止,你可以使用continue或类似的命令恢复进程.

你现在可以用quit来退出GDB

(gdb) quit

现在调试需要更多的步骤了,那让我们来把这些步骤打包成一个叫openocd.gdb的GDB脚本. 这个文件在cargo generate步骤中已经生成了,按理说不用做修改就能用.让我们看一下:

$ cat openocd.gdb
target extended-remote :3333

# print demangled symbols
set print asm-demangle on

# detect unhandled exceptions, hard faults and panics
break DefaultHandler
break HardFault
break rust_begin_unwind

monitor arm semihosting enable

load

# start the process but immediately halt the processor
stepi

现在运行<gdb> -x openocd.gdb target/thumbv7em-none-eabihf/debug/examples/hello会自动连接GDB到OpenOCD,启动semihosting,然后烧录程序并启动.

$ head -n10 .cargo/config
[target.thumbv7m-none-eabi]
# uncomment this to make `cargo run` execute programs on QEMU
# runner = "qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel"

[target.'cfg(all(target_arch = "arm", target_os = "none"))']
# uncomment ONE of these three option to make `cargo run` start a GDB session
# which option to pick depends on your system
runner = "arm-none-eabi-gdb -x openocd.gdb"
# runner = "gdb-multiarch -x openocd.gdb"
# runner = "gdb -x openocd.gdb"
$ cargo run --example hello
(..)
Loading section .vector_table, size 0x400 lma 0x8000000
Loading section .text, size 0x1e70 lma 0x8000400
Loading section .rodata, size 0x61c lma 0x8002270
Start address 0x800144e, load size 10380
Transfer rate: 17 KB/sec, 3460 bytes/write.
(gdb)

内存映射寄存器

嵌入式系统只能通过执行常规的Rust代码并在RAM中移动数据来达到目标. 如果我们想从外部读取信息到系统,或从系统中获取信息(例如,点亮LED,检测到按钮按下,或者与总线上某种设备进行通信),那我们必须要接触外设和"内存映射寄存器"

您可能会发现,已经在以下级别之一编写了访问微控制器外围设备所需的代码:

  • Micro-architecture Crate - 这种库可处理你使用的mcu的通用部分,以及使用该内核的所有mcu的通用的外设.例如,cortex-m可以提供启用禁用中断的功能,这些功能对所有Cortex-m处理器都适用.他还可以让你访问所有基于Cortex-m微控制器所带的'SysTick'外设.
  • Peripheral Access Crate (PAC) - 这种库是根据你的mcu型号来提供一个对内存包装寄存器的简单包装.例如tm4c123x对应Texas Instruments Tiva-C TM4C123系列,stm32f30x对应ST-Micro STM32F30x系列.在这里你将按照mcu的参考手册中给出的每个外设的操作说明直接操作寄存器.
  • HAL Crate - 这些库给实现embedded-hal的一些trait,来为你的mcu提供一个更加用户友好的API.例如,这些库可能会提供一个Serial Struct,它的构造函数使用适当GPIO与波特率,并提供某种write_byte函数来发送数据.有关嵌入式HAL的更多信息,请参考移植
  • Board Crate - 这些库通过预先配置好的外设和GPIO引脚来让你使用特定的开发板,像是stm32f3-discovery对STM32F3DISCOVERY,这些库比HAL库更进一步.

Board Crate

如果你在嵌入式系统方面是个萌新,拿使用Board Crate是一个很好的起点. 他们很好的抽象了我们在学习过程中会遇到的硬件细节,并简化像是开关LED的操作. 他们暴露的函数在不同开发板之间差别很大.由于本书旨在不涉及硬件的细节,所以本书不会使用board crate.

如果你想使用STM32F3DISCOVERY进行试验,那很推荐你去看一看stm32f3-discovery board crate,这个库提供了一些列功能,包括开关LED,使用指南针,蓝牙等. Discovery这本书提供了一个使用这个board crate很好的介绍.

但是如果你使用一个没有board crate的系统,或者你需要使用现有board crate没有提供的功能,请从底部开始阅读micro-architecture.

Micro-architecture crate

让我们看一下所有基于Cortex-M的微控制器共有的SysTick外设.我们可以在cortex-m中找到一个非常非常低级的API,我们能这么用:

#![no_std]
#![no_main]
use cortex_m::peripheral::{syst, Peripherals};
use cortex_m_rt::entry;
use panic_halt as _;

#[entry]
fn main() -> ! {
    let peripherals = Peripherals::take().unwrap();
    let mut systick = peripherals.SYST;
    systick.set_clock_source(syst::SystClkSource::Core);
    systick.set_reload(1_000);
    systick.clear_current();
    systick.enable_counter();
    while !systick.has_wrapped() {
        // Loop
    }

    loop {}
}

SYST struct的函数与ARM Technical Reference Manual定义的很相似. 此API中没有没有关于延迟X毫秒的函数 - 我们得使用while循环 来大致的实现这个功能.注意,我们在调用Peripherals::take()函数前, 我们没法使用SYST - 这是一个特殊的历程,可以确保整个程序中只有一个SYST. 关于更多,可以参考Peripherals章节

使用Peripheral Access Crate (PAC)

如果我们把自己束缚在Cortex-M自带的基本外设上,那注定我们的嵌入式之路是走不远的. 在某个时候,我们需要编写一些特定于我们正在使用的硬件的代码. 在这个实例中,先假设我们有一个德州仪器(TI)的TM4C123,一个有256KiB闪存,80MHz的中等的Cortex-M4微控制器. 我们打算使用tm4c123x库来玩这块芯片.

#![no_std]
#![no_main]

use panic_halt as _; // panic handler

use cortex_m_rt::entry;
use tm4c123x;

#[entry]
pub fn init() -> (Delay, Leds) {
    let cp = cortex_m::Peripherals::take().unwrap();
    let p = tm4c123x::Peripherals::take().unwrap();

    let pwm = p.PWM0;
    pwm.ctl.write(|w| w.globalsync0().clear_bit());
    // Mode = 1 => Count up/down mode
    pwm._2_ctl.write(|w| w.enable().set_bit().mode().set_bit());
    pwm._2_gena.write(|w| w.actcmpau().zero().actcmpad().one());
    // 528 cycles (264 up and down) = 4 loops per video line (2112 cycles)
    pwm._2_load.write(|w| unsafe { w.load().bits(263) });
    pwm._2_cmpa.write(|w| unsafe { w.compa().bits(64) });
    pwm.enable.write(|w| w.pwm4en().set_bit());
}

除了我们调用tm4c123x::Peripherals::take()外,我们使用PWM0外设的方法是和SYST相同的. 因为此库是使用svd2rust自动生成的,所以我们访问寄存器需要闭包参数,而不是数字参数. 尽管这看起来很多,但是rust编译器会执行一堆检查,然后生成的机器码与我们手写的汇编非常接近! 自动生成的代码无法确定特定寄存器的所有参数(例如,如果SVD定义寄存器有32bit,但并没有说明其中的某些位有什么特殊功能),所以被标记为unsafe. 我们可以在上面这个例子中看到如何使用bits()的子函数load,compa.

读取

read()函数会返回一个包含有制造商SVD文件定义的寄存器各个子段的只读权限的对象. 你可以在tm4c123x documentation中特定外设,特定寄存器的特殊R返回值类型中的所有可用函数.

if pwm.ctl.read().globalsync0().is_set() {
    // Do a thing
}

写入

write()函数需要一个只有一个参数的闭包参数.我们叫他w. 这个参数有该设备制造商SVD文件定义的寄存器所有子段的读写权限. 你也可以在tm4c123x documentation中找到针对该芯片该外设该寄存器w的所有函数. 请注意,我们未设置的所有子字段都将被设置为我们的默认值-寄存器中的所有现有内容都将丢失.

pwm.ctl.write(|w| w.globalsync0().clear_bit());

修改

如果我们想修改寄存器中某一子段的值而不修改其他的,我们可以使用modify()函数. 该函数需要一个包括两个参数的闭包参数,一个用来读,一个用来写.我们经常叫rw. r可以用来查看当前寄存器中的内容,w可以用来修改寄存器中的值.

pwm.ctl.modify(|r, w| w.globalsync0().clear_bit());

modify函数在这真的展现了闭包的强大.在C中,我们先要把值读取到几个临时变量中,然后做修改,然后再写回去.这意味着会存在很大错误范围:

uint32_t temp = pwm0.ctl.read();
temp |= PWM0_CTL_GLOBALSYNC0;
pwm0.ctl.write(temp);
uint32_t temp2 = pwm0.enable.read();
temp2 |= PWM0_ENABLE_PWM4EN;
pwm0.enable.write(temp); // Uh oh! Wrong variable!

使用 HAL(硬件抽象层) 库

芯片的HAL库通常通过为PAC暴露的原始结构来实现自定义trait.通常这个trait会为单独的外设定义一个叫constrain()的函数,为类似GPIO这样有多个引脚的外设定义split()函数.此函数包装最原始的结构,然后提供拥有一个高级的API的对象. 这个API可以做很多事情,例如串口的new需要借用Clock结构,Clock只能通过配置PLL设置时钟频率获得. 通过这种方法,在没有创建配置时钟或没法将波特率与始终速率对应起来之前没法创建一个串口对象. 一些库甚至为GPIO引脚定义了特殊的trait,需要用户选择引脚的正确状态(或者说,选择合适的复用功能).都不要运行时花销.

让我们看个例子:

#![no_std]
#![no_main]

use panic_halt as _; // panic handler

use cortex_m_rt::entry;
use tm4c123x_hal as hal;
use tm4c123x_hal::prelude::*;
use tm4c123x_hal::serial::{NewlineMode, Serial};
use tm4c123x_hal::sysctl;

#[entry]
fn main() -> ! {
    let p = hal::Peripherals::take().unwrap();
    let cp = hal::CorePeripherals::take().unwrap();

    // Wrap up the SYSCTL struct into an object with a higher-layer API
    let mut sc = p.SYSCTL.constrain();
    // Pick our oscillation settings
    sc.clock_setup.oscillator = sysctl::Oscillator::Main(
        sysctl::CrystalFrequency::_16mhz,
        sysctl::SystemClock::UsePll(sysctl::PllOutputFrequency::_80_00mhz),
    );
    // Configure the PLL with those settings
    let clocks = sc.clock_setup.freeze();

    // Wrap up the GPIO_PORTA struct into an object with a higher-layer API.
    // Note it needs to borrow `sc.power_control` so it can power up the GPIO
    // peripheral automatically.
    let mut porta = p.GPIO_PORTA.split(&sc.power_control);

    // Activate the UART.
    let uart = Serial::uart0(
        p.UART0,
        // The transmit pin
        porta
            .pa1
            .into_af_push_pull::<hal::gpio::AF1>(&mut porta.control),
        // The receive pin
        porta
            .pa0
            .into_af_push_pull::<hal::gpio::AF1>(&mut porta.control),
        // No RTS or CTS required
        (),
        (),
        // The baud rate
        115200_u32.bps(),
        // Output handling
        NewlineMode::SwapLFtoCRLF,
        // We need the clock rates to calculate the baud rate divisors
        &clocks,
        // We need this to power up the UART peripheral
        &sc.power_control,
    );

    loop {
        writeln!(uart, "Hello, World!\r\n").unwrap();
    }
}

Semihosting

恐慌

Panicking是Rust语言的核心一部分. 像是索引一样的内置操作会在运行时检查安全性. 当尝试超出索引范围时, 结果就会导致panic.

在标准库之中, 恐慌有一个定义的行为: 除非用户选择在出现恐慌时结束程序, 否则它将展开出现恐慌行为的线程的堆栈.

然而, 在没有使用标准库的程序中, 恐慌行为没有定义. 可以使用#[panic_handler]来指定恐慌行为. 这个函数在整个程序的语法树中只能出现一次, 并且必须有如下标志: fn(&PanicInfo) -> !, PanicInfo是一个包含了恐慌位置信息的结构体.

鉴于嵌入式系统的范围从面型用户到所以安全至关重要(不崩溃), 所以没有任何一种适用于全部情况的恐慌行为, 但是有很多经常使用的. 这些库中已经定义了#[panic_handler]函数. 这有几个例子:

  • panic-abort. 恐慌使指令停止运行.
  • panic-halt. 恐慌使当前程序或线程进入一个无限循环.
  • panic-itm. 使用ITM(Cortex-M的一种外设)记录恐慌消息.
  • panic-semihosting. 使用semihosting技术将恐慌信息输出到主机上.

你可以在crates.io上使用关键词panic-handler搜索到更多库.

程序可以通过简单的链接到其中一个库来选择一个恐慌行为. 恐慌行为在应用程序的源代码中表示为一行代码这一事实不仅可用作文档, 而且还可以根据编译配置文件用于更改恐慌行为. 例如:

#![no_main]
#![no_std]

// dev profile: easier to debug panics; can put a breakpoint on `rust_begin_unwind`
#[cfg(debug_assertions)]
use panic_halt as _;

// release profile: minimize the binary size of the application
#[cfg(not(debug_assertions))]
use panic_abort as _;

// ..

在这个例子中我们选择在开发时(cargo build)我们选择panic-halt, 但在发布时(cargo build --release)我们选择panic-abort.

use panic_abort as _;使用use语句来确保在最终二进制产物中panic_abort被包含进去, 同时也让编译器知道我们不会使用其中的任何内容. 没有as _的话, 编译器会给我们一个Warn来告诉我们有个没有使用的导入库. 又是你会看见extern crate panic_abort, 这是一个在2018版本之前的旧版本的写法, 现在仅仅应用于"sysroot"库(那些随着Rust一起发布的库), 像是 proc_macro, alloc, std, 还有 test.

一个例子

这有一个试图越界数组的例子. 最终结果会导致恐慌.

#![no_main]
#![no_std]

use panic_semihosting as _;

use cortex_m_rt::entry;

#[entry]
fn main() -> ! {
    let xs = [0, 1, 2];
    let i = xs.len() + 1;
    let _y = xs[i]; // out of bounds access

    loop {}
}

这个例子选择使用semihosting技术输出信息到主机的panic-semihosting.

$ cargo run
     Running `qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb (..)
panicked at 'index out of bounds: the len is 3 but the index is 4', src/main.rs:12:13

你可以试着把恐慌行为改成panic-halt来确认一下还会不会有信息输出.

异常

异常与中断是一种硬件机制, 处理器通过该机制来来异步处理事件或错误(例如执行无效指令). 异常意味着抢占, 涉及异常处理程序, 这些子处理程序使为了响应触发事件的信号而执行的子线程.

cortex-m-rt库提供了一个exception这个属性用来定义异常处理函数.

// Exception handler for the SysTick (System Timer) exception
#[exception]
fn SysTick() {
    // ..
}

除了exception这个属性, 这个函数看着就像一个普通函数, 但有一点不同: exception处理函数不能被普通程序调用. 跟着前一个例子, 调用SysTick会导致编译错误.

此行为是可以预期的, 并且需要提供一个特性: 定义在exception处理中的static mut变量必须能够安全使用.

#[exception]
fn SysTick() {
    static mut COUNT: u32 = 0;

    // `COUNT` has transformed to type `&mut u32` and it's safe to use
    *COUNT += 1;
}

和你知道的一样, 在函数中使用static mut变量让其不可重入. 从多个异常或中断函数中, 或从main和一个或多个异常或中断处理函数中直接或间接调用可重入函数是未定义行为.

安全Rust必须不能导致未定义行为, 所以可重入函数必须要标记为unsafe. 但是我只是告诉我们的exception处理函数可以安全的使用static mut变量. 这怎么可能? 但这是可能的, 因为exception处理不能被软件调用, 所以也就不可能重入.

注意, exception属性将函数中的静态变量定义包装到unsafe中, 并为我们提供了具有相同名称的&mut引用, 从而在函数内部转换他们. 因此, 我们可以使用*解引用, 来访问变量的值, 而无需把他们放在unsafe中.

一个完整的例子

这是一个使用系统计时器每秒来触发一个SysTick异常的例子. SysTick异常处理函数使用COUNT变量追踪一共触发了多少次, 并且用semihostingCOUNT的值输出到主机上.

NOTE 你可以在任何Cortex-M设备上运行, 也可以在QEMU上运行.

#![deny(unsafe_code)]
#![no_main]
#![no_std]

use panic_halt as _;

use core::fmt::Write;

use cortex_m::peripheral::syst::SystClkSource;
use cortex_m_rt::{entry, exception};
use cortex_m_semihosting::{
    debug,
    hio::{self, HStdout},
};

#[entry]
fn main() -> ! {
    let p = cortex_m::Peripherals::take().unwrap();
    let mut syst = p.SYST;

    // configures the system timer to trigger a SysTick exception every second
    syst.set_clock_source(SystClkSource::Core);
    // this is configured for the LM3S6965 which has a default CPU clock of 12 MHz
    syst.set_reload(12_000_000);
    syst.clear_current();
    syst.enable_counter();
    syst.enable_interrupt();

    loop {}
}

#[exception]
fn SysTick() {
    static mut COUNT: u32 = 0;
    static mut STDOUT: Option<HStdout> = None;

    *COUNT += 1;

    // Lazy initialization
    if STDOUT.is_none() {
        *STDOUT = hio::hstdout().ok();
    }

    if let Some(hstdout) = STDOUT.as_mut() {
        write!(hstdout, "{}", *COUNT).ok();
    }

    // IMPORTANT omit this `if` block if running on real hardware or your
    // debugger will end in an inconsistent state
    if *COUNT == 9 {
        // This will terminate the QEMU process
        debug::exit(debug::EXIT_SUCCESS);
    }
}
$ tail -n5 Cargo.toml
[dependencies]
cortex-m = "0.5.7"
cortex-m-rt = "0.6.3"
panic-halt = "0.2.0"
cortex-m-semihosting = "0.3.1"
$ cargo run --release
     Running `qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb (..)
123456789

如果你使用Discovery开发板, 你会在OpenOCD上看到输出. 另外, 直到计数达到9才会停止.

默认异常处理

exception属性实际上做的是用一个特定的异常处理函数覆盖默认的异常处理函数. 如果你不覆盖的话, 异常处理程序是DefaultHandler, 内容如下:

fn DefaultHandler() {
    loop {}
}

这个函数是cortex-m-rt提供的, 并且被标记为#[no_mangle], 所以你可以在"DefaultHandler"中打一个断点来捕获未处理的异常.

也使用exception属性来覆盖DefaultHandler

#[exception]
fn DefaultHandler(irqn: i16) {
    // custom default handler
}

irqn参数是正在处理的异常. 负数表示正在处理的是Cortex-M异常. 0或正数表示正在处理设备特定的异常, 又被叫做中断.

硬件故障处理

HardFault有一点特殊. 当程序进入无效状态的时候, 会引发此异常, 所以这个函数不会return, 因为这可能会导致未定义行为. 另外, 在调用用户定义的HardFault函数时, 运行时会会做一些方便debug的工作.

结果是HardFault函数必须要像这样: fn(&ExceptionFrame) -> !. 函数的参数是一个指向由异常入栈的寄存器的指针. 这些寄存器是出现异常时处理器状态的快照, 可以用来诊断故障.

这有一个产生非法操作的例子: 读取不存在的内存地址.

NOTE 该程序在QEMU上不起作用, 不会崩溃, 因为qemu-system-arm -machine lm3s6965evb不会检查内存, 并且会在访问无效地址时返回一个0.

#![no_main]
#![no_std]

use panic_halt as _;

use core::fmt::Write;
use core::ptr;

use cortex_m_rt::{entry, exception, ExceptionFrame};
use cortex_m_semihosting::hio;

#[entry]
fn main() -> ! {
    // read a nonexistent memory location
    unsafe {
        ptr::read_volatile(0x3FFF_FFFE as *const u32);
    }

    loop {}
}

#[exception]
fn HardFault(ef: &ExceptionFrame) -> ! {
    if let Ok(mut hstdout) = hio::hstdout() {
        writeln!(hstdout, "{:#?}", ef).ok();
    }

    loop {}
}

HardFault函数打印ExceptionFrame的值. 如果你运行它, 会在OpenOCD上看到:

$ openocd
(..)
ExceptionFrame {
    r0: 0x3ffffffe,
    r1: 0x00f00000,
    r2: 0x20000000,
    r3: 0x00000000,
    r12: 0x00000000,
    lr: 0x080008f7,
    pc: 0x0800094a,
    xpsr: 0x61000000
}

pc是当前程序计数器发生异常时的值, 指向触发异常的指令.

如果你看看程序的反汇编:

$ cargo objdump --bin app --release -- -d --no-show-raw-insn --print-imm-hex
(..)
ResetTrampoline:
 8000942:       movw    r0, #0xfffe
 8000946:       movt    r0, #0x3fff
 800094a:       ldr     r0, [r0]
 800094c:       b       #-0x4 <ResetTrampoline+0xa>

你可以在反汇编中找到程序计数器0x0800094a的值. 看这, 有个加载行为 (ldr r0, [r0] ) 导致异常. ExceptionFramer0字段将告诉你此时寄存器r0的值为0x3fff_fffe

中断

中断在许多方面与异常有区别, 但是他们的操作与使用又在很大程度上相同, 并且他们也由同一中断控制器控制. 尽管异常是由Cortex-M架构定义的, 但是在中断的命名和功能上确实供应商(或芯片)确定的.

中断有很大的灵活性, 在尝试使用高级方法使用他们前, 应该考虑这些灵活性. 在本书中我们不会介绍用法, 但请牢记下面几点:

  • 中断具有可编程的优先级, 可以用来确定他们处理程序的顺序.
  • 中断可以嵌套, 可以抢占, 就是中断处理过程可能被另一个更高优先级的中断打断.
  • 通常需要处理掉触发中断的原因, 以防止无限进入中断.

运行时的常规初始化步骤始终相同:

  • 设置外设来启用中断.
  • 在中断处理器中设置优先级.
  • 在终端控制器中启用中断函数.

和异常一样, cortex-m-rt提供了一个interrupt属性来定义中断处理函数. 可用的中断(还有中断向量表中的位置)通常使用svd2rust由SVD文件自动生成.

// Interrupt handler for the Timer2 interrupt
#[interrupt]
fn TIM2() {
    // ..
    // Clear reason for the generated interrupt request
}

中断处理函数看起来就像普通函数一样(除了缺少参数). 但是由于特殊的调用约定, 他们不能直接被固件的其他部分直接调用. 但是可以在软件中生成中断请求, 以触发对中断函数的转移.

与异常处理类似, 也可以在中断处理函数中定义static mut变量来保持状态安全.

#[interrupt]
fn TIM2() {
    static mut COUNT: u32 = 0;

    // `COUNT` has type `&mut u32` and it's safe to use
    *COUNT += 1;
}

有关此机制的更加详细的说明, 请参考异常.

外设

什么是外设?

很多微控制器不仅仅只有 CPU, RAM, 或 FLASH闪存 - 他们是用与微控制器之外的系统进行交互, 即通过传感器, 马达, 或者像是显示器或键盘这样的人机界面直接的或简洁的与周围交互. 这些部件就叫做外设.

这些外设很有用, 因为他们能够让开发者把要干的事丢给它们, 这样就不用让软件来处理所有的事情. 就像是桌面开发者把图像处理的部分丢给显卡一样, 嵌入式开发者能够把一些任务放在外围设备上, 这样就可以让CPU有时间去干更重要的事, 或者什么都不干来省电.

如果你看一下上个世纪70年代到80年代的旧型号家用电脑(上个世代的电脑和微控制器没差多少), 你可以发现:

  • 一个处理器
  • 一个 RAM 芯片
  • 一块 ROM 芯片
  • 一个 I/O 控制器

RAM, ROM芯片还有I/O控制器(系统中的外设)会通过一系列并行接口,又叫做'总线'参与处理器的工作. 总线带有地址信息, 用来在总线上选择处理器想与那个外设通信. 在我们的嵌入式处理器中, 也是同样的规则 - 只不过是它们都被集成在了一块硅片上.

线性实际内存空间

在微控制器, 向某些地址写入数据, 如 0x4000_00000x0000_0000 , 也会是一个完全有效的操作.

在一个桌面系统上, 对内存的读取写入操作要经过 MMU, Memory Management Unit. 它有两个主要功能: 限制对内存的访问(防止一个进程读取或者修改另一个程序的内存); 并且将物理内存重新映射到虚拟内存中. 微控制器一般没有 MMU , 作为代替它们在软件中使用真实的内存地址.

尽管32位微控制器有一个实际的线性内存地址, 起始 0x0000_00000xFFFF_FFFF, 它们通常只使用该范围内的几百KB作为实际内存. 留下了很多内存空间. 在前面的章节中, 我们说过 RAM 位于地址 0x2000_0000. 如果我们的 RAM 有64KB 大小(最大地址0xFFFF), 那实际内存空间就是从 0x2000_00000x2000_FFFF. 当我们向 0x2000_1234 写入时, 实际上内部发生的是一些逻辑检测到我们用到地址的上半部分(这里是 0x2000 ), 然后激活RAM, 以便寻找下半部分地址(这里是 0x1234 ). 在 Cortex-M 上, 我们将ROM映射到 0x0000_0000 上, 假设你有 512KB ROM, 那就是从 0x0000_00000x0007_FFFF. 微控制器的设计人员没有忽略这两部分之间的剩余地址, 而是在他们上面映射了外设的接口. 最后看起来像这样:

nrf52-memory-map

Nordic nRF52832 Datasheet (pdf)

内存映射外设

乍一看, 与外设的交互十分简单, 将正确的数据写入到正确的内存地址上. 例如, 通过串口发送32位的数据就和直接向一个地址写入32位数据一样简单, 串口就会自动读取然后发送数据.

配置这些外设的工作都很相似. 不是调用用于配置它们的函数, 而是直接公开一块内存用于硬件API. 将 0x8000_0000 写入配置寄存器, SPI端口便会以 8Mb/s 的速度发送数据. 将 0x2000_0000 写入相同地址, SPI就会以 125kb/s 的速度发送数据. 这些配置寄存器看起来和这个一样:

nrf52-spi-frequency-register

Nordic nRF52832 Datasheet (pdf)

无论使用哪种语言, C, 汇编还是Rust, 都是像这样直接与硬件交互.

第一次尝试

寄存器

我们来看看 SysTick 这个外设-每个 Cortex-M 处理器内核都有的简单计时器. 通常情况下, 你可以在芯片制造商的数据手册中找到这些信息, 但是此示例对于所有的 Arm Cortex-M 内核都是通用的, 让我们在ARM参考手册中进行查找. 我们看到有四个寄存器:

OffsetNameDescriptionWidth
0x00SYST_CSRControl and Status Register32 bits
0x04SYST_RVRReload Value Register32 bits
0x08SYST_CVRCurrent Value Register32 bits
0x0CSYST_CALIBCalibration Value Register32 bits

C怎么做

在Rust中, 我们可以使用与 C 完全相同的方式来表示寄存器的合集-使用 struct .


#![allow(unused)]
fn main() {
#[repr(C)]
struct SysTick {
    pub csr: u32,
    pub rvr: u32,
    pub cvr: u32,
    pub calib: u32,
}
}

限定符 #[repr(C)] 告诉 Rust 编译器像 C 一样对这个 struct 布局. 这非常重要, 因为 Rust 会对 struct 的字段进行重新排序, 而 C 不会. 你可以想象一下, 如果 Rust 对其进行了重新排序, 我们进行调试找 BUG 会多难! 使用这个限定符之后, 我们就有四个32位字段, 它们与上表相对应. 但是, 当然仅有这个 struct 是不够的, 我们还需要一个变量.


#![allow(unused)]
fn main() {
let systick = 0xE000_E010 as *mut SysTick;
let time = unsafe { (*systick).cvr };
}

有序访问

现在, 我们碰到了一堆问题.

  1. 我们需要用到 unsafe 来访问我们的外设.
  2. 我们没有办法确定那个寄存器是只读的或可读可写的.
  3. 代码中的任何部分都可以通过这个结构来访问硬件.
  4. 最重要的, 现在它还不能用...

现在的问题是编译器很聪明. 如果你向同一块内存做两次写入, 一前一后, 编译器会注意到这个操作, 然后优化掉第一次写入. 在 C 中, 我们可以把这个变量标记为 volatile 来确保每次读写操作都会准确发生. 在 Rust 中, 我们将 指针 标记为 volatile, 而不是变量.


#![allow(unused)]
fn main() {
let systick = unsafe { &mut *(0xE000_E010 as *mut SysTick) };
let time = unsafe { core::ptr::read_volatile(&mut systick.cvr) };
}

所以, 我们修复了上面四个问题之一, 但是我们却写出了更 unsafe 的代码! 幸运的是, 这由第三方的库能帮我们 - volatile_register.


#![allow(unused)]
fn main() {
use volatile_register::{RW, RO};

#[repr(C)]
struct SysTick {
    pub csr: RW<u32>,
    pub rvr: RW<u32>,
    pub cvr: RW<u32>,
    pub calib: RO<u32>,
}

fn get_systick() -> &'static mut SysTick {
    unsafe { &mut *(0xE000_E010 as *mut SysTick) }
}

fn get_time() -> u32 {
    let systick = get_systick();
    systick.cvr.read()
}
}

现在, 读取写入都通过 readwrite 安排妥当了. 虽然写入还是 unsafe , 不过现在, 硬件现在是一堆可变的状态, 编译器没法知道这些操作是不是安全的, 所以这是一个不错的默认位置.

Rust风格的外壳

我们需要用一个更高级的 API 来封装一下这个 struct 来让能让我们安全使用. 作为驱动作者, 我们人工确定 unsafe 代码是否正确, 然后给我们的用户提供一个 safe 的 API来让我们的用户不去担心这些.

一个栗子:


#![allow(unused)]
fn main() {
use volatile_register::{RW, RO};

pub struct SystemTimer {
    p: &'static mut RegisterBlock
}

#[repr(C)]
struct RegisterBlock {
    pub csr: RW<u32>,
    pub rvr: RW<u32>,
    pub cvr: RW<u32>,
    pub calib: RO<u32>,
}

impl SystemTimer {
    pub fn new() -> SystemTimer {
        SystemTimer {
            p: unsafe { &mut *(0xE000_E010 as *mut RegisterBlock) }
        }
    }

    pub fn get_time(&self) -> u32 {
        self.p.cvr.read()
    }

    pub fn set_reload(&mut self, reload_value: u32) {
        unsafe { self.p.rvr.write(reload_value) }
    }
}

pub fn example_usage() -> String {
    let mut st = SystemTimer::new();
    st.set_reload(0x00FF_FFFF);
    format!("Time is now 0x{:08x}", st.get_time())
}
}

现在的问题是, 如下这样的代码是被编译器完完全全接受的:


#![allow(unused)]
fn main() {
fn thread1() {
    let mut st = SystemTimer::new();
    st.set_reload(2000);
}

fn thread2() {
    let mut st = SystemTimer::new();
    st.set_reload(1000);
}
}

我们对 set_reload 函数的 &mut self 参数可以确保没有其他对这个特定的 SystemTimer 的引用, 但是它们并不阻止用户创建第二个指向同一个外设的变量! 如果作者很努力的发现所有这样"重复"的却动, 那这样的写法会有作用, 但是一旦代码分散到多个模块, 驱动, 开发者之中, 那出现错误会越来越容易.

可变全局状态

很不幸, 硬件基本是就是可变的全局状态, 这对于 Rust 开发者来说非常恐怖. 硬件独立于我们写的代码而存在, 并且可以随时被现实世界改变状态.

我们的规则?

我们如何和这些外设进行可靠的交互?

  1. 始终使用 volatile 方法读取或写入外设内存, 因为它随时可能发生变化
  2. 在软件中, 我们应该只共享这些外设的不可变引用
  3. 如果某些软件需要对外设进行读写, 则应该保留对该外设的唯一引用

引用检查器

这些规则的最后两条听起来很想引用检查器已经在做的事情!

想象一下我们是否可以放弃对这些外设的所有权, 或者只是用可变或者不可变的引用?

好吧, 我们可以, 但是对于引用检查器, 我们需要每个外设都存在一个唯一实例, 来让 Rust 正确处理引用检查. 幸运的是, 在硬件中任何外设都只有一个实例, 但是如何在代码中展示出来呢?

单例

在软件工程中, 单例模式是一种限制一个类只能存在一种的设计模式

Wikipedia: Singleton Pattern

但是我们为什么直接用全局变量?

我们可以把任何都设成一个全局变量, 像这样:

static mut THE_SERIAL_PORT: SerialPort = SerialPort;

fn main() {
    let _ = unsafe {
        THE_SERIAL_PORT.read_speed();
    };
}

但是这有一些问题. 在 Rust 中, 与全局变量交互是 unsafe 的. 这些变量始终对你的程序可见, 这意味这引用检查器不能帮你追踪引用与所有权.

我们在 Rust 中怎么做?

代替将外设做为全局变量, 我们决定创建一个叫做 PERIPHERALS 的全局变量, 它包含我们每个外设的可空引用 Option<T>.

struct Peripherals {
    serial: Option<SerialPort>,
}
impl Peripherals {
    fn take_serial(&mut self) -> SerialPort {
        let p = replace(&mut self.serial, None);
        p.unwrap()
    }
}
static mut PERIPHERALS: Peripherals = Peripherals {
    serial: Some(SerialPort),
};

这个结构允许我们获取每个外设的单一实例. 如果我们多次使用 take_serail() , 我们的程序就会 panic !

fn main() {
    let serial_1 = unsafe { PERIPHERALS.take_serial() };
    // This panics!
    // let serial_2 = unsafe { PERIPHERALS.take_serial() };
}

尽管这么交互还是 unsafe , 但是我们一旦拿到它持有的 SerialPort , 我们就不需要再使用 unsafe 或者 PERIPHERALS 了.

这有很小的运行时开销, 因为我们必须将 SerialPort 包装在一个 Option<T> 中, 并且需要调用一次 take_serial(), 但是, 这一点点成本能够让我们在剩余所有过程中使用引用检查器来检查我们的程序.

已有的库支持

尽管我们在前面创建了我们自己的 Peripherals , 但是你没必要再自己的代码中这么些, cortex-m 库中包含了一个叫 singleton!() 的宏, 它会帮你.

#[macro_use(singleton)]
extern crate cortex_m;

fn main() {
    // OK if `main` is executed only once
    let x: &'static mut bool =
        singleton!(: bool = false).unwrap();
}

cortex_m docs

另外, 如果你使用cortex-m-rtic, 那它会帮你抽象这个定义和获取外围设备的步骤, 直接给你外设, 而不是你定义的 Option<T>.


#![allow(unused)]
fn main() {
// cortex-m-rtic v0.5.x
#[rtic::app(device = lm3s6965, peripherals = true)]
const APP: () = {
    #[init]
    fn init(cx: init::Context) {
        static mut X: u32 = 0;
         
        // Cortex-M peripherals
        let core: cortex_m::Peripherals = cx.core;
        
        // Device specific peripherals
        let device: lm3s6965::Peripherals = cx.device;
    }
}
}

但是为什么?

但是这些单例如何在我们的代码中产生明显的不同?


#![allow(unused)]
fn main() {
impl SerialPort {
    const SER_PORT_SPEED_REG: *mut u32 = 0x4000_1000 as _;

    fn read_speed(
        &self // <------ This is really, really important
    ) -> u32 {
        unsafe {
            ptr::read_volatile(Self::SER_PORT_SPEED_REG)
        }
    }
}
}

这有两个重要因素:

  • 因为我们在使用单例, 所以我们只有一种方法来获取一个 SerialPort
  • 为了使用 read_speed() 函数, 我们必须有 SerialPort的所有权或他的引用

这两个因素加在一起意味着我们只有在满足条件的情况下才能访问硬件, 意味着我们在任何时候都不能对同一硬件有多个可变引用!

fn main() {
    // missing reference to `self`! Won't work.
    // SerialPort::read_speed();

    let serial_1 = unsafe { PERIPHERALS.take_serial() };

    // you can only read what you have access to
    let _ = serial_1.read_speed();
}

把你的硬件看成数据

另外, 由于某些引用是可变的, 有些是不可变的, 因此可以查看某个函数或方法时候有潜在的可能修改硬件的状态. 例如:

这允许修改硬件设置:


#![allow(unused)]
fn main() {
fn setup_spi_port(
    spi: &mut SpiPort,
    cs_pin: &mut GpioPin
) -> Result<()> {
    // ...
}
}

这不允许:

fn read_button(gpio: &GpioPin) -> bool {
    // ...
}

这能够让我们在编译时(而不是运行时)确定代码是否能够修改硬件状态. 需要注意的是, 这通常仅仅在一个应用中可行, 但是对于裸金属系统, 我们的代码通常只会编译为一个应用, 所以不受限制.

静态保证

Rust 的类型系统可以防止在编译时发生数据竞争(参考SendSync traits). 类型系统还可以用来检查编译时的其他属性; 减少了对运行时检查的需求.

当应用于嵌入式程序时, 可以使用下面这些静态检查, 例如, 强制完成I/O的正确配置. 例如, 可以设计一种API, 在该API中只能先配置该就扣用到的引脚来初始化串口.

人们还可以静态检查操作, 像是只能通过正确配置的外设来让一个引脚为低. 例如, 改变浮动输入模式的引脚的输出状态会产生编译错误.

而且, 像上一章说的, 所有权的概念可以应用于外设, 以确保只有程序的某些部分才能修改外设. 与将外设设置为全局变量的方法相比, 这种访问控制的方法使应用更容易理解.

状态机编程

[typestate] 的概念描述了将当前的状态编码为一个类型. 尽管这听起来不可思议, 但如果你在 Rust 中使用了 Builder Pattern (建造者模式), 你就已经在用状态机编程了!

pub mod foo_module {
    #[derive(Debug)]
    pub struct Foo {
        inner: u32,
    }

    pub struct FooBuilder {
        a: u32,
        b: u32,
    }

    impl FooBuilder {
        pub fn new(starter: u32) -> Self {
            Self {
                a: starter,
                b: starter,
            }
        }

        pub fn double_a(self) -> Self {
            Self {
                a: self.a * 2,
                b: self.b,
            }
        }

        pub fn into_foo(self) -> Foo {
            Foo {
                inner: self.a + self.b,
            }
        }
    }
}

fn main() {
    let x = foo_module::FooBuilder::new(10)
        .double_a()
        .into_foo();

    println!("{:#?}", x);
}

在这个例子中, 没有一个直接创建 Foo 对象的方法. 我们必须先创建一个 FooBuilder, 然后正确的初始化它才能得到我们想要的 Foo 对象.

这个简单的小例子编码了两种状态:

  • FooBuilder 代表了一个"未配置", "在配置中"的状态
  • Foo 代表了一个"配置完成", "准备使用"的状态

强类型

因为 Rust 有一个 Strong Type System (强类型系统), 所以没有什么花里胡哨的办法去直接创建一个 Foo 的实例, 或者不用 into_foo() 方法把一个 FooBuilder 转变为 Foo. 另外, 调用 into_foo() 方法会消费掉原来的 FooBuilder, 意思是你没法再用它去创建一个新实例.

强类型系统让我们能把我们的系统状态表示为类型, 并且可以用方法来把由一种状态到另一种状态所需的步骤描述出来. 通过创建一个 FooBuilder, 并把它转变为 Foo 对象, 我们完成了一个基本的状态机.

外设状态机

MCU 的外设可以被看作是一种状态机. 例如, 简化的 GPIO Pin (GPIO 引脚) 的配置可以表示为如下状态树:

  • Disabled (禁用)
  • Enabled (启用)
    • Configured as Output (输出)
      • Output: High (高电平输出)
      • Output: Low (低电平输出)
    • Configured as Input (输入)
      • Input: High Resistance (高阻)
      • Input: Pulled Low (拉低)
      • Input: Pulled High (拉高)

如果外设开始是 Disabled 状态, 为了把它转换到 Input: High Resistance 模式, 我们要这么做:

  1. Disabled
  2. Enabled
  3. Configured as Input
  4. Input: High Resistance

如果我们想从 Input: High Resistance 状态到 Input: Pulled Low 状态, 我们需要:

  1. Input: High Resistance
  2. Input: Pulled Low

同样的, 如果我们想把一个 GPIO 从 Input: Pulled Low 设置到 Output: High, 我们需要:

  1. Input: Pulled Low
  2. Configured as Input
  3. Configured as Output
  4. Output: High

硬件表示

通常, 上面列出来的状态是将给定的值写入到 GPIO 外设的寄存器上来实现的. 让我们来定义一个虚构的 GPIO 寄存器来说明一下:

NameBit Number(s)ValueMeaningNotes
enable00disabledDisables the GPIO
1enabledEnables the GPIO
direction10inputSets the direction to Input
1outputSets the direction to Output
input_mode2..300hi-zSets the input as high resistance
01pull-lowInput pin is pulled low
10pull-highInput pin is pulled high
11n/aInvalid state. Do not set
output_mode40set-lowOutput pin is driven low
1set-highOutput pin is driven high
input_status5xin-val0 if input is < 1.5v, 1 if input >= 1.5v

我们可以在 Rust 中公开这个结构体来展示这个寄存器结构:


#![allow(unused)]
fn main() {
/// GPIO interface
struct GpioConfig {
    /// GPIO Configuration structure generated by svd2rust
    periph: GPIO_CONFIG,
}

impl GpioConfig {
    pub fn set_enable(&mut self, is_enabled: bool) {
        self.periph.modify(|_r, w| {
            w.enable().set_bit(is_enabled)
        });
    }

    pub fn set_direction(&mut self, is_output: bool) {
        self.periph.modify(|_r, w| {
            w.direction().set_bit(is_output)
        });
    }

    pub fn set_input_mode(&mut self, variant: InputMode) {
        self.periph.modify(|_r, w| {
            w.input_mode().variant(variant)
        });
    }

    pub fn set_output_mode(&mut self, is_high: bool) {
        self.periph.modify(|_r, w| {
            w.output_mode.set_bit(is_high)
        });
    }

    pub fn get_input_status(&self) -> bool {
        self.periph.read().input_status().bit_is_set()
    }
}
}

但是, 这样做会让我们能够修改其他寄存器. 例如, 如果当 GPIO 实际处于输入状态时, 我们将模式设置为输出会发生什么?

通常来说, 使用此结构体可以让我们达到状态机没有定义的状态: 例如, 被拉低的输出, 或者一个被设置为高电平的输入. 对于某些硬件, 这些可能不会起作用. 在其他硬件上, 这可能会导致 exception 或未定义行为.

尽管这个接口很方便, 但并不满足我们的设计.

设计合同

再上一章, 我们写了一个不符合设计合同的接口. 让我们再看一下我们假设的 GPIO 寄存器配置:

NameBit Number(s)ValueMeaningNotes
enable00disabledDisables the GPIO
1enabledEnables the GPIO
direction10inputSets the direction to Input
1outputSets the direction to Output
input_mode2..300hi-zSets the input as high resistance
01pull-lowInput pin is pulled low
10pull-highInput pin is pulled high
11n/aInvalid state. Do not set
output_mode40set-lowOutput pin is driven low
1set-highOutput pin is driven high
input_status5xin-val0 if input is < 1.5v, 1 if input >= 1.5v

如果我们改为在使用硬件前先检查状态, 在运行时强制执行我们的设计合同, 我们可能会写出如下的替代:


#![allow(unused)]
fn main() {
/// GPIO interface
struct GpioConfig {
    /// GPIO Configuration structure generated by svd2rust
    periph: GPIO_CONFIG,
}

impl GpioConfig {
    pub fn set_enable(&mut self, is_enabled: bool) {
        self.periph.modify(|_r, w| {
            w.enable().set_bit(is_enabled)
        });
    }

    pub fn set_direction(&mut self, is_output: bool) -> Result<(), ()> {
        if self.periph.read().enable().bit_is_clear() {
            // Must be enabled to set direction
            return Err(());
        }

        self.periph.modify(|r, w| {
            w.direction().set_bit(is_output)
        });

        Ok(())
    }

    pub fn set_input_mode(&mut self, variant: InputMode) -> Result<(), ()> {
        if self.periph.read().enable().bit_is_clear() {
            // Must be enabled to set input mode
            return Err(());
        }

        if self.periph.read().direction().bit_is_set() {
            // Direction must be input
            return Err(());
        }

        self.periph.modify(|_r, w| {
            w.input_mode().variant(variant)
        });

        Ok(())
    }

    pub fn set_output_status(&mut self, is_high: bool) -> Result<(), ()> {
        if self.periph.read().enable().bit_is_clear() {
            // Must be enabled to set output status
            return Err(());
        }

        if self.periph.read().direction().bit_is_clear() {
            // Direction must be output
            return Err(());
        }

        self.periph.modify(|_r, w| {
            w.output_mode.set_bit(is_high)
        });

        Ok(())
    }

    pub fn get_input_status(&self) -> Result<bool, ()> {
        if self.periph.read().enable().bit_is_clear() {
            // Must be enabled to get status
            return Err(());
        }

        if self.periph.read().direction().bit_is_set() {
            // Direction must be input
            return Err(());
        }

        Ok(self.periph.read().input_status().bit_is_set())
    }
}
}

因为我们给硬件加了强制约束, 所以在结束的时候要进行大量的运行时检查, 这浪费时间又浪费性能, 并且让人看着难受.

状态类型

但是如果反过来, 我们使用 Rust 的类型系统来执行转换规则的话, 看看这个例子:


#![allow(unused)]
fn main() {
/// GPIO interface
struct GpioConfig<ENABLED, DIRECTION, MODE> {
    /// GPIO Configuration structure generated by svd2rust
    periph: GPIO_CONFIG,
    enabled: ENABLED,
    direction: DIRECTION,
    mode: MODE,
}

// Type states for MODE in GpioConfig
struct Disabled;
struct Enabled;
struct Output;
struct Input;
struct PulledLow;
struct PulledHigh;
struct HighZ;
struct DontCare;

/// These functions may be used on any GPIO Pin
impl<EN, DIR, IN_MODE> GpioConfig<EN, DIR, IN_MODE> {
    pub fn into_disabled(self) -> GpioConfig<Disabled, DontCare, DontCare> {
        self.periph.modify(|_r, w| w.enable.disabled());
        GpioConfig {
            periph: self.periph,
            enabled: Disabled,
            direction: DontCare,
            mode: DontCare,
        }
    }

    pub fn into_enabled_input(self) -> GpioConfig<Enabled, Input, HighZ> {
        self.periph.modify(|_r, w| {
            w.enable.enabled()
             .direction.input()
             .input_mode.high_z()
        });
        GpioConfig {
            periph: self.periph,
            enabled: Enabled,
            direction: Input,
            mode: HighZ,
        }
    }

    pub fn into_enabled_output(self) -> GpioConfig<Enabled, Output, DontCare> {
        self.periph.modify(|_r, w| {
            w.enable.enabled()
             .direction.output()
             .input_mode.set_high()
        });
        GpioConfig {
            periph: self.periph,
            enabled: Enabled,
            direction: Output,
            mode: DontCare,
        }
    }
}

/// This function may be used on an Output Pin
impl GpioConfig<Enabled, Output, DontCare> {
    pub fn set_bit(&mut self, set_high: bool) {
        self.periph.modify(|_r, w| w.output_mode.set_bit(set_high));
    }
}

/// These methods may be used on any enabled input GPIO
impl<IN_MODE> GpioConfig<Enabled, Input, IN_MODE> {
    pub fn bit_is_set(&self) -> bool {
        self.periph.read().input_status.bit_is_set()
    }

    pub fn into_input_high_z(self) -> GpioConfig<Enabled, Input, HighZ> {
        self.periph.modify(|_r, w| w.input_mode().high_z());
        GpioConfig {
            periph: self.periph,
            enabled: Enabled,
            direction: Input,
            mode: HighZ,
        }
    }

    pub fn into_input_pull_down(self) -> GpioConfig<Enabled, Input, PulledLow> {
        self.periph.modify(|_r, w| w.input_mode().pull_low());
        GpioConfig {
            periph: self.periph,
            enabled: Enabled,
            direction: Input,
            mode: PulledLow,
        }
    }

    pub fn into_input_pull_up(self) -> GpioConfig<Enabled, Input, PulledHigh> {
        self.periph.modify(|_r, w| w.input_mode().pull_high());
        GpioConfig {
            periph: self.periph,
            enabled: Enabled,
            direction: Input,
            mode: PulledHigh,
        }
    }
}
}

现在让我们看看这段代码怎么用:


#![allow(unused)]
fn main() {
/*
 * Example 1: Unconfigured to High-Z input
 */
let pin: GpioConfig<Disabled, _, _> = get_gpio();

// Can't do this, pin isn't enabled!
// pin.into_input_pull_down();

// Now turn the pin from unconfigured to a high-z input
let input_pin = pin.into_enabled_input();

// Read from the pin
let pin_state = input_pin.bit_is_set();

// Can't do this, input pins don't have this interface!
// input_pin.set_bit(true);

/*
 * Example 2: High-Z input to Pulled Low input
 */
let pulled_low = input_pin.into_input_pull_down();
let pin_state = pulled_low.bit_is_set();

/*
 * Example 3: Pulled Low input to Output, set high
 */
let output_pin = pulled_low.into_enabled_output();
output_pin.set_bit(true);

// Can't do this, output pins don't have this interface!
// output_pin.into_input_pull_down();
}

这绝对是存储引脚状态的方便方法, 但是我们为什么要这么做? 为什么这么做比我们写一个 GpioConfigenum 要好?

编译时函数安全

因为我们在编译时完全执行设计约束, 所以不会产生运行时成本. 当引脚处于输入状态时, 无法设置输出模式. 相反, 你必须通过改变状态来把它转换为输出引脚, 然后设置输出模式. 正因为如此, 在编译时检查状态, 不会造成运行时的性能损失.

而且, 因为这些状态是由类型系统强制约束的, 所以使用者不会出错, 如果他们尝试做一些非法的状态转换, 编译就无法通过!

零成本抽象

类型状态是零成本抽象的一个非常棒的例子, 将一些确定的行为转移到编译步骤. 这些状态不包含实际数据, 而是用来标记. 因为他们不包含数据, 所以在运行时, 他们在内存中并不存在.


#![allow(unused)]
fn main() {
use core::mem::size_of;

let _ = size_of::<Enabled>();    // == 0
let _ = size_of::<Input>();      // == 0
let _ = size_of::<PulledHigh>(); // == 0
let _ = size_of::<GpioConfig<Enabled, Input, PulledHigh>>(); // == 0
}

零大小类型


#![allow(unused)]
fn main() {
struct Enabled;
}

像这样定义的类型叫做 Zero Sized Types (零大小类型), 因为他们并不包含实际的数据. 尽管这些类型在编译时实际存在, 你能复制, 移动, 引用他们. 但是优化器会完完全全的删掉他们.

在这段代码中:


#![allow(unused)]
fn main() {
pub fn into_input_high_z(self) -> GpioConfig<Enabled, Input, HighZ> {
    self.periph.modify(|_r, w| w.input_mode().high_z());
    GpioConfig {
        periph: self.periph,
        enabled: Enabled,
        direction: Input,
        mode: HighZ,
    }
}
}

我们返回的 GpioConfig 在运行时是不存在的. 调用此函数通常会简化为单个汇编指令 - 把一个固定的值写入一个固定的寄存器. 这意味着我们开发的状态类型接口是一个零成本抽象, 他不占用 CPU, RAM, 或代码空间来追踪 GpioConfig 的状态然后呈现为与直接访问寄存器相同的汇编代码.

嵌套

通常来说, 这些抽象你可以随便套, 只要使用的都是零大小类型, 整个结构在运行时就不会存在.

对于复杂或深度嵌套的结构, 定义所有的状态组合会非常麻烦, 在这种情况下, 使用宏会很舒服.

可移植性

在嵌入式环境中,可移植性是一个非常重要的指标:每家厂商或一家厂商中的不同系列都提供不同的外设与功能,并且交互的方法也不同。

一个普遍的方法是通过硬件抽象层,或者说是 HAL 解决。

硬件抽象是软件中的一组程序,他们模拟某些平台特定的操作细节,来让程序可以直接访问硬件资源。

他们提供对于硬件的标准 OS 接口可以让程序员来写出不依赖于设备,高性能的程序。

Wikipedia: Hardware Abstraction Layer

嵌入式系统在这方面有些特殊,因为我们通常没有操作系统,也不能安装软件,固件是作为一个整体来编译的,有着许多其他限制。 因此,尽管 Wikipedia 所定义的传统方法可能有用,但是并不是一个最有效的方法来确保可移植性。

我们怎么在 Rust 中做?来看看 embedded-hal...

什么是 embedded-hal

简而言之,它是一组 trait ,它定义了 HAL 的实现驱动还有应用(或固件)。这些约定包括能力(例如,如果你为某些类型实现了 trait ,那 HAL 就为它提供了一系列功能)和方法(例如,如果你构建了一个实现 trait 的类型,那就可以使用他的方法)。

典型的分型可能如下所示:

embedded-hal 中定义的一些 trait 如下:

  • GPIO (输入输出引脚)
  • 串行通信
  • I2C
  • SPI
  • 计数器、定时器
  • 数模转换

设计 embedded-hal trait 与 crate 并使用他们的原因是为了控制复杂性。 考虑一下,如果某一个应用必须实现硬件外设的使用方法,并且可能使用其他驱动,那么保持可移植性是很难的。 用数学一点的方法来表示,如果 M 是外设HAL实现的数量, N 是驱动的数量,那么如果我们为每一个应用重新造轮子,那么最终会得到 M * N 种实现,但是使用 embedded-hal trait 提供的 API 会把复杂度降低到 M + N。当然也有其他好处,例如这些定义良好、反复测试过的易用 API 。

embedded-hal 的应用场合

如前所述, HAL 的应用场合如下:

HAL 实现

HAL 实现提供了硬件与 HAL traits 用户的交互。典型的实现包括三个部分:

  • 一个或多个硬件具体类型
  • 提供多个配置(速度,操作方式,引脚等)来创建或初始化一个类型的函数
  • 一个或多个 embedded-haltraitimpl

这样的一个 HAL 实现有如下几种实现形式:

  • 通过低级硬件全是先,如寄存器
  • 通过操作系统,如在linux下通过 sysfs
  • 通过适配器,如单元测试中的 mock
  • 通过硬件适配器驱动,如 I2C 多路复用 或 GPIO 拓展

驱动

一个驱动为一个内部或外部的连接到实现了 embedded-hal traits 的组件实现一系列功能。 典型的例子包括各种传感器(温度,磁场,加速器,光),显示设备( LED , LCD 显示器)还有执行器(马达,发送器)。

一个驱动需要一个实现了 embedded-hal trait 的类型来初始化,这通过类型绑定来保证,并且为它自身提供一系列方法来让使用者与被驱动的设备来交互。

应用

应用将各个部分组合在一起来实现所需功能。在不同系统间移植时需要花费大量精力,因为应用程序需要通过 HAL 来正确的初试话实际硬件,并且不同的硬件之间初始化方法也有不同。此外,用户的决定通常也有着很大的影响,因为硬件可以物理的连接到不同位置,硬件有时候需要外部硬件才能正确配置,或者在使用内部设备时也面临着不同的选择(例如,多个定时器有着不同的功能并且可能有冲突)

并发

并发发生在你程序的不同的部分在不同时间发生或者无需执行时。 在嵌入式开发中,包括:

  • 中断处理,当相关中断发生时
  • 各种多线程,当你的微处理器交换线程时
  • 在某些系统中,多核处理器中每个核都可以独立运行

由于许多嵌入式应用都需要处理中断,所以并发迟早都会发生,同时也最容易出现许多奇怪难懂的 BUG 。 幸运的是, Rust 提供了许多抽象与安全保证来帮助我们写出正确的代码。

无并发

最简单的并发就是没有并发:你的应用就一个循环一直在运行,也没有中断。 有时候这就足够解决手头上的问题了! 典型的情况时是你的循环读取一些输入然后做一些处理进行输出。

#[entry]
fn main() {
    let peripherals = setup_peripherals();
    loop {
        let inputs = read_inputs(&peripherals);
        let outputs = process(inputs);
        write_outputs(&peripherals, outputs);
    }
}

因为没有并发,所以你也没必要担心在程序的不同部分分享数据或是同步外设的访问权限。 如果你能用这种方法解决问题那很好。

全局可变数据

不像非嵌入式的 Rust ,我们通常不会创建堆然后把对数据的引用传递给新建的线程。 相反我们的中断处理函数可能在任意时刻被调用,并且必须知道如何访问我们正在使用的内存。 在底层上这意味着我们必须静态分配可变内存,让这块内存可以被中断和主程序引用。

在 Rust 中,像 static mut 这样的变量是读写不安全的,因为没有特殊照顾的情况下,这可能会出现竞态, 即你对数据的访问在半路上被同样要访问该数据的中断打断。

举个例子,设想一下有个应用,它用一个计数器测量在一秒内一个信号有多少上升沿(频率计):

static mut COUNTER: u32 = 0;

#[entry]
fn main() -> ! {
    set_timer_1hz();
    let mut last_state = false;
    loop {
        let state = read_signal_level();
        if state && !last_state {
            // DANGER - Not actually safe! Could cause data races.
            unsafe { COUNTER += 1 };
        }
        last_state = state;
    }
}

#[interrupt]
fn timer() {
    unsafe { COUNTER = 0; }
}

定时器中断每秒把计数器归零。 同时主循环还在不停的测量信号,有一个上升沿就 +1 。 我们使用 unsafe 来操作 COUNTER ,因为它是个 static mut ,这意味着我们向编译器保证我们不会做任何未定义行为。 你能看出来这有竞态吗? COUNTER 的增加 不是 原子的 -- 事实上,在绝大多数嵌入式平台上,这个操作会被分成读取、增加、保存三个步骤。 如果中断发生在读取之后,保存之前,那清零的操作就会被忽略,我们便会一个周期计两次。

临界区

那么,我们要怎么做?一个简单的方法是使用 临界区 ,在这中断被关闭。 通过在 main 中使用一个临界区来包裹 COUNTER 我们可以保证在我们完成增加 COUNTER 前定时器中断不会触发。

static mut COUNTER: u32 = 0;

#[entry]
fn main() -> ! {
    set_timer_1hz();
    let mut last_state = false;
    loop {
        let state = read_signal_level();
        if state && !last_state {
            // New critical section ensures synchronised access to COUNTER
            cortex_m::interrupt::free(|_| {
                unsafe { COUNTER += 1 };
            });
        }
        last_state = state;
    }
}

#[interrupt]
fn timer() {
    unsafe { COUNTER = 0; }
}

在这个例子中,我们使用 cortex_m::interrupt::free ,其他平台也有类似的步骤。 这等效于禁用中断,执行代码,重启中断。

注意我们不需要在中断中使用临界区,因为:

  • COUNTER 写0不会导致竞态,因为我们没读它
  • 它不可能被 main 中断

如果 COUNTER 被多个中断处理函数 共用 ,那么每个中断可能都需要一个临界区。

这解决了我们眼前的问题,但是我们还是得写一堆需要仔细考虑的不安全代码,而且也有可能写了没必要的临界区。 因为每个临界区都暂时的停止了中断,所以会有一些额外的代码大小,还增加了中断的延迟与中断处理的时间。 这是不是个问题取决于你的系统,但我们应该避免。

需要注意,虽然临界区保证不会发生中断,但是它并不能在多核系统上做出同样的保证! 即使没有中断,其他的核心也可以访问你操作的核的内存。如果你使用多核系统,那么你需要更强的同步原语。

原子操作

在一些平台上,我们可以使用特殊的原子指令,为读取-修改-保存操作提供保证。 针对 Cortex-M: thumbv6(Cortex-M0, Cortex-M0+) 只提供原子读和原子写, thumbv7(Cortex-M3 及以上 ) 提供完整的比较交换(CAS)操作。 这些 CAS 指令提供了消耗严重的禁用中断的替代方法:我们直接增加,大多数时候会成功,但如果被中断,它会自动尝试重新增加。 即使是多核系统,这些操作仍然是安全的。

use core::sync::atomic::{AtomicUsize, Ordering};

static COUNTER: AtomicUsize = AtomicUsize::new(0);

#[entry]
fn main() -> ! {
    set_timer_1hz();
    let mut last_state = false;
    loop {
        let state = read_signal_level();
        if state && !last_state {
            // Use `fetch_add` to atomically add 1 to COUNTER
            COUNTER.fetch_add(1, Ordering::Relaxed);
        }
        last_state = state;
    }
}

#[interrupt]
fn timer() {
    // Use `store` to write 0 directly to COUNTER
    COUNTER.store(0, Ordering::Relaxed)
}

这次 COUNTER 是一个安全的 static 变量。多亏 AtomicUsize 类型, COUNTER 能从中断和主循环中不停用中断安全修改。 如果可行的话这是个更好的方法 -- 但它取决于你的系统支不支持。

关于 Ordering 的说明: 这影响编译器和硬件对指令的重新排序方式,也会对缓存产生影响。 如果单核的话, Relaxed 就够了,也是效率最高的方法。 更严格的排序会让编译器围绕原子操作生成内存屏障; 根据你使用的原子操作的对象选择是否使用。原子模型很复杂,在这里不做介绍。

如果想了解更多有关原子与排序的内容,请看 nomicon

抽象、发送和同步

上面的方法都不是很让人满意。他们需要使用 unsafe ,所以我们得非常仔细的检查,很反人类。 在 Rust 中我们有更好的解决办法!

我们可以把 COUNTER 抽象成一个我们可以在哪都能用的安全的接口。 在这个例子中,我们使用临界区,但你也可以用原子操作做到相同的功能。

use core::cell::UnsafeCell;
use cortex_m::interrupt;

// Our counter is just a wrapper around UnsafeCell<u32>, which is the heart
// of interior mutability in Rust. By using interior mutability, we can have
// COUNTER be `static` instead of `static mut`, but still able to mutate
// its counter value.
struct CSCounter(UnsafeCell<u32>);

const CS_COUNTER_INIT: CSCounter = CSCounter(UnsafeCell::new(0));

impl CSCounter {
    pub fn reset(&self, _cs: &interrupt::CriticalSection) {
        // By requiring a CriticalSection be passed in, we know we must
        // be operating inside a CriticalSection, and so can confidently
        // use this unsafe block (required to call UnsafeCell::get).
        unsafe { *self.0.get() = 0 };
    }

    pub fn increment(&self, _cs: &interrupt::CriticalSection) {
        unsafe { *self.0.get() += 1 };
    }
}

// Required to allow static CSCounter. See explanation below.
unsafe impl Sync for CSCounter {}

// COUNTER is no longer `mut` as it uses interior mutability;
// therefore it also no longer requires unsafe blocks to access.
static COUNTER: CSCounter = CS_COUNTER_INIT;

#[entry]
fn main() -> ! {
    set_timer_1hz();
    let mut last_state = false;
    loop {
        let state = read_signal_level();
        if state && !last_state {
            // No unsafe here!
            interrupt::free(|cs| COUNTER.increment(cs));
        }
        last_state = state;
    }
}

#[interrupt]
fn timer() {
    // We do need to enter a critical section here just to obtain a valid
    // cs token, even though we know no other interrupt could pre-empt
    // this one.
    interrupt::free(|cs| COUNTER.reset(cs));

    // We could use unsafe code to generate a fake CriticalSection if we
    // really wanted to, avoiding the overhead:
    // let cs = unsafe { interrupt::CriticalSection::new() };
}

我们把 unsafe 的代码移到了我们精心设计好的抽象中,现在我们的应用不包含任何 unsafe 的部分。

这个设计要求我们传入一个 CriticalSection 标记:这些标记只能由 interrupt::free 安全生成, 所以通过要求传入一个标志,我们保证这个操作实在临界区执行的,而不用自己去操作。 这个保证由编译器提供:不会在运行时有任何关于 cs 的开销。 如果我们有多个计数器,它们也可以使用相同的 cs ,不用嵌套多个临界区。

这也引出了 Rust 中一个重要的话题: Send and Sync traits 。 总结一下, 实现 Send 的类型可以被安全的转移到另一个线程, 而实现 Sync 的可以安全的在多个线程中共用。 在嵌入式开发中,我们把中断视为新开线程,所以主代码块与中断共用的变量一定实现 Sync 。

对于 Rust 中的大多数类型,这两个 traits 通常由编译器自动派生。 然而,因为 CSCounter 包含一个 UnsafeCell ,它并不 Sync , 所以我们没法声明一个 static CSCounterstatic 一定 是修饰 Sync 的,因为能被多线程共用。

为了让编译器知道 CSCounter 事实上多线程共用是安全的,我们主动为它加上 Sync 。 与之前用的临界区一样,它只在单核系统上安全。

互斥量

我们针对计数器问题创造了一种抽象,同时还有很多用于并发的通用的抽象。

一种 同步原语 叫互斥(mutex), mutual exclusion 的缩写。 这种结构确保对变量的独占访问,如我们的计数器。 一个线程可以尝试去 (或 需求 )这个互斥锁,然后要么马上成功,要么等锁被用完,要么返回一个没法上锁的错误。 当该线程持有这个锁时,它能够访问这个受保护的数据。 当线程结束时,它 解锁 (或 释放 )这个互斥锁,以便让其他线程上锁。 在 Rust 里,我们通常使用 Drop trait 来修饰 Unlock ,以确保互斥量超出作用域时能正确释放锁。

把中断和互斥量用在一起可能有点难:中断中通常来说都不能阻塞,并且在中断中阻塞等待主循环解锁是不可能的, 会发生 死锁 (主线程因为等待中断结束而不会解锁)。 死锁是不安全的,即使在没有 unsafe 的 Rust 中也有可能发生。

为了避免这种情况的发生,我们可以实现一个需要临界区来上锁的互斥量,就像例子一样。 只要临界区和锁生命周期一样我们就可以保证我们独占被包装的变量,甚至不需要管互斥量锁没锁。

cortex-m 库已经帮我们完成了这些!我们可以用这种方法来写我们的计数器:

use core::cell::Cell;
use cortex_m::interrupt::Mutex;

static COUNTER: Mutex<Cell<u32>> = Mutex::new(Cell::new(0));

#[entry]
fn main() -> ! {
    set_timer_1hz();
    let mut last_state = false;
    loop {
        let state = read_signal_level();
        if state && !last_state {
            interrupt::free(|cs|
                COUNTER.borrow(cs).set(COUNTER.borrow(cs).get() + 1));
        }
        last_state = state;
    }
}

#[interrupt]
fn timer() {
    // We still need to enter a critical section here to satisfy the Mutex.
    interrupt::free(|cs| COUNTER.borrow(cs).set(0));
}

我们现在使用 Cell ,它与他的兄弟 RefCell 共同提供安全的内部可变性。 我们已经见过 UnsafeCell 了,他是 Rust 内部可变性的最底层:它允许你获取多个它的可变引用,但只能在 unsafe 中。 一个 CellUnsafeCell 差不多,但是它提供一个安全的接口: 它只允许获取当前值的一个复制或者替换它,而不允许引用,并且因为它不 Sync ,它没法在线程中共用。 这些特性意味着我们能安全使用,但我们没法直接用 static 修饰它,因为 static 只能用在 Sync 身上。

那为什么上面的例子能用? Mutex<T> 为任何实现 Send 的 T 实现 Sync。 这么做是安全的因为它只在临界区中允许访问它的内容。 因此我们能得到一个完全不用 unsafe 的安全计数器。

对于像 u32 这样的简单结构很棒,但是不能 Copy 的复杂类型呢? 嵌入式开发中一个很常见的示例是外设,它通常是不能 Copy 的。 因此我们可以使用 RefCell

分享外设

使用 svd2rust 和相关抽象生成设备库通过强制外设只有一个实例保证了安全。 但是也给从主线程与中断中操作外设造成了困难。

为了安全的分享外设权限,我们可以像之前一样使用 Mutex 。 我们还要用到 RefCell ,它有一个运行时检查来确保一次只给出一个外设的引用。 相比普通的 Cell 有着更多的开销,但因为我们提供引用而不是副本,我们必须确保同时只能存在一个。

最后,我们还需要考虑怎么在主线程初始化后把外设移动到共享变量中。 为此我们可以使用 Option 类型,初始化为 None 然后再设置为外设的实例。

use core::cell::RefCell;
use cortex_m::interrupt::{self, Mutex};
use stm32f4::stm32f405;

static MY_GPIO: Mutex<RefCell<Option<stm32f405::GPIOA>>> =
    Mutex::new(RefCell::new(None));

#[entry]
fn main() -> ! {
    // Obtain the peripheral singletons and configure it.
    // This example is from an svd2rust-generated crate, but
    // most embedded device crates will be similar.
    let dp = stm32f405::Peripherals::take().unwrap();
    let gpioa = &dp.GPIOA;

    // Some sort of configuration function.
    // Assume it sets PA0 to an input and PA1 to an output.
    configure_gpio(gpioa);

    // Store the GPIOA in the mutex, moving it.
    interrupt::free(|cs| MY_GPIO.borrow(cs).replace(Some(dp.GPIOA)));
    // We can no longer use `gpioa` or `dp.GPIOA`, and instead have to
    // access it via the mutex.

    // Be careful to enable the interrupt only after setting MY_GPIO:
    // otherwise the interrupt might fire while it still contains None,
    // and as-written (with `unwrap()`), it would panic.
    set_timer_1hz();
    let mut last_state = false;
    loop {
        // We'll now read state as a digital input, via the mutex
        let state = interrupt::free(|cs| {
            let gpioa = MY_GPIO.borrow(cs).borrow();
            gpioa.as_ref().unwrap().idr.read().idr0().bit_is_set()
        });

        if state && !last_state {
            // Set PA1 high if we've seen a rising edge on PA0.
            interrupt::free(|cs| {
                let gpioa = MY_GPIO.borrow(cs).borrow();
                gpioa.as_ref().unwrap().odr.modify(|_, w| w.odr1().set_bit());
            });
        }
        last_state = state;
    }
}

#[interrupt]
fn timer() {
    // This time in the interrupt we'll just clear PA0.
    interrupt::free(|cs| {
        // We can use `unwrap()` because we know the interrupt wasn't enabled
        // until after MY_GPIO was set; otherwise we should handle the potential
        // for a None value.
        let gpioa = MY_GPIO.borrow(cs).borrow();
        gpioa.as_ref().unwrap().odr.modify(|_, w| w.odr1().clear_bit());
    });
}

需要考虑的内容很多,让我们来挑出重要的几行。

static MY_GPIO: Mutex<RefCell<Option<stm32f405::GPIOA>>> =
    Mutex::new(RefCell::new(None));

我们的共享变量现在是一个 Mutex 套娃 RefCell 套娃 OptionMutex 确保我们仅能够在临界区有访问权限,来让本来不 Sync 的 RefCell Sync 。 RefCell 通过引用为我们提供了内部可变性,让我们能够用我们的 GPIOAOption 让我们能够初始化一个空值然后再把我们的变量塞进去。 我们没法静态访问外设实例,只有在运行时可以,所以这是必须的。

interrupt::free(|cs| MY_GPIO.borrow(cs).replace(Some(dp.GPIOA)));

在临界区中,我们对互斥量使用 borrow() ,让我们拿到一个 RefCell 的引用。 使用 replace() 来替换 RefCell 中的值。

interrupt::free(|cs| {
    let gpioa = MY_GPIO.borrow(cs).borrow();
    gpioa.as_ref().unwrap().odr.modify(|_, w| w.odr1().set_bit());
});

最后我们能够安全并发使用 MY_GPIO 。临界区防止中断发生,让我们解锁互斥量。 RefCell 给我们一个 &Option<GPIOA> , 并且跟踪它的生命周期 -- 一旦生命周期结束, RefCell 将被更新以表示它不再被使用。

因为我们没法把 GPIOA 移出 &Option ,我们需要使用 as_ref() 转换成 &Option<&GPIOA> , 让我们最终能 unwarp() 出能操作外设的 &GPIOA

如果我们需要一个共享资源的可变引用,那使用 borrow_mutderef_mut 来替代。 下面的例子使用 TIM2 来展示。

use core::cell::RefCell;
use core::ops::DerefMut;
use cortex_m::interrupt::{self, Mutex};
use cortex_m::asm::wfi;
use stm32f4::stm32f405;

static G_TIM: Mutex<RefCell<Option<Timer<stm32::TIM2>>>> =
	Mutex::new(RefCell::new(None));

#[entry]
fn main() -> ! {
    let mut cp = cm::Peripherals::take().unwrap();
    let dp = stm32f405::Peripherals::take().unwrap();

    // Some sort of timer configuration function.
    // Assume it configures the TIM2 timer, its NVIC interrupt,
    // and finally starts the timer.
    let tim = configure_timer_interrupt(&mut cp, dp);

    interrupt::free(|cs| {
        G_TIM.borrow(cs).replace(Some(tim));
    });

    loop {
        wfi();
    }
}

#[interrupt]
fn timer() {
    interrupt::free(|cs| {
        if let Some(ref mut tim)) =  G_TIM.borrow(cs).borrow_mut().deref_mut() {
            tim.start(1.hz());
        }
    });
}

哇!这很安全,但也有点憨批。我们还有什么可以做的吗?

RTIC

一种替代是 RTIC framework ( Real Time Interrupt-driven Concurrency )。 它强制执行静态优先级并跟踪对 static mut 变量(“资源”)的访问,以确保共享资源始终安全访问, 而不用进入临界区和使用引用计数(如在 RefCell 中)的开销。 它有许多优点,例如保证没有死锁并提供极快的时间和内存开销。

该框架还提供了许多其他功能,如消息传递,能减少对显式共享状态的需求,还有能在指定时间调度任务的能力,可以用来实现周期性任务。 查看 the documentation 获取更多信息!

实时操作系统

嵌入式并发的另一种常见方法是实时操作系统( RTOS )。 虽然在 Rust 中发展还不是很好,但他们广泛应用于传统嵌入式开发。 开源项目包括 FreeRTOSChibiOS 。 这些实时操作系统为运行多个线程提供 CPU 调度的支持,包括线程让出控制(协作多任务)与基于常规计时器与中断(抢占式任务)。 RTOS 通常提供互斥量与其他同步原语,并且经常与硬件引擎(如 DMA 控制器)进行互操作。

在本文撰写时,还没有许多 Rust 的 RTOS 例子,但请仍然关注。

多核

在嵌入式系统中,多核系统越来越普遍,这给并发又增加了难度与复杂程度。 所有使用临界区的例子(包括 cortex_m::interrupt::Mutex )都假设唯一能打断的线程是中断, 但在多核系统上不是这样。 相反我们需要为多核系统设计的同步原语(也叫 SMP ,symmetric multi-processing )。

这些通常使用我们之前看到的原子指令,因为处理系统将确保在所有内核上保持原子性。

详细介绍这些主题目前超出了本书的范围,但一般模式与单核情况相同。

Collections

Design Patterns

HALs

Checklist

Naming

Interoperability

Predictability

GPIO

Tips for embedded C developers

Interoperability

A little C with your Rust

A little Rust with your C

Unsorted topics

Optimizations: The speed size tradeoff

Appendix A: Glossary