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.
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
embedded-hal gives us a common way to read octets or write octets (or words,
or whatever size data the specific port supports).
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.
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
but it also does mix in the
embedded-hal traits where the functionality overlaps.
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
PC6 could alternatively be configured for usage with
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)
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,
pa12 are of type
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
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
<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
I can finally use the HAL method to configure my serial port thingy:
let mut serial = Serial::usart6( usart6, (tx_pin, rx_pin), ... ...);
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
embedded-hal and can be handed off to an actual device driver that
is written purely from the point-of-view of
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.
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.