Rust on the CH32V003
Published on 2023-04-02 by Noxim. 2246 words, about 12 minutes
In the previous post we finally got our project off the ground and controller a GPIO pin. We did this by constructing raw pointers into special memory addresses and performing volatile writes to them. It works, but is not particularily ergonomic and doing everything manually leaves a lot to go wrong. For just blinking a single LED it is still manageable, but what if we start writing driver code for many other peripherals on the CH32V003, like I2C or SPI. How can we make this maintainable and even better, reusable?
The Rust Embedded Devices Working Group has taken on these issues to build an ergonomic and composable ecosystem for Rust developers. We will cover 2 parts of the ecosystem, the Peripheral Access Crates and embedded-hal
.
Our problem of having to write raw pointers manually is not unique to Rust. Even more traditional embedded languages like C don't want to copy hundreds or thousands of magic memory addresses to access peripheral registers. To tackle the issue, a company called Keil developed a new standard in 2008 called Cortex Microcontroller Software Interface Standard, or CMSIS. Originally focused around just ARM Cortex microcontrollers, the standard has seen wide adoption when it comes to machine readable hardware definitions. CMSIS has many parts, but we are going to focus on something called the CMSIS System View Description Format (CMSIS-SVD). SVD is a format based on XML and describes the microcontrollers hardware, including all of the memory mapped peripheral registers. Your hardware manufacturer provides an SVD file and you can use automatic tools to generate an API for the peripherals.
The Rust Embedded WG maintains a program for converting SVD files to Rust crates, called svd2rust
. You feed the program with your SVD file and it produces a big lib.rs
file containing accessor types for all of the peripherals on your microcontroller. We'll use it to generate ourself a Peripheral Access Crate, so let's install it off of crates.io.
$ cargo install svd2rust
...
Finished release [optimized] target(s) in 37.18s
Installing ~/.cargo/bin/svd2rust.exe
Installed package `svd2rust v0.28.0` (executable `svd2rust.exe`)
WinChipHead does not directly list the CH32V003's SVD definitions in their download sections, but the files come bundled with the installation of their MounRiver Studio
. MRS is an Eclipse-derived IDE specifically developing C firmware for WinChipHead's and GigaDevice's microcontrollers. After digging around the MounRiver installation folder we find a suspiciously useful sounding file CH32V003xx.svd
.
Let's open it up see what it looks like. At the top level there are some definitions about the chip itself. For example <addressUnitBits>8</addressUnitBits>
tells us that the chip we have addresses memory in byte (8 bit) increments. A bit down the file we find a list of <peripheral>
. Each peripheral has bunch of settings, including the baseAddress
where the peripheral is mapped. For each peripheral there is also a list of registers, and each register
has properties like default value and offset from baseAddress
.
Lets grab the file and move it into a new crate's src/
. We'll run it through svd2rust
. By default the tool will produce code for ARM Cortex processors, so we have to specify the RISC-V target
$ cargo new --lib ch32v003xx-pac
Created library `ch32v003xx-pac` package
$ cd ch32v003xx-pac/src
$ cp $MRS/template/wizard/WCH/RISC-V/CH32V003/NoneOS/CH32V003xx.svd .
$ svd2rust -i CH32V003xx.svd --target riscv
[INFO svd2rust] Parsing device from SVD file
[ERROR svd2rust] Error parsing SVD XML file
Caused by:
0: In device `CH32V00xxx`
1: In peripheral `EXTEND`
2: In register `EXTEND_CTR`
3: In field `LOCKUP_RESET`
4: Parsing unknown access at 766:15
5: unknown access variant 'read/clear' found
Well, life is never easy. SVD specification says access type much be one of read-only
, write-only
, read-write
, writeOnce
or read-writeOnce
. WCH has chosen to break this specification and use their own read/clear
term. We can simply replace it in the file with the correct read-write
term.
$ svd2rust -i CH32V003xx.svd --target riscv
[INFO svd2rust] Parsing device from SVD file
[INFO svd2rust] Rendering device
$
No errors this time. Our lib.rs
has now been transformed into a 10466 line behemoth. When generating code for RISC-V svd2rust
uses a dependency called vcell
. After adding it to our crate dependencies we can build our new crate.
$ cargo add vcell
Updating crates.io index
Adding vcell v0.1.3 to dependencies.
Features:
- const-fn
$ cargo build
Compiling vcell v0.1.3
Compiling ch32v003xx-pac v0.1.0 (~/ch32v003xx-pac)
Finished dev [unoptimized + debuginfo] target(s) in 1.94s
You could even generate the rustdoc for this crate and you would be presented with fully categorized and documented type definitions for all your device registers. You could use the crate as is, but GitHub user andelf has already started the work on a full ecosystem for the CH32 series of microcontrollers with the ch32-rs organization and repository. Since they have already put all the good effort, I'll be using their crate from now on.
Let's go back to our hello-wch
project and rewrite the GPIO access to use a Peripheral Access Crate. The ch32-rs
project releases the PAC for our V003 in the ch32v0
crate underneath the ch32v003
feature.
#![no_std]
#![no_main]
use riscv_rt::entry;
use panic_halt as _;
#[entry]
fn main() -> ! {
// To ensure safe access to peripherals, all types are !Copy singletons. The
// PAC makes us pass these marker types around to access the registers
let p = unsafe { ch32v0::ch32v003::Peripherals::steal() };
// Enable GPIOC bank
p.RCC.apb2pcenr.modify(|_, w| w.iopcen().set_bit());
// Configure GPIOC pin 1
p.GPIOC.cfglr.modify(|_, w| {
w.cnf1().variant(0b00) // Push-pull
.mode1().variant(0b01) // Output, 10Mhz
});
loop {
// Turn pin 1 on
p.GPIOC.outdr.modify(|_, w| w.odr1().set_bit());
for _ in 0..1_000_000 {
core::hint::black_box(());
}
// Turn pin 1 off
p.GPIOC.outdr.modify(|_, w| w.odr1().clear_bit());
for _ in 0..1_000_000 {
core::hint::black_box(());
}
}
}
And everything of course still builds and works.
$ cargo add ch32v0 --features ch32v003
Updating crates.io index
Adding ch32v0 v0.1.6 to dependencies.
Features:
+ ch32v003
- critical-section
- rt
Updating crates.io index
$ cargo build
Compiling ch32v0 v0.1.6
Compiling vcell v0.1.3
Compiling hello-wch v0.1.0 (~/hello-wch)
Finished dev [optimized + debuginfo] target(s) in 4.59s
That looks nicer. Now we no longer have any magic numbers in our code, and more importantly all of the peripherals are wrapped in type safe accessors. We have safe access to the CFGLR
register for example, and the API forbids us from setting an invalid bit pattern. The only unsafe we have left is the Peripherals::steal()
. This function is the root of our peripheral access. It is forbidden to call this function twice in your program, otherwise the safety guarantees of all the periperals are broken. There is even a safe version of this API, Peripherals::take()
. It basically wraps steal
in a way that can be thought of as Mutex<Option<Peripherals>>::lock().take()
. This will yield a Some(peripherals)
only once, but requires us to enable a feature called critical-section
in our PAC. Why?
To ensure that there is no data race in Peripherals::take()
and have it return Some
twice, we need to synchronization. Since we are on embedded, there are no operating system provided Mutex
es. But why do we need synchronization at all? I mean our microcontroller only has 1 core. The problem is with interrupts. In short, interrupts are a hardware mechanism where some event causes the program execution to jump to a predetermined location, called the interrupt handler. The handler performs whatever actions it needs and then returns from the handler and the processor state is restored to whatever it was doing before. From programs own view, nothing ever happened. Interrupts are often used for inputs, like when another device sends your microcontroller data, the interrupt handler will read the input and store in a buffer for later processing. This effectively results in our single core processor being a concurrent system. Between two instructions in our program, the processor may execute any code freely and without us seeing. What if our code calls take
, and during that call an interrupt handler is triggered that also calls take
? Now we have two instances of our Peripherals
singleton and UB-galore.
So how do we synchronize on a microcontroller that has no OS-provided mutexes and no support for atomic instructions (RISC-V A Extension)? We can disable the interrupt processing completely. This ability is part of the RISC-V specification. Like our microcontroller peripherals, there are some RISC-V defined control registers that are accessed by special instructions. One of the registers, called mcause
contains a single bit called the mie
bit. mie
stands for "Machine Interrupt Enable", and by toggling this bit we can enable and disable the processing of interrupts.
Note however, that disabling interrupts as a form of a critical section is only valid when the microcontroller has a single core. That is why our PAC cannot provide an implementation of its own, because we might be using a chip with two cores where proper atomics are required.
To get around this we need a new crate. This one is simply called riscv
and it provides very level access to the ISAs features. It can for example help us access the mcause
register or manually invoke specific instructions. More importantly, the crate can provide us with an implementation of critical-section
if we enable the critical-section-single-hart
feature. In RISC-V lingo, "hart" stands for a hardware thread. By enabling this feature we declare that our target platform only has a single hart and thus the crate can implement a critical section through the mie
bit.
$ cargo add riscv --features critical-section-single-hart
Updating crates.io index
Adding riscv v0.10.1 to dependencies.
Features:
+ critical-section-single-hart
$ cargo add ch32v0 --features ch32v003,critical-section
Updating crates.io index
Adding ch32v0 v0.1.6 to dependencies.
Features:
+ ch32v003
+ critical-section
- rt
$ cargo build
Compiling hello-wch v0.1.0 (~/hello-wch)
Finished dev [optimized + debuginfo] target(s) in 5.02s
With that we have removed all unsafe
code from our crates code and can enjoy a relatively worry free life. You could absolutely write a small project with just a PAC like this. But what if we are making a larger project and are interfacing with lots of different hardware. Wouldn't we like to make use of generic driver crates?
A Peripheral Access Crate is a good step forwards towards building an embedded ecosystem, but there is still one bit we need. Say we want to drive a common WS2812-style RGB LED. These are controlled by sending serial data over a single line at roughly 800khz. In short, the protocol shifts out 24 bit color values on the serial line one LED after another, so you might want to use a nice driver crate to handle the timing and chaining functionality. That driver has to then depend directly on our PAC to control our GPIO pins. That either means that the specific driver crate only works for our specific microcontroller, or that the crate has to implement support for tons of different microcontroller PACs.
To tackle this problem, the Rust Embedded WG started work on an abstraction crate. embedded-hal
is a crate that only really defines a set of traits that describe common peripheral functionality. In there you'll find interfaces for a number of different peripheral types like GPIOs, timers, busses etc. A lot of care has been put into making sure that the abstractions are zero-cost and can model the platform differences reasonably well. With this, our WS2812 driver crate can workaround a generic P: OutputPin
type instead of some concrete type from a PAC.
To make use of embedded-hal
, one more crate is needed. This is crate is called ch32v0xx-hal
, much like its PAC-cousin. This HAL implementation crate defines a set of user friendly types that provide type level safety over the raw peripheral access and implement the embedded-hal
traits. I've started the effort (with a fork from the ch32v2xx HAL), but there is quite a lot be done. If you'd like to help, the repository lives here.
Once the HAL is in better shape, we will be able to write higher level code and leverage the existing ecosystem. Looking at inspiration from an equivalent crate for an STM32F0xx microcontroller, the final blinkenlights will look something like this
#![no_main]
#![no_std]
use panic_halt as _;
use riscv_rt::entry;
use ch32v0xx_hal::{pac, prelude::*};
#[entry]
fn main() -> ! {
let mut p = pac::Peripherals::take().unwrap();
// Sets chip clock to default 24Mhz
let hse = 24.mhz();
let mut rcc = p.RCC.configure().core(hse).freeze();
// Delay based on skipping cycles
let delay = riscv::delay::McycleDelay::new(hse.0);
// Automatically powers on the GPIOC peripheral
let gpioc = p.GPIOC.split(&mut rcc);
// Configures our pin to output mode
let mut pin = riscv::interrupt::free(|cs| gpioc.pc1.into_push_pull_output(cs));
loop {
pin.set_high().unwrap();
delay.delay_ms(500);
pin.set_low().unwrap();
delay.delay_ms(500);
}
}
With this, we have enabled access to the entire embedded Rust ecosystem. We can now use hundreds of driver crates to build whatever we want. Hopefully this post either helped you get started with the CH32V003, showed you how to extend Rust for some other new chip or just taught you something new about low level Rust.