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.

USART registers

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.