06 - Inter-Integrated Circuit
This lab will teach you how to communicate with hardware devices using the Inter-Integrated Circuit (I2C) protocol, in Embassy.
Resources
-
STMicroelectronics, STM32U545 Datasheet
-
STMicroelectronics, Nucleo STM32U545 User manual
-
Raspberry Pi Ltd, RP2350 Datasheet
- Chapter 12 - Peripherals
- Chapter 12.2 - I2C
- Chapter 3 - Functional Description
- Chapter 4 - Global memory map and register description
- Chapter 5 - Digital Interfaces
- Subchapter 5.2 - I2C Interface
-
Paul Denisowski, Understanding Serial Protocols
-
Paul Denisowski, Understanding I2C
Inter-Integrated Circuit (I2C)
The Inter-Integrated Circuit (I2C) is a synchronous, multi-controller/multi-target communication protocol. Similarly to the SPI, it allows data transfer between a controller and one or more peripheral ICs, but it uses only 2 wires (1 data line and 1 clock line, making it half-duplex) and has a different way of addressing the peripherals: using their unique addresses.
Configuration
I2C transmission uses 2 lines:
- SCL - Serial CLock line - clock is generated by the controller - used to synchronize communication between the controller and the targets
- SDA - Serial DAta line - carries data between the controller and the addressed target
- targets read data from SDA only when the clock is low
- targets write data to SDA only when the clock is high
The communication is half-duplex. This means that data is transmitted only in one direction at a time, since there is only one data line that can be used both for sending data to the target and receiving data from the target.
The SDA and SCL wires are never actually driven (set to LOW/HIGH) by the controller/peripherals. The line is controlled by either pulling the line low or releasing the line high.
When the line is pulled down, this means that it is connected directly to GND. This electronically translates to LOW.
When the line is released, or pulled up, this means that it connects back to 3V3 (which we can consider as being the "default" state of the wire) through a pull-up resistor. This electronically translates to HIGH.
This is called open-drain connection. You can read more about how it works here, at section 2.2.
Data transmission
Each target is associated with a unique address. The controller uses this address to initiate communication with that target. This address can either be 7 or 10 bits.
Initiation
Before the transmission, both the SCL and SDA lines are set to HIGH. First thing the controller does is to signal a start condition by pulling the SDA line to LOW. All targets understand that the communication is about to commence and listen on the SDA line. Next, the controller starts the clock and begins to write the address of the target it wants to talk to, followed by a command bit that signifies whether the controller wants to read from the target or write to it. Whichever target recognizes its address, responds with an ACK (acknowledged), by pulling the SDA to LOW. If no target responds and the SDA stays HIGH, then it is considered a NACK (not acknowledged). Afterwards, the data transmission can begin.
Transmission
Depending on the command bit (R/W), either the controller or the target begins to send data over the SDA line. Data is sent one byte at a time, and then acknowledged by the receiver. One sequence of a data byte and ack is called a frame.
During the communication, data can be:
- written to the
SDAline only whenSCLisLOWor - read from the
SDAline only whenSCLisHIGH.
End
To end the transmission, the controller signals a stop condition. This is done by releasing the SCL line to HIGH, and then also releasing the SDA line. Since data can be written only when SCL is LOW, the target understands that this is a special event, that means that the communication has ended.
For 10-bit addresses, the controller first issues a specific sequence of bits. This sequence is reserved, therefore targets with 7-bit addresses are prohibited from having addresses that start with this sequence. These bits mark the fact that the controller is attempting to initiate communication with a target with a 10-bit address, so all 7-bit targets ignore the SDA line once they recognize this sequence. After the special sequence, the controller sends the upper 2 bits of the address and the command bit, then waits for an ack from the target(s) that have an address that begins with these 2 bits. Afterwards, it sends the rest of the address, and waits for an acknowledgement from the target.
I2C in Embassy
- STM32 Nucleo-U545RE-Q
- Raspberry Pi Pico 1 / 2
These are the I2C imports we will be using.
use embassy_stm32::i2c::I2c;
use embassy_stm32::{bind_interrupts, i2c, peripherals};
We start by initializing the peripherals.
let peripherals = embassy_stm32::init(Default::default());
Next, we declare the pins we will be using for the SDA and SCL lines. We can find which pins of the STM32 Nucleo-U545RE-Q have these functions by looking at the pinout.
let sda = peripherals.PXn;
let scl = peripherals.PYm;
We then initialize our I2C instance, using the pins we defined earlier and a default configuration. It's recommended to use the asynchronous version, since it won't block the executor.
/// I2C
let mut i2c = I2c::new(
peripherals.I2C1,
scl,
sda,
Irqs,
peripherals.GPDMA1_CH0,
peripherals.GPDMA1_CH1,
Default::default(),
);
The first argument of the new function is the I2C channel that will be used. The STM32 Nucleo-U545RE-Q has multiple usable I2C channels. Each has multiple sets of pins that can be used for and you can find them on the pinout diagram.
The Irqs variable refers to the interrupt that the I2C driver will use when handling transfers. We also need to bind this interrupt, which depends on the I2C channel we are working with.
bind_interrupts!(struct Irqs {
I2C1_EV => i2c::EventInterruptHandler<peripherals::I2C1>;
I2C1_ER => i2c::ErrorInterruptHandler<peripherals::I2C1>;
});
These are the I2C imports we will be using.
use embassy_rp::i2c::{I2c, InterruptHandler as I2CInterruptHandler, Config as I2cConfig};
use embedded_hal_async::i2c::{Error, I2c as _};
use embassy_rp::peripherals::I2C0;
I2c trait importingWe use I2c as _ from embedded_hal_async because in order to use the trait methods, we need to import it.
We start by initializing the peripherals.
let peripherals = embassy_rp::init(Default::default());
Next, we declare the pins we will be using for the SDA and SCL lines. We can find which pins of the Raspberry Pi Pico have these functions by looking at the pinout.
let sda = peripherals.PIN_X;
let scl = peripherals.PIN_Y;
We then initialize our I2C instance, using the pins we defined earlier and a default configuration. It's recommended to use the asynchronous version, since it won't block the executor.
/// I2C
let mut i2c = I2c::new_async(peripherals.I2CX, scl, sda, Irqs, I2cConfig::default());
The first argument of the new function is the I2C channel that will be used. The Raspberry Pi Pico 2 has two usable I2C channels: I2C0 and I2C1. Each has multiple sets of pins that can be used for and you can find them marked in blue on the pinout diagram.
The Irqs variable refers to the interrupt that the I2C driver will use when handling transfers. We also need to bind this interrupt, which depends on the I2C channel we are working with.
bind_interrupts!(struct Irqs {
I2C0_IRQ => I2CInterruptHandler<I2C0>;
});
I2cConfig and I2cInterruptHandler are renamed importsBecause of the Embassy project naming convention, multiple Configs and InterruptHandlers can exist in one file. To solve this without having to prefix them with their respective module in code every time we use them (i.e use i2c::Config and i2c::InterruptHandler), in the code examples above I2cConfig and I2CInterruptHandler are renamed imports:
use embassy_rp::i2c::{I2c, InterruptHandler as I2CInterruptHandler, Config as I2cConfig};
Reading from a target
To read from a target, we will be using the read async function of the I2C driver.
The function takes 2 parameters:
- the address of the target we are attempting to receive the data from
- the receiving buffer in which we will store the data received from the target
The following example reads two bytes from the target of address 0x44.
const TARGET_ADDR: u8 = 0x44;
let mut rx_buf = [0x00u8; 2];
i2c.read(TARGET_ADDR, &mut rx_buf).await.unwrap();
Writing to a target
To write data to a target, we will be using the write async function of the I2C driver.
This function also takes 2 parameters:
- the address of the target we are attempting to transmit the data to
- the transmitting buffer that contains the data we want to send to the target
The following example writes two bytes to the target of address 0x44.
const TARGET_ADDR: u16 = 0x44;
let tx_buf = [0x01, 0x05];
i2c.write(TARGET_ADDR, &tx_buf).await.unwrap();
We can also use write_read if we want to perform both a write and a read one after the other.
i2c.write_read(TARGET_ADDR, &tx_buf, &mut rx_buf).await.unwrap();
BMP390 Digital Pressure Sensor
The BMP390 is a digital temperature and pressure sensor designed by Bosch. It can be interfaced both with SPI and with I2C. In this lab, we will use the I2C protocol to communicate with the sensor, in order to retrieve the pressure and temperature values.
You can find its datasheet containing more relevant information here.
The default I2C address of the BMP280 is 0x76.
Register map

Next, we are going to explore some of the key registers we are going to use.
Register PWR_CTRL (0x1B)
The Power Control register enables or disables pressure and temperature measurements and sets the power mode.
| Address (0x1B) | Name | Description |
|---|---|---|
| Bit 5..4 | mode | These 2 bits control the power mode of the device. |
| Bit 1 | temp_en | Enables or disables the temperature sensor. |
| Bit 0 | press_en | Enables or disables the pressure sensor. |
mode possible values:
00- Sleep mode (no measurements are performed, and power consumption is at a minimum)01and10- Forced mode (a single measurement is performed, then the sensor returns to Sleep mode)11- Normal mode (continuously cycles between a measurement period and a standby period)
temp_en/press_en possible values:
0- Disabled (Skipped)1- Enabled
Register OSR (0x1C)
The Oversampling register controls the oversampling settings for pressure and temperature measurements.
| Address (0x1C) | Name | Description |
|---|---|---|
| Bit 5..3 | osr_t | These 3 bits control the oversampling of the temperature data. |
| Bit 2..0 | osr_p | These 3 bits control the oversampling of the pressure data. |
osr_t and osr_p possible values:
000- No oversampling (x1)001- Oversampling x 2010- Oversampling x 4011- Oversampling x 8100- Oversampling x 16101- Oversampling x 32
Oversampling reduces noise and increases the output resolution. Each step reduces noise and increases the output resolution by one bit.
Register DATA_0...DATA_2 (Pressure Data) (0x04...0x06)
The pressure data is split and stored in three consecutive registers. The measurement output is an unsigned 24-bit value.
| Address | Name | Description |
|---|---|---|
| 0x06 | DATA_2 (PRESS_MSB_23_16) | Contains the most significant 8 bits (press[23:16]) of the raw pressure measurement. |
| 0x05 | DATA_1 (PRESS_LSB_15_8) | Contains the middle 8 bits (press[15:8]) of the raw pressure measurement. |
| 0x04 | DATA_0 (PRESS_XLSB_7_0) | Contains the least significant 8 bits (press[7:0]) of the raw pressure measurement. |
Burst Read Required To guarantee data consistency (shadowing), you must use a single burst read to read all data registers at once. Reading registers individually may result in mixed data from different measurement cycles.
Reading the raw pressure value Because the output is a standard 24-bit unsigned integer (unlike the 20-bit format of previous generations), the calculation is straightforward:
// Assuming the values are read into 32-bit integers (or u8 cast to u32).
// Note: Registers must be read in a burst transaction!
let raw_press: u32 = (press_msb << 16) | (press_lsb << 8) | press_xlsb;
Register DATA_3...DATA_5 (Temperature Data) (0x07...0x09)
The temperature data is split and stored in three consecutive registers. The measurement output is an unsigned 24-bit value.
| Address | Name | Description |
|---|---|---|
| 0x09 | DATA_5 (TEMP_MSB_23_16) | Contains the most significant 8 bits (temp[23:16]) of the raw temperature measurement. |
| 0x08 | DATA_4 (TEMP_LSB_15_8) | Contains the middle 8 bits (temp[15:8]) of the raw temperature measurement. |
| 0x07 | DATA_3 (TEMP_XLSB_7_0) | Contains the least significant 8 bits (temp[7:0]) of the raw temperature measurement. |
Based on the BMP390 datasheet, the temperature data registers have moved to addresses 0x07 through 0x09, and the resolution is now 24 bits.
Here is the adapted content:
Register DATA_3...DATA_5 (Temperature Data) (0x07...0x09)
The temperature data is split and stored in three consecutive registers. The measurement output is an unsigned 24-bit value.
Address Name Description 0x09 DATA_5 (TEMP_MSB_23_16)
Contains the most significant 8 bits (temp[23:16]) of the raw temperature measurement.
0x08 DATA_4 (TEMP_LSB_15_8)
Contains the middle 8 bits (temp[15:8]) of the raw temperature measurement.
0x07 DATA_3 (TEMP_XLSB_7_0)
Contains the least significant 8 bits (temp[7:0]) of the raw temperature measurement.
Burst Read Required To guarantee data consistency (shadowing), you must use a single burst read to read all data registers at once. Reading registers individually may result in mixed data from different measurement cycles.
// Assuming the values are read into 32-bit integers (or u8 cast to u32).
// Note: Registers must be read in a burst transaction!
let raw_temp: u32 = (temp_msb << 16) | (temp_lsb << 8) | temp_xlsb;
Trimming Coefficients (0x31...0x45)
These are u8, i8, u16, and i16 factory-calibrated parameters stored inside the BMP390 non-volatile memory (NVM). Due to manufacturing variations, Bosch measures each sensor individually at the factory and saves its personal correction factors to be used to accurately compensate the raw pressure and temperature values.
| Register Address (LSB / MSB) | Register Name | Trimming Coefficient | Data type |
|---|---|---|---|
| 0x31 / 0x32 | NVM_PAR_T1 | PAR_T1 | unsigned short (u16) |
| 0x33 / 0x34 | NVM_PAR_T2 | PAR_T2 | unsigned short (u16) |
| 0x35 | NVM_PAR_T3 | PAR_T3 | signed byte (i8) |
| 0x36 / 0x37 | NVM_PAR_P1 | PAR_P1 | signed short (i16) |
| 0x38 / 0x39 | NVM_PAR_P2 | PAR_P2 | signed short (i16) |
| 0x3A | NVM_PAR_P3 | PAR_P3 | signed byte (i8) |
| 0x3B | NVM_PAR_P4 | PAR_P4 | signed byte (i8) |
| 0x3C / 0x3D | NVM_PAR_P5 | PAR_P5 | unsigned short (u16) |
| 0x3E / 0x3F | NVM_PAR_P6 | PAR_P6 | unsigned short (u16) |
| 0x40 | NVM_PAR_P7 | PAR_P7 | signed byte (i8) |
| 0x41 | NVM_PAR_P8 | PAR_P8 | signed byte (i8) |
| 0x42 / 0x43 | NVM_PAR_P9 | PAR_P9 | signed short (i16) |
| 0x44 | NVM_PAR_P10 | PAR_P10 | signed byte (i8) |
| 0x45 | NVM_PAR_P11 | PAR_P11 | signed byte (i8) |
The BMP390 datasheet recommends converting these raw integer coefficients into floating-point values before applying the compensation formula. Each coefficient must be divided by a specific power of 2 (e.g., ).
Temperature computation formula
This formula is based on the Compensation formula in fixed point which can be found in section 8.5 of the datasheet.
Coefficient Conversion before calculating the temperature, convert the raw NVM coefficients into floats using the scaling factors defined in Section 8.4:
// Powers of 2 used for scaling (Section 8.4)
let par_t1 = (nvm_par_t1 as f32) / 0.00390625; // 2^-8
let par_t2 = (nvm_par_t2 as f32) / 1073741824.0; // 2^30
let par_t3 = (nvm_par_t3 as f32) / 281474976710656.0; // 2^48
Compensation Calculation using the floating-point coefficients and the raw temperature (raw_temp), calculate the compensated temperature (t_lin).
// Based on Appendix 8.5: Temperature compensation
// `raw_temp` is the u32 value read from registers 0x07..0x09
let partial_data1 = (raw_temp as f32) - par_t1;
let partial_data2 = partial_data1 * par_t2;
// t_lin is the compensated temperature in degrees Celsius
let t_lin = partial_data2 + (partial_data1 * partial_data1) * par_t3;
The resulting t_lin is the actual temperature in degrees Celsius (e.g., 23.5 represents 23.5°C).
t_lin dependency Keep the calculated t_lin value stored in memory. It is required as an input to calculate the compensated pressure value later.
Wiring
For the I2C protocol, you connect the SDI (Data) and SCK (Clock) pins. The SDO pin is used to select the I2C address (connected to GND for address 0x76 or VDDIO for 0x77).
| Pin | Function |
|---|---|
VDDIO | Digital Supply |
VDD | Analog Supply |
VSS | Ground |
SCK | Acts as SCL for I2C or SCK for SPI. |
SDI | Acts as SDA for I2C or MOSI for SPI. |
SDO | Acts as the I2C Address Select (LSB) or MISO for SPI. |
CSB | Chip Select |
INT | Interrupt |
The BMP390 is integrated already integrated in the lab board, having some of these pins already wired, and some exposed in the J8 breakout.

Reading and writing to the BMP390
Instructions on how to use I2C with the BMP280 can be found in the datasheet, at section 5.2.
Before we start, we initialize the I2C driver with the pins and channel we will be using.
- STM32 Nucleo-U545RE-Q
- Raspberry Pi Pico 1 / 2
use embassy_stm32::i2c::I2c;
use embassy_stm32::{bind_interrupts, i2c, peripherals};
bind_interrupts!(struct Irqs {
I2C1_EV => i2c::EventInterruptHandler<peripherals::I2C1>;
I2C1_ER => i2c::ErrorInterruptHandler<peripherals::I2C1>;
});
fn main() {
let peripherals = embassy_stm32::init(Default::default());
// I2C pins
let sda = peripherals.PXn;
let scl = peripherals.PYm;
// I2C definition
let mut i2c = I2c::new(
peripherals.I2C1,
scl,
sda,
Irqs,
peripherals.GPDMA1_CH0,
peripherals.GPDMA1_CH1,
Default::default(),
);
...
}
, PYm` do not existYou will need to replace them with the proper I2C peripheral and corresponding pins.
use embassy_rp::i2c::{I2c, InterruptHandler as I2CInterruptHandler, Config as I2cConfig};
use embedded_hal_async::i2c::{Error, I2c as _};
use embassy_rp::peripherals::I2CX;
bind_interrupts!(struct Irqs {
I2CX_IRQ => I2CInterruptHandler<I2CX>;
});
fn main() {
let peripherals = embassy_rp::init(Default::default());
// I2C pins
let sda = peripherals.PIN_X;
let scl = peripherals.PIN_Y;
// I2C definition
let mut i2c = I2c::new_async(peripherals.I2CX, scl, sda, Irqs, I2cConfig::default());
...
}
I2CX, PIN_X, PIN_Y do not existYou will need to replace them with the proper I2C peripheral and corresponding pins.
In section 5.2.1 and 5.2.2 of the datasheet, we get the information we need in order to read/write to a register of the BMP390 using I2C.
Reading a register

To read the value of a register, we first need to send the BMP390 the address of the register we want to read. Afterwards, the sensor will send back the value of the register we requested.
For this, we need to first write this register address over I2C and then read the value we get back from the sensor. We could use the write_read_async function to do this.
// The tx buffer contains the address of the register we want to read
let tx_buf = [REG_ADDR];
// the rx buffer will contain the value of the requested register
// after the transfer is complete.
let mut rx_buf = [0x00u8];
// The function's arguments are the I2C address of the BMP390 and the two buffers
i2c.write_read(BMP390_ADDR, &tx_buf, &mut rx_buf).await.unwrap();
The write_read function performs two separate transfers: a write and a read. As opposed to SPI, the basic transactions are unidirectional, and that's why we need two of them.
Like with SPI, we can also read multiple registers with consecutive addresses at a time. All we need to do is modify the size of the receive buffer to be able to hold more register values, the rest of the procedure is the same.
let mut rx_buf = [0x00u8; 3];
This is explained in section 5.3 of the datasheet.
Writing to a register

To write to a register, we need to send the sensor a buffer containing pairs of register addresses and values we want to write to those registers. For example, if we wanted to write 0x00 to REG_A:
let tx_buf = [REG_A, 0x00];
i2c.write(BMP280_ADDR, &tx_buf).await.unwrap();
If we wanted to write both REG_A and REG_B to 0x00:
let tx_buf = [REG_A, 0x00, REG_B, 0x00];
i2c.write(BMP280_ADDR, &tx_buf).await.unwrap();
AT24C256 EEPROM
The AT24C256 is a 256-kilobit Electrically Erasable Programmable Read-Only Memory (EEPROM) device that communicates using the I2C protocol. It is commonly used for storing non-volatile data, such as configuration settings or calibration data, which need to persist even when the device is powered off.
Device Addressing
The AT24C256 uses a 7-bit I2C address, with the most significant 5 bits fixed as 10100. The remaining 2 bits are configurable by connecting the A1 and A0 pins to either GND or VCC, allowing up to 4 devices to be connected on the same I2C bus. Knowing the state of the pins, you can determine the address using the formula: 0x50 | (A1 << 1) | A0. To determine the address of the EEPROM used by our board, you can perform an I2C scan.
Wiring
The AT24C256 has 8 pins with the following functions. For more information, consult the datasheet.
| Pin | Function |
|---|---|
| AO - A1 | Address Inputs |
| SDA | Serial Data |
| SCL | Serial Clock Input |
| WP | Write Protect |
| NC | No Connect |
| GND | Ground |
The AT24C256 is integrated already integrated in the lab board, having some of these pins already wired, and some exposed in the J8 breakout.

Memory Organization
The memory is organized into 32,768 bytes, divided into 512 pages of 64 bytes each. Each byte can be accessed individually, or multiple bytes can be written/read in a single operation using page addressing.
We can connect the EEPROM to the same I2C bus as the BMP390, therefore we can reuse the same I2c instance we previously initialized.
Reading from the AT24C256
To read data from the EEPROM, you first need to write the memory address you want to read, then read the byte at that memory location. Because we are working with 32,768 bytes of memory (which is 215 bytes), we are working about 2-byte addresses that need to be sent High byte first (big endian).
let mem_addr: u16 = 0xCAFE; // 16 bit address
let memory_address: [u8; 2] = mem_addr.to_be_bytes(); // `be` stands for big endian
let mut data: [u8; 1] = [0];
i2c.write_read(EEPROM_ADDR, &memory_address, &mut data).await.unwrap();
The AT24C256 supports sequential reads. After the EEPROM sends a data word (byte), if the microcontroller sends a responds with an ACK instead of a Stop Condition the memory will continue to increment the internal data word address and serially clock out sequential data words. When the memory address limit is reached, the data word address will "roll over" (begin writing from the beginning) and the sequential read will continue.
This means that we can read multiple consecutive bytes:
let mem_addr: u16 = 0xBABE; // 16 bit address
let mem_buff: [u8; 2] = mem_addr.to_be_bytes(); // `be` stands for big endian
let mut data: [u8; 10] = [0; 10];
i2c.write_read(EEPROM_ADDR, &mem_buff, &mut data).await.unwrap();
Writing to the AT24C256
The EEPROM supports the writing of up to 64 bytes (one page) in a single transaction. The microcontroller performs a write transaction where the first two bytes are the 16-bit memory location in big endian format, followed by a number of bytes that should be written, starting from that respective address. The particularity of this memory module is that, for a write within a page when reaching the upper page boundary, the internal data word address would do a "roll over" to the address of the first byte of the same page.
let mem_addr: u16 = 0xBABE; // 16 bit address
let mem_buff: [u8; 2] = mem_addr.to_be_bytes(); // `be` stands for big endian
let data: [u8; 8] = [0xCA, 0xFE, 0xBA, 0xBE, 0xDE, 0xAD, 0xBE, 0xEF];
let mut tx_buf = [0x00; 8 + 2];
tx_buf[..2].copy_from_slice(&mem_buff);
tx_buf[2..].copy_from_slice(&data);
i2c.write(EEPROM_ADDR, &tx_buf).await.unwrap();
After each complete memory write transaction, the EEPROM an internally-timed write cycle of roughly 5ms. If you have to perform a series of consecutive writes, make sure to space them out appropriately.
eeprom24x crate
To simplify the interfacing with the non-volatile memory for your project, you can use the eeprom24x crate. It is a is a platform agnostic Rust driver for the 24x series serial EEPROM, based on the embedded-hal traits. This means that you will not be able to harness the power of the async executor, and you will need to use the conventional blocking API.
At the end of this lab, you should be familiar with both the blocking and async I2C traits exported by the embedded-hal and embedded-hal-async and with a grasp of how I2C works. You could begin your journey into the OpenSource world by contributing to this crate, by creating a PR on their github repository that adds support for the async API.
Exercises
- Connect both the BMP390 and AT24C256 at the same I2C pins and perform a scan to determine their addresses. (2p)
You can perform an I2C scan by attempting a one byte read at every address within the viable address range. The addresses used by the I2C protocol are 7-bit address, ranging from 0x00 to 0x7F, but some of these are either reserved, or have a special function (the general call 0x00 address). The unusable addresses range from 0x00 to 0x07 and 0x78 to 0x7F. This leaves us with addresses ranging from 0x08 to 0x77
- Read the raw temperature value from the BMP390 and print it once a second. To do that, you have to configure the
OSRandPWR_CTRLregisters. Writing to a register is explained here, and for details about the registers' function, you can consult theOSRandPWR_CTRLsubsections of the register map.
You need to perform two writes to set up the sensor:
-
OSR(0x1C): Set temperature oversampling to x 2. (Hint0b00_001_000) . -
PWR_CTRL(0x1B): Opt for Normal mode, enable temperature, and disable the pressure measurement. (Hint0b00_11_00_10) . (2p)
This should be done only once, before reading the sensor.
- Read the raw temperature value stored in the
DATA_3...DATA_5register once a second, and print it to the terminal using thedefmtmacros. Details on how this can be performed can be found in this subsection of the register map and in the Reading a register subsection. (2p)
- Based on the raw temperature value previously determined, compute the actual temperature value using the mock calibration values provided bellow and the formula described in the sections above. (1p)
let nvm_par_t1: u16 = 27504;
let nvm_par_t2: u16 = 26435;
let nvm_par_t3: i8 = -50;
You can print the actual_temp value like so:
info!("Temperature {}°C", t_lin);
- Read the calibration data (
nvm_par_t1,nvm_par_t2andnvm_par_t3) from the sensor's internal storage, instead of using the mock values previously provided to improve the accuracy of the measurement. (1p)
The reading of the calibration values should be performed only once, after configuring the PWR_CTRL register.
Hint!
You need to read 5 bytes starting from register NVM_PAR_T1 (address 0x31). If you read them into a 5-byte u8 buffer called data, you can reconstruct the values like this:
// 0x31 (LSB) & 0x32 (MSB) -> u16
let nvm_par_t1: u16 = ((data[1] as u16) << 8) | (data[0] as u16);
// 0x33 (LSB) & 0x34 (MSB) -> u16 let nvm_par_t2: u16 = ((data[3] as u16) << 8) | (data[2] as u16);
// 0x35 -> i8 (Note: This is an 8-bit signed value!) let nvm_par_t3: i8 = data[4] as i8;
- Every time you perform a sensor reading, log the temperature value (in hundredths of a degree) in the non-volatile memory at the address
0xACDC. To be able to test that this is properly working, the first thing we will do when the board boots will be to print the previously written temperature value. You have more details about reading and writing to the EEPROM in this section. (2p)
i32 to bytes and vice-versaTo quickly convert an i32 variable into an array of u8s, we can use either the from_be_bytes() and to_be_bytes() or the from_le_bytes() and to_le_bytes() methods. You can find more details about them in the i32 documentation page.