Bare-Metal STM32: Using I2C bus in master-transceiver mode

As one of the most popular buses today for on-and-board communication between systems, there is a good opportunity to use it with an embedded system. The I2C only offers different speeds when two wires (clock and data) are needed, which makes it significantly easier to handle than options like SPI. Within the STM32 family of MCUs, you will find at least one I2C peripheral on each device.

As a shared, half-duplex medium, I2C uses a rather straightforward call-and-response design, where one device controls the clock and the other devices simply wait and listen until their specific address is sent to the I2C bus. Configuring an STM32 I2C peripheral requires a few steps, then using it is quite painless, as we will see in this article.

Basic steps

Assuming that the receiving devices, such as sensors, are properly wired up with the necessary pull-up resistors, we can later begin to configure the I2C peripherals of the MCU. We will use STM32F042 as the target MCU, but other STM32 families are rather similar from an I2C perspective. We will use CMSIS-style peripherals and register references.

First, we set the GPIO pins to use for I2C peripherals by enabling the appropriate Alternative Function (AF) mode. This is recorded in the datasheet for the target MCU. For STM23F042 MCU, the standard SCL (watch) pin is on PA11, including AF 5. SDA (data) is available in PA12 with the same AF. To do this we need to set the appropriate bit in the GPIO_AFRH (Alternate Function Register High) register:

GPIO_AFRH on STM32F042 with AF value.

Selecting AF 5 for pins 11 and 12 (AFSEL11 and AFSEL12), these pins are then internally connected to the first I2C peripheral (I2C1). This is similar to what we did in the previous article on URT. We also need to enable AF mode for pin in GPIO_MODER:

STM32F0x2 GPIO_MODER layout (RM0091, 8.4.4).

All of this is done using the following code:

uint8_t pin = 11;                // Repeat for pin 12
uint8_t pin2 = pin * 2;
GPIOA->MODER &= ~(0x3 << pin2); 
GPIOA->MODER |= (0x2 << pin2);   // Set AF mode.

// Set AF mode in appropriate (high/low) register.
if (pin < 8) { 
    uint8_t pin4 = pin * 4; 
    GPIOA->AFR[0] &= ~(0xF << pin4); 
    GPIOA->AFR[0] |= (af << pin4); 
else { 
    uint8_t pin4 = (pin - 8) * 4; 
    GPIOA->AFR[1] &= ~(0xF << pin4); 
    GPIOA->AFR[1] |= (af << pin4);

Note that we want both SCL and SDA pins to be configured in the GPIO register so that they remain floating without pullup or pulldown and remain in open drain configuration. This matches the features of the I2C bus, which is designed for open drain. Practically this means that pull-ups on the bus line keep the signal high, unless pulled by a bus owner or slave device.

The clock is turned on for the first I2C peripheral RCC_APB1ENR (Enable registration) with:


Some STM32F0 MCUs have only one I2C peripheral (STM32F03x and F04x), others have two. However, if the I2C peripheral exists, after setting its clock-enabled bit in this register, we can now proceed to configure the I2C peripheral itself as a master.

Clock configuration

Before we do anything else with the I2C peripheral, we must make sure that it is in a disabled state:

I2C1->CR1 &= ~I2C_CR1_PE;

Clock settings are set I2C_ TIMINGR:

I2C_TIMINGR layout, as per RM0091 (26.7.5)
I2C_TIMINGR layout, as per RM0091 (26.7.5)

The reference manual lists several tables, including time settings, for the speed of the 8 MHz I2C clock in the STM32F0, depending on the I2C clock:

IC2_TIMINGR configuration example table.  Source: RM0091, 26.4.10.
IC2_TIMINGR configuration example table. Source: RM0091, 26.4.10.

This table can be converted to a ready-to-use array of values ​​for configuring I2C peripherals by placing these values ​​in the correct order for insertion into I2C_TIMINGR, such as for STM32F0:

uint32_t i2c_timings_4[4];
uint32_t i2c_timings_8[4];
uint32_t i2c_timings_16[4];
uint32_t i2c_timings_48[4];
uint32_t i2c_timings_54[4];

i2c_timings_4[0] = 0x004091F3;
i2c_timings_4[1] = 0x00400D10;
i2c_timings_4[2] = 0x00100002;
i2c_timings_4[3] = 0x00000001;
i2c_timings_8[0] = 0x1042C3C7;
i2c_timings_8[1] = 0x10420F13;
i2c_timings_8[2] = 0x00310309;
i2c_timings_8[3] = 0x00100306;
i2c_timings_16[0] = 0x3042C3C7;
i2c_timings_16[1] = 0x30420F13;
i2c_timings_16[2] = 0x10320309;
i2c_timings_16[3] = 0x00200204;
i2c_timings_48[0] = 0xB042C3C7;
i2c_timings_48[1] = 0xB0420F13;
i2c_timings_48[2] = 0x50330309;
i2c_timings_48[3] = 0x50100103;
i2c_timings_54[0] = 0xD0417BFF;
i2c_timings_54[1] = 0x40D32A31;
i2c_timings_54[2] = 0x10A60D20;
i2c_timings_54[3] = 0x00900916;

Other options available here are to allow STMicroelectronic-provided tools (such as CubeMX) to calculate values ​​for you or to manually calculate the information contained in the reference manual. At this point, the implementation of the Node Framework I2C for STM32 uses both the default value for STM32F0 and the dynamically calculated value for other households.

The advantage of dynamically calculating the value of time is that it does not depend on the default I2C clock speed. One of the disadvantages is that instead of reading these values ​​directly out of the table, additional delays are involved in calculating these values. Which method works best depends on the needs of the project.

With I2C_TIMINGR Registration configured in this way, we can enable peripherals:

I2C1->CR1 |= I2C_CR1_PE;

Writing data

With the I2C peripheral ready and waiting, we can start sending data. Much like USART, this is done by typing in a transmission (TX) register and waiting for the transmission to complete. The steps to follow are covered in the helpful flow diagram provided in the reference manual:

Reproduced from Master Transmitter Flowchart, RM0091.
Reproduced from Master Transmitter Flowchart, RM0091.

Note that with some checks, such as for I2C_ISR_TC (transfer complete), the idea is not to check and complete once, but to wait with a time-out.

For a typical 1 byte transfer, we will set I2C_CR2 as:

I2C1->CR2 |= (slaveID << 1) | I2C_CR2_AUTOEND | (uint32_t) (1 << 16) | I2C_CR2_START;

This will start the transfer for a total of 1 byte (shifted left to the NBYTES position in the I2C_CR2 register), targeting 7-bit slaveID, I2C stop condition has been created automatically. After the transfer is complete (NBYTES transferred), STOP is created, which sets a flag named STOPF in I2C_ISR.

Once we know that we have completed the data transfer, we must wait for this flag to be set, then clear the flag in I2C_ICR and clear the I2C_CR2 register:

instance.regs->ICR |= I2C_ICR_STOPCF;
instance.regs->CR2 = 0x0;

This completes a basic data transfer. To transfer more than a single byte, just loop the same procedure, enter a single byte I2C_TXDR Wait for each cycle and I2C_ISR_TXIS Set (with required time-out). To transfer more than 255 bytes, setting I2C_CR2_RELOAD Instead I2C_CR2_AUTOEND Allow a new batch transfer of 255 bytes or less to I2C_CR2.

Reading data

When reading data from a device, make sure that interruptions have been disabled (using NVIC_DisableIRQ) Normally, a reading request is sent to the device by the microcontroller, the device responds by sending the content of the requested article as a reply. For example, if a BME280 MEMS sensor is sent 0xd0 As a payload only, it will respond by returning its (specified) ID as programmed in that factory register.

The basic flowchart for receiving from a device looks like this:

Master receiver flowchart for STM32F0.  Source: RM0091.
Master receiver flowchart for STM32F0. Source: RM0091.

The basic idea here is the same as sending data. We configure I2C_CR2 as before. The main difference here is that we wait until the I2C_ISR_RXNE flag is set, then we can read the single-byte content of I2C_RXDR in our buffer.

Just like writing data, after we read the NBYTES, we have to wait for the I2C_ISR_STOPF flag to be set, then we clear it through the I2C_ICR register and clear the I2C_CR2 register.

Barrier-based reading

To set up interrupts with I2C we need to enable interrupts for I2C peripherals. This must be done with the peripheral in a disabled state. We can then activate the barrier:

NVIC_SetPriority(I2C1_IRQn, 0);

Obstacles are activated in the peripheral by setting the configuration bit:

I2C1->CR1 |= I2C_CR1_RXIE;

Ensure that a properly named interrupt handler (ISR) is applied to the name specified in the boot-up code:

volatile uint8_t i2c_rxb = 0;

void I2C1_IRQHandler(void) {
    // Verify interrupt status.
    if ((I2C->ISR & I2C_ISR_RXNE) == I2C_ISR_RXNE) {
        // Read byte (which clears RXNE flag).
        i2c_rxb = instance.regs->RXDR;

Be sure to add extern "C" { } Block around the handler if the function name uses a language other than C to prevent mangling.

In place of this code, every time the read buffer receives a byte, the ISR will be called and we can copy it to the buffer or elsewhere.

Multi-device usage

Presumably at this point, the use of multiple devices from a single microcontroller transceiver only requires sending the correct device identifier before any payload. It is important to clean the I2C_CR2 register and set the next transmit or receive cycle correctly to prevent any mixing between the device identifiers.

Depending on the code implementation (such as with a multi-threaded RTOS), it is possible that there may be conflicting reading and writing. In this case it is essential that I2C integrates writing and reading so that no data or commands are lost or sent to the wrong device.

Unwrapping the wrapper

Using I2C in STM32 is not very complicated, once the clock configuration is removed. This is a topic that may be worthy of its own article, along with advanced topics related to I2C such as clock stretching and noise filtering. By default the STM32 MCU has a sound filter enabled at the I2C inputs of the I2C peripherals, but these can be further configured.

As easy as initial reading and writing with I2C is, you still need to explore a whole rabbit hole when applying your own I2C device to STM32. Stay tuned for more articles on these topics.

Leave a Reply

Your email address will not be published.