I²C Driver Exercise - Easy Version
We're not going to write an entire driver, merely the first step: the hello world
of driver writing: reading the device ID of the sensor. This version is labelled easy, because we explain the code fragments, and you only have to copy and paste the fragments into the right place. Use this version if you have very little previous experience with Rust, if these workshops are your first in the embedded domain, or if you found the hard version too hard. You can work in the same file with either version.
i2c-driver/src/icm42670p.rs
is a gap text of a very basic I²C IMU sensor driver. The task is to complete the file, so that running main.rs
will log the device ID of the driver.
i2c-driver/src/icm42670p_solution.rs
provides the solution to this exercise. If you want to run it, the imports need to be changed in main.rs
and lib.rs
. The imports are already there, you only need to comment the current imports out and uncomment the solutions as marked in the line comments.
Driver API
Instance of the Sensor
To use a peripheral sensor first you must get an instance of it. The sensor is represented as a struct that contains both its device address, and an object representing the I²C bus itself. This is done using traits defined in the embedded-hal
crate. The struct is public as it needs to be accessible from outside this crate, but its fields are private.
#![allow(unused)] fn main() { #[derive(Debug)] pub struct ICM42670P<I2C> { // The concrete I²C device implementation. i2c: I2C, // Device address address: DeviceAddr, } }
We add an impl
block that will contain all the methods that can be used on the sensor instance. It also defines the Error Handling. In this block, we also implement an instantiating method. Methods can also be public or private. This method needs to be accessible from outside, so it's labelled pub
. Note that written this way, the sensor instance takes ownership of the I²C bus.
#![allow(unused)] fn main() { impl<I2C, E> ICM42670P<I2C> where I2C: i2c::WriteRead<Error = E> + i2c::Write<Error = E>, { /// Creates a new instance of the sensor, taking ownership of the i2c peripheral. pub fn new(i2c: I2C, address: DeviceAddr) -> Result<Self, E> { Ok(Self { i2c, address }) } // ... }
Device Address
- The device's addresses are available in the code:
#![allow(unused)] fn main() { pub enum DeviceAddr { /// 0x68 AD0 = 0b110_1000, /// 0x69 AD1 = 0b110_1001, } }
- This I²C device has two possible addresses -
0x68
and0x69
. We tell the device which one we want it to use by applying either0V
or3.3V
to theAP_AD0
pin on the device. If we apply0V
, it listens to address0x68
. If we apply3.3V
it listens to address0x69
. You can therefore think of pinAD_AD0
as being a one-bit input which sets the least-significant bit of the device address. More information is available in the datasheet, section 9.3
Representation of Registers
The sensor's registers are represented as enums. Each variant has the register's address as value. The type Register
implements a method that exposes the variant's address.
#![allow(unused)] fn main() { #[derive(Clone, Copy)] pub enum Register { WhoAmI = 0x75, } impl Register { fn address(&self) -> u8 { *self as u8 } } }
read_register()
and write_register()
We define a read and a write method, based on methods provided by the embedded-hal
crate. They serve as helpers for more specific methods and as an abstraction that is adapted to a sensor with 8-bit registers. Note how the read_register()
method is based on a write_read()
method. The reason for this lies in the characteristics of the I²C protocol: We first need to write a command over the I²C bus to specify which register we want to read from. Helper methods can remain private as they don't need to be accessible from outside this crate.
#![allow(unused)] fn main() { impl<I2C, E> ICM42670P<I2C> where I2C: i2c::WriteRead<Error = E> + i2c::Write<Error = E>, { /// Creates a new instance of the sensor, taking ownership of the i2c peripheral. pub fn new(i2c: I2C, address: DeviceAddr) -> Result<Self, E> { Ok(Self { i2c, address }) } // ... /// Writes into a register // This method is not public as it is only needed inside this file. #[allow(unused)] fn write_register(&mut self, register: Register, value: u8) -> Result<(), E> { let byte = value; self.i2c .write(self.address as u8, &[register.address(), byte]) } /// Reads a register using a `write_read` method. // This method is not public as it is only needed inside this file. fn read_register(&mut self, register: Register) -> Result<u8, E> { let mut data = [0]; self.i2c .write_read(self.address as u8, &[register.address()], &mut data)?; Ok(u8::from_le_bytes(data)) } }
✅ Implement a public method that reads the WhoAmI
register with the address 0x75
. Make use of the above read_register()
method.
✅ Optional: Implement further methods that add features to the driver. Check the documentation for the respective registers and their addresses. 💡 Some ideas:
- Switching the gyroscope sensor or the accelerometer on
- Starting measurements
- Reading measurements
🔎 General Info About Peripheral Registers
Registers can have different meanings, in essence, they are a location that can store a value.
In this specific context, we are using an external device (since it is a sensor, even if it is on the same PCB). It is addressable by I2C, and we are reading and writing to its register addresses. The addresses each identify a unique location that contains some information. In this case, we want the address for the location that contains the current temperature, as read by the sensor.
You can find the register map of the ICM-42670 in section 14 should you want to try to get other interesting data from this sensor.
Simulation
This project is available for simulation through two methods: