淘先锋技术网

首页 1 2 3 4 5 6 7

文章目录

1.介绍

欢迎来到rust嵌入式书:这本书介绍怎么使用rust开发如微控制器的裸板嵌入式系统。

嵌入式rust是为谁设计的

嵌入式rust是为需要嵌入式编程同时还需要rust语言高抽象和安全概念的人。

范围

这本书的目的是:

  • 让开发者快速的进行rust嵌入式开发,比如如何配置好开发环境。
  • 分享当前最好的嵌入式rust开发练习。比如怎么最好用rust的特性去实现更多的嵌入式软件。
  • 如何与c混合的开发项目

本书使用 ARM Cortex-M框架作为例子,零开发经验的也没关系,本书会细心的讲解。

这本书是为谁而写

这本书迎合了有一些嵌入式背景或一些Rust背景的人,但是我们相信每个对嵌入式Rust编程感兴趣的人都可以从这本书中得到一些东西。对于那些没有任何先验知识的人,我们建议你阅读“假设和先决条件”部分,弥补遗漏的知识,从书中获得更多知识,改善你的阅读体验。您可以查看“其他参考资料”部分,以找到关于您可能想要了解的主题的参考资料。

假设和先决条件

你已经有了一些开发rust程序的经验,也熟悉2018版的语法。

你已经有了些嵌入式编程经验,可以使用c ,c++,Ada等类似语言。并熟悉以下概念:

  • 交叉编译
  • 外围设备的内存映射
  • 常用接口:I2C,SPI,串口等

其他的学习资源

如果你不是上面提及到的,或你对指定的课题还需要更多的信息,下面的资源可能很有帮助。

TopicResourceDescription
RustRust Book如果你对rust还是很陌生,请你阅读来熟悉rust这门语言。
Rust, EmbeddedDiscovery Book如果你从未做个任何嵌入式项目,这本书将很适合你
Rust, EmbeddedEmbedded Rust Bookshelf如何提供其他芯片资源给rust嵌入式工作组
Rust, EmbeddedEmbedonomicon使用rust进行嵌入式编程的一些细节
Rust, Embeddedembedded FAQ嵌入式环境中关于Rust的常见问题。
InterruptsInterrupt-
Memory-mapped IO/PeripheralsMemory-mapped I/O-
SPI, UART, RS232, USB, I2C, TTLStack Exchange about SPI, UART, and other interfaces-

如何使用这本书

这本书通常假设你是从头到尾读的。后面的章节建立在前面章节的概念基础上,前面的章节可能不会深入到一个主题的细节,在后面的章节中重新审视这个主题。

这本书将使用 STM32F3DISCOVERY开发板作为实验教材。这个板子是基于ARM Cortex-M架构的,虽然基于这个架构的大多数cpu的基本功能是相同的,但不同厂商的微控制器的外设和其他实现细节是不同的,甚至同一厂商的微控制器系列也不同。

因为这个原因,我们建议购买STM32F3DISCOVERY,来搭配本书进行实验。

给本书做贡献

这本书在repository 中合作完成,主导开发的是resources team.

如果你跟随此书开发出现问题,或者发现本书哪里有不清楚的地方,或者有bug,应该及时的报告这本书的issue tracker

修正和增新是我们非常欢迎的!

这本书的重复使用

这本是遵循以下的几个许可:

如果你想要使用我们的文本或者图像在你的工作中,你需要:

在你的文件中提供链接

提供CC-BY-SA v4.0 链接许可。

说明您是否以任何方式更改了材料,并根据同一许可对我们的材料作出任何更改。

1.1硬件

看看你的硬件

让我们熟悉熟悉我们将使用的硬件。

STM32F3DISCOVERY (F3系列)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HocoqbNd-1659849615094)(/mnt/笔记/img/f3.jpg)]

这块板中包含什么?

一个STM32F303VCT6 微控制器。这片微控制器中有:

  1. 一个单核的ARM Cortex-M4F处理器硬件支持单精度浮点运算,最大时钟频率为72 MHz。
  • 256 KB 闪存 (1 KB = 1024 bytes).
  • 48KB RAM.
  • 多种集成外设,如定时器,I2C, SPI和USART。
  • 通用输入输出(GPIO)和其他类型的引脚.
  • USB接口可通过标记为“USB USER”的USB端口访问。

2.LSM303DLHC 一个加速度传感器芯片

3.LSM303DLHC一个电磁传感器芯片

4.L3GD20一个陀螺仪芯片

5.8个绕城一圈的led灯

6.第二个微控制器:STM32F103这个微控制器实际上是机载编程/调试器的一部分,连接到名为“USB ST-LINK”的USB端口。

更多的细节可以去官网查询STMicroelectronics

提醒一句:如果你想将外部信号应用到电路板上,要小心。
微控制器STM32F303VCT6引脚的标称电压为3.3伏。
有关进一步信息,请参阅手册中的6.2绝对最大额定值部分

1.2no_std

一个no_std环境

嵌入式1编程有一个很广泛的范围,小到只有很少内存容量的8bitMCU(ST72325xx)大到有操作系统的树莓派(Model B 3+)根据目标和用例类型的不同,在编写代码时将会应用不同的限制。

有两种最常用的嵌入式编程类型:

托管环境

有几种环境比较接近于常用的PC环境。这种环境提供系统接口,系统接口为你提供与各种系统交互的方式,如文件系统、网络、内存管理、线程等。反过来,标准库通常依赖于这些方式来实现其功能。您可能还有一些sysroot和RAM/ Rom使用的限制,可能还有一些特殊的HW或I/ O。总的来说,这感觉就像在一个特殊用途的PC环境中编写代码。

裸机环境

在你编程之前裸机没有加载任何代码,离开操作系统提供的软件标准库。程序,crate要运行在裸机no_std环境中。与平台无关的标准库可以从libcore获得.

Libcore还排除了嵌入式环境中不总是需要的东西。其中一个是用于动态内存分配的内存分配器。如果你需要这个或其他功能,通常有crate提供这些。

libstd运行时

正如前面提到的,使用libstd需要某种类型的系统集成,但这不仅仅是因为libstd提供了一种访问操作系统抽象的通用方法,它还提供了一个运行时。这个运行时主要负责设置堆栈溢出保护、处理命令行参数以及在调用程序的main函数之前生成主线程。
此运行时在no_std环境中也不可用。

总结

#![no_std]是一个crate级的属性,这个属性说明crate将链接到core-crate而不是标准std-crate。libcore crate是std crate的一个与平台无关的子集,它对程序将运行的系统没有任何假设。
因此,它提供了用于语言原语(如浮点数、字符串和片)以及用于公开处理器特性(如原子操作和SIMD指令)的api。
然而,它缺乏任何涉及平台集成的api。由于这些属性,no_std和libcore代码可以用于任何类型的引导(阶段0)代码,如引导加载程序、固件或内核。

综上所述

特性no_stdstd
堆 (动态申请内存)*
集合 (Vec, HashMap, 等)**
栈溢出保护
在main函数之前运行初始化代码
libstd 可用
libcore 可用
编写固件、内核或引导加载程序代码

*只有当你使用alloc crate和使用一个合适的分配器,如alloc-cortex-m。

**仅当您使用collections crate并配置全局默认分配器时。

工具链

处理微控制器涉及到使用几种不同的工具,因为我们将处理与你的笔记本电脑不同的架构,我们必须在远程设备上运行和调试程序。

我们将使用下面列出的所有工具。当没有指定最小版本时,任何最近的版本都可以工作,但是我们列出了已经测试过的版本。

接下来的文本将解释为什么我们要使用这些工具。安装指令将在下一节。

cargo-generate或git

裸机程序是非标准的(no_std) Rust程序,它需要对链接过程进行一些调整,以获得正确的程序的内存布局。
这需要一些额外的文件(如链接脚本)和设置(如链接标志)。
我们已经将它们打包在一个模板中,这样您只需要填写缺失的信息(例如项目名称和目标硬件的特征)。

cargo-generate:是Cargo子命令,用于从模板创建新的Cargo项目。你也可以使用git,curl,wget或者web浏览器去获得模板。

cargo-binutils

cargo-binutils是cargo子命令的一个集合,它使跟随rust工具链的LLVM工具更好用。这些工具包括LLVM版本的objdump, nm 和size,这些工具是用来检查二进制文件。

用LLVM工具而不是GNU工具的优点是用一句命令就能安装全部(rustup component add llvm-tools-preview) ,不管你的OS还是二进制工具如objdump都支持,rustc支持ARM到x86_64,因为他们同用LLVM后端。

qemu-system-arm

QEMU是一个模拟器。在本例中我们使用这个软件可以完全的模拟ARM系统。我们在主机中使用QEMU运行嵌入式程序。对亏这个软件你可以在没有相应硬件的情况下跟着本书的例子做实验。

GDB

调试器对于嵌入式开发是一个非常重要的部分,当你可能不总有在控制台记录东西的习惯。在某些情况下,你可能都没有能提示你程序是否正确的led灯在你的硬件中。

通常来说,LLDB在调试时与GDB是一样的,但是LLDB没有与GDB对应的load命令,load命令可以上传程序到目标硬件,所以现在我们推荐的是使用GDB。

Openocd

GDB不能直接的与ST-Link交互来调试你的STM32F3DISCOVERY开发板,它需要一个转换器和开源片上调试器,Openocd就是那个转换器。OpenOCD是一个运行在你的笔记本/PC上的一个程序,它可以转换基于远程调试协议的gdbTCP/IP协议和ST-link的usb协议。

OpenOCD还执行其他重要的工作,作为其转换的一部分,用于调试STM32F3DISCOVERY开发板上基于ARM Cortex-M的微控制器:

1.它知道如何与ARM CoreSight调试外设使用的内存映射寄存器进行交互。正是这些CoreSight寄存器允许:

  • 断点/检测点的操作
  • 读或写CPU寄存器
  • 检测CPU因调试事件而停止
  • 调试事件后继续执行CPU
  • 等等

2.它还知道如何擦去和写入控制器的闪存中。

工具安装

这节包括一些与操作无关的工具的安装。

Rust工具链

安装rustup可以从官网中学习 https://rustup.rs.

注意:确保你安装的编译器大于等于1.31版本,rustc -V会显示当编译器版本。

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

对于带宽和磁盘使用情况,默认安装只支持本地编译。要为ARM Cortex-M架构添加交叉编译支持,请选择以下编译目标之一。对于本书中用于示例的STM32F3DISCOVERY开发板,使用thumbv7em-none-eabihf目标。

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

rustup target add thumbv6m-none-eabi

Cortex-M3 (ARMv7-M 框架):

rustup target add thumbv7m-none-eabi

没有硬件浮点运算的Cortex-M4 和 M7 (ARMv7E-M 框架):

rustup target add thumbv7em-none-eabi

有硬件浮点运算的Cortex-M4F 和 M7F:

rustup target add thumbv7em-none-eabihf

Cortex-M23 (ARMv8-M 框架):

rustup target add thumbv8m.base-none-eabi

Cortex-M33 和 M35P (ARMv8-M 框架):

rustup target add thumbv8m.main-none-eabi

有硬件浮点运算的Cortex-M33F 和 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

注意:在一些linux的衍生版(如ubuntu)你可能需要先安装libssl-dev和pkg-config依赖

1.4.1Linux

这就是几种版本linux的命令。

Packages

  • Ubuntu 18.04/Debian stretch 或更高的版本.

注意:gdb-multiarch是GDB命令,你将使用这个命令调试 ARM Cortex-M程序。

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

注意:arm-none-eabi-gdb是GDB命令,你将使用这个命令调试 ARM Cortex-M程序。

sudo apt install gdb-arm-none-eabi openocd qemu-system-arm
  • Fedora 27 或这更高版本

注意:arm-none-eabi-gdb是GDB命令,你将使用这个命令调试 ARM Cortex-M程序。

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

注意:arm-none-eabi-gdb是GDB命令,你将使用这个命令调试 ARM Cortex-M程序。

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

udev规则

udev规则让你在没有跟权限的开发板中使用OpenOCD.

创造文件/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 use
user::rw- 
user:you:rw-

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

现在,去下一节。

1.4.2macOS

所有工具都可以安装使用

Homebrew:

$ # GDB 

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

$ # OpenOCD 

$ brew install openocd 

$ # QEMU

 $ brew install qemu

注意:如果OpenOCD崩溃,你可能需要安装最新版本使用:

$ brew install --HEAD openocd

这样就可以了。

1.4.3Windows

arm-none-eabi-gdb

ARM公司提供.exe安装器给windows系统。从这下载一个here,跟随下列的指令。在安装过程结束之前,勾选/选择“添加环境变量的路径”选项。然后验证工具在你的%PATH%中:

$ arm-none-eabi-gdb -v 

GNU gdb (GNU Tools for Arm Embedded Processors 7-2018-q2-update) 8.1.0.20180315-git

 (..)

OpenOCD

OpenOCD没有针对Windows的官方二进制发行版,但是如果你没有心情自己编译它,xPack项目提供了一个二进制发行版,在这里here。按照提供的安装说明操作。然后更新%PATH%环境变量,以包括安装二进制文件的路径。

(C:\Users\USERNAME\AppData\Roaming\xPacks\@xpack-dev-tools\openocd\0.10.0-13.1\.content\bin\,如果你一直在使用简单的安装)

验证你环境变量里的openocd:

$ openocd -v 

Open On-Chip Debugger 0.10.0

 (..)

QEMU

这是qemu的下载网站the official website.

ST-LINK USB驱动

您还需要安装这个USB驱动程序this USB driver ,否则OpenOCD将无法工作。按照安装说明,确保安装了正确的版本(32位或64位)的驱动程序。

这就完成了。

安装验证

在本节中,我们将检查一些必需的工具/驱动程序是否已正确安装和配置。

用micro USB连接笔记本和开发板。发现板有两个USB连接器;
使用一个标有“USB ST-LINK”,位于板边缘的中心。

还要检查是否填充了ST-LINK头。见下图;ST-LINK的标题用红色圆圈标出。Connected discovery board

现在运行接下来的命令:

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

**注意:**openocd的老版本,包括2017年发布的0.10.0,不包含新的(最好)interface/stlink.cfg文件;取而代之的应该需要interface/stlink-v2.cfginterface/stlink-v2-1.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进程并进入下一节

如果你没有得到"breakpoints"行,请尝试接下来的指令。

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

如果其中一个命令有效,就意味着你得到了一个旧的硬件版本的发现板。这不是问题,但请记住这一事实,因为稍后您将需要配置一些不同的东西。你可以进入下一部分了。

如果这些命令都不能作为普通用户运行,那么尝试使用root权限运行它们 (e.g. sudo openocd ..)。如果命令在root权限下工作,那么检查udev规则是否设置正确。

如果你已经到达这个点,OpenOCD不工作,请打开一个问题,我们会帮助你!

2开始

在本节中,我们将带您完成编写、构建、闪烁和调试嵌入式程序的过程。
您将能够在不使用任何特殊硬件的情况下尝试大多数示例,因为我们将使用QEMU(一种流行的开源硬件模拟器)向您展示基础知识。
当然,唯一需要硬件的部分就是硬件部分,我们使用OpenOCD对STM32F3DISCOVERY进行编程。

2.1 QEMU

我们将开始为LM3S6965,一个Cortex-M3编写程序控制器。我们选择它作为我们的初始目标,因为它可以使用QEMU进行模拟,因此在本节中不需要摆弄硬件,我们可以将重点放在工具和开发过程上。

重要的是:在本教程中,我们将使用“app”作为项目名称。
当你看到“app”这个词的时候,你应该把它替换成你为你的项目选择的名称。或者,你也可以将你的项目命名为“app”,避免替换。

创建一个非标准rust程序

我们将使用cortex-m-quickstart项目模板从中生成一个新项目。创建的项目将包含一个基本的应用程序:一个新的嵌入式rust应用程序的良好起点。
此外,该项目将包含一个示例目录,带有几个单独的应用程序,突出显示一些关键的嵌入式rust功能。

使用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}}" -> "app"
version = "0.1.0"

# ..

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

使用neither

现在最新版本的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

然后添加cargo。

程序概述

为了方便这里是大部分重要的源代码,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]显示程序不会连接标准crate std。而是会连接std的子集core crate。

#![no_main]显示程序不会使用普通rust程序的标准的main函数接口这个main(没有双关语义)使用no_main的原因是使用main接口在no_std环境下需要nightly版本。

use panic_halt as _;.这个crate提供了一个panic_handler,它定义了恐慌行为的程序。我们可以恐慌这一章节找到更细节。

[#entry] 是一个由cortex-m-rt crate提供的属性,用于标记程序的入口点。
因为我们没有使用标准的main入口,所以我们需要另一种方式来表示程序的入口点,那就是#[entry]。

fn main() -> !。。我们的程序将是运行在硬件上的唯一进程,所以我们不希望它结束!我们用一个不同的函数divergent function (t-> ! bit 在函数签名中) 来确保它不会停。

交叉编译

接下来的步骤就是交叉编译程序,目标是Cortex-M3 框架。很容易的运行cargo build --target $TRIPLE。你应该知道 $TRIPLE是什么,幸运的是,模板中的.cargo/config.toml有这个答案:

tail -n6 .cargo/config.toml

[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。目标平台不是在安装rust工具链就自动安装上的,现在正式好时候去添加目标平台到工具链:

rustup target add thumbv7m-none-eabi

因为thumbv7m-none-eabi编译目标已在.cargo/config.toml中设置为默认值。下面的两个命令执行相同的操作:

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

检查

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

使用cargo-readobj,我们可以打印ELF报头来确认这是一个ARM二进制文件。

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

注意:

  • --bin 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

ELF链接器段的复习

  • .text 包含程序指令
  • .rodata 包含类似字符串的常量
  • .data 包含初始值不为零的静态分配变量
  • .bss 还包含初始值为零的静态分配变量(未初始化变量)
  • .vector_table是一个非标准的段,我们用来存储向量(中断)表
  • .ARM.attributes和.debug_*段包含元数据和在烧写二进制文件时不被加载

**重要:**ELF文件包括像调试信息这样的元数据,它们在磁盘上的大小不能准确反应烧写在设备上占用的空间。

总是要用cargo-size来检查二进制文件的真实大小。

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

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

注意: 如果上面的命令爆出Unknown command line argument的问题,见bug报告

https://github.com/rust-embedded/book/issues/269

注意:这个输出在您的系统上可能不同。
新版本的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例子来做一些事。

为了方便这就是源代码,examples/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打印文本在主机的控制台。当真正跑在硬件的时候需要调试会话,但是在QEMU这样就可以输出调试信息了。

让我们编译这个例子:

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!

在打印文本之后,命令应该会成功退出(成功退出的代码为0).

在类unix系统上你可以检查是否成功用接下来的命令:

echo $?
0

让我们分解以下上面这个QEMU指令:

  • qemu-system-arm。这是QEMU模拟器。这些QEMU二进制文件有几个变体。这是一个ARM机器的完整系统模拟,因此得名。
  • -cpu cortex-m3。告诉QEMU模拟的是Cortex-M3 CPU.指定CPU模型可以让我们捕捉一些错误编译错误:举个例子,运行一个Cortex-M4F的编译程序,在QEMU在执行的时候会产生错误。
  • -machine lm3s6965evb.这个命令告诉QEMU模拟LM3S6965EVB,一个包含LM3S6965单片机的评估板。
  • -nographic.这个指令告诉QEMU不要使用GUI。
  • -semihosting-config (..).这个指令告诉QEMU开启semihosting。semihosting允许模拟设备使用主机标准输出、标准错误和标准输入,并在主机上创建文件。
  • -kernel $file.这将告诉QEMU在仿真机器上加载和运行哪个二进制文件。

输入冗长的QEMU命令工作量太大了!我们可以设置一个定制的运行器来简化这个过程。

.cargo/config.toml有一行注释掉了的运行器,它会调用QEMU;让我们取消它:

head -n3 .cargo/config.toml
[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或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

这个命令不会打印任何东西到控制台但会堵塞终端。这时我们要添加两个额外的flag:

  • -gdb tcp::3333.这个将会告诉QEMU等待GDB的TCP连接在3333端口。
  • -S。这告诉QEMU在启动时冻结机器。如果不这样做,程序就会在我们有机会启动调试器之前到达main的末尾!

接下来,我们在另一个终端启动GDB,并告诉它加载示例的调试符号:

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

注意:你可能需要另一个版本的gdb来取代gdb-multiarch,这就取决于在安装章节你安装的是哪一个。这个gdb可以是arm-none-eabi-gdb或就是gdb。

然后GDB所在的shell 我们连接QEMU,等待TCP连接到3333端口。

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的函数。
这就是复位处理器:Cortex-M内核在启动时所执行的。

注意:注意,在某些设置中,不显示Reset () at $REGISTRY/cortex-m-rt-0.6.1/src/lib.rs:473如上所示,GDB可能会打印如下警告:
`

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

这是一个众所周知的故障。您可以放心地忽略这些警告,您最可能使用的是Reset()。

这个重置终将调用我们的主函数。让我们使用断点和continue命令跳过这些步骤。在设置断点之前我们要在哪打断我们的代码。使用 list 命令。

list main

这将显示examples/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

我们想要添加断点在“Hello world“之前,在13行,我们使用break命令:

break 13

我们现在可以指示gdb继续运行我们的主函数,使用 continue 命令:

continue
Continuing.

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

我们1现在接近 打印“Hello,world!”代码。让我们继续向后执行使用next命令。

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

使用qemu-system-arm.你将看见“Hello,world!”打印在终端上。

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

再次调用next将终止QEMU进程。

next
[Inferior 1 (Remote target) exited normally]

你现在可以退出GDB会话。

quit

2.2硬件

到目前而止,你也已经熟悉工具链和开发进程。在这节中,我们将转换到真的硬件中;在很大程度都是相同的,让我们一探究竟把。

了解你的硬件

在开始之前,你需要识别一些在目标设备上的字符,这些字符将会用来配置工程:

  • ARM内核 如Cortex-M3.
  • ARM内核中是否包括FPU?Cortex-M4F 和 Cortex-M7F就包括。
  • 多少的闪存和RAM容量 如 256KiB的Flash 和32KiB RAM.
  • 闪存和RAM映射的地址空间在哪?如 RAM 通常位于地址0x2000_0000。

在这节中,我们将使用STM32F3DISCOVERY开发板,这个开发板包括STM32F303VCT6微控制器.这个控制器有;

有一个Cortext-M4F内核包括一个单精度FPU

256KiB闪存在地址0x0800_0000.

40KiBRAM在0x2000_0000。(还有另一个RAM区域,但为了简单起见,我们忽略它)

配置

我们将从头开始使用一个新的模板实例。参考previous section on QEMU 从头构建一个模板,不用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

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

tail -n5 .cargo/config.toml
# 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
}

**注意:**在你第一次构建指定构建目标之后,如果你因为些原因改变了memory.x文件,然后在执行cargo build之前执行 cargo clean,因为 cargo build 可能没有跟踪更新了的memory.x.

我们将再次从hello示例开始,但首先我们必须做一个小更改。

examples/hello.rs中,确保debug::exit()被注释或者被删掉,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 crate处理了让你的芯片运行所需的所有魔法,因为有帮助的是,几乎所有Cortex-M cpu都以相同的方式启动。

cargo build --example hello

调试

调试看起来有点不一样。实际上,第一步看起来不同是因为目标设备。在本节中,我们将展示调试在STM32F3DISCOVERY上运行的程序所需的步骤。这是作为一个参考;有关调试的设备特定信息在 the Debugonomicon.

和以前一样,我们将进行远程调试,客户端将是一个GDB进程。
然而,这一次,服务器将是OpenOCD。

正如在验证部分所做的那样,将发现板连接到您的笔记本电脑/ PC,并检查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]

注意:如果在验证阶段发现发现板有一个旧版本,那么此时应该修改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,等待TCP连接端口3333.

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

现在继续使用load命令将程序加载到微控制器上。

(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命令向OpenOCD发送命令。

(gdb) monitor arm semihosting enable
semihosting is enabled

通过调用monitor help命令可以查看所有OpenOCD命令。

像以前一样,我们可以使用断点和continue命令跳过到main。

(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();

注意如果在发出上面的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

但是,微控制器上运行的进程还没有终止,您可以使用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,加载程序并启动进程。

或者,你可以将<gdb> -x openocd.gdb变成一个自定义运行器去cargo run构建一个程序并打开一个GDB会话。这个运行器被包括在.cargo/config.toml但是被注释掉了。

head -n10 .cargo/config.toml
[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)

2.3内存映射寄存器

嵌入式系统只能通过执行普通的Rust代码和在RAM中移动数据走到这一步。如果我们想让任何信号进出我们的系统(如点亮一个led灯,检测按钮按下或与某种总线上的芯片外设备通信)我们将不得不进入外设的世界和他们的“内存映射寄存器”。

你可能会发现你需要在微控制器中访问外设的代码已经写好了,在以下级别之一:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0ecAyMyV-1659849615098)(/mnt/笔记/img/crates.png)]

板级crate

如果你是嵌入式Rust的新手,crate是一个完美的起点。它们很好地抽象出了在开始学习这门学科时可能会令人不知所措的硬件细节,并使标准任务变得简单,像LED的开关。板之间展现的功能差距过大。由于本书的目标是保持硬件不可知,所以crate不在本书的讨论范围之内。
如果你想实验STM32F3DISCOVERY板,强烈建议看一下stm32f3-discovery crate,它提供了闪烁led,指南针,蓝牙等功能。

Discovery书提供板级crate很棒的介绍。

但是,如果你的系统还没有专门的crate,或者你需要现有crate所不提供的功能,请继续阅读我们从0开始的微架构crate。

微架构crate

让我们看看所有基于Cortex-M的微控制器都常见的SysTick外围设备。我们可以在cortex-m crate中找到一个相当底层的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结构上的函数与ARM技术参考手册中为这个外围设备定义的功能非常接近。这个API中没有关于‘‘延时x毫秒’’–我们必须使用while循环来粗略地实现它。注意,只有调用Peripherals::take()才能访问SYST结构体-。这是一个特殊的例程,它保证在整个程序中只有一个SYST结构。. 有关这方面的更多信息,请参阅外设部分Peripherals

使用外围设备访问(PAC)

如果我们把自己限制在每个Cortex-M都包含的基本外设上,我们就不会在嵌入式软件开发上走得太远。在某些情况下,我们需要编写一些特定于我们所使用的特定微控制器的代码。在这个例子中,让我们假设我们有一个Texas Instruments TM4C123 -一个中等的80MHz Cortex-M4闪存256 KiB的控制器。我们将引入tm4c123x crate来使用该芯片。

#![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());
}


我们访问PWM0外设的方式与之前访问SYST外设的方式完全相同,只是调用了tm4c123x::Peripherals::take()。当这个crate被svd2rust自动生成,寄存器字段的访问函数接受一个闭包,而不是一个数字参数。虽然这看起来像很多代码,但Rust编译器可以使用它来为我们执行一堆检查,然后生成机器代码,这非常接近于手写的汇编程序!如果自动生成的代码不能确定特定访问器函数的所有可能参数都是有效的(例如,SVD将寄存器定义为32位,但没有说明这些32位值是否有特殊含义,不安全,溢出问题),然后这些被生成的函数被标记为unsafe。在上面的例子中,当使用bits()函数设置load和compa子字段时,我们可以看到这一点。

read()函数返回一个对象,它提供了对该寄存器内各个子字段的只读访问权限,由制造商的SVD文件定义的芯片。您可以在tm4c123x文档中找到这个特定寄存器的特殊R返回类型的所有可用函数,在指定的外围设备上,在指定的芯片上。

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

write()函数接受一个带有单个参数的闭包。通常我们称之为w.然后,该参数提供了对该寄存器内的各个子字段的读写访问权,该寄存器由制造商针对该芯片的SVD文件定义。

同样,您可以在tm4c123x documentation文档中找到针对指定的寄存器、指定的外设、指定的芯片的’w’上的所有可用函数。请注意,我们没有设置的所有子字段都将被设置为缺省值——寄存器中任何现有的内容都将丢失。

修改

如果我们只希望更改这个寄存器中的一个特定子字段,而不改变其他子字段,我们可以使用modify函数。这个函数接受一个带有两个参数的闭包—一个用于读取,一个用于写入。通常我们分别叫它们r和w.参数r可用于检查寄存器的当前内容,参数w可用于修改寄存器内容。

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 crate

芯片的HAL crate,通常通过为PAC暴露的原始结构实现自定义trait来工作。通常,这个trait会定义一个函数,叫做constraint()用于单个外设,或者split()用于像GPIO端口有多个引脚。这个函数将使用底层的原始外围设备结构,并返回一个具有更高级别API的新对象。这个API也可以做一些事情,比如串口new函数需要借用Clock结构,这只能通过调用配置锁相环和设置所有时钟频率的函数来生成。这样,不可能在没有配置时钟速率的情况下静态创建串口对象,或者串口对象错误地将波特率转换为时钟滴答声。

有些crate甚至为每个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();
    }
}

2.4Semihosting

Semihosting 是一个允许嵌入式设备在主机上进行I/O操作,主要用于将消息记录到主机控制台的装置。Semihosting需要一个调试会话即可,所以它用起来非常的方便。但缺点是速度非常慢:由于硬件调试器的原因,每一个写入操作都会消耗几毫秒。

cortex-m-semihosting crate提供了一个API来在Cortex-M设备上执行Semihosting操作。下面的操作就是是Semihosting版的!“Hello,world!”;

#![no_main]
#![no_std]

use panic_halt as _;

use cortex_m_rt::entry;
use cortex_m_semihosting::hprintln;

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

    loop {}
}

如果你运行这个程序在硬件上,你将看到“Hello,world”信息在openocd记录上。

$ openocd
(..)
Hello, world!
(..)

你需要首先从GDB开启openocd的semihosting:

(gdb) monitor arm semihosting enable
semihosting is enabled

QEMU理解semihosting操作,因此上述程序也可以与qemu-system-arm一起工作,而无需启动调试会话。需要注意的是你将需要传递-semihosting-config到QEMU去开启semiihosting支持;这些标志已经被包括在模板的.cargo/config.toml文件。

$ # this program will block the terminal
$ cargo run
     Running `qemu-system-arm (..)
Hello, world!

还有一个exit semihosting操作可以用来终止QEMU进程。

重要的是 一定不要使用debug::exit在硬件中;这个函数会破坏openocd会话,你将不能调试更多的信息直到你重启它。

#![no_main]
#![no_std]

use panic_halt as _;

use cortex_m_rt::entry;
use cortex_m_semihosting::debug;

#[entry]
fn main() -> ! {
    let roses = "blue";

    if roses == "red" {
        debug::exit(debug::EXIT_SUCCESS);
    } else {
        debug::exit(debug::EXIT_FAILURE);
    }

    loop {}
}

$ cargo run
     Running `qemu-system-arm (..)

$ echo $?
1

最后一个提示:你可以设置恐慌行为到exit(EXIT_FAILURE)。这将允许您编写no_std运行通过测试。

为了方便,panic-semihosting crate有一个“exit”特性,这个特性开启调用exit(EXIT_FAILURE)在记录恐慌信息到主机的stderr。

#![no_main]
#![no_std]

use panic_semihosting as _; // features = ["exit"]

use cortex_m_rt::entry;
use cortex_m_semihosting::debug;

#[entry]
fn main() -> ! {
    let roses = "blue";

    assert_eq!(roses, "red");

    loop {}
}

$ cargo run
     Running `qemu-system-arm (..)
panicked at 'assertion failed: `(left == right)`
  left: `"blue"`,
 right: `"red"`', examples/hello.rs:15:5

$ echo $?
1

注意:在panic-semihosting上开启这个特征,需要编辑Cargo.toml

panic-semihosting = { version = "VERSION", features = ["exit"] }

其中VERSION是所需的版本。有关依赖关系特性的更多信息,请查看Cargo book的指定依赖关系部分。specifying dependencies

2.4恐慌

恐慌是Rust语言的核心部分。像索引这样的内置操作会在运行时检查内存安全性。当尝试越界索引时,这会导致恐慌。

在标准库中,恐慌有一个已定义的行为:除非用户选择在panic时中止程序,否则它会解开panic线程的堆栈。

然而,在没有标准库的程序中,恐慌行为是未定义的。可以通过声明#[panic_handler]函数来选择行为。这个函数必须在一个程序的依赖图中只出现一次,并且必须有以下签名:fn(&PanicInfo) -> !,其中PanicInfo 是一个结构体,包含有关恐慌发生地点的信息。

考虑到嵌入式系统的范围从用户面对到安全关键(不能崩溃),没有一种方法适合所有的恐慌行为,但有很多常用的行为。这些常见行为被打包到定义#[panic_handler]函数的crate中。一些例子包括:

  • panic-abort。恐慌会导致执行中止指令。
  • panic-halt.panic通过进入无限循环导致程序或当前线程停止。
  • panic-itm.恐慌消息使用ITM (ARM Cortex-M特定的外围设备)记录。
  • panic-semihosting恐慌消息使用semihosting被记录到主机上。

在crates.io上搜索panic-handler关键字,您可能会找到更多的crate。

程序可以通过链接到相应的crate来选择其中一种行为。恐慌行为在应用程序源代码中表示为一行代码,这不仅作为文档有用,而且还可以用于根据编译cargo.toml文件更改恐慌行为。

举例:

#![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)时crate链接到panic-halt crate ,而构建发行版配置文件的时候连接的是panic-abort crate (cargo build --release).

use panic_abort as _;use语句的形式用于确保panic_abort恐慌处理程序包含在最终的可执行文件中,同时向编译器明确表示不会显式使用crate中的任何东西。如果没有as _ 重新命名,编译器会警告我们有一个未使用的导入。有时你可能会看到extern crate panic_abort来代替use panic_abort as _;,这是rust2018版本之前的rust导入crate的写法,你可能在一些语言系统包中能看到这些语句,如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 {}
}

这个例子使用panic-semihosting,它用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,你将确信这个将不会有信息打印在主机控制台。

2.6异常

异常和中断是硬件的机制,处理器通过这种硬件机制处理异步事件和致命错误(如执行一个非法指令)。异常意味着抢占并涉及异常处理程序,即响应触发事件的信号而执行的子例程。

cortex-m-rt crate提供了一个exception属性声明异常处理程序:

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

除了exception属性之外,异常处理程序看起来像普通函数,但还有一个区别:exception处理器不能被软件调用。跟随前面的例子声明SysTick();将导致编译错误。

这种行为是非常有意的,它需要提供一个特性:static mut变量声明在exception处理器中使用是安全的。

#[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。然而,我刚刚告诉异常处理程序可以安全地使用static mut变量,这怎么可能呢?这是可能的,因为异常处理器不能被软件调用,因此重入是不可能的。

注意,exception属性将函数内静态变量的定义转换为unsafe块,并为我们提供相同名称的类型&mut的新适当变量。
因此,我们可以通过*对引用进行解引用,以访问变量的值,而不需要将它们包装在unsafe块中。

一个完整的例子

这个例子用系统时钟大约每秒抛出一个SysTick异常。SysTick异常处理程序在COUNT变量中跟踪它被调用的次数,然后使用semihosting将COUNT的值打印到主机控制台。

注意:你可以运行这个例子在任意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

如果你运行开发板的程序你将在openocd输出在控制台中。而且,当计数达到9时,程序不会停止。

默认异常处理程序

exception属性的实际作用是覆盖特定异常的默认异常处理程序。如果你没有覆盖特定异常的处理程序,它将被DefaultHandler函数处理,默认为:

fn DefaultHandler() {
    loop {}
}

可以使用exception属性重写这个DefaultHandler:

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

irqn参数表示正在处理哪个异常。负值表示正在处理一个Cortex-M异常;和0或正的值表明一个特定于设备的异常,又名中断,正在被服务。

硬错误处理器

HardFault异常有点特殊。当程序进入无效状态时触发此异常,因此其处理程序无法返回,因为这可能导致未定义的行为。此外,运行时crate在调用用户定义的HardFault处理程序之前会做一些工作,以提高可调试性。

结果是HardFault处理程序必须具有以下签名:fn(&ExceptionFrame) -> !.处理程序的参数是一个指针,指向被异常推入堆栈的寄存器。
这些寄存器是触发异常时处理器状态的快照,对于诊断硬故障非常有用。

下面是一个执行非法操作的例子:读取一个不存在的内存位置。

注意:这个程序将不会工作,它在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] )引起异常。ExceptionFrame的r0字段将告诉您当时寄存器r0的值是0x3fff_fffe。

2.7中断

中断与异常在很多方面不同,但它们的操作和使用在很大程度上是相似的,而且它们也由同一个中断控制器处理。尽管异常是由Cortex-M架构定义的,但中断总是由供应商(甚至通常是芯片)特定的实现,无论是在命名还是功能上。

  • 中断确实提供了很大的灵活性,这在试图以一种高级的方式使用它们时需要考虑。我们不会在本书中介绍这些用法,但是最好记住以下几点:
  • 中断具有可编程的优先级,这些优先级决定了它们的处理程序的执行顺序
  • 中断可以嵌套和抢占,即一个中断处理程序的执行可能会被另一个高优先级的中断中断
  • 通常,需要清除导致中断触发的原因,以防止无止境地重新进入中断处理程序

运行时的一般初始化步骤总是相同的:

  • 设置外设,以便在需要的场合生成中断请求
  • 在中断控制器中设置所需的中断处理程序的优先级
  • 在中断控制器中启用中断处理程序

与异常类似,cortex-m-rt crate提供了一个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;
}

有关这里演示的机制的更详细描述,请参阅例外部分 exceptions section.。

2.8 IO

**待做:**使用寄存器覆盖内存映射I/O。

3 外围设备

什么是外围设备

绝大部分控制器不仅有CPU,RAM,闪存,还包括用于交互的控制器外围系统,他们可以直接与传感器,电机驱动或者是人的输入输出接口(显示屏,键盘)交互。这些统称外围设备。

这些外设很有用,因为它们允许开发人员将处理工作转移到外设上,从而避免在软件中处理所有事情。
就像桌面开发人员将图形处理任务转移到显卡上一样,嵌入式开发人员也可以将一些任务转移到外围设备上,让CPU把时间花在其他重要的事情上,或者为了节省电力什么都不做。

如果你看一下20世纪70年代或80年代老式家用电脑的主板(实际上,昨天的台式电脑与今天的嵌入式系统相差不远),你会期望看到:

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

RAM芯片、ROM芯片和I/O控制器(这个系统的外围设备)将通过一系列被称为“总线”的并行轨迹与处理器连接。该总线携带地址信息,用于选择处理器与总线上希望通信的设备通信,以及携带实际数据的数据总线。在我们的嵌入式微控制器中,同样的原理也适用——只是所有的东西都被封装在一块硅片上。

然而,显卡通常有一个软件API(如Vulkan、Metal或OpenGL),与之不同的是,外设通过一个硬件接口暴露给微控制器,这个硬件接口被映射到内存的一块上。

线性内存空间与实际内存空间

在微控制器上,将一些数据写入其他任意地址,如0x4000_0000或0x0000_0000,也可能是一个完全有效的动作。

在桌面系统中,对内存的访问是由MMU(内存管理单元)严格控制的。该组件有两个主要职责:强制对内存段的访问权限(防止一个进程读取或修改另一个进程的内存);并将物理内存段重新映射到软件中使用的虚拟内存范围。微控制器通常没有MMU,只在软件中使用真实的物理地址。

尽管32位微控制器的实际线性地址空间来自0x0000_0000和0xFFFF_FFFF,但它们通常只使用这一范围中的几百千字节作为实际内存。这就留下了大量的地址空间。
在前面的章节中,我们讨论了RAM位于0x2000_0000地址。如果我们的RAM是64kib长(即最大地址为0xFFFF),那么0x2000_0000到0x2000_FFFF的地址将对应我们的RAM。当我们写入地址为0x2000_1234的变量时,内部发生的是一些逻辑检测到地址的上部(在这个例子中是0x2000),然后激活RAM,以便它可以作用于地址的下部(在这个例子中是0x1234)。在Cortex-M上,我们还将Flash ROM映射到地址0x0000_0000到地址0x0007_FFFF(如果我们有一个512kib Flash ROM)。微控制器设计人员不是忽略这两个区域之间的所有剩余空间,而是将外设接口映射到特定的内存位置。结果是这样的:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0GlLsvea-1659849615099)(/mnt/笔记/img/nrf52-memory-map.png)]

Nordic nRF52832 Datasheet (pdf)

内存映射的外围设备

乍一看,与这些外设的交互很简单——将正确的数据写入正确的地址。例如,通过串行端口发送一个32位字可能与将该32位字写入某个内存地址一样直接。然后串口外围设备将接管并自动发送数据。

这些外设的配置工作原理类似。不是调用一个函数来配置外设,而是公开一块内存作为硬件API。将0x8000_0000写入SPI频率配置寄存器,SPI端口将以每秒8兆比特的速度发送数据。将0x0200_0000写入相同的地址,SPI端口将以每秒125千比特的速度发送数据。这些配置寄存器看起来有点像这样:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DCkKdIy0-1659849615100)(/mnt/笔记/img/nrf52-spi-frequency-register.png)]

Nordic nRF52832 Datasheet (pdf)

这个接口是如何与硬件进行交互的,不管使用什么语言,不管语言是汇编语言、C语言还是Rust语言。

3.1 第一次尝试

寄存器

让我们看看“SysTick”外设-一个简单的定时器,每个Cortex-M处理器核心。通常你会在芯片制造商的数据表或技术参考手册中查找这些,但是这个例子对于所有的ARM Cortex-M内核都是常见的,让我们看看ARM的参考手册。我们看到有四个寄存器:

OffsetNameDescriptionWidth
0x00SYST_CSR控制和状态寄存器32 bits
0x04SYST_RVR重新加载值寄存器32 bits
0x08SYST_CVR当前值寄存器32 bits
0x0CSYST_CALIB校准值寄存器

C的方法

在Rust中,我们可以用与在C中完全相同的方式来表示寄存器的集合——用一个struct。

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

修饰语#[repr(C)]告诉Rust编译器像C编译器那样布局这个结构。这是非常重要的,因为Rust允许结构字段被重新排序,而C不允许。你可以想象,如果编译器默默地重新安排这些字段,我们将不得不做的调试!有了这个限定符,我们就有了四个32位字段,它们对应于上面的表。但是当然,这个结构本身是没有用的——我们需要一个变量。

let systick = 0xE000_E010 as *mut SysTick;
let time = unsafe { (*systick).cvr };

不稳定的访问

现在,上面的方法有几个问题。

  1. 每次我们想要访问外围设备,我们必须使用unsafe。
  2. 我们没有办法指定哪些寄存器是只读的,哪些寄存器是读写的。
  3. 程序中任何地方的任何一段代码都可以通过这个结构访问硬件。
  4. 最重要的是,它实际上不起作用……

现在,问题是编译器很聪明。如果对同一块RAM进行两次写操作,一个接一个,编译器会注意到这一点,并完全跳过第一次写操作。在C语言中,我们可以将变量标记为volatile,以确保每次读或写都按预期发生。在Rust中,我们将访问对象标记为volatile,而不是变量。

let systick = unsafe { &mut *(0xE000_E010 as *mut SysTick) };
let time = unsafe { core::ptr::read_volatile(&mut systick.cvr) };

所以,我们已经修复了四个问题中的一个,但现在我们有了更unsafe的代码!幸运的是,有一个第三方crate可以帮助您——volatile_register.。

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()
}

现在,volatile访问通过read和write方法自动执行。执行写操作仍然是unsafe的,但公平地说,硬件是一堆可变的状态,编译器没有办法知道这些写操作是否安全,所以这是一个很好的默认位置。

rust包装

我们需要将这个struct封装到一个更高层的API中,以便用户安全地调用。作为驱动程序的作者,我们手动验证不安全的代码是正确的,然后为我们的用户提供一个安全的API,这样他们就不用担心了(前提是他们相信我们能做对!)

一个例子:

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())
}

现在,这种方法的问题是,以下代码是完全可以被编译器接受的:

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结构体,但它们不能阻止用户创建第二个指向完全相同的外围设备的SystemTimer !如果作者足够勤奋地找出所有这些“重复”的驱动程序实例,以这种方式编写的代码将会工作,但一旦代码分散到多个模块、驱动程序、开发人员和天,就会越来越容易犯这类错误。

3.2 借用检查器

可变的全局状态

不幸的是,硬件基本上就是可变的全局状态,这对Rust开发人员来说是非常可怕的。硬件独立于我们所编写的代码结构而存在,并且可以在现实世界中随时修改。

我们应该遵守什么样的规则

我们如何才能可靠地与这些外围设备交互?

  1. 总是使用volatile方法来读写外设内存,因为它随时都可能发生变化。
  2. 在软件中,我们应该能够共享对这些外设的任意数量的只读访问
  3. 如果某些软件应该对外设具有读写访问权限,那么它应该持有对该外设的唯一引用

借用检查器

最后两个规则听起来与Borrow Checker已经做的事情非常相似!

想象一下,如果我们可以传递这些外围设备的所有权,或者为它们提供不可变或可变的引用?

当然,我们可以,但是对于Borrow Checker,我们需要每个外围设备都有一个实例,这样Rust才能正确地处理这个问题。幸运的是,在硬件中,任何给定的外设都只有一个实例,但是我们如何在代码结构中暴露它呢?

单例

在软件工程中,单例模式是一种软件设计模式,它将一个类的实例化限制为一个对象。

Wikipedia: Singleton Pattern

但是为什么我们不能直接使用全局变量呢?

我们可以把一切都变成公共静态,就像这样

static mut THE_SERIAL_PORT: SerialPort = SerialPort;

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

但这有一些问题。它是一个可变的全局变量,在Rust中,与这些变量交互总是不安全的。这些变量在整个程序中也是可见的,这意味着借用检查器无法帮助您跟踪这些变量的引用和所有权。

我们如何在Rus中做到这一点?

我们不只是将外围设为一个全局变量,我们也可以设置一个全局变量,在本例中称为PERIPHERALS,它包含每个外设的Option。

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_serial(),代码就会出错!

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

虽然和这个结构交互是unsafe的,一旦包含了SerialPort,我们就不再需要使用unsafe的了,或者PERIPHERALS结构体。

这有一个小的运行时开销,因为我们必须将SerialPort结构包装在一个选项中,并且,我们需要调用一次take_serial(),然而,这个小小的前置成本允许我们在程序的其余部分中利用借用检查器。

现有库支持

尽管我们在上面创建了自己的Peripherals结构,但对您的代码没有必要这样做。
cortex_m crate包含一个名为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,定义和获得这些外设的整个过程都会被抽象出来,相反,您会收到一个Peripherals结构体,该结构体包含您定义的所有非option<T> 版本的项目。

// 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;
    }
}

但是为什么?

但是这些单例如何在Rust代码的工作方式中产生明显的不同呢?

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();
}

像对待数据一样对待你的硬件

此外,由于一些引用是可变的,而另一些是不可变的,因此可以看到函数或方法是否可能修改硬件的状态。例如,这允许更改硬件设置:

fn setup_spi_port(
    spi: &mut SpiPort,
    cs_pin: &mut GpioPin
) -> Result<()> {
    // ...
}

不是:

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

这允许我们强制代码是否应该在编译时而不是运行时对硬件进行更改。注意,这通常只适用于一个应用程序,但对于裸机系统,我们的软件将被编译成一个应用程序,所以这通常不是限制。

4静态保证

rust类型系统在编译时就阻止数据竞争(见 SendSync traits)。类型系统还可以用于在编译时检查其他属性;
减少在某些情况下对运行时检查的需求。

当应用于嵌入式程序时,可以使用这些静态检查,例如,强制正确完成I/O接口的配置。例如,我们可以设计一个API,它只能通过首先配置接口将使用的引脚来初始化串行接口。

您还可以静态地检查操作,如设置低引脚,只能在正确配置的外设上执行。例如,试图更改在浮动输入模式下配置的引脚的输出状态将引发编译错误。

并且,如前一章所见,所有权的概念可以应用于外设,以确保只有程序的某些部分可以修改外设。与将外设作为全局可变状态处理相比,这种访问控制使软件更容易推理。

4.1.Typestate编程

typestates概念描述将有关对象当前状态的信息编码为该对象的类型。虽然这听起来有点神秘,但如果你已经在Rust中使用了建造者模式,你已经开始使用Typestate状态编程了!

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有一个强类型系统,没有简单的方法来神奇地创建一个Foo的实例,或者在不调用into_foo()方法的情况下把FooBuilder变成一个Foo。另外,调用into_foo()方法会使用原始的FooBuilder结构,这意味着如果不创建新实例,就不能重用它。

这允许我们将系统的状态表示为类型,并包括将状态转换为将一种类型转换为另一种类型的方法所需的操作。
通过创建一个FooBuilder,并将其交换为一个Foo对象,我们已经完成了基本状态机的步骤。

4.2外设作状态机

微控制器的外围设备可以被认为是一组状态机。
例如,一个简化的GPIO引脚
GPIO pin的配置可以表示为如下状态树:

  • 取消
  • 开启
    • 设置为输出
      • 输出:高电平
      • 输出:低电平
    • 设置输入
      • 输入:高阻态
      • 输入: 拉低
      • 输入: 拉高

如果外设在Disabled(取消)模式启动,移动到Input: High Resistance(输入:高阻态)模式,我们必须执行以下步骤:

1.Disabled(取消)

2.enabled(开启)

3.设置为输入模式

4.输入:高阻态

如果我们想要从Input: High Resistance(输入:高阻态)切换到(输入:拉低),我们必须执行以下步骤:

1.输入:高阻态

2.输入:拉低

类似地,如果我们想将GPIO引脚从配置为Input: Pulled Low移到Output: High,我们必须执行以下步骤:

  1. 输入:拉低
  2. 设置为输入
  3. 设置输出
  4. 输出:高电平

硬件表示

通常,上面列出的状态是通过向映射到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中公开以下结构来控制GPIO:

/// 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被配置为输入时,如果我们设置output_mode字段会发生什么?

通常,使用这个结构将允许我们到达上面的状态机没有定义的状态:例如,拉低的输出,或设置高的输入。对于某些硬件来说,这可能无关紧要。在其他硬件上,它可能会导致意外或未定义的行为!

尽管这个接口编写起来很方便,但它并没有执行我们的硬件实现所设定的设计契约。

4.3设计契约

在上一章中,我们编写了一个没有执行设计契约的接口。让我们再看看我们想象中的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

如果我们在使用底层硬件之前检查状态,在运行时执行我们的设计契约,我们可能会写这样的代码:

/// 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的类型系统来执行状态转换规则呢?
把这个例子:

/// 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,
        }
    }
}

这绝对是一种方便的方式来存储pin的状态,但为什么要这样做呢?为什么这比在GpioConfig结构中以enum形式存储状态更好呢?

编译时间功能安全

因为我们在编译时完全强制我们的设计约束,这不会产生运行时成本。当你有一个输入模式的引脚时,是不可能设置输出模式的。相反,您必须通过将其转换为输出引脚,然后设置输出模式来遍历这些状态。因此,在执行函数之前不会检查当前状态而造成运行时损失。而且,因为这些状态是由类型系统强制的,因此接口的使用者不再有犯错的空间。如果它们试图执行非法的状态转换,代码将无法编译!

4.4零成本抽象

类型状态也是零成本抽象(Zero Cost Abstractions)的一个很好的例子——将某些行为移动到编译时执行或分析的能力。这些类型状态不包含实际数据,而是用作标记。因为它们不包含数据,所以在运行时它们在内存中没有实际的表示:

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

零大小类型

这样定义的结构称为零大小类型,因为它们不包含实际数据。虽然这些类型在编译时是“真实的”——你可以复制它们,移动它们,引用它们,等等,但是优化器会完全剥离它们。

在这段代码中:

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的状态,并将其呈现为与直接寄存器访问相同的机器码。

嵌套

通常,这些抽象可以嵌套得越深越好。只要使用的所有组件都是零大小的类型,那么整个结构在运行时将不存在。

对于复杂或深度嵌套的结构,定义所有可能的状态组合可能是乏味的。在这些情况下,可以使用宏生成所有实现。

5 可移植性

在嵌入式环境中,可移植性是一个非常重要的话题:每个供应商甚至来自同一个制造商的每个家族都提供不同的外设和功能,同样,与外设交互的方式也会有所不同。

平衡这种差异的一种常见方法是通过一个称为硬件抽象层(HAL)的层。

硬件抽象是软件中的一组例程,它们模拟一些特定于平台的细节,使程序可以直接访问硬件资源。

它们通常允许程序员通过向硬件提供标准操作系统(OS)调用来编写与设备无关的高性能应用程序。

Wikipedia: Hardware Abstraction Layer

嵌入式系统在这方面有点特殊,因为我们通常没有操作系统和用户可安装的软件,但固件映像是作为一个整体编译的,以及许多其他限制。

因此,尽管维基百科定义的传统方法可能有效,但在确保可移植性方面可能不是最有效的方法。

我们如何在《Rust》中做到这一点?进入embedded-hal……

什么是嵌入式hal?

简而言之,它是一组定义HAL实现、驱动程序和应用程序(或固件)之间实现契约的trait。这些契约包括两个功能(例如,如果一个trait是为某种类型实现的,HAL实现提供了某种功能)和方法(例如,如果你可以构造一个实现特征的类型,那么就可以保证你拥有该特征中指定的方法)。

典型的分层可能是这样的:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3xa5biQV-1659849615102)(/mnt/笔记/img/rust_layers.svg)]

一些trait被定义在embedded-hal中:

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

实现和使用embedded-hal traits和 相关crate的主要原因是为了控制复杂性。如果您考虑到应用程序可能必须在硬件中实现外设的使用,以及应用程序和附加硬件组件的潜在驱动程序,那么很容易看到可重用性非常有限。

从数学上讲,如果M是外围设备HAL实现的数量,N是驱动程序的数量,那么如果我们为每个应用重新发明轮子,那么我们最终将得到M*N个实现,而使用embedded-hal trait提供的API将使实现的复杂性达到M+N。当然,还有其他的好处,例如,由于有一个定义良好的、随时可用的api,所以可以减少试错。

embedded-hal的用户

如上所述,HAL有三个主要用户:

HAL的实现

HAL实现提供了硬件和HAL特性用户之间的接口。
典型的实现包括三个部分:

  • 一个或多个硬件特定类型
  • 用于创建和初始化这种类型的函数,通常提供各种配置选项(速度、操作模式、使用引脚等)。
  • embedded-hal trait的一个或多个trait impl为了这个类型

这样的HAL实现可以有多种形式:

  • 通过低级硬件访问,例如通过寄存器
  • 通过操作系统,例如在Linux下使用sysfs
  • 通过适配器,例如用于单元测试的类型模拟
  • 通过驱动硬件适配器,如I2C多路复用器或GPIO扩展器

驱动

驱动程序为内部或外部组件实现一组自定义功能,连接到实现embedded-hal trait的外围设备。这种驱动器的典型例子包括各种传感器(温度、磁力计、加速度计、光)、显示设备(LED阵列、LCD显示器)和执行器(电机、变送器)。

驱动程序必须用一个类型实例进行初始化,该实例实现了 embedded-hal的某个trait,这是通过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; }
}

每一秒,计时器中断将计数器设置回0。同时,主循环不断测量信号,并在看到从低到高的变化时增加计数器。我们必须使用unsafe来访问COUNTER,因为它是static mut,这意味着我们要向编译器保证不会导致任何未定义的行为。你能发现竞态条件吗?COUNTER上的增量并不一定是原子的—事实上,在大多数嵌入式平台上,它将被拆分为加载、增量和存储。如果中断在加载之后,但在存储之前触发,那么在中断返回后,将忽略重置为0的操作——我们将在这段时间内计算两倍的转换次数。

临界区

那么,对于数据竞赛我们能做些什么呢?一种简单的方法是使用临界区,这是一个中断被禁用的上下文。通过将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不会受到竞争的影响,因为我们没有读取它
  • 它永远不会被主线程中断

如果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是一个安全的静态变量。由于AtomicUsize类型,COUNTER可以从中断处理程序和主线程安全地修改,而不需要禁用中断。如果可能的话,这是一个更好的解决方案—但是您的平台可能不支持它。

使用Ordering要注意的是:这会影响编译器和硬件重新排序指令的方式,还会影响缓存的可见性。假设目标是单个核心平台,在这种特殊情况下,Relaxed就足够了,也是最有效的选择。更严格的排序将导致编译器在原子操作周围发出内存屏障;根据您使用原子的用途,您可能需要也可能不需要它!原子模型的精确细节是复杂的,最好的描述在别处。

有关原子和排序的更多细节,请参阅 nomicon

抽象、发送(Send)和同步(Sync)

上述解决方案没有一个特别令人满意。他们需要unsafe的块,必须非常仔细地检查,不符合人体工程学。当然我们可以在Rust做得更好!

我们可以将计数器抽象成一个安全的接口,可以在代码的任何地方安全地使用它。在本例中,我们将使用临界区计数器,但您也可以对原子执行非常类似的操作。

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的块。

这种设计要求应用程序传入一个CriticalSection token:这些token只有由interrupt::free,因此,通过要求传入一个函数,我们可以确保操作是在临界区内部进行的,而不必自己实际执行锁操作。这个保证是由编译器静态提供的:不会有任何与cs相关的运行时开销。如果我们有多个计数器,它们都可以被赋予相同的cs,而不需要多个嵌套的临界区。

这也引出了一个关于Rust中并发性的重要话题:SendSync

trait。对Rust这本书的总结是,当它可以安全地移动到另一个线程时,类型是Send,而当它可以在多个线程之间安全地共享时,类型是Sync。

在嵌入式上下文中,我们认为中断是在应用程序代码的单独线程中执行的,因此中断和主代码访问的变量必须是Sync。

对于Rust中的大多数类型,这两个特征都是由编译器自动生成的。但是,因为CSCounter包含UnsafeCell,所以它不是同步的,因此我们不能创建static CSCounter:static 变量必须是同步的,因为它们可以被多个线程访问。为了告诉编译器我们已经注意到CSCounter实际上是线程间共享的安全的,我们显式地实现了Sync特征。

互斥

我们已经创建了一个针对计数器问题的有用的抽象,但是还有许多用于并发的通用抽象。

其中一个同步原语是互斥锁,即互斥锁。这些结构确保了对变量的独占访问,比如我们的counter。线程可以尝试锁定(或获取)互斥锁,然后要么立即成功,要么阻塞等待获得锁,要么返回无法锁定互斥锁的错误。当该线程持有锁时,它被授予对受保护数据的访问权。当线程完成时,它解锁(或释放)互斥锁,允许另一个线程锁定它。在Rust中,我们通常使用Drop trait来实现解锁,以确保当互斥量超出范围时它总是被释放。

在中断处理程序中使用互斥是很棘手的:中断处理程序阻塞通常是不可接受的,它在等待主线程释放锁时阻塞将是特别灾难性的,因为我们将会死锁(主线程永远不会释放锁,因为执行停留在中断处理程序中)。死锁并不被认为是不安全的:即使在安全的Rust,它也是可能的。

为了完全避免这种行为,我们可以实现一个需要临界区来锁定的互斥锁,就像我们的反例一样。只要临界区必须和锁的时间一样长,我们就可以确保对包装变量的独占访问,甚至不需要跟踪互斥锁的锁定/解锁状态。

这实际上是在cortex_m crate中为我们完成的!我们可以使用它来编写计数器:

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一起被用来提供安全的内部可变性。我们已经在Rust中看到了UnsafeCell,它是内部可变的底层:它允许您获得对其值的多个可变引用,但只能使用不安全的代码。Cell类似于UnsafeCell,但它提供了一个安全界面:它只允许获取当前值的副本或替换它,而不允许获取引用,因为它不是同步的,它不能在线程之间共享。这些约束意味着它可以安全使用,但我们不能直接在静态变量中使用它,因为static必须是Sync。

那么,为什么上面的例子有效呢?Mutex<T>实现了对任何T的同步,例如一个Cell。它可以安全地这样做,因为它只在临界区提供对其内容的访问。因此,我们能够得到一个没有不安全代码的安全计数器!

这对于像计数器的u32这样的简单类型来说非常棒,但是对于那些不是Copy的更复杂的类型呢?内嵌上下文中一个非常常见的例子是外围结构,它通常不是Copy。为此,我们可以转RefCell。

共享外围设备

使用svd2rust和类似的抽象生成的设备crate通过强制一次只能存在一个外设结构的实例来提供对外设的安全访问。这确保了安全性,但使从主线程和中断处理程序访问外设变得困难。

为了安全地共享外围设备访问,我们可以使用前面看到的互斥锁。我们还需要使用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));

我们的共享变量现在是一个围绕RefCell的互斥锁,其中包含一个Option。互斥锁确保我们只有在临界区才有访问权限,因此使变量Sync,即使一个普通的RefCell不是Sync。RefCell为我们提供了内部可变的引用,这是我们使用GPIOA所需要的。Option允许我们将该变量初始化为空值,并在稍后将变量移进去。我们不能静态地访问外围单例,只有在运行时,所以这是必需的。

在临界区中,我们可以对互斥对象调用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,并跟踪它被借了多长时间——一旦引用超出范围,RefCell将被更新以表明它不再被借了。

因为我们不能将GPIOA从&Option中移出,我们需要用as_ref()将它转换为&Option<&GPIOA>,我们最终可以打开()来获得&GPIOA,它可以让我们修改外围设备。

如果需要对共享资源的可变引用,则应该使用borrow_mut和deref_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框架 RTIC framework,,它是实时中断驱动并发的简称。它强制执行静态优先级并跟踪对static mut变量(“资源”)的访问,以静态地确保共享资源总是被安全地访问,而不需要总是进入临界区并使用引用计数(如RefCell)的开销。这有许多优点,例如保证没有死锁,并提供极低的时间和内存开销。

该框架还包括其他特性,如消息传递,它减少了对显式共享状态的需求,以及调度任务在给定时间运行的能力,这可以用于实现周期性任务。

查看文档以获得更多信息! the documentation

实时操作系统

嵌入式并发的另一个常见模型是实时操作系统(RTOS)。虽然目前在Rust中还没有得到很好的探索,但是它们在传统的嵌入式开发中被广泛使用。开源的例子包括FreeRTOS和ChibiOS。这些RTOSs支持运行多个应用程序线程,CPU可以在这些线程之间进行切换,当这些线程获得控制时(称为协作多任务处理),或者基于常规计时器或中断(抢占多任务处理)。RTOS通常提供互斥锁和其他同步原语,并经常与硬件特性(如DMA引擎)进行互操作。

在撰写本文时,Rust RTOS的例子并不多,但这是一个有趣的领域,所以请关注这个领域!

多核

在嵌入式处理器中使用两个或更多的核心变得越来越普遍,这为并发性增加了额外的复杂性。所有使用临界部分的例子(包括cortex_m::interrupt::Mutex)都假设唯一的其他执行线程是中断线程,但在多核系统上,这就不成立了。相反,我们需要为多核设计的同步原语(也称为SMP,用于对称多处理)。

它们通常使用我们前面看到的原子指令,因为处理系统将确保在所有核心上维护原子性。详细介绍这些主题目前超出了本书的范围,但一般模式与单核情况相同。

7集合

最终,您将希望在程序中使用动态数据结构(AKA集合)。std提供一组常用集合:Vec, String, HashMap,等等。在std中实现的所有集合都使用全局动态内存分配器(又称堆)。

根据定义,由于core没有内存分配,这些实现在那里是不可用的,但可以在编译器附带的alloc crate中找到它们。

如果需要集合,则堆分配实现不是惟一的选择。您还可以使用固定容量收集;一个这样的实现可以在无堆heapless中找到。

在本节中,我们将探讨并比较这两种实现。

使用alloc

alloc crate是与标准的Rust分发crate。要导入crate,您可以直接使用它,而无需在Cargo.toml中声明它作为依赖项。

#![feature(alloc)]

extern crate alloc;

use alloc::vec::Vec;

为了能够使用任何集合,您首先需要使用global_allocator属性来声明程序将使用的全局分配器。要求您选择的分配器实现GlobalAlloc trait。

为了完整,并使本节尽可能自包含,我们将实现一个简单的凹凸指针分配器,并使用它作为全局分配器。但是,我们强烈建议你使用crate的战斗测试分配器。IO,而不是这个分配器。

// Bump pointer allocator implementation

extern crate cortex_m;

use core::alloc::GlobalAlloc;
use core::ptr;

use cortex_m::interrupt;

// Bump pointer allocator for *single* core systems
struct BumpPointerAlloc {
    head: UnsafeCell<usize>,
    end: usize,
}

unsafe impl Sync for BumpPointerAlloc {}

unsafe impl GlobalAlloc for BumpPointerAlloc {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        // `interrupt::free` is a critical section that makes our allocator safe
        // to use from within interrupts
        interrupt::free(|_| {
            let head = self.head.get();
            let size = layout.size();
            let align = layout.align();
            let align_mask = !(align - 1);

            // move start up to the next alignment boundary
            let start = (*head + align - 1) & align_mask;

            if start + size > self.end {
                // a null pointer signal an Out Of Memory condition
                ptr::null_mut()
            } else {
                *head = start + size;
                start as *mut u8
            }
        })
    }

    unsafe fn dealloc(&self, _: *mut u8, _: Layout) {
        // this allocator never deallocates memory
    }
}

// Declaration of the global memory allocator
// NOTE the user must ensure that the memory region `[0x2000_0100, 0x2000_0200]`
// is not used by other parts of the program
#[global_allocator]
static HEAP: BumpPointerAlloc = BumpPointerAlloc {
    head: UnsafeCell::new(0x2000_0100),
    end: 0x2000_0200,
};

除了选择全局分配器外,用户还必须使用不稳定的alloc_error_handler属性定义如何处理内存不足(OOM)错误。

#![feature(alloc_error_handler)]

use cortex_m::asm;

#[alloc_error_handler]
fn on_oom(_layout: Layout) -> ! {
    asm::bkpt();

    loop {}
}

一旦所有这些就绪,用户就可以在alloc中使用集合了。

#[entry]
fn main() -> ! {
    let mut xs = Vec::new();

    xs.push(42);
    assert!(xs.pop(), Some(42));

    loop {
        // ..
    }
}

如果您在std crate中使用过集合,那么您就会熟悉它们,因为它们是完全相同的实现。

使用heapless

heapless不需要设置,因为它的集合不依赖于全局内存分配器。只需use它的集合并继续实例化它们:

extern crate heapless; // v0.4.x

use heapless::Vec;
use heapless::consts::*;

#[entry]
fn main() -> ! {
    let mut xs: Vec<_, U8> = Vec::new();

    xs.push(42).unwrap();
    assert_eq!(xs.pop(), Some(42));
}

您将注意到这些集合与alloc中的集合之间的两个区别。

首先,你必须预先声明收集的容量。heapless集合永远不会重新分配,并且具有固定的容量;该容量是集合的类型签名的一部分。在这种情况下,我们声明了xs的容量为8个元素,也就是向量最多可以容纳8个元素。这由类型签名中的U8(参见typenum)表示。

其次,push方法和许多其他方法返回一个Result。由于heapless集合具有固定的容量,所有向集合中插入元素的操作都可能失败。API通过返回一个Result来反映这个问题,该Result指示操作是否成功。相反,alloc集合将在堆上重新分配自己,以增加它们的容量。

从v0.4版本开始。所有heapless集合都以内联方式存储它们的所有元素。这意味着像let x = heapless::Vec::new();将在堆栈上分配集合,但也可以在静态变量上分配集合,甚至在堆上分配(Box<Vec<_, _>>).

权衡

在选择堆分配的、可重定位的集合和固定容量的集合时,请记住这些。

内存不足和错误处理

在堆分配中,内存溢出始终是可能的,并且可能发生在集合需要增长的任何地方:例如,所有的alloc::Vec。push调用可能会生成一个OOM条件。因此,有些操作可能会隐式失败。
有些alloc集合公开try_reserve方法,允许您在增加集合时检查潜在的OOM条件,但您需要主动使用它们。

如果您专门使用heapless集合,并且您不为任何其他事情使用内存分配器,那么OOM条件是不可能的。相反,您必须根据具体情况处理集合耗尽容量的问题。也就是说,你需要处理Vec.push等方法返回的所有Result。

与打开由heapless::Vec返回的所有结果相比,OOM失败可能更难调试。推送是因为观察到的故障位置可能与问题原因的位置不匹配。例如,如果分配器几乎耗尽,甚至vec.reserve(1)也会触发OOM,因为其他一些收集正在泄漏内存(内存泄漏在安全的Rust中是可能的)。

内存使用

很难推断堆分配的集合的内存使用情况,因为长生命周期集合的容量可能在运行时发生变化。一些操作可能会隐式地重新分配集合,从而增加其内存使用量,而一些集合会公开诸如shrink_to_fit这样的方法,这些方法可能会减少集合使用的内存——最终,由分配器决定是否实际收缩内存分配。此外,分配器可能必须处理内存碎片,这可能会增加明显的内存使用。

另一方面,如果您专门使用固定容量集合,将它们中的大部分存储在静态变量中,并为调用堆栈设置最大大小,那么链接器将检测您是否试图使用比物理可用内存更多的内存。

此外,在堆栈上分配的固定容量集合将通过-Z emit-stack-sizes标志报告,这意味着分析堆栈使用情况的工具(如堆栈-size)将在其分析中包括它们。

但是,固定容量收集不能缩小,这可能导致比可重定位收集所能达到的负载系数(收集大小与其容量之间的比率)更低。

最坏情况执行时间(WCET)

如果你在构建对时间敏感的应用程序或硬实时应用程序,你可能会非常关心,程序不同部分最坏的执行时间。

alloc集合可以重新分配,因此可能增加集合的操作的WCET还包括重新分配集合所需的时间,这本身取决于集合的运行时容量。这使得确定例如alloc::Vec.push的WCET变得困难,因为它取决于所使用的分配器及其运行时容量。

另一方面,固定容量收集从不重新分配,因此所有操作都有一个可预测的执行时间。例如,heapless::Vec.push执行的时间为常数。

简易操作

alloc需要设置全局分配器,而heapless则不需要。但是,heapless需要您选择实例化的每个集合的容量。

alloc API几乎为每一位rust开发者所熟悉。heapless API试图紧密模仿alloc API,但由于其显式错误处理,它永远不会完全相同——一些开发人员可能觉得显式错误处理过多或太麻烦。

8设计模式

本章的目的是为嵌入式Rust收集各种有用的设计模式。

8.1HAL设计模式

8.1.1HAL设计模式检查表

命名((crate与Rust命名约定一致))

​ crate有适当的名称 (C-CRATE-NAME)

互操作性(crate与其他库功能很好地交互)

​ 包装器类型提供析构函数方法(C-FREE)

​ hal重新导出他们的寄存器访问crate(C-REEXPORT-PAC)

​ 类型实现embedded-hal trait (C-HAL-TRAITS)

可预见性(crate允许清晰的代码按照它的样子运行)

​ 使用构造函数代替扩展特征(C-CTOR)

GPIO接口(GPIO接口遵循通用模式)

​ Pin类型默认为零大小(C-ZST-PIN)

​ 引脚类型提供了擦除引脚和端口的方法(C-ERASED-PIN)

​ 引脚状态应编码为类型参数(C-PIN-STATE)

8.1.2命名

crate有适当的命名(C-CRATE-NAME)

HAL crate应该以其目标支持的芯片或芯片家族命名。
它们的名称应该以-hal结尾,以区别于寄存器访问crate。
名称不应该包含下划线(使用破折号代替)。

8.1.3互用性

包装器类型提供析构函数方法(C-FREE)

HAL提供的任何非Copy包装器类型都应该提供一个free方法,该方法使用包装器并返回创建它的原始外设(可能还有其他对象)。

该方法应在必要时关闭并复位外围设备。使用free返回的原始外围设备调用new不应该因为外围设备的意外状态而失败。

如果HAL类型需要构造其他非copy对象(例如I/O引脚),任何这样的对象也应该被free释放并返回。在这种情况下,Free应该返回一个元组。

举个例子:


#![allow(unused)]
fn main() {
pub struct TIMER0;
pub struct Timer(TIMER0);

impl Timer {
    pub fn new(periph: TIMER0) -> Self {
        Self(periph)
    }

    pub fn free(self) -> TIMER0 {
        self.0
    }
}
}

HAL重新导出他们的寄存器访问箱crate(C-REEXPORT-PAC)

可以在svd2rust生成的pac上编写HALs,也可以在提供原始寄存器访问的其他板条箱上编写HAL。HAL应该总是在它们的crate根目录中重新导出它们所基于的注册访问crate。

PAC应该以pac的名称重新导出,而不管crate的实际名称是什么,因为HAL的名称应该已经清楚地表明正在访问什么PAC。

类型实现embedded-hal trait(C-HAL-TRAITS)

由HAL提供的类型应该实现由embedded-hal crate提供的所有适用特性。

同一类型可以实现多个trait。

8.1.4可预测性

使用构造函数代替扩展trait(C-CTOR)

HAL添加功能的所有外设都应该包装在一个新类型中,即使该功能不需要额外的字段。

应该避免为原始外设实现的扩展trait。

方法在适当的地方用#[inline]装饰(C-INLINE)

默认情况下,Rust编译器不会跨crate边界执行完全内联。由于嵌入式应用程序对意外的代码大小增加很敏感,应该使用#[inline]来指导编译器,如下所示:

  • 所有“小”函数都应该标记为#[inline]。所谓的“小”是主观的,但一般来说,所有被期望编译成一位数指令序列的函数都被认为是小的。

  • 很可能使用常量值作为参数的函数应该标记为#[inline]。这使得编译器能够在编译时计算复杂的初始化逻辑,前提是函数输入是已知的。

8.1.5GPIO接口推荐

Pin类型默认为零大小(C-ZST-PIN)

由HAL公开的GPIO接口应该为每个接口或端口上的每个引脚提供专用的零大小类型,从而在所有引脚分配都是静态已知的情况下实现零成本的GPIO抽象。

每个GPIO接口或端口都应该实现一个split方法,每个引脚返回一个结构体。

举例:


#![allow(unused)]
fn main() {
pub struct PA0;
pub struct PA1;
// ...

pub struct PortA;

impl PortA {
    pub fn split(self) -> PortAPins {
        PortAPins {
            pa0: PA0,
            pa1: PA1,
            // ...
        }
    }
}

pub struct PortAPins {
    pub pa0: PA0,
    pub pa1: PA1,
    // ...
}
}

引脚类型提供了擦除引脚和端口的方法(C-ERASED-PIN)

引脚应该提供类型擦除方法,将它们的属性从编译时移动到运行时,并在应用程序中允许更多的灵活性。

例子:


#![allow(unused)]
fn main() {
/// Port A, pin 0.
pub struct PA0;

impl PA0 {
    pub fn erase_pin(self) -> PA {
        PA { pin: 0 }
    }
}

/// A pin on port A.
pub struct PA {
    /// The pin number.
    pin: u8,
}

impl PA {
    pub fn erase_port(self) -> Pin {
        Pin {
            port: Port::A,
            pin: self.pin,
        }
    }
}

pub struct Pin {
    port: Port,
    pin: u8,
    // (these fields can be packed to reduce the memory footprint)
}

enum Port {
    A,
    B,
    C,
    D,
}
}

引脚状态应编码为类型参数(C-PIN-STATE)

由HAL公开的GPIO接口应该为每个接口或端口上的每个引脚提供专用的零大小类型,从而在所有引脚分配都是静态已知的情况下实现零成本的GPIO抽象。

每个GPIO接口或端口都应该实现一个split方法,每个引脚返回一个结构体

举例:


#![allow(unused)]
fn main() {
pub struct PA0;
pub struct PA1;
// ...

pub struct PortA;

impl PortA {
    pub fn split(self) -> PortAPins {
        PortAPins {
            pa0: PA0,
            pa1: PA1,
            // ...
        }
    }
}

pub struct PortAPins {
    pub pa0: PA0,
    pub pa1: PA1,
    // ...
}
}

引脚应该提供类型擦除方法,将它们的属性从编译时移动到运行时,并在应用程序中允许更多的灵活性。

举例:


#![allow(unused)]
fn main() {
/// Port A, pin 0.
pub struct PA0;

impl PA0 {
    pub fn erase_pin(self) -> PA {
        PA { pin: 0 }
    }
}

/// A pin on port A.
pub struct PA {
    /// The pin number.
    pin: u8,
}

impl PA {
    pub fn erase_port(self) -> Pin {
        Pin {
            port: Port::A,
            pin: self.pin,
        }
    }
}

pub struct Pin {
    port: Port,
    pin: u8,
    // (these fields can be packed to reduce the memory footprint)
}

enum Port {
    A,
    B,
    C,
    D,
}
}

引脚状态应编码为类型参数(C-PIN-STATE)

根据芯片或系列的不同,引脚可以配置为具有不同特性的输入或输出。这种状态应该在类型系统中编码,以防止在不正确的状态下使用引脚。另外,芯片特定的状态(如。
驱动强度)也可以使用附加的类型参数以这种方式进行编码。

改变引脚状态的方法应该作为into_input和into_output方法提供。另外,应该提供_{input,output}_state方法来临时重新配置一个pin到不同的状态,而不需要移动它。

以下方法应该为每一种引脚类型提供(也就是说,擦除和非擦除引脚类型应该提供相同的API):

  • pub fn into_input<N: InputState>(self, input: N) -> Pin<N>
  • pub fn into_output<N: OutputState>(self, output: N) -> Pin<N>
pub fn with_input_state<N: InputState, R>(
    &mut self,
    input: N,
    f: impl FnOnce(&mut PA1<N>) -> R,
) -> R

pub fn with_output_state<N: OutputState, R>(
    &mut self,
    output: N,
    f: impl FnOnce(&mut PA1<N>) -> R,
) -> R

引脚状态应以密封特性为界。HAL的用户应该不需要添加自己的状态。trait可以提供实现引脚状态API所需的hal特定方法。

举例:


#![allow(unused)]
fn main() {
use std::marker::PhantomData;
mod sealed {
    pub trait Sealed {}
}

pub trait PinState: sealed::Sealed {}
pub trait OutputState: sealed::Sealed {}
pub trait InputState: sealed::Sealed {
    // ...
}

pub struct Output<S: OutputState> {
    _p: PhantomData<S>,
}

impl<S: OutputState> PinState for Output<S> {}
impl<S: OutputState> sealed::Sealed for Output<S> {}

pub struct PushPull;
pub struct OpenDrain;

impl OutputState for PushPull {}
impl OutputState for OpenDrain {}
impl sealed::Sealed for PushPull {}
impl sealed::Sealed for OpenDrain {}

pub struct Input<S: InputState> {
    _p: PhantomData<S>,
}

impl<S: InputState> PinState for Input<S> {}
impl<S: InputState> sealed::Sealed for Input<S> {}

pub struct Floating;
pub struct PullUp;
pub struct PullDown;

impl InputState for Floating {}
impl InputState for PullUp {}
impl InputState for PullDown {}
impl sealed::Sealed for Floating {}
impl sealed::Sealed for PullUp {}
impl sealed::Sealed for PullDown {}

pub struct PA1<S: PinState> {
    _p: PhantomData<S>,
}

impl<S: PinState> PA1<S> {
    pub fn into_input<N: InputState>(self, input: N) -> PA1<Input<N>> {
        todo!()
    }

    pub fn into_output<N: OutputState>(self, output: N) -> PA1<Output<N>> {
        todo!()
    }

    pub fn with_input_state<N: InputState, R>(
        &mut self,
        input: N,
        f: impl FnOnce(&mut PA1<N>) -> R,
    ) -> R {
        todo!()
    }

    pub fn with_output_state<N: OutputState, R>(
        &mut self,
        output: N,
        f: impl FnOnce(&mut PA1<N>) -> R,
    ) -> R {
        todo!()
    }
}

// Same for `PA` and `Pin`, and other pin types.
}

9给嵌入式c开发者的建议

本章收集各种各样的技巧,可能是有用的经验丰富的嵌入式C开发人员想要开始写rust。它将特别突出你在C中已经习惯的东西在Rust中是如何不同的。

预处理器

在嵌入式C语言中,预处理器通常用于各种目的,例如:

  • 与# ifdef编译时选择的代码块
  • 编译时数组大小和计算
  • 简化常用模式的宏(以避免函数调用开销)

在Rust中没有预处理器,所以很多用例都以不同的方式处理。在本节的其余部分,我们将介绍使用预处理器的各种替代方案。

编译时代码的选择

最接近于#ifdef…#endif在rust是Cargo features.这比C预处理器更正式一些:每个crate显式列出所有可能的特性,并且只能是打开或关闭。当你把crate列为依赖项时,功能就会打开,并且是附加的:如果您的依赖树中的任何一个crate为另一个crate启用了一个特性,那么该特性将为该crate的所有用户启用。

例如,您可能有一个提供信号处理原语库的crate。每一个都可能需要一些额外的时间来编译或声明一些您希望避免的大型常量表。你可以在Cargo.toml中为每个组件声明一个Cargo特性:

[features]
FIR = []
IIR = []

然后,在你的代码中,使用#[cfg(feature=“FIR”)]来控制是包括在内。


#![allow(unused)]
fn main() {
/// In your top-level lib.rs

#[cfg(feature="FIR")]
pub mod fir;

#[cfg(feature="IIR")]
pub mod iir;
}

类似地,只有当某个功能部件未启用,或者功能部件的任何组合已启用或未启用时,才可以包含代码块。

另外,Rust提供了许多您可以使用的自动设置条件,例如target_arch来根据架构选择不同的代码。有关条件编译支持的详细信息,请参阅Rust参考资料的条件编译(conditional compilation )一章。

条件编译只适用于下一个语句或块。如果一个块不能在当前范围内使用,那么cfg属性将需要多次使用。值得注意的是,大部分的时间最好是简单地包含所有代码,允许编译器优化时去除死代码:这样对您和您的用户都更简单,通常编译器会很好地删除未使用的代码。

编译时大小和计算

Rust支持const fn,这些函数保证在编译时可求值,因此可以在需要常量的地方使用,比如数组的大小。这可以和上面提到的功能一起使用,例如:


#![allow(unused)]
fn main() {
const fn array_size() -> usize {
    #[cfg(feature="use_more_ram")]
    { 1024 }
    #[cfg(not(feature="use_more_ram"))]
    { 128 }
}

static BUF: [u32; array_size()] = [0u32; array_size()];
}

这些都是1.31版本中稳定版Rust的新版本,所以文档仍然很少。在编写本文时,const fn可用的功能也非常有限;
在未来的Rust版本中,它有望扩展const fn中允许的内容。

Rust提供了一个非常强大的宏系统(macro system. )。C预处理器几乎直接操作源代码的文本,而Rust宏系统则在更高的层次上操作。Rust宏有两种类型:示例宏和过程宏。前者更简单,也最常见;它们看起来像函数调用,可以扩展为完整的表达式、语句、项或模式。过程宏更为复杂,但允许向Rust语言添加极其强大的功能:它们可以将任意Rust语法转换为新的Rust语法。

通常,当您使用C预处理器宏时,您可能想看看示例宏是否可以完成这项工作。它们可以在您的crate中定义,并且很容易被您自己的crate使用或导出给其他用户。请注意,由于它们必须展开以完成表达式、语句、项或模式,因此C预处理器宏的一些用例无法工作,例如,展开为变量名的一部分或列表中不完整的项集的宏。

与Cargo特性一样,您甚至需要考虑是否需要宏。
在许多情况下,常规的更容易理解并将内联函数相同的代码作为一个宏。#[inline]和#[inline(always)]属性为您提供了对这个过程的进一步控制,不过这里也要注意—编译器将在适当的地方自动内联来自同一crate的函数,因此不适当地强制它这样做实际上可能会导致性能下降。

解释整个Rust宏系统超出了本技巧页面的范围,因此建议您查阅Rust文档以获得完整的细节。

构建系统

大多数Rust crate是使用Cargo创建的(尽管它不是必需的)。这解决了传统构建系统的许多难题。但是,您可能希望定制构建过程。cargo提供了build.rs脚本用于此目的。它们是可以根据需要与Cargo构建系统交互的Rust脚本。

构建脚本的常见用例包括:

  • 提供构建时信息,例如静态地将构建日期或Git提交哈希嵌入到可执行文件中
  • 在构建时根据所选特性或其他逻辑生成链接器脚本
  • 更改Cargo构建配置
  • 添加额外的静态库来链接

交叉编译

使用Cargo为你构建系统还简化了交叉编译。在大部分情况下,它足以告诉Cargo --target thumbv6m-none-eabi 和找到可运行的target/thumbv6m-none-eabi/debug/myapp.

对于不被Rust原生支持的平台,您将需要自己为该目标构建libcore。在这样的平台上,Xargo可以作为Cargo的替代品,它会自动为你构建libcore。

迭代器vs数组访问

在C语言中,你可能习惯于直接通过数组的索引访问数组:

int16_t arr[16];
int i;
for(i=0; i<sizeof(arr)/sizeof(arr[0]); i++) {
    process(arr[i]);
}

在Rust中,这是一种反模式:索引访问可能会比较慢(因为它需要进行边界检查),并且可能会阻止各种编译器优化。这是一个重要的区别,值得重复:Rust会检查手工数组索引的越界访问,以保证内存安全,而C会很乐意在数组外进行索引。

相反,使用迭代器:

let arr = [0u16; 16];
for element in arr.iter() {
    process(*element);
}

迭代器提供了一组强大的功能,您必须在C中手动实现这些功能,例如链接、压缩、枚举、查找最小值或最大值、求和等等。迭代器方法也可以被串联起来,从而提供非常可读的数据处理代码。

Iterators in the BookIterator documentation来获得更多信息。

引用vs指针

在Rust中,指针(称为原始指针)存在,但只在特定的情况下使用,因为解除对它们的引用总是被考虑的unsafe–Rust不能提供它通常的保证,关于指针后面可能有什么。

在大多数情况下,我们使用由&符号表示的引用,或由&mut表示的可变引用。引用的行为类似于指针,因为它们可以被解除引用来访问底层的值,但它们是Rust的所有权系统的关键部分:Rust将严格规定,在任何给定的时间,对同一个值只能有一个可变引用或多个不可变引用。

在实践中,这意味着你必须更加小心你是否需要对数据的可变访问:在C语言中,默认值是可变的,你必须明确const,而在Rust中恰恰相反。

一种情况下,你可能仍然使用原始指针是直接与硬件交互(例如,将一个指向缓冲区的指针写入DMA外围寄存器),它们还被用于所有外设访问crate,以允许您读写内存映射寄存器。

不稳定访问

在C语言中,单个变量可能被标记为volatile,向编译器表明变量中的值可能在访问之间发生变化。Volatile变量通常用于内存映射寄存器的嵌入式上下文中。

在Rust中,我们使用特定的方法来执行volatile访问,而不是将变量标记为volatile:core::ptr::read_volatilecore::ptr::write_volatile.这些方法需要* const T或*mut T原始指针,正如上面所讨论的)和执行volatile读或写的。

例如,在C语言中,你可以这样写:

volatile bool signalled = false;

void ISR() {
    // Signal that the interrupt has occurred
    signalled = true;
}

void driver() {
    while(true) {
        // Sleep until signalled
        while(!signalled) { WFI(); }
        // Reset signalled indicator
        signalled = false;
        // Perform some task that was waiting for the interrupt
        run_task();
    }
}

在Rust中,类似的方法会在每次访问中使用volatile方法:

static mut SIGNALLED: bool = false;

#[interrupt]
fn ISR() {
    // Signal that the interrupt has occurred
    // (In real code, you should consider a higher level primitive,
    //  such as an atomic type).
    unsafe { core::ptr::write_volatile(&mut SIGNALLED, true) };
}

fn driver() {
    loop {
        // Sleep until signalled
        while unsafe { !core::ptr::read_volatile(&SIGNALLED) } {}
        // Reset signalled indicator
        unsafe { core::ptr::write_volatile(&mut SIGNALLED, false) };
        // Perform some task that was waiting for the interrupt
        run_task();
    }
}

代码示例中有几点值得注意:

  • 我们可以将&mut SIGNALLED传递给需要*mut T的函数,因为&mut T会自动转换为*mut(对于*const T也是如此)
  • 我们需要unsafe块read_volatile / write_volatile方法,因为它们是不安全的功能.确保安全使用是程序员的责任:参见方法文档了解更多细节。

很少在代码中直接要求这些函数,因为它们通常会由更高级别的库来处理。对于内存映射外设,外设访问板条箱将自动实现volatile访问,而对于并发原语,有更好的抽象可用(see the Concurrency chapter).

打包和对齐类型

在嵌入式C语言中,通常会告诉编译器一个变量必须有一定的对齐方式,或者一个结构必须被打包而不是对齐,通常是为了满足特定的硬件或协议需求。

在Rust中,这是由一个结构或联合的repr属性控制的。默认表示没有提供布局的保证,所以不应该用于与硬件或C语言互操作的代码。编译器可能会重新排序结构成员或插入填充,并且这些行为可能会随着未来版本的Rust而改变。

struct Foo {
    x: u16,
    y: u8,
    z: u16,
}

fn main() {
    let v = Foo { x: 0, y: 0, z: 0 };
    println!("{:p} {:p} {:p}", &v.x, &v.y, &v.z);
}

// 0x7ffecb3511d0 0x7ffecb3511d4 0x7ffecb3511d2
// Note ordering has been changed to x, z, y to improve packing.

为了确保布局与C可互操作,使用repr©:

#[repr(C)]
struct Foo {
    x: u16,
    y: u8,
    z: u16,
}

fn main() {
    let v = Foo { x: 0, y: 0, z: 0 };
    println!("{:p} {:p} {:p}", &v.x, &v.y, &v.z);
}

// 0x7fffd0d84c60 0x7fffd0d84c62 0x7fffd0d84c64
// Ordering is preserved and the layout will not change over time.
// `z` is two-byte aligned so a byte of padding exists between `y` and `z`.

确保包装表示,使用repr(packed):

#[repr(packed)]
struct Foo {
    x: u16,
    y: u8,
    z: u16,
}

fn main() {
    let v = Foo { x: 0, y: 0, z: 0 };
    // References must always be aligned, so to check the addresses of the
    // struct's fields, we use `std::ptr::addr_of!()` to get a raw pointer
    // instead of just printing `&v.x`.
    let px = std::ptr::addr_of!(v.x);
    let py = std::ptr::addr_of!(v.y);
    let pz = std::ptr::addr_of!(v.z);
    println!("{:p} {:p} {:p}", px, py, pz);
}

// 0x7ffd33598490 0x7ffd33598492 0x7ffd33598493
// No padding has been inserted between `y` and `z`, so now `z` is unaligned.

注意,使用repr(packed)还会将类型的对齐方式设置为1。

最后,要指定特定的对齐方式,使用repr(align(n)),其中n是要对齐的字节数(且必须是2的幂次):

#[repr(C)]
#[repr(align(4096))]
struct Foo {
    x: u16,
    y: u8,
    z: u16,
}

fn main() {
    let v = Foo { x: 0, y: 0, z: 0 };
    let u = Foo { x: 0, y: 0, z: 0 };
    println!("{:p} {:p} {:p}", &v.x, &v.y, &v.z);
    println!("{:p} {:p} {:p}", &u.x, &u.y, &u.z);
}

// 0x7ffec909a000 0x7ffec909a002 0x7ffec909a004
// 0x7ffec909b000 0x7ffec909b002 0x7ffec909b004
// The two instances `u` and `v` have been placed on 4096-byte alignments,
// evidenced by the `000` at the end of their addresses.

注意,我们可以结合repr©和repr(align(n))来获得一个对齐的和C兼容的布局。不允许将repr(align(n))与repr(packed)组合使用,因为repr(packed)将对齐方式设置为1。也不允许repr(packed)类型包含repr(align(n))类型。

关于类型布局的更多细节,请参阅Rust Reference的类型布局章节type layout

其他资源

10.交互性

Rust和C代码之间的互操作性始终依赖于两种语言之间的数据转换。为此,标准库中有两个专用模块std::ffistd::os::raw.。

std::os::raw处理可以由编译器隐式转换的低级原语类型,因为Rust和C之间的内存布局足够相似或相同。

std::ffi提供了一些实用工具,用于转换更复杂的类型,如字符串,将&str和String映射到更容易处理和更安全的c类型。

这两个模块是核心,但是你可以找到一个#![no_std]兼容的std::ffi::{CStr,CString}在cstr_core crate中,以及大多数std::os::raw类型在cty crate中。

Rust typeIntermediateC type
StringCString*char
&strCStr*const char
()c_voidvoid
u32 or u64c_uintunsigned int
etc

如上所述,编译器可以隐式地转换基本类型。

unsafe fn foo(num: u32) {
    let c_num: c_uint = num;
    let r_num: u32 = c_num;
}

与其他构建系统的互操作性

在您的嵌入式项目中包含Rust的一个常见需求是将Cargo与您现有的构建系统相结合,例如make或cmake。

我们收集的例子在我们的问题跟踪器和用例问题# 61issue #61.。

与操作系统的互操作性

将Rust与FreeRTOS或ChibiOS等实时操作系统集成仍在进行中;特别是从Rust中调用RTOS函数是很棘手的。我们正在我们的问题跟踪器中收集相关的例子和用例issue #62.

Rust中来点C

在Rust项目中使用C或c++包含两个主要部分:

  • 包装暴露的C API以与Rust一起使用
  • 构建与Rust代码集成的C或c++代码

因为c++没有一个稳定的ABI供Rust编译器使用,所以建议在将Rust与C或c++结合使用时使用C ABI。

定义接口

在从Rust使用C或c++代码之前,有必要(在Rust中)定义链接代码中存在哪些数据类型和函数签名。在C或c++,您将包括一个头文件(. h . hpp)定义了这个数据。在Rust中,需要手动地将这些定义转换为Rust,或者使用工具来生成这些定义。

首先,我们将介绍手动将这些定义从C/ c++转换为Rust。

包装C函数和数据类型

通常,用C或c++编写的库将提供一个头文件,定义公共接口中使用的所有类型和函数。一个示例文件可能像这样:

/* File: cool.h */
typedef struct CoolStruct {
    int x;
    int y;
} CoolStruct;

void cool_function(int i, char c, CoolStruct* cs);

当翻译成Rust时,这个界面看起来是这样的:

/* File: cool_bindings.rs */
#[repr(C)]
pub struct CoolStruct {
    pub x: cty::c_int,
    pub y: cty::c_int,
}

extern "C" {
    pub fn cool_function(
        i: cty::c_int,
        c: cty::c_char,
        cs: *mut CoolStruct
    );
}

让我们逐一来看一下这个定义,来解释每个部分。

#[repr(C)]
pub struct CoolStruct { ... }

默认情况下,Rust不保证顺序、填充或者包含在struct中的数据的大小。为了保证与C代码的兼容性,我们添加了#[repr©]属性,该属性指示Rust编译器始终使用与C相同的规则来组织结构中的数据。

pub x: cty::c_int,
pub y: cty::c_int,

由于C或c++定义int或char类型的灵活性,建议使用在cty中定义的原始数据类型,这将把C中的类型映射到Rust中的类型。

这个语句定义了一个使用C ABI的函数的签名,该函数名为cool_function。通过定义签名而不定义函数体,将需要在其他地方提供该函数的定义,或将其链接到最终库或静态库的二进制文件中。

    i: cty::c_int,
    c: cty::c_char,
    cs: *mut CoolStruct

与上面的数据类型类似,我们使用兼容c语言的定义来定义函数实参的数据类型。为了清晰起见,我们还保留了相同的参数名称。

这里有一个新类型,*mut CoolStruct。因为C没有Rust引用的概念,它看起来像这样:&mut CoolStruct,取而代之的是一个原始指针。由于解除对该指针的引用是不安全的,而且该指针实际上可能是一个空指针,所以在与C或c++代码交互时,必须小心确保典型的Rust保证。

自动生成接口

手工生成这些接口可能很繁琐,而且容易出错,但是有一个名为bindgen的工具可以自动执行这些转换。bindgen的使用说明,请参阅bindgen用户手册 bindgen user’s manual,但是典型的过程包括以下:

  1. 收集所有C或c++头文件,定义你想用Rust的接口或数据类型。
  2. 写一个bindings.h文件,其中#include "…"是你在第一步收集到的所有文件。
  3. 输入这个bindings.h文件,以及用于将代码编译为bindgen的任何编译标志。
    提示:使用Builder.ctypes_prefix("cty") / --ctypes-prefix=ctyBuilder.use_core() / --use-core 要使生成的代码#![no_std]兼容的。
  4. bindgen会将生成的Rust代码输出到终端窗口。
    这个文件可以通过管道传输到项目中的一个文件,例如bindings.rs。你可以在你的Rust项目中使用这个文件来与C/ c++代码交互,这些代码被编译并链接为一个外部库.提示:如果生成的绑定中的类型以cty为前缀,不要忘记使用cty crate。

构建你的c/c++代码

因为Rust编译器不直接知道如何编译C或c++代码(或者来自任何其他语言的代码,它提供了一个C接口),所以有必要提前编译你的非Rust代码。

对于嵌入式项目,这通常意味着将C/ c++代码编译为静态存档(如cool-library.a),然后可以结合你的Rust代码在最后的链接步骤。

如果您想使用的库已经作为静态存档分发,则没有必要重新构建代码。只需按照上面的描述转换提供的接口头文件,并在编译/链接时包含静态归档文件。

如果你的代码作为一个源项目存在,将有必要编译你的C/ c++代码到一个静态库,通过触发你现有的构建系统(如make, CMake等),或者通过移植必要的编译步骤来使用一个名为cc crate的工具。对于这两个步骤,都需要使用一个build.rs脚本。

Rust build.rs 构建脚本

build.rs使用Rust语法写的脚本文件,它在编译机器上运行,在构建依赖之后,构建项目之前运行。

全部的参考文件在here.build.rs脚本对于生成代码是很有用的(比如通过 bindgen)调用外部构建系统,如Make,或通过使用cc crate直接编译C/ c++。

触发外部构建系统

对于具有复杂外部项目或构建系统的项目,使用std::process::Command通过遍历相对路径“shell out”到其他构建系统可能是最简单的,调用一个固定的命令(例如make库),然后将生成的静态库复制到目标构建目录中的适当位置。

虽然你的crate可能是针对一个no_std嵌入式平台,但你的build.rs只在编译你的crate的机器上执行。这意味着你可以使用任何可以在编译主机上运行的Rust crate。

使用cc crate构建C/ c++代码

对于依赖性或复杂性有限的项目,或对于难以修改构建系统以生成静态库(而不是最终的二进制或可执行文件)的项目,使用cc crate可能更容易,它为宿主提供的编译器提供了一个惯用的Rust接口。

最简单的例子是将单个C文件编译为静态库的依赖项,下面是一个例子build.rs脚本使用cc crate应该是这样的:

extern crate cc;

fn main() {
    cc::Build::new()
        .file("foo.c")
        .compile("libfoo.a");
}

10.2在你的c中来点rust

在C或c++项目中使用Rust代码主要由两部分组成。

  • 在Rust中创建一个c友好的API
  • 将Rust项目嵌入到外部构建系统中

除了cargo和meson之外,大多数构建系统都没有原生的Rust支持。所以你最好只使用cargo来编译你的crate和任何依赖项。

建立一个project

像往常一样创建一个新的cargo项目。有标志告诉cargo发出一个系统库,而不是它的常规rust目标。这也允许您为您的库设置不同的输出名称,如果您希望它不同于您的crate的其他部分。

[lib]
name = "your_crate"
crate-type = ["cdylib"]      # Creates dynamic lib
# crate-type = ["staticlib"] # Creates static lib

构建一个C API

因为c++没有稳定的ABI作为Rust编译器的目标,所以我们使用C来实现不同语言之间的互操作性。在C和c++代码中使用Rust也不例外。

#[no_mangle]

Rust编译器对符号名称的处理与本地代码链接器所期望的不同。
因此,任何Rust导出的用于Rust之外的函数都需要被告知不要被编译器破坏。

extern “C”

默认情况下,用Rust编写的任何函数都将使用Rust ABI(它也是不稳定的)。相反,在构建面向外部的FFI api时,我们需要告诉编译器使用系统ABI。

根据您的平台,您可能希望以特定的ABI版本为目标,here.有文档说明。


把这些部分放在一起,你会得到一个大致像这样的函数。

#[no_mangle]
pub extern "C" fn rust_function() {

}

就像在Rust项目中使用C代码一样,您现在需要将数据从一个表单转换为应用程序的其他部分能够理解的表单。

链接和更大的项目环境

这就解决了一半的问题。你现在怎么用这个?

这在很大程度上取决于您的项目和/或构建系统

cargo将创建一个my_lib.so/my_lib.dllmy_lib.a 文件, 这取决于您的平台和设置。这个库可以通过您的构建系统进行简单的链接。

但是,从C语言中调用Rust函数需要一个头文件来声明函数签名。

你的Rust-ffi API中的每个函数都需要有一个对应的头函数。

#[no_mangle]
pub extern "C" fn rust_function() {}

就会变成

等等

有一个工具可以自动化这个过程,叫做 cbindgen ,它可以分析你的Rust代码,然后从中生成C和c++项目的头文件。

此时,使用C中的Rust函数就像包含头文件并调用它们一样简单!

#include "my-rust-project.h"
rust_function();

11.未分类主题

11.1优化:速度和大小的权衡

每个人都希望自己的程序超级快又超级小,但通常不可能同时拥有这两个特性。本节讨论rustc提供的不同优化级别,以及它们如何影响程序的执行时间和二进制大小。

没有优化

这是默认值。
当你调用cargo build时,你使用开发(AKA dev)配置文件。这个概要文件是为调试而优化的,因此它启用调试信息,而不启用任何优化,即它使用**-C opt-level = 0**。

至少对于裸机开发来说,调试信息是零成本的,因为它不会占用Flash / ROM的空间,所以我们建议你在发布配置文件中启用debuginfo——默认是禁用的。

这将允许您在调试发布版本时使用断点。

[profile.release]
# symbols are nice and they don't increase the size on Flash
debug = true

没有任何优化对于调试来说都是很好的,因为逐行执行代码感觉就像在逐句执行程序语句,而且还可以在GDB中打印堆栈变量和函数参数。当代码被优化时,尝试打印变量会导致$0 = <value optimized out>被打印出来。

dev文件最大的缺点是,生成的二进制文件将是巨大和缓慢的。大小通常是一个更大的问题,因为未优化的二进制文件可以占用几十KiB的Flash,这是你的目标设备可能没有——结果:您的未优化的二进制文件不适合您的设备!

我们可以有更小的、调试器友好的二进制文件吗?
是的,有一个技巧。

依赖优化

Cargo有一个特性profile-overrides,它可以允许重写依赖关系的优化级别。您可以使用该特性来优化所有依赖项的大小,同时保持顶部crate未优化和调试器友好。

这是一个例子:

# Cargo.toml
[package]
name = "app"
# ..

[profile.dev.package."*"] # +
opt-level = "z" # +

没有重写

$ cargo size --bin app -- -A
app  :
section               size        addr
.vector_table         1024   0x8000000
.text                 9060   0x8000400
.rodata               1708   0x8002780
.data                    0  0x20000000
.bss                     4  0x20000000

重写了的

$ cargo size --bin app -- -A
app  :
section               size        addr
.vector_table         1024   0x8000000
.text                 3490   0x8000400
.rodata               1100   0x80011c0
.data                    0  0x20000000
.bss                     4  0x20000000

这就减少了6kib的Flash使用,而没有损失顶部crate的可调试性。如果您进入一个依赖,那么您将再次看到<value optimized out>,但通常情况下,您想要调试顶部crate而不是依赖。

如果您确实需要调试一个依赖项,那么您可以使用profile-overrides特性来排除优化的特定依赖项。
请参见下面的例子:

# ..

# don't optimize the `cortex-m-rt` crate
[profile.dev.package.cortex-m-rt] # +
opt-level = 0 # +

# but do optimize all the other dependencies
[profile.dev.package."*"]
codegen-units = 1 # better optimizations
opt-level = "z"

现在顶部的crate和cortex-m-rt是调试器友好的!

优化速度

在2018-09-18 rustc支持“速度优化”等级:opt-level = 1, 2 和3。当你运行cargo build --release就是在使用release配置,默认优化等级为opt-level = 3`.

opt-level = 2和3都以牺牲二进制大小来优化速度,但是级别3比级别2做了更多的向量化和内联。

特别是,您将看到在opt级别等于或大于2的LLVM将展开循环。就Flash / ROM而言,循环展开的成本相当高(例如,在数组循环为0的情况下,从26字节到194字节),但在适当的条件下(例如,迭代次数足够多),也可以将执行时间减半。

目前还没有办法在opt-level = 2和3中禁用循环展开,所以如果你负担不起它的成本,你应该优化你的程序的大小。

优化大小

自2018-9-18 rustc支持两个尺寸优化等级:opt-level = "s""z". 这些名称从clang / LLVM继承而来,没有太多的描述性,但“z”意味着它产生的二进制文件比“s”更小。

如果您想要您的发布二进制文件优化大小,那么更改profile.release.opt-level 设置Cargo.toml文件。

[profile.release]
# or "z"
opt-level = "s"

这两个优化级别大大降低了LLVM的内联阈值(用于决定是否内联函数的指标)。Rust的原则之一是零成本抽象;这些抽象倾向于使用大量的新类型和小函数来保存不变量(例如借用内部值的函数,如deref, as_ref)因此,低内联阈值会使LLVM错过优化机会(例如消除死分支、内联闭包调用)。

在优化大小时,您可能希望尝试增加内联阈值,以查看这是否对二进制大小有任何影响。更改内联阈值的建议方法是添加-C inline-threshold标志 在.cargo/config.toml.

# .cargo/config.toml
# this assumes that you are using the cortex-m-quickstart template
[target.'cfg(all(target_arch = "arm", target_os = "none"))']
rustflags = [
  # ..
  "-C", "inline-threshold=123", # +
]

使用什么值?在1.29.0版本中,不同优化级别使用的内联阈值如下:

  • opt-level = 3 uses 275
  • opt-level = 2 uses 225
  • opt-level = "s" uses 75
  • opt-level = "z" uses 25

在优化大小时,您应该尝试225和275。

11.2使用#[no_std]执行数学功能

如果你想执行数学相关的功能,比如计算平方根或数字的指数,并且你有完整的标准库可用,你的代码可能是这样的:

//! Some mathematical functions with standard support available

fn main() {
    let float: f32 = 4.82832;
    let floored_float = float.floor();

    let sqrt_of_four = floored_float.sqrt();

    let sinus_of_four = floored_float.sin();

    let exponential_of_four = floored_float.exp();
    println!("Floored test float {} to {}", float, floored_float);
    println!("The square root of {} is {}", floored_float, sqrt_of_four);
    println!("The sinus of four is {}", sinus_of_four);
    println!(
        "The exponential of four to the base e is {}",
        exponential_of_four
    )
}

如果没有标准库的支持,这些函数是不可用的。可以使用像libm 这样的外部crate来代替。
示例代码如下所示:

#![no_main]
#![no_std]

use panic_halt as _;

use cortex_m_rt::entry;
use cortex_m_semihosting::{debug, hprintln};
use libm::{exp, floorf, sin, sqrtf};

#[entry]
fn main() -> ! {
    let float = 4.82832;
    let floored_float = floorf(float);

    let sqrt_of_four = sqrtf(floored_float);

    let sinus_of_four = sin(floored_float.into());

    let exponential_of_four = exp(floored_float.into());
    hprintln!("Floored test float {} to {}", float, floored_float).unwrap();
    hprintln!("The square root of {} is {}", floored_float, sqrt_of_four).unwrap();
    hprintln!("The sinus of four is {}", sinus_of_four).unwrap();
    hprintln!(
        "The exponential of four to the base e is {}",
        exponential_of_four
    )
    .unwrap();
    // exit QEMU
    // NOTE do not run this on hardware; it can corrupt OpenOCD state
    // debug::exit(debug::EXIT_SUCCESS);

    loop {}
}

如果您需要在MCU上执行更复杂的操作,如DSP信号处理或高级线性代数,下面的crate可能会帮助您

附录

嵌入式生态系统充满了不同的协议、硬件组件和特定于供应商的东西,它们使用自己的术语和缩写。
本术语表试图用指针列出它们,以便更好地理解它们。

BSP

Board Support Crate提供了为特定单板配置的高级接口。它通常依赖于HAL crate。在内存映射寄存器(memory-mapped registers page)页面上有更详细的描述,或者要了解更广泛的概述,请参阅本视频this video.。

FPU

浮点单元。一个只运行浮点数运算的“数学处理器”。

HAL

硬件抽象层板条箱为微控制器的特性和外设提供了一个开发者友好的界面。它通常是在外围设备访问crate(PAC)Peripheral Access Crate (PAC). 。它也可以实现来自embedded-hal crate的特性。在内存映射寄存器页面memory-mapped registers page上有更详细的描述,或者要了解更广泛的概述,请参阅本视频this video.。

I2C

有时也被称为I²C或Inter-IC。它是一种用于单个集成电路内硬件通信的协议。详见这里here

PAC

外围设备访问箱提供对微控制器外围设备的访问。它是较低级的Crate之一,通常直接从提供的SVD生成,经常使用svd2rust生成.

硬件抽象层Hardware Abstraction Layer 通常依赖于这个crate。
在内存映射寄存器页面 memory-mapped registers page上有更详细的描述,或者要了解更广泛的概述,请参阅本视频this video.。

SPI

串行外围接口。查看这里 here了解更多信息。

SVD

系统视图描述是一种XML文件格式,用于描述微控制器设备的程序员视图。你可以在ARM CMSIS文档网站上 the ARM CMSIS documentation site.阅读更多信息。

UART

通用异步收发两用机。查看这里了解更多信息 here

USART

通用同步和异步收发机。查看这里了解更多信息。here