Intro to BitByteStructs, Motivation and Implementation


Preface

The first time I re-read this doc it came across as if I was being a bit dismissive of some of the ways that the rust community have made cool things.

And that is not at all what I was trying to achieve or communicate.

If your creating a lib then I completely support those following the kiss (Keep it simple silly) principle.

If you're using a driver that takes ownership of a bus then the bus crate is a great option.

The API's I am proposing will work just fine with the bus crate and should work just fine with existing drivers using the common patterns.

But if you're creating or developing a lib then instead of having to do extra work to support spi on top of i2c or vice versa then using this lib, should, i hope save you work.

That said this is not perfect, far from it, and contributions in the form of feed back or code are very welcome.

Intro - Motivation

One of rusts appealing properties for embedded programing is ownership.

Ownership allows us to use the compiler to control state without having to build explicit stat machines.

Several libs use ownership to achieve this, by taking ownership of embedded traits bus objects eg. i2c, they make sure that others do not interfere with there use of the bus.

This causes quite a few issues:
  • Using the embedded-hal traits directly, makes it harder to write drivers that can communicate over multiple bus'
  • Taking ownership of a bus early and keeping ownership, stops others using it.
    • Even if drivers release the bus so that multiple drivers can share it, then they need to re initialize every time the bus is passed back and forth.

Inspiration

The Bme280 driver crate has a PR open that adds spi support to the existing i2c. It does this by putting most of the common code in a structure that takes a trait that the crate defines. The spi and i2c implementations still take ownership of the bus's and then pass them on to each bus' implementation of the trait.

Each bus' Bme280 structure dose have some bus' specific functions. These are sensible as there are some things the Bme280 does do in a bus specific way.

The other inspiration is Aidafuits BusIO lib for arduino, Aidafuit have a lot of different drivers for Arduino. They have a BusIO library that many of them use so that each crate does not need to implement common functions and they do them for both i2c and spi.

Notes on Bus crate

The Bus crate allows multiple sensors to share the same bus. This crate does this by placing the bus behind a lock and then only lets one driver use the bus at a time. This is not too bad for the i2c bus where chip select is performed per read and write. As every packet of data is addressed it is generally acceptable if commands to different peripherals are interleaved but for the spi bus this does not work as a separate pin is used. There for some de-conflation, out side of the bus struct, must be done to ensure only one chip select pin is ever active at once, and that the correct select pin is active before using the bus.

Rust's ownership of the bus gives us the effect of a state machine without the cost of explicitly creating a state machine. Meaning that if a driver struct/function has a borrow of the bus it knows it is safe to activate its own chip select pin. And so long as the driver de activates the pin before it returns the borrow Then no data will be inadvertently sent to its chip.

This also means that by using the bus crates locking we add extra clock cycles for every operation of the bus when we could just use rusts ownership rules to achieve the same effect protection for i2c but with out the over head.

The bus crate also does not do anything to help you write bus agnostic drivers for chips that either have both spi and i2c in the same sensor or similar chips that are the same except For the bus interface.

Proposal

Create a crate that has:
  • A trait that can be implemented for many buses.
  • The trait provides basic read and write functionality and takes a mutable borrow.
  • Implement common functions for the trait to reduce common code across drivers.

BitByteStructs

The BitByteStructs crate provides a common trait that others can use, in the same way as the embedded traits crate does. It also provides implementations of the trait for i2c and spi.

The BitByteStructs also provides a series of structs that use the common trait and provide common functionality.

The BitStruct struct has a number of type parameters defining things like, the number of bits and the shift of the register, that the struct represents. Then when a user reads/writes via BitStruct it does common tasks like bit masking and shifting for reading or it reads only changes the relevant bits and then writes to the bus.

The CrossByteBitStructI16 struct reads or writes multiple bytes at a time and converts these to or from I16's.

Reference implementation

My icm20948 driver uses the BitByteStructs but is not meant to be definitive.

It has i2c, owned i2c and spi implementations, the i2c and spi implementations take mutable borrows of the bus only when needed. This means that those drivers can share the bus with other peripheral drivers. They use the lifetime of the borrow to ensure that there are no bus collisions.

It also has a owned i2c driver that is more like the other existing libs, this can be useful for simple executables that do not share the bus. This implementation has a slightly simpler api as the bus is only exposed for the initialize and release functions.

But all 3 implementations are very thin wrappers around the common code. This is enabled by the trait and implementation from BitByteStructs.

BitByteStructs -- The interface trait

The key part of the BitByteStructs crate that everything else is built on is the Interface trait

/// This Trait allows all the other Bus agnostic code to interface
/// with a common bus independent api
///
/// The trait is not meant to be supper preferment as any code that
/// needs to be particularly preformat probably wants to go in
/// bus specific code.
pub trait Interface {
    type Error;

    fn read_register(&mut self, register: u8, buffer: &mut [u8]) -> Result<(), Self::Error>;
    fn write_register(&mut self, register: u8, bytes: &[u8]) -> Result<(), Self::Error>;
}

This trait currently has implementations for i2c and spi buses but it is meant to be as bus agnostic as possible and could have more implementation added ether in the create or others could create there own and still use all the other features/structs of the create.

The trait defines just two functions, a read and a write, each takes u8 that specifies the register to inte/eract with and a borrow of a list to read or write from. the length of the interaction is a function of the the length of the list.

Note: we are assuming that the thing we are communicating with is a peripheral with a number of bank(s) of registers. This is very common for spi or i2c sensor peripherals.

The current implementation of the interface trait for spi takes the chip select pin and then sets and un-sets the pin as appropriate. This ensures that the chip select pin is always unset before the interface returns its borrow of the bus. This means the driver author dose not need to worry about the chip select pin once they have provided the pin to the interface implementation. But it would be easy to create an implementation that did not mange the SPI pin and so long as the author of the driver ensure the chip select pin was un-set before returning the borrow of the bus then the system will still work and we do not need a explicit state machine to mange the chip select pins.

An interesting note for further work would be to use the drop trait from the core rust lib to mange the chip select pin.

By using the interface trait your i2c and spi implementations can look something like

impl<I2C, E, const ADDRESS: u8> ICMI2C<I2C, E, ADDRESS>
where
    I2C: Read<Error = E> + Write<Error = E> + WriteRead<Error = E>,
{
    pub fn init(
        &mut self,
        i2c_bus: &mut I2C,
        delay: &mut dyn DelayMs<u16>,
    ) -> Result<(), ICMError<E>> {
        let mut interface = bus::I2CPeripheral::<I2C, E, ADDRESS>::new(i2c_bus);
        self.comm.init(&mut interface, delay)
    }
    ...
}

impl<SPI, CS, E> ICMSPI<SPI, CS, E>
where
    SPI: Transfer<u8, Error = E>,
    CS: OutputPin,
{
    pub fn init(
        &mut self,
        spi_bus: &mut SPI,
        cs: &mut CS,
        delay: &mut dyn DelayMs<u16>,
    ) -> Result<(), ICMError<E>> {
        let mut interface = bus::SPIPeripheral::<SPI, CS, E>::new(spi_bus, cs);
        self.comm.init(&mut interface, delay)
    }
    ...
}

Both these bus specific implementations with bus specific API's can use a shared common set of code that uses the interface trait for its API.

impl<I, E> ICMCommon<I, E>
where
    I: Interface,
    I: ?Sized,
{
    pub fn init(
        &mut self,
        interface: &mut dyn Interface<Error = InterfaceError<E>>,
        delay: &mut dyn DelayMs<u16>,
    ) -> Result<(), ICMError<E>> {
        self.set_bank(interface, 0)?;
        self.check_chip(interface)?;
        self.restart(interface, delay)?;
        delay.delay_ms(500 as u16);
        self.wake(interface, delay)?;

        self.set_accel_range(interface, delay, AccelRangeOptions::G2)?;
        ...
        Ok(())
    }
    ...
}

BitByteStructs -- The structures

The BitByteStructs create provides a number of structs that perform common tasks, eg. BitStruct for setting or reading bits within a register, CrossByteBitStructI16 for reading or writing 16bit values spanging 2 8 bit consecutive registers.

By using const generics we can keep all the details of the struct in the program and not add to the ram usage, a handy feature. By manually doing the bit masking and bit shifting you can achieve the same but by using a lib it helps mitigate repeating code and avoid fixing the same thing twice.

/// Struct for representing registers of 0-8bits
pub struct BitStruct<Interface: ?Sized, const ADDRESS: u8, const BITS: u8, const SHIFT: u8> {
    phantom: PhantomData<Interface>,
}

In fact adding BitStruct's to your driver's struct should not effect the final program size unless the user of your driver actually uses the function that uses that bitStruct and even they it will not add to the ram foot print of your struct (Assuming the compiler is set to a sufficiently high optimization level)

I need to verify ^ but I believe it to be true.

BitByteStructs -- Issues

All read and write functions act straight away and block while they operate. A optimization that i would like to investigate in the future is to stack multiple bit operations on the same byte.

const REGISTER_A: u8 = 0xAB;
let mut interface = bus::I2CPeripheral::<I2C, E, ADDRESS>::new(i2c_bus);
let optionA = BitStruct::<
        dyn Interface<Error = InterfaceError<E>>,
        REGISTER_A,
        2,
        0,
    >::new()?;
let optionB = BitStruct::<
        dyn Interface<Error = InterfaceError<E>>,
        REGISTER_A,
        2,
        4,
    >::new()?;
optionA.write(&mut interface, raw)?;
optionB.write(&mut interface, raw)?;

The above code reads and then writes to REGISTER_A twice. By using different masks and shifts this will not affect the final result but is not very performant especially if you're using a slow clocked bus.

Trade Offs

By writing bus specific code you can probably write faster code, the way I envisage using BitByteStructs is to use it to write configuration code were you need to read and write many registers but where the performance is not very important, but to then write bus specific code where performance is important, eg for reading the sensor data.

Examples

From the icm20948-rs examples

let mut icm_obj = ICMSPI::new(&mut spi, &mut cs).unwrap();
icm_obj.init(&mut spi, &mut cs, &mut delay_obj).unwrap();
loop {
    delay(clocks.sysclk().0 / 100);

    let bits = icm_obj.get_values_accel_gyro(&mut spi, &mut cs).unwrap();
    hprintln!("{:?}", bits).unwrap();
    let (xa, ya, za, xg, yg, zg) = icm_obj.scale_raw_accel_gyro(bits);

    write!(
        &mut arr_message,
        "results C {:?} {:?} {:?} {:?} {:?} {:?}",
        xa, ya, za, xg, yg, zg
    )
    .expect("Can't write");

    for (i, c) in arr_message.chars().enumerate() {
        buffer[i] = c as u8;
    }

    hprintln!("buffer {:?}?", buffer).unwrap();

    let mut lora = sx127x_lora::LoRa::new(spi, cs_lora, reset, 434, delay_ob).unwrap();
    lora.set_tx_power(17, 1).unwrap(); //Using PA_BOOST. See your board for correct pin.

    let transmit = lora.transmit_payload(buffer, arr_message.len());
    match transmit {
        Ok(()) => hprintln!("Sent packet").unwrap(),
        Err(e) => hprintln!("Error {:?}", e).unwrap(),
    };
    delay(clocks.sysclk().0 * 5);
    (spi, cs_lora, reset, delay_ob) = lora.release();
}

This code contrasts the two ways to deal with the spi bus.

The icm struct borrows the bus while the struct initializes the sensor the struct then holds any state needed and then borrows the bus again when it reads the data.

// The constructors are called once before the loop
let mut icm_obj = ICMSPI::new(&mut spi, &mut cs).unwrap();
icm_obj.init(&mut spi, &mut cs, &mut delay_obj).unwrap();
loop {
    // Only the sensor read function needs to happen per iteration and other drivers
    // can use the bus to talk to their peripherals while the icm struct holds state data until
    // the next interaction when it will borrow the bus again but only while it is needed.
    let bits = icm_obj.get_values_accel_gyro(&mut spi, &mut cs).unwrap();
    ...
}

For the radio, the driver struct owns the bus and then has to lose all its state when it releases the bus. It has to re-initialize the peripheral each time. It is not too bad in this case but if you wished to stream the data faster this would not be ideal.

loop {
    // This constructor must be called for every iteration
    let mut lora = sx127x_lora::LoRa::new(spi, cs_lora, reset, 434, delay_ob).unwrap();
    ...
    // Actually send the data for this iteration.
    let transmit = lora.transmit_payload(buffer, arr_message.len());
    ...
    // This destructor must be called for every iteration so the sensor can also share the bus.
    (spi, cs_lora, reset, delay_ob) = lora.release();
}

Limitations

BitByteStructs also implements some multi byte functions but I have not worked out nice ways to get the code to allocate a dynamic amount of memory from the stack this means that some functions that should not have max numbers of bytes do.

The BitByteStructs crate is still very much in alpha development and I am sure there are still plenty of bugs to shake out and sensible features that need adding.