An introduction to writing platform agnostic drivers in Rust using the MCP3008 as an example.
Here is how a device like an accelerometer works: you send the device commands over a serial interface (SPI, I2C), say something like: “get me the X-axis reading”; the device responds by sending back some data.
The way the SPI/I2C/GPIO (and other) interfaces are programmed is very much dependent on the specific microcontroller family. But the actions you need to perform to interface with a device like a temperature sensor / accelerometer etc depend only on the device and are independent of the controller being used. What if you can write a generic accelerometer driver and use it on any microcontroller platform - right from low-end ARM Cortex-M, AVR, MSP430 etc to complex Embedded Linux platforms like the Raspberry Pi? What if you can distribute these drivers on crates.io so that you only have to include one line in your Cargo.toml to use it in your code?
Seems to be fun! Let’s see how to go about doing this!
Getting started
It will be great if you can spend some time going through this blog post by Jorge Aparicio to get a broad overview of the exciting framework (which includes the idea of platform agnostic drivers) that he is building to facilitate embedded systems programming in Rust! One of my earlier articles might also be useful reading.
The embedded-hal traits
The embedded-hal traits define the behaviours which all peripherals (Digital I/O, SPI, I2C, USART, Timer/Counter units etc) should support. Let’s take a very simple example, a digital output pin.
Here is a part of the source file which defines the digital I/O traits:
/// Single digital output pin
pub trait OutputPin {
/// Is the output pin high?
fn is_high(&self) -> bool;
/// Is the output pin low?
fn is_low(&self) -> bool;
/// Sets the pin low
fn set_low(&mut self);
/// Sets the pin high
fn set_high(&mut self);
}
This just says that a digital output pin is any object on which you can apply functions like set_low, set_high etc.
A trait in Rust specifies only an interface - there is no mention of exactly how the digital output pin is going to be made HIGH or LOW etc.
An example embedded-hal implementation
An embedded-hal implementation for a particular platform (which is most probably a microcontroller with no Operating System running on it, or a complex Embedded Linux system like the Raspberry Pi, Beaglebone etc) should provide code to control all the relevant peripherals; this code must implement the embedded-hal traits.
Let’s take one of the simplest of all the embedded-hal implementations available as of now - the linux-embedded-hal.
Let’s examine snippets of code from the file src/lib.rs:
extern crate embedded_hal as hal;
So, the linux-embedded-hal is going to use the embedded-hal traits.
Now, let’s see how a Pin is defined:
pub struct Pin(pub sysfs_gpio::Pin);
GPIO pins on an embedded Linux system can be easily manipulated through the sysfs interface. The Pin structure here is simply a wrapper over the sysfs abstraction of an I/O pin.
Now, in order to make our Pin abstraction a proper member of the embedded-hal family, we have to implement the OutputPin trait:
impl hal::digital::OutputPin for Pin {
fn is_low(&self) -> bool {
unimplemented!()
}
fn is_high(&self) -> bool {
unimplemented!()
}
fn set_low(&mut self) {
self.0.set_value(0).unwrap()
}
fn set_high(&mut self) {
self.0.set_value(1).unwrap()
}
}
The functions set_low and set_high simply delegate the work of setting/clearing the I/O pin to the underlying sysfs routines!
An implementation of the embedded-hal traits for a microcontroller family with no Operating System support will be considerably more complex as the implementation itself will have to take care of ALL the low-level work involved in configuring and setting/clearing the pins. Fortunately, a driver writer need not be bothered about all these details!
All that the driver writer needs to understand is this: the embedded-hal specifies the behaviours which various peripherals must exhibit, and an implementation of the embedded-hal for a particular platform will provide platform-specific code which implements all these behaviours.
The MCP3008 ADC
With this much of a background, let’s now find out how we can implement a platform agnostic driver for the MCP3008 / MCP3004 analog to digital converter.
The ADC datasheet is available here. The MCP3008 is a 10 bit ADC with 8 input channels and the MCP3004 is a 10 bit ADC with 4 input channels. Both are identical except in the number of input channels.
The ADC communicates with a microcontroller over the SPI bus.
SPI Communication
When the MCP3008 receives a “1” bit with the chip select line held low, it is considered to be a start bit.
The next bit will decide whether the device should do single-ended or differential conversion. A “1” bit indicates single-ended conversion.
The next 3 bits encode the channel number (0 to 7 in the case of MCP3008).
Once the MCP3008 gets this information, it will start responding with a 10 bit pattern representing the analog value on the specified channel (in MSB-first order).
Electrical connections (MCP3008)
The CLK pin (pin 13) of the MCP3008 should be connected to the pin configured as the CLK of the microcontroller’s SPI peripheral.
DOUT (pin 12) of the MCP3008 should be connected to the MISO pin of the microcontroller’s SPI peripheral.
DIN (pin 11) of the MCP3008 should be connected to the MOSI pin of the microcontroller’s SPI peripheral.
CS (inverted, pin 10) of the MCP3008 should be connected to the digital I/O pin of the microcontroller which acts as the chip select output.
The MCP3008 driver
You can find the driver code here. Let’s look at src/lib.rs in detail.
The first thing you note is that we use only the embedded-hal crate; we do not use any platform-specific embedded-hal implementations. This is what makes our code platform agnostic.
extern crate embedded_hal as hal;
use hal::blocking::spi::Transfer;
use hal::spi::{Mode, Phase, Polarity};
use hal::digital::OutputPin;
Transfer and OutputPin are traits; here is the definition of Transfer (in file src/blocking/spi.rs of the embedded-hal crate):
/// Blocking transfer
pub trait Transfer<W> {
/// Error type
type Error;
/// Sends `words` to the slave. Returns the `words` received from the slave
fn transfer<'w>(&mut self, words: &'w mut [W]) -> Result<&'w [W], Self::Error>;
}
Any implementation of the Transfer trait should define a function called transfer.
The constant MODE defines the SPI transfer mode. The MCP3008 operates in mode 0 (polarity = 0, phase = 0).
/// SPI mode
pub const MODE: Mode = Mode {
phase: Phase::CaptureOnFirstTransition,
polarity: Polarity::IdleLow,
}
Now comes the most important struct in the code:
/// MCP3008 driver
pub struct Mcp3008<SPI, CS> {
spi: SPI,
cs: CS,
}
The driver has two fields, spi and cs, which are defined as having two generic types, SPI and CS. Note that the structure is visible outside the crate. Any application code which needs to use the MCP3008 driver will have to create an instance of this structure, like this:
let m = Mcp3008 { spi: 10, cs: 20 }
This is obviously nonsense, as the spi and cs fields are supposed to be representations of the SPI peripheral and chip select output which the driver will use to communicate with the MCP3008.
The Mcp3008 struct is useful only in the context of the two functions defined on it: new and transfer.
The new method
Let’s look at new:
impl<SPI, CS, E> Mcp3008<SPI, CS>
where SPI: Transfer<u8, Error = E>,
CS: OutputPin
{
/// Creates a new driver from an SPI peripheral and a chip select
/// digital I/O pin.
pub fn new(spi: SPI, cs: CS) -> Result<Self, E> {
let mcp3008 = Mcp3008 { spi: spi, cs: cs };
Ok(mcp3008)
}
/* more code */
}
The implementation of functions on Mcp3008 imposes a restriction on the nature of the generic types SPI and CS.
SPI has to be a type which implements the Transfer trait that is part of the embedded-hal - which means it is guaranteed to have a function called transfer that will take care of communication over the SPI bus.
CS has to be a type which implements the OutputPin trait that is part of the embedded-hal. This means the driver can use the cs field of the struct to drive a digital I/O pin (which acts as the chip select) HIGH or LOW using the methods set_high and set_low.
An application program which uses this driver must create a new instance of the Mcp3008 structure by calling:
let m = Mcp3008::new(x, y);
where x and y must have types that implement the Transfer and OutputPin traits.
The read_channel method
Here is how read_channel is defined:
impl<SPI, CS, E> Mcp3008<SPI, CS>
where SPI: Transfer<u8, Error = E>,
CS: OutputPin
{
/* Code for new method */
/// Read a MCP3008 ADC channel and return the 10 bit value as a u16
pub fn read_channel(&mut self, ch: Channels8) -> Result<u16, E> {
self.cs.set_low();
let mut buffer = [0u8; 3];
buffer[0] = 1;
buffer[1] = ((1 << 3) | (ch as u8)) << 4;
self.spi.transfer(&mut buffer)?;
self.cs.set_high();
let r = (((buffer[1] as u16) << 8) | (buffer[2] as u16)) & 0x3ff;
Ok(r)
}
}
Here is how an application program will call this method:
let m = Mcp3008::new(x, y);
let r = m.read_channel(Channels8::CH0);
The read_channel method first drives the chip select low:
self.cs.set_low();
Then it fills up a 3 element buffer with the values necessary to read the analog data on the specified channel.
The start bit is in buffer[0].
The bit representing single ended/differential mode operation (we are doing single ended read only) and the 3 bit encoding of the channel number are in buffer[1]. These 4 bits should be in the MSB position in buffer[1].
The contents of buffer[2] do not matter.
This is the line which performs the actual communication over the SPI bus:
self.spi.transfer(&mut buffer)?;
The result will be available as a 10 bit number in buffer[1] and buffer[2] combined.
The chip select line is driven high once the transfer is done.
Application Code
The embedded-hal defines the traits which each peripheral of the microcontroller should support.
The device driver code works with a generic representation of the peripherals (in our case, SPI interface and a digital output pin).
The application code should invoke the driver and pass to it concrete representations of the microcontroller peripherals it needs (in this case, SPI and a digital output pin). The driver code expects these concrete representations to satisfy certain traits defined in the embedded-hal (in this case, Transfer and OutputPin).
Let’s look at an application program which uses the linux-embedded-hal and works on a Raspberry Pi (and similar embedded Linux systems)!
Using the linux-embedded-hal
linux-embedded-hal example on github
Our application code mainly depends on the linux-embedded-hal and the driver crate adc_mcp3008.
extern crate linux_embedded_hal as hal;
extern crate adc_mcp3008;
use std::thread;
use std::time::Duration;
use adc_mcp3008::{Mcp3008, Channels8};
use hal::spidev::{self, SpidevOptions};
use hal::{Pin, Spidev};
use hal::sysfs_gpio::Direction;
Next, we create and initialize a linux-embedded-hal specific representation of an SPI peripheral.
/* Configure SPI */
let mut spi = Spidev::open("/dev/spidev0.0").unwrap();
let options = SpidevOptions::new()
.bits_per_word(8)
.max_speed_hz(1_000_000)
.mode(spidev::SPI_MODE_0)
.build();
spi.configure(&options).unwrap();
This is followed by code that creates and initializes a linux-embedded-hal specific version of a digital output pin:
/* Configure Digital I/O Pin to be used as Chip Select */
let ncs = Pin::new(25);
ncs.export().unwrap();
while !ncs.is_exported() {}
ncs.set_direction(Direction::Out).unwrap();
ncs.set_value(1).unwrap();
Once we create representations of these two peripherals, we pass them to the driver code to create an instance of the Mcp3008 structure:
let mut mcp3008 = Mcp3008::new(spi, ncs).unwrap();
And start reading data:
loop {
println!("{:?}", mcp3008.read_channel(Channels8::CH0));
thread::sleep(Duration::from_millis(1000));
}
Using the stm32f30x-hal
stm32f30x-hal-example on github
The same driver crate adc-mcp3008 will work on an STM32F3discovery (or any other) board provided there is an embedded-hal implementation for that platform. Fortunately, we have the stm32f30x-hal for the STM32F3Discovery!
Let’s go through parts of src/main.rs. We start with the part that creates an stm32 device specific instance of an SPI peripheral which implements the embedded-hal traits:
/* SPI Config */
let sck = gpioa.pa5.into_af5(&mut gpioa.moder, &mut gpioa.afrl);
let miso = gpioa.pa6.into_af5(&mut gpioa.moder, &mut gpioa.afrl);
let mosi = gpioa.pa7.into_af5(&mut gpioa.moder, &mut gpioa.afrl);
let spi = Spi::spi1(
p.SPI1,
(sck, miso, mosi),
adc_mcp3008::MODE,
1.mhz(),
clocks,
&mut rcc.apb2,
);
Next comes the chip/slave select output pin:
/* ADC Chip Select */
let mut nss = gpiob
.pb5
.into_push_pull_output(&mut gpiob.moder, &mut gpiob.otyper);
nss.set_high();
Now, we create an instance of the Mcp3008 driver struct:
let mut adc = adc_mcp3008::Mcp3008::new(spi, nss).unwrap();
Finally, we read data from MCP3008 channel 0, scale the result to 8 bits, and send it out through the STM32 UART (which will be connected to the USB port of a Linux system using a serial-to-usb converter):
loop {
let r = adc.read_channel(adc_mcp3008::Channels8::CH0)
.unwrap();
// scale 10 bits to 8 bits
let r1 = ((r as u32 * 255 as u32)/1023 as u32) as u8;
// wait till UART transmission is over
block!(tx.write(r1)).ok();
}
Contributing to the embedded Rust ecosystem
These are early days as far as Rust/embedded is concerned; there are plenty of things which we can do to help grow the ecosystem.
Create implementations for the embedded-hal for our favourite platforms. There are already implementations for some STM32 processors, TI TM4C123, LPC ARM processors, NRF51 devices (many of which are work-in-progress).
Write embedded-hal based drivers for new devices!
Further reading
Keep track of anything that gets posted on http://blog.japaric.io/! You will enjoy reading his description of the drivers he is writing!