Выбрать главу

Because we are enforcing our design constraints entirely at compile time, this incurs no runtime cost. It is impossible to set an output mode when you have a pin in an input mode. Instead, you must walk through the states by converting it to an output pin, and then setting the output mode. Because of this, there is no runtime penalty due to checking the current state before executing a function.

Also, because these states are enforced by the type system, there is no longer room for errors by consumers of this interface. If they try to perform an illegal state transition, the code will not compile!

Type states are also an excellent example of Zero Cost Abstractions - the ability to move certain behaviors to compile time execution or analysis. These type states contain no actual data, and are instead used as markers. Since they contain no data, they have no actual representation in memory at runtime:

use core::mem::size_of;

let _ = size_of::<Enabled>(); // == 0

let _ = size_of::<Input>(); // == 0

let _ = size_of::<PulledHigh>(); // == 0

let _ = size_of::<GpioConfig<Enabled, Input, PulledHigh>>(); // == 0

struct Enabled;

Structures defined like this are called Zero Sized Types, as they contain no actual data. Although these types act "real" at compile time - you can copy them, move them, take references to them, etc., however the optimizer will completely strip them away.

In this snippet of code:

pub fn into_input_high_z(self) -> GpioConfig<Enabled, Input, HighZ> {

self.periph.modify(|_r, w| w.input_mode().high_z());

GpioConfig {

periph: self.periph,

enabled: Enabled,

direction: Input,

mode: HighZ,

}

}

The GpioConfig we return never exists at runtime. Calling this function will generally boil down to a single assembly instruction - storing a constant register value to a register location. This means that the type state interface we've developed is a zero cost abstraction - it uses no more CPU, RAM, or code space tracking the state of GpioConfig, and renders to the same machine code as a direct register access.

In general, these abstractions may be nested as deeply as you would like. As long as all components used are zero sized types, the whole structure will not exist at runtime.

For complex or deeply nested structures, it may be tedious to define all possible combinations of state. In these cases, macros may be used to generate all implementations.

In embedded environments portability is a very important topic: Every vendor and even each family from a single manufacturer offers different peripherals and capabilities and similarly the ways to interact with the peripherals will vary.

A common way to equalize such differences is via a layer called Hardware Abstraction layer or HAL.

Hardware abstractions are sets of routines in software that emulate some platform-specific details, giving programs direct access to the hardware resources.

They often allow programmers to write device-independent, high performance applications by providing standard operating system (OS) calls to hardware.

Wikipedia: Hardware Abstraction Layer

Embedded systems are a bit special in this regard since we typically do not have operating systems and user installable software but firmware images which are compiled as a whole as well as a number of other constraints. So while the traditional approach as defined by Wikipedia could potentially work it is likely not the most productive approach to ensure portability.

How do we do this in Rust? Enter embedded-hal...

In a nutshell it is a set of traits which define implementation contracts between HAL implementations, drivers and applications (or firmwares). Those contracts include both capabilities (i.e. if a trait is implemented for a certain type, the HAL implementation provides a certain capability) and methods (i.e. if you can construct a type implementing a trait it is guaranteed that you have the methods specified in the trait available).

A typical layering might look like this:

Some of the defined traits in embedded-hal are:

   • GPIO (input and output pins)

   • Serial communication

   • I2C

   • SPI

   • Timers/Countdowns

   • Analog Digital Conversion

The main reason for having the embedded-hal traits and crates implementing and using them is to keep complexity in check. If you consider that an application might have to implement the use of the peripheral in the hardware as well as the application and potentially drivers for additional hardware components, then it should be easy to see that the re-usability is very limited. Expressed mathematically, if M is the number of peripheral HAL implementations and N the number of drivers then if we were to reinvent the wheel for every application then we would end up with M*N implementations while by using the API provided by the embedded-hal traits will make the implementation complexity approach M+N. Of course there're additional benefits to be had, such as less trial-and-error due to a well-defined and ready-to-use APIs.

As said above there are three main users of the HAL:

A HAL implementation provides the interfacing between the hardware and the users of the HAL traits. Typical implementations consist of three parts:

   • One or more hardware specific types

   • Functions to create and initialize such a type, often providing various configuration options (speed, operation mode, use pins, etc.)

   • one or more trait impl of embedded-hal traits for that type

Such a HAL implementation can come in various flavours:

   • Via low-level hardware access, e.g. via registers

   • Via operating system, e.g. by using the sysfs under Linux

   • Via adapter, e.g. a mock of types for unit testing

   • Via driver for hardware adapters, e.g. I2C multiplexer or GPIO expander

A driver implements a set of custom functionality for an internal or external component, connected to a peripheral implementing the embedded-hal traits. Typical examples for such drivers include various sensors (temperature, magnetometer, accelerometer, light), display devices (LED arrays, LCD displays) and actuators (motors, transmitters).