As noted in the first post, I'm working towards doing more IoT using Rust in an embedded ARM Cortex-M world. Thankfully, the Rust compiler leverages LLVM and can target quite a few different processors.
Instruction Sets & Processors
That being said, there are quite a few different processors even within the Cortex-M family. They all have mostly similar instruction sets, which makes the generation of the executable pretty straightforward (for the compiler).
But each processor contains different amounts of flash memory (where the program is stored) and different amounts of RAM (used during the execution of the program).
The ARM processors are different than you might have experienced with the Intel and AMD processors, because ARM is licensed intellectual property where a multitude of manufacturers can create their own processor, mostly following ARM's specifications.
Peripherals
One of the largest sources of variety is in the peripherals within the processor. With desktop machines, we think of peripherals as "a display" or "an external harddrive" or "a mouse". Within an embedded processor, a perhipheral is lower-level, embodying things like timers and SPI busses and serial port thingies.
Each of these peripherals is interacted with by code through one or more registers, which is a byte (or two or four) of memory within the processor. These registers get memory mapped into the normal RAM-addressable memory that code can operate upon.
For instance, within the STM32F401 processor from STMicroelectronics, there are a few registers for configuring and interacting with the USART (a "serial port"). Some bits of the registers control the baud rate, stop-bits, and parity of the underlying serial bus. Other registers are used to transfer octets from your code to the serial bus and to receive octets from the serial bus into your code. Additional registers are used to communicate if errors have occurred while attempting to move data across (overruns, parity errors, noise).
The reference manuals for processors describe the memory location and semantics of each bit/byte of each register for each peripheral.
Working with memory addresses and bitwise manipulation of these registers would be... challenging.
Peripheral Access Crates (PAC)
Thankfully, some producers of ARM Cortex-M chips also ship a related XML file
called an SVD (System View Descriptor). These files take the prose information
from the reference manuals and makes it machine-readable. Tooling in the Rust
community, called svd2rust can consume
these files and produce Rust code so we can use friendly name to manipulate registers.
The result of using svd2rust
is what's called a peripheral access crate, or PAC.
One example is the stm32f4 crate.
Once you have a PAC, you can at least write slightly better-looking code, but you are still thinking in terms of registers, bits and bytes. A PAC doesn't quite get you up to the semantics of a serial port thingy.
Hardware Abstraction Layer (HAL)
While different silicon manufacturers produce similar but different ARM Cortex-M chips, using possibly different registers to accomplish things, at the end of the day, many still have serial port thingies (e.g. a USART).
To begin to bring commonality across the chips, there's a hardware abstraction layer (HAL).
The HAL is created in two parts. First is the common embedded-hal
which defines abstractions such as how to read or write to a serial port.
Note, it does not define an abstraction for serial port itself, since the configuration
and setup of a serial port is still quite chip-specific. But assuming you've configured
a port, embedded-hal
gives us a common way to read octets or write octets (or words,
or whatever size data the specific port supports).
The embedded-hal
is purely a crate of Rust traits, and by itself is not functional.
Humans create a HAL implementation for a specific bit of silicon using the generated PAC in order to provide a friendly way of interacting with different chips.
For instance, there's the stm32f4xx-hal HAL crate which has been lovingly hand-crafted, using the stm32f PAC in order to safely configure and use a chip from the STM32F4 family of silicon.
The HAL includes plenty of bits that don't map to the embedded-hal
traits, because
activities such as setting up the serial port thingy is outside of scope for embedded-hal
,
but it also does mix in the embedded-hal
traits where the functionality overlaps.
Safety
One benefit Rust HALs have over other HALs based on other languages is the safety that can be provided using zero cost abstractions.
Take, for instance, on the STM32F401 chip, one of the serial port thingies (USART6) can be connected to a couple of different sets of pins. Since each chip tends to ultimately have more peripherals than can be surfaced through physical pins, a lot of pins can perform one of several functions.
In the case of USART6, the transmission line can be attached to pin PA11
or PC6
.
PC6
could alternatively be configured for usage with i2c
or SDIO
. Until you
explicitly configure the pin for usage as the USART6 transmission line, the HAL
will not allow you to use it to further configure the USART6 serial port thingy.
In my case, I've wired up my serial port device to pins PA11
for transmission (tx)
and PA12
for receiving (rx).
First I get the HAL
representation of the GPIO pins, the little bits of metal sticking
out of my board:
let gpioa = device.GPIOA.split();
Then I select off the two pins I need for my USART:
let pa11 = gpioa.pa11;
let pa12 = gpioa.pa12;
At this point, pa11
and pa12
are of type PA11<Input<Floating>>
and
PA12<Input<Floating>>
respectively. Two floating digital input pins is
not sufficient for usage as a serial point. In their initial natural state,
I could use them to sense voltage changes, as digital inputs, but I cannot
harness the internal USART peripheral that may be attached to them.
So next, I configure the processor to know I specifically want to use them for USART6
instead of any of the other usages they could have. The into_alternative_af8()
is
how I tell the processor (through the PAC) that I intend to use them as USART6:
let tx_pin = pa11.into_alternate_af8();
let rx_pin = pa12.into_alternate_af8();
Behind the scenes, the HAL is using the PAC to twiddle the appropriate bits in the appropriate registers to configure the pointy bits of metal sticking out of the board and what the processor thinks I intend to do with them.
Now, the types of tx_pin
and rx_pin
are PA11<Alternate<AF8>>
and
<PA12<Alternate<AF8>>
respectively. I can't use them a simple digital I/O
pins now, because Rust has consumed the generic pins and returned me USART-configured
pins.
I can finally use the HAL method to configure my serial port thingy:
let mut serial = Serial::usart6(
usart6,
(tx_pin, rx_pin),
...
...);
The usart6(...)
function will only accept appropriate configured pins which
are usable as USART6. The compiler will not let me use arbitrary pins, or even
correct pins that haven't been fully configured.
Now, though, our serial
implements the Read<...>
and Write<...>
traits
from embedded-hal
and can be handed off to an actual device driver that
is written purely from the point-of-view of embedded-hal
.
The device driver doesn't need to know which vendor's silicon I'm using, just that there's now an object that can be written to and read from.
Finally...
Embedded coding is hard, especially when you're not stupendously experienced doing it. On the other hand, using Rust provides a nice set of abstractions and a ton of guardrails to prevent you from flying off the cliff of correct code.