Introduction
When I set out on learning embedded Rust, I recall one of my struggles is material spanning multiple abstraction levels. As might be known in embedded Rust, the abstraction level sitting directly on top of the controller is the peripheral access crate (PAC). The PAC gives access to the controller registers to configure and control controller functions. On top of the PAC sits the hardware abstraction layer (HAL), providing higher-level abstractions and safe code assurances as well. Although some resources would mention that they target for example the HAL, the material would mix in code from other levels like the PAC. The ease with which one can mix abstraction levels in Rust is something to admire, though, in the context of learning, it was a confusing factor. It really made me wonder if I correctly understood the abstractions.
In a prior blog series I wrote, "STM32F4 with Embedded Rust at the HAL", I covered examples of various peripherals sticking to the HAL. In this post, I will be starting a new series doing something similar but rather sticking to the PAC. This would mean something a bit different in which context about the STM32 peripheral registers is required. At the HAL, this wasn't required as we used methods that described configurations and functions. Register manipulations happened under the hood, and other than knowing controller features, not much understanding of the registers was required.
Developing code at the PAC, well, requires a PAC crate for the targeted controller. For the STM32 there exists a repo for all the supported PACs. These PACs are all generated using a command line tool called svd2rust. svd2rust grabs what is called an svd file and converts it into a PAC exposing API allowing access to peripheral registers. An SVD file is an Extensible Markup Language (XML) formatted file describing the hardware features of a device, listing all the peripherals and the registers associated with them. SVD files typically are released by microcontroller manufacturers.
In the context of STM32F4, there is already a PAC available publicly, so it can be leveraged directly in the project dependencies. However, there are cases one might run into for a newer controller or one that does not have an existing PAC. As such, one would have to go through generating a PAC using svd2rust. In this post, I go through an example of the steps to generate a PAC from an SVD file for the STM32F401 device. The steps should be more or less the same for any other device as long as an SVD file exists.
Step 1 - Install svd2rust ๐พ
svd2rust can be easily installed in the command line using the following cargo command:
$ cargo install svd2rust
Step 2 - Create a Library Package ๐
The files generated by svd2rust need to be contained in a library package. This is done through cargo using the new
command and named the crate stm32f401_pac
:
$ cargo new stm32f401_pac --lib
Step 3 - Locate and Download SVD File ๐๏ธ
ST microelectronics keeps a full list of zip files containing SVDs for different families of the STM32 on their own website. Consequently, I downloaded the STM32F4 System View Description zip file and unzipped it. After that, I navigated to find the STM32F401.svd file and placed it in the library package folder I created in step 2.
Step 4 - Generate Rust Files โ๏ธ
In this step, I execute all the commands as indicated by the svd2rust documentation. Here there are two things to note. First, the controller core architecture needs to be known so that the correct target can be specified (Cortex-M for the STM32F4). Second, if the form
command line tool needs to be installed if it's not, this is done through cargo as follows:
$ cargo install form
Next, one needs to execute the commands as indicated by svd2rust documentation:
$ svd2rust -i STM32F30x.svd
$ rm -rf src
$ form -i lib.rs -o src/ && rm lib.rs
$ cargo fmt
Additionally, in the same library package Cargo.toml
the necessary dependencies and features need to be added:
[dependencies]
critical-section = { version = "1.0", optional = true }
cortex-m = "0.7.6"
cortex-m-rt = { version = "0.6.13", optional = true }
vcell = "0.1.2"
[features]
rt = ["cortex-m-rt/device"]
At this point, we essentially have a PAC that can be imported into other projects!
Step 5 - Import PAC into Project ๐ฅ
Now we would need to create a new binary project and import the PAC we just created so that we can use it. I navigated to the same folder I placed the stm32f401_pac
folder in and ran the following command:
$ cargo new stm32f401_pactest --bin
In Cargo.toml
of the new binary I included the necessary dependencies:
[package]
name = "stm32401_pactest"
version = "0.1.0"
edition = "2021"
[dependencies]
cortex-m = { version = "0.7.7", features = ["critical-section-single-core"] }
stm32f401_pac = { path = "../stm32f401_pac", features = ["rt", "critical-section"] }
panic-halt = "0.2.0"
cortex-m-rt = "0.7.2"
There are a few things to note here. In the stm32f401_pac
dependency, a path is provided to the stm32f401_pac
package that was created earlier. Also, note the features
included. The svd2rust documentation states that the take
method used to get an instance of the device peripherals needs a critical-section
implementation provided. As such, the implementation of critical-section
is provided through the cortex-m
dependency through critical-section-single-core
.
After that, I wanted to do a test to make sure that everything builds ok. In main.rs
I wrote the code below. The code doesn't necessarily do anything useful. It only obtains a handle for the peripherals and then loops forever.
#![no_std]
#![no_main]
use cortex_m_rt::entry;
use panic_halt as _;
use stm32f401_pac::Peripherals;
#[entry]
fn main() -> ! {
let per = Peripherals::take().unwrap();
loop {}
}
The code is then built with cargo specifying the target architechture:
$ cargo build --target thumbv7em-none-eabihf
๐ Note: At first I tried to achieve step 5 by naievly placing a
main.rs
in thestm32f401_pac
folder thinking I can build my code from there. This created a conflict where the features specified in the cargo.toml did not get recognized.
The PAC API
In Rust a singleton pattern is adopted. This means that only one instance of a device peripherals can exist. As such, access to peripherals is obtained through the take
method. In the earlier code, this was done in the let per = Peripherals::take().unwrap();
line. The take
method provides an instance to the Peripherals
struct and returns an Option
. Due to the singleton pattern, any subsequent calls beyond the first one will return a None
. The per
handle is later used to create instances to specific peripherals.
After obtaining access to the peripherals, the PAC provides read
, modify
, and write
methods to manipulate device registers. Essentially giving access to individual bits. The type of manipulation allowed in a register depends on what is specified in the datasheet. More detail on this will follow in example posts. Generally, as will be seen going forward, PAC code takes the following form:
[Peripheral Handle].[Peripheral Register Name].[Operation]
[Operation]
being one of the read
, modify
, and write
methods. In the following posts, examples will be demonstrated for using this PAC access API to control and configure peripherals.
Conclusion
The peripheral access crate (PAC) is a lower lever of abstraction in embedded Rust. PACs provide type-safe access to peripheral registers through API that allows manipulation of individual bits. PACs can also be generated using manufacturer SVD files that describe controllers and a command line tool called svd2rust. In this post, I walk through the steps of creating a PAC using the svd2rust tool. This post is also the first part of a series experimenting with STM32 peripherals using PAC access API. Have any questions/comments? Share your thoughts in the comments below ๐.