Creating an embedded device driver in Rust
In this post, we take a look at developing a device driver in Rust using Dion Dokter’s device driver crate
Dion has very kindly put together a book outlining the use of this crate. We will also reference the documentation for the crate when it come to understanding the data types that are exposed.
The promise of device-driver
is to take the boring part out of writing a low-level interface to a device.
This post might not be directly aimed at beginners
of Rust as some of the code incorporates concepts
such as generics.
Target device
The device we're going to implement a driver for is the Hynitron CST816S touch device. This is used on the Waveshare RP2040 Touch LCD 1.28 inch and it will be the test device we're going to work with as well.
Digging deeper into this chip reveals that it's most likely going to be different from implementation to implementation as it seems the supplier can load customer supplied configuration and maybe even modified firmwares to support a given requirement. So any registers and functionality is not guaranteed to work even if the chip and overall use seem similar.
Prior art
Other libraries in rust and other languages do exist for this device and I might reference their implementations as I go through this implementation process to make sure that my implementation is at least as correct if not better.
The Journey
Studying the device documentation
It's a bit light on information, but here's what we know from the Waveshare Datasheet.
The chip is described as "High performance self-capacitance touch chip".
The high performance part refers to the chip supporting ">100Hz" touch reporting frequency in "Dynamic Mode". It also has low power consumption for each of it's three modes: <1.6mA, <6.0uA, <1.0uA in Dynamic, Standby, and Sleep mode respectively.
Note that in sleep-mode, the chip is effectively turned off and no touch events will be reported. In Standby mode, the chip will scan for inputs much less frequently than in Dynamic mode. The chip can be "woken up", that is go from Standby to Dynamic mode by specifying a touch gesture wake command. Sleep mode can either be entered by the chip automatically by configuring the auto-sleep register values or by sending an undocumented sleep-command (I'm still not sure if this command is real.) Exiting Sleep mode requires that the reset- procedure be followed.
Self-capacitance refers to the way the chip implements the touch sensing part by have wires with current passing over each-other, a finger near those wires affect the capacitance between the wires, which can be read by the chip. CST816S supports up to 14 sensing channels (or 13 according to section 4.2).
For communication with the main processor, the chip implements I²C at rates from 10KHz-400KHz. Do note that the chip
will only respond to read and write requests on the bus just after a reset and then subsequently only after it has
received touch inputs. To help with this minor inconvenience the chip has an extra pin for communication:
The Interrupt Request (IRQ) pin. The chip will pull this pin low for an amount of time as configured by the
IrqPulseWidth
register.
Resetting the chip to wake it from sleep mode or in general to put it into dynamic mode requires pull the reset-pin low for a little bit, then setting it high again. The reset-circuit inside the chip has a pull-up resistor and filters to make sure there aren't any spurious resets due to issues with floating voltage on the wire.
Low-level driver
Now on to implementing the low-level driver. Here we mostly manually convert the register information from the
Waveshare register documentation into the DSL the device-driver
crate needs to generate code.
Driver DSL implementation
In this section we will go through a pretty thorough setup of the repository for our driver as well as going through the DSL code we will write to convert the registers.
Firstly we will host the driver and example code in a cargo workspace to we create a new folder with the following structure:
mkdir driver-workspace
cd driver-workspace
For cargo
to understand that we're working in a repository with a workspace the top-level Cargo.toml
will have to
contain a specific workspace
section. We can populate it with only resolver = "2"
since we're using 2021 edition but
workspace resolution defaults to version 1
.
[workspace]
resolver = "2"
Then we create a library crate for the device driver and a binary create for the example.
cargo new --lib driver
cargo new --bin example
This also automatically adds a new value to the top-level Cargo.toml
for each of the members in the workspace
members = ["driver", "example"]
Navigate to the newly created library crate directory
cd driver
Install device-driver
dependency as well as some other dependencies that are needed
cargo add device-driver
cargo add embedded-hal
Clean out the contents of src/lib.rs
so we can start fresh.
To develop a driver the device-driver crate provides a macro that lets us specify everything using a DSL or a manifest file in a variety of languages (JSON, Yaml, TOML, DSL)
I'm going to be using the macro for educational purposes as any errors encountered will surface through the use of rust-analyzer before we even compile the project.
In src/lib.rs
:
device_driver::create_device!(
device_name: Device,
dsl: {
// Global config
// Registers
// Commands
// Buffers
}
);
The global config will have to contain the address types for register, buffer, or command access.
dsl: {
config {
type RegisterAddressType = u8;
}
}
If we need to use buffers or commands we have to include the BufferAddressType
and CommandAddressType
respectively.
Filling out the DSL from the datasheet
A fairly tedious and very manual task to copy the register definitions from the datasheet into the DSL for the macro.
For most of the registers they convert straight into integer values so they can be transcribed simply.
E.g. like this for the ChipId
register.
- Access Type is
RO
meaning Read Only. - Address
0xA7
- Value size in bits is
8
- The field set is a single entry with the name
value
, type uint, and the bits selected from the read is index 0 to 8
register ChipId {
type Access = RO;
const ADDRESS = 0xA7;
const SIZE_BITS = 8;
value: uint = 0..8,
},
For fields that we can write values to, we can leave out the type Access
to opt-in to the default value or we could
specify WO
for write-only access.
Some of the registers are a little more complex. The first register overall is actually best represented as an enum
.
We see this because it will return an integer value that can be interpreted based off the table from the
register definition.
Variant | Value |
---|---|
NoGesture | 0x00 |
SlideUp | 0x01 |
SlideDown | 0x02 |
SlideLeft | 0x03 |
SlideRight | 0x04 |
SingleClick | 0x05 |
DoubleClick | 0x0B |
LongPress | 0x0C |
As we can see we aren't covering every single option in the range of values that could be given for an integer to be converted to this enum, so we need to use a special invocation of the conversion.
value: uing as try enum Gesture { ... } = 0..8
With this we tell device-driver
that the 8 bits of the register value
should be used to reconstruct the enum variant needed. However, since we don't cover all options fully, the conversion
might fail so we use as try enum
as opposed to as enum
. Gesture
is our chosen name for the enum type that gets
generated. The resulting register definition then looks like this:
register GestureId {
type Access = RO;
const ADDRESS = 0x01;
const SIZE_BITS = 8;
value: uint as try enum Gesture {
NoGesture = 0x00,
SlideUp = 0x01,
SlideDown = 0x02,
SlideLeft = 0x03,
SlideRight = 0x04,
SingleClick = 0x05,
DoubleClick = 0x0B,
LongPress = 0x0C,
} = 0..8,
},
Some fields are bit-fields, meaning the value needs to be translated into more than one flag type value. We do this
by defining more than one field in the field set list in the register. For instance, the MotionMask
register will need
such a setup.
/// Control which motion actions are enabled
register MotionMask {
const ADDRESS = 0xEC;
const SIZE_BITS = 3;
/// Enable Double Click Action
EnDClick: bool = 0,
/// Enable Continuous Up-Down Scrolling Action
EnConUD: bool = 1,
/// Enable Continuous Left-Right Scrolling Action
EnConLR: bool = 2,
},
Custom Conversion Types
device-driver
supports converting the data in a field set to a custom type. We implement this for fields
that are uint
by implementing either From<u8>
or TryFrom<u8>
for infallible or infallible conversions
respectively.
To explore this topic I've decided to implement conversion for the IrqPulseWidth
register.
The DSL for this looks like the following
register IrqPulseWidth {
...
value: uint as PulseWidth = 0..8
}
In this case we can safely implement the conversion as we know the value coming from the device should be from 1-200.
#[derive(Debug)]
pub struct PulseWidth {
value: u8,
}
impl PulseWidth {
pub fn new(value: u8) -> Self {
debug_assert!(value > 0);
debug_assert!(value <= 200);
Self { value }
}
}
impl From<u8> for PulseWidth {
fn from(value: u8) -> Self {
dbg_assert!(value > 0);
dbg_assert!(value <= 200);
Self { value }
}
}
impl From<PulseWidth> for u8 {
fn from(value: PulseWidth) -> Self {
*value
}
}
So while we're developing out our final application and running debug builds. Rust will help us out upholding the invariants of this particular register.
Teaching the driver to speak I²C
We've generated the code for our low-level driver, but it doesn't know how to speak with the outside world. We have to help it along, by defining a struct that implements a few traits that we can give to it.
The way we do this is to implement the {Buffer,Command,Register}Interface
and/or
Async{Buffer,Command,Register}Interface
traits supplied to us by device-driver
.
impl<BUS: embedded_hal::i2c::I2c> RegisterInterface for DeviceInterface<BUS> {
type Error = DeviceError<BUS::Error>;
type AddressType = u8;
fn write_register(
&mut self,
address: Self::AddressType,
_size_bits: u32,
data: &[u8],
) -> Result<(), Self::Error> {
self.i2c.transaction(
self.device_address,
&mut [Operation::Write(&[address]), Operation::Write(data)],
)?;
Ok(())
}
fn read_register(
&mut self,
address: Self::AddressType,
_size_bits: u32,
data: &mut [u8],
) -> Result<(), Self::Error> {
self.i2c.write_read(self.device_address, &[address], data)?;
Ok(())
}
}
Here we have implemented the two provided methods for this trait.
For writing to a register we have to first write
the register address to the device, then we write out the data buffer and return Ok(())
. We do this in a
transaction so the i2c physical protocol is upheld and the device can respond correctly.
For reading from a register we can use the embedded_hal
provided write_read
method. It'll deal with sending
the right data to the right address.
High-level Driver
For code readability in the final repository, I've split up the low-level code from the higher level code. So after
putting the above code into src/lib.rs
I moved it into a new file called src/driver.rs
. I'll elide over this in the
rest of the post as refactoring is an endless endeavour which could be a post of its own.
Outlining the public interface
In theory, we could take the output that gets generated and use it directly in our own projects, however, this can both be tedious and error-prone. Especially if some operations need to happen repeatedly and in a specific manner. Maybe some operations need specific delays or certain pins need to be read before a command can be sent.
So we will define a public interface for our crate, that will wrap the lower-level code generated
for us by device-driver
.
in src/lib.rs
:
pub struct CST816S<I2C, TPINT, TPRST> {
device: Device<DeviceInterface<I2C>>,
interrupt_pin: TPINT,
reset_pin: TPRST,
}
We have here a struct that has three generic parameters. As we will need to support a variety of different embedded
targets we need to be generic over the types that implement functionality. I2C
for the communication protocol, TPINT
for the pin for touch interrupt, TPRST
for the pin for resetting the chip.
We then implement methods for the struct
impl<I2C, TPINT, TPRST> CST816S<I2C, TPINT, TPRST>
where
I2C: embedded_hal::i2c::I2c,
TPINT: embedded_hal::digital::InputPin,
TPRST: embedded_hal::digital::OutputPin,
{
pub fn new(i2c: I2C, address: SevenBitAddress, interrupt_pin: TPINT, reset_pin: TPRST) -> Self { ... }
pub fn reset(&mut self, delay: &mut impl DelayNs) -> Result<(), TPRST::Error> { ... }
pub fn event(&mut self) -> Option<TouchEvent> { ... }
}
In the beginning of the impl
block we say that the generic types I2C
, TPINT
, and TPRST
must be types that
implement their respective embedded_hal
traits.
The new
associated function, invoked to setup the device interface initially, is fairly straightforward.
pub fn new(i2c: I2C, address: embedded_hal::i2c::SevenBitAddress, interrupt_pin: TPINT, reset_pin: TPRST) -> Self {
Self {
device: Device::new(DeviceInterface::new(i2c, address)),
interrupt_pin,
reset_pin,
}
}
We need to take ownership of an instance of the communication interface. We also need to store the address for the
device, and the two pins. Then return an instance of the struct with a device created from the low-level driver
instantiated with the DeviceInterface
we created to speak the communications protocol.
For the reset
method, I referenced other implementation for the sequence of pin states and delays as this isn't
actually documented anywhere. It does seem to work here, so we're keeping it. It might be possible to tweak the delays
to waste less time in starting up the device.
pub fn reset(&mut self, delay: &mut impl embedded_hal::delay::DelayNs) -> Result<(), TPRST::Error> {
self.reset_pin.set_low()?;
delay.delay_ms(20);
self.reset_pin.set_high()?;
delay.delay_ms(50);
Ok(())
}
Note that we need to take a mutable reference to a type that implements the embedded hal trait DelayNs
to be able to
do the delay. This function is blocking so nothing else will be running.
Reading an event
from the device requires us to make sure that the interrupt pin is low. This is because it's the best
indicator that we're able to get a response on the I²C interface from the touch chip. We could also let the user setup
an interrupt handler for the falling edge of the interrupt pin.
pub fn event(&mut self) -> Option<TouchEvent> {
let int_pin_value = self.interrupt_pin.is_low();
match int_pin_value {
Ok(true) => {
let x = self.device.xpos().read();
let y = self.device.ypos().read();
let gesture = self.device.gesture_id().read();
if x.is_err() || y.is_err() || gesture.is_err() {
return None;
}
let x = x.unwrap().value();
let y = y.unwrap().value();
let gesture = gesture.unwrap().value().unwrap();
let point: Point = (x, y);
Some(TouchEvent { point, gesture })
}
_ => None,
}
}
We do several reads here to put together the data needed for a proper touch event to reported back to the user code.
Some slightly advanced error handling is also going on, I've tried to keep it fairly simple. The first line in the
method, reading the interrupt pin is_low()
returns a Result<bool, _>
style value. So we will match on this value
to ensure we only proceed if it is Ok(true)
. Then we read the x-position, y-position, and gesture id. All three of
these reads also return Results, that can error if the protocol encounters a problem. We don't really care for the error
in this example, so we just do an early return if any of them returns true for is_err()
. We can then unwrap each of
the returned results safely and extract the value
field from the register field set.
Note that for gesture
we need to unwrap the result of the value()
call as we defined the conversion with
as try enum
, which means it could fail if the value read from the chip isn't within the values required in the enum.
If we wanted to be extra safe, we could also handle this error case but in the interest of not getting to bogged down,
I'll leave this as an exercise for later.
Writing to the chip is also important since we might need to alter the default settings describing the way the device
works. We're able to do this is in our public interface by calling write
. Here we've implemented a method to change
the Interrupt Request pin pulse width register:
pub fn set_irq_pulse_width(&mut self, pulse_width: PulseWidth) {
self.device
.irq_pulse_width()
.write(|write_object| write_object.set_value(pulse_width))
.unwrap();
}
The write
method on the register accepts a closure function, that is called with a write object where we can call
set_value
which is setting the field of the field set in the irq_pulse_width
register.
The Destination
We made a device driver! Huzzah! Now, there are several improvements that could be made. For instance, it's possible to initialise the and then try to use it without knowing it's in the correct running state. With the typestate pattern the state of the device can be encoded ensuring we use the device as intended.
Please navigate to the driver and example source repository to view the final implementation. I have also included an example repository that utilises the driver on the target hardware to report the touch events on the display.
Don't hesitate to reach out with any feedback or check out my resume website as I'm available for hire!