shared-bus

2018-10-10 rust embedded 10 min read

This is a blog post accompanying the release of shared-bus. shared-bus is a small crate to allow sharing a bus between multiple devices. The current implementation is limited to i2c but extension to other bus protocols is easily possible.

GitHub

crates.io

Original Issue

The Issue

I created shared-bus because of #embedded-hal/35. Essentially the issue is that currently a device that uses a bus will own the peripheral, denying access to it from other devices. The pattern used throughout the eco-system looks like this:

use hal::blocking::i2c;

pub struct Pcf8574<I2C> {
    i2c: I2C,
    address: u8,
}

impl<I2C, E> Pcf8574<I2C>
where
    I2C: i2c::Write<Error = E>,
{
    pub fn new(i2c: I2C, address: u8) -> Result<Self, E> {
        Ok(Pcf8574 { i2c, address })
    }

    pub fn set(&mut self, bits: u8) -> Result<(), E> {
        self.i2c.write(self.address, &[bits])?;
        Ok(())
    }
}

If you have 2 port expanders (pcf8574) on the same bus, this pattern denies you to use both of them at the same time:

let i2c = ...;

let port_a = Pcf8574(i2c, 0x39).unwrap();
// This will not compile because i2c was moved into port_a
let port_b = Pcf8574(i2c, 0x38).unwrap();

The most straightforward solution looks to be just not owning the bus peripheral. Doing so makes a lot of sense, however: Drivers take ownership of other peripherals needed to control their hardware as well. This ensures that multiple drivers can't accidentally use the same peripheral or access it in a racey way. A bus is no different from these peripherals, just that a lot of times multiple drivers need to access it.

The Solution

shared-bus is one of many attempts at solving this issue. The idea is the following: The bus is owned by a BusManager instead of the device that wants to use the bus. This manager can then create as many BusProxy objects as a user requests. These proxy objects implement all the bus traits the actual bus implements and will redirect calls to it. Because of this, they can be used instead of the actual bus and owned by the devices.

But this isn't all shared-bus does: Under the hood, the manager wraps the peripheral in a mutex. This ensures that bus sharing also works between multiple threads or when interrupts come into play. Because shared-bus is supposed to be generic, it just defines a trait called BusMutex that needs to be implemented by some concrete mutex types.

Essentially shared-bus creates multiple virtual bus peripherals ontop of one underlying bus. By looking at it this way, shared-bus is a device driver that takes responsibility for synchronization of multiple devices using the bus. Similar to how the shift register driver makes pins that are not connected directly to the hardware available as "virtual pins". This way, shared-bus does not break the aforementioned convention that drivers should own all peripherals they need.

For convenience, there are a few features defined that will enable implementations for commonly used mutex types:

FeatureMutexManager
stdstd::sync::Mutexshared_bus::StdBusManager
cortexmcortex_m::interrupt::Mutexshared_bus::CortexMBusManager

Now, how does all this look in practice?

extern crate shared_bus;

// Create your bus peripheral as usual:
// let i2c = I2c::i2c1(dp.I2C1, (scl, sda), 90.khz(), clocks, &mut rcc.apb1);

let manager = shared_bus::CortexMBusManager::new(i2c);

// You can now acquire bus handles:
let mut handle = manager.acquire();
// handle implements `i2c::{Read, Write, WriteRead}`, depending on the
// implementations of the underlying peripheral

// Now, this works! :+1:
let port_a = Pcf8574(manager.acquire(), 0x39).unwrap();
let port_b = Pcf8574(manager.acquire(), 0x38).unwrap();

As you can see, once the manager is initialized, its acquire() method allows creating as many proxy objects as your application needs. Rust's lifetime-checker will ensure that the bus will not go out of scope before the devices and the mutex you chose will ensure that access always happens synchronized.

Note:

shared_bus::CortexMBusManager and shared_bus::StdBusManager are just aliases:

#[cfg(feature = "std")]
pub type StdBusManager<L, P> = BusManager<std::sync::Mutex<L>, P>;

#[cfg(feature = "cortexm")]
pub type CortexMBusManager<L, P> = BusManager<cortex_m::interrupt::Mutex<L>, P>;

There is no additional logic needed when using different mutex types. Adding support for more mutex types is really easy, if you have suggestions please open an issue on GitHub or write it yourself. There is a short explanation available here.

Once compiled, the overhead created by shared-bus is just 150 bytes as pointed out by @therealprof.

shared-bus vs Alternatives

There are other solutions that were proposed. A few include:

Not owning the bus peripheral

The idea is that you supply a reference to the bus everytime your device needs it:

let port = Pcf8574::new(0x39);

port.set(&mut i2c, 0xFF);

Although this solution seems easier at first glance, it has several drawbacks:

RefCell<> Wrapping

Wrapping the bus in a RefCell is very similar to what shared-bus does: The bus gets wrapped in some type that can be duplicated or referenced more than once to allow multiple devices owning the bus. There are a few differences however:

The Type-State Approach

Another attempt at solving this issue is using type-states. The idea is to move the bus into the device and back out once another device needs it. I am not going to detail how this works in this post, but if you are interested, please take a look here.

The obvious drawback of this solution is that it does not allow simultaneous use of devices, because only one can own the bus at a time (Unless going the borrowing route mentioned in the linked comment, but that is basically equivalent to the first approach of not owning the bus at all). This would in practice lead to a lot of unnecessary code and headaches because you need to always have the bus in the right place at the right time.

Conclusion

There are other solutions available and while they are very valid and would definitely work, in my opinion the drawbacks are too big for them to be made the standard for embedded rust.

A very nice property of shared-bus that I haven't yet talked about is its compatibility with all the other solutions: You can easily use the BusProxy as the bus peripheral with devices that implement the RefCell, type-state solutions or don't own the bus at all.

let i2c = ...;

let bus = shared_bus::CortexMBusManager::new(i2c);

/// The traditional API that owns the peripheral
let device_using_traditional_api = Dev::new(bus.acquire(), 0xc0);

/// A device that doesn't own the bus at all
let mut proxy = bus.acquire();
let device_without_owning = Dev::new(0xff);
device_without_owning.do_something(&mut proxy, 0xee);

/// A device using the RefCell approach (Although not advised)
let cell = core::cell::RefCell::new(bus.acquire());
let device_with_refcell = Dev::new(&cell, 0xc0);

/// A device using type-states
let device_typestate_with = Dev::new_with_bus(bus.acquire(), 0xff);
device_typestate_with.do_something(0xee);

// Move bus into another device
let (device_typestate_without, bus_peripheral) = device_typestate_with.disown();
let device_typestate_with_b = DevB::new_with_bus(bus_peripheral, 0xc0);
device_typestate_with_b.do_something(0xff);

// Move back into first device
let (device_typestate_without_b, bus_peripheral) = device_typestate_with_b.disown();
let device_typestate_with = device_typestate_without.own(bus_peripheral);
device_typestate_with.do_something(0xee);