Skip to main content
Version: FILS English

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

  1. STMicroelectronics, STM32U545 Datasheet

  2. STMicroelectronics, Nucleo STM32U545 User manual

  3. Raspberry Pi Ltd, RP2350 Datasheet

  • Chapter 12 - Peripherals
    • Chapter 12.2 - I2C
  1. BOSCH, BMP390 Digital Pressure Sensor
  • Chapter 3 - Functional Description
  • Chapter 4 - Global memory map and register description
  • Chapter 5 - Digital Interfaces
    • Subchapter 5.2 - I2C Interface
  1. Atmel Two-wire Serial EEPROMs AT24C128 AT24C256

  2. Paul Denisowski, Understanding Serial Protocols

  3. 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_protocol

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
Half duplex

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.

I2C inner works

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 SDA line only when SCL is LOW or
  • read from the SDA line only when SCL is HIGH.

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.

i2c_transmission

10-bit addresses

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_transmission_10_bit

I2C in Embassy

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>;
});

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();
info

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.

BMP280 Address

The default I2C address of the BMP280 is 0x76.

Register map

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)NameDescription
Bit 5..4modeThese 2 bits control the power mode of the device.
Bit 1temp_enEnables or disables the temperature sensor.
Bit 0press_enEnables or disables the pressure sensor.

mode possible values:

  • 00 - Sleep mode (no measurements are performed, and power consumption is at a minimum)
  • 01 and 10 - 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)NameDescription
Bit 5..3osr_tThese 3 bits control the oversampling of the temperature data.
Bit 2..0osr_pThese 3 bits control the oversampling of the pressure data.

osr_t and osr_p possible values:

  • 000 - No oversampling (x1)
  • 001 - Oversampling x 2
  • 010 - Oversampling x 4
  • 011 - Oversampling x 8
  • 100 - Oversampling x 16
  • 101 - Oversampling x 32
Oversampling

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.

AddressNameDescription
0x06DATA_2 (PRESS_MSB_23_16)Contains the most significant 8 bits (press[23:16]) of the raw pressure measurement.
0x05DATA_1 (PRESS_LSB_15_8)Contains the middle 8 bits (press[15:8]) of the raw pressure measurement.
0x04DATA_0 (PRESS_XLSB_7_0)Contains the least significant 8 bits (press[7:0]) of the raw pressure measurement.
Important

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.

tip

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.

AddressNameDescription
0x09DATA_5 (TEMP_MSB_23_16)Contains the most significant 8 bits (temp[23:16]) of the raw temperature measurement.
0x08DATA_4 (TEMP_LSB_15_8)Contains the middle 8 bits (temp[15:8]) of the raw temperature measurement.
0x07DATA_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.

Important

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 temperature value To determine the raw temperature measurement, you need to first read the temp_msb, temp_lsb and temp_xlsb register values in a burst, then assemble the raw value:
// 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 NameTrimming CoefficientData type
0x31 / 0x32NVM_PAR_T1PAR_T1unsigned short (u16)
0x33 / 0x34NVM_PAR_T2PAR_T2unsigned short (u16)
0x35NVM_PAR_T3PAR_T3signed byte (i8)
0x36 / 0x37NVM_PAR_P1PAR_P1signed short (i16)
0x38 / 0x39NVM_PAR_P2PAR_P2signed short (i16)
0x3ANVM_PAR_P3PAR_P3signed byte (i8)
0x3BNVM_PAR_P4PAR_P4signed byte (i8)
0x3C / 0x3DNVM_PAR_P5PAR_P5unsigned short (u16)
0x3E / 0x3FNVM_PAR_P6PAR_P6unsigned short (u16)
0x40NVM_PAR_P7PAR_P7signed byte (i8)
0x41NVM_PAR_P8PAR_P8signed byte (i8)
0x42 / 0x43NVM_PAR_P9PAR_P9signed short (i16)
0x44NVM_PAR_P10PAR_P10signed byte (i8)
0x45NVM_PAR_P11PAR_P11signed byte (i8)
Floating Point Conversion

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., PAR_T1=NVM_PAR_T1/28PAR\_T1=NVM\_PAR\_T1/2^{−8}).

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).

Important

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).

PinFunction
VDDIODigital Supply
VDDAnalog Supply
VSSGround
SCKActs as SCL for I2C or SCK for SPI.
SDIActs as SDA for I2C or MOSI for SPI.
SDOActs as the I2C Address Select (LSB) or MISO for SPI.
CSBChip Select
INTInterrupt

The BMP390 is integrated already integrated in the lab board, having some of these pins already wired, and some exposed in the J8 breakout.

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.

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(),
);

...
}
``PXn, PYm` do not exist

You 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

i2c_bmp280_read

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();
I2C is half-duplex.

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.

Consecutive reads

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

i2c_bmp280_write

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.

PinFunction
AO - A1Address Inputs
SDASerial Data
SCLSerial Clock Input
WPWrite Protect
NCNo Connect
GNDGround

The AT24C256 is integrated already integrated in the lab board, having some of these pins already wired, and some exposed in the J8 breakout.

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();
Sequential read

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();
Write delay

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.

Call to action

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

  1. Connect both the BMP390 and AT24C256 at the same I2C pins and perform a scan to determine their addresses. (2p)
I2C scan

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

  1. Read the raw temperature value from the BMP390 and print it once a second. To do that, you have to configure the OSR and PWR_CTRL registers. Writing to a register is explained here, and for details about the registers' function, you can consult the OSR and PWR_CTRL subsections of the register map.

You need to perform two writes to set up the sensor:

  • OSR (0x1C): Set temperature oversampling to x 2. (Hint 0b00_001_000) .

  • PWR_CTRL (0x1B): Opt for Normal mode, enable temperature, and disable the pressure measurement. (Hint 0b00_11_00_10) . (2p)

Configuring the data acquisition options

This should be done only once, before reading the sensor.

  • Read the raw temperature value stored in the DATA_3...DATA_5 register once a second, and print it to the terminal using the defmt macros. 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)
  1. 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;
Temperature format

You can print the actual_temp value like so:

info!("Temperature {}°C", t_lin);
  1. Read the calibration data (nvm_par_t1, nvm_par_t2 and nvm_par_t3) from the sensor's internal storage, instead of using the mock values previously provided to improve the accuracy of the measurement. (1p)
note

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;
  1. 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-versa

To 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.