Bare-metal I2C Driver for STM32F411CEx

Kunal Salvi
7 min readJul 13, 2021

Highly efficient and small footprint driver for everyone’s favorite I2C.

There aren’t enough blogs or articles on the web explaining how the notorious STM32F series implements their I2C peripheral when I was writing my driver so I decided to write my own. This post will take you through the nitty-gritty of the hardware and firmware you need to write to get it working.

You can surely use HAL to get your application running. But if you wanted HAL you wouldn't have searched for this. Anyway, I gotcha.

I’m guessing you are pretty familiar with the I2C protocol but if you are not, here’s a post that might help you get started. If you are done with that we can get dive right into it.

STM32F411CEx has 3 I2C blocks namely I2C1, I2C2, and I2C3. All of them can send and receive 7 and 10-bit addresses. I2Cx block can generate clocks of 100KHz and 400KHz. Bus frequency can be amped up to 1MHz but I’m not going to dare set it up that high.

Users can select various modes :

  1. Slave Transmitter
  2. Slave Receiver
  3. Master Transmitter
  4. Master Receiver

Let’s tackle Master mode first. The maximum clock frequency of the STM32F411CEx is 96MHz, peripheral clock 1 can reach up to 48MHz and peripheral clock 2 can reach up to 96MHz. If you are handling everything bare-metal, I would suggest you take a look at my post where I’ve shown how you can set the clock speed to 96MHz. Once the MCU starts running at 96MHz we can start setting up the peripheral settings.

We need to follow the transfer sequence :

Taken from Figure 164 on page 481

Now you can decipher the diagram word to word and come up with the code that you think might work, but it won’t. ST has left a lot of stuff out (at least I think so) and that’s why the code probably won’t work.

First, you need to provide the clocks to the peripherals I2Cx and GPIOx. I’m using I2C1 and correspondingly GPIOB (pins 6 for SCL and 7 for SDA). So write the following

RCC -> APB1ENR |= RCC_APB1ENR_I2C1EN;
RCC -> AHB1ENR |= RCC_AHB1ENR_GPIOBEN;

Now you need to set pins 6 and 7 of Port B to Alternate Function with Pull-up enabled and Open-Drain. We’ll also set them to the highest speed possible. The pins need to be configured as I2C alternate functions and for that STM32F411CEx has 2 special registers called AFRL and AFRH. But in code, they are disguised as AFR[0] and AFR[1] respectively. I also found that setting MODER register directly like this works great so I won’t bother to set individual bits and neither should you. So go ahead and add this code.

GPIOB -> MODER = 0x00000000;
GPIOB -> MODER |= 0xa280;
GPIOB -> OTYPER |= GPIO_OTYPER_OT6 | GPIO_OTYPER_OT7;
GPIOB -> OSPEEDR |= GPIO_OSPEEDER_OSPEEDR6 | GPIO_OSPEEDER_OSPEEDR7;
GPIOB -> PUPDR |= GPIO_PUPDR_PUPD6_0 | GPIO_PUPDR_PUPD7_0;
GPIOB -> AFR[0] |= (4 << 24) | (4 << 28);
GPIOB -> IDR = 0x0000;

When you are debugging, your GPIOB register should look something like this:

Screenshot of GPIOB register.

With this, you are done with setting up the pins and now onto the peripheral itself. Setting up the I2C peripheral isn’t as tough as you might think but the hardware is designed in such a way that it might look tricky. Luckily, the initialization steps are few and I’ll explain them along the way.

Steps:

  1. Disable the peripheral by resetting the I2C_CR1_PE bit in the I2C1_CR register.
  2. Enable the software reset by setting the I2C_CR1_SWRST bit in the I2C1_CR register followed by immediately resetting the bit.
  3. Set a device address (this is optional but I have done it ).
  4. Put the value 0x30 (48 in decimal) in the I2C1_CR2 register. This sets the I2C1 peripheral clock to 48MHz.
  5. Put 0x8028 in the I2C1_CCR register to set up SCL to 400KHz.
  6. Set I2C1_TRISE as 0xF.
  7. Finally, enable the peripheral by setting I2C_CR1_PE in I2C1_CR1.

All these steps look like this in the code:

I2C1 -> CR1 &= ~I2C_CR1_PE;
I2C1 -> CR1 |= I2C_CR1_SWRST;
I2C1 -> CR1 &= ~I2C_CR1_SWRST;
I2C1 -> OAR1 = 0x4000;
I2C1 -> CR2 = 0x30;
I2C1 -> CCR = 0x8028;
I2C1 -> TRISE = 0xf;
I2C -> CR1 |= I2C_CR1_PE;

After initialization, the I2C1 register should look like this:

Screenshot of I2C1 registers

Seems easy? The perception is about to change, or not. Now we’ll start writing the actual communication functions. Generate Start condition by setting I2C_CR1_START in the I2C1_CR1 register. But before this, reset the I2C_CR1_POS bit in the same register. Don’t worry there’s a snippet of code at the end of this paragraph. Once the I2C_CR1_START bit is set, we’ll have to wait till the I2C_SR1_SB bit in the I2C1_SR1 register. This bit is set only when a successful Start condition is generated. After that, read the I2C1_SR1 register.

void I2C_Master_Start(I2C_Config I2C)
{
uint16_t reg;
I2C1 -> CR1 &= ~I2C_CR1_POS;
I2C1 -> CR1 |= I2C_CR1_START;
while(!(I2C1 -> SR1 & I2C_SR1_SB)){}
reg = I2C1 -> SR1;
}

The next step is to send the slave address. write the I2C1_DR register with the slave address by shifting it to the right and setting the LSB to zero. Wait till the I2C_SR1_ADDR bit is set. Read the registers I2C1_SR1 and I2C1_SR2. Wait till the I2C_SR1_TXE bit is set.

void I2C_Master_Send_Address(I2C_Config I2C, char address)
{
uint16_t reg1;
I2C1 -> DR = (address << 1) | 0x00;
while(!(I2C1 -> SR1 & I2C_SR1_ADDR)){}
reg = 0x00;
reg = I2C1 -> SR1;
reg = I2C1 -> SR2;
while(!(I2C1 -> SR1 & I2C_SR1_TXE)){}
}

Now you’ve sent the slave address too, so let’s move on to sending some actual data. Write the data you want to send to the slave device by writing the I2C1_DR register. After writing the data, wait till the I2C_SR1_TXE bit is set in the I2C1_SR1 register.

void I2C_Master_Send_Data(I2C_Config I2C, char data)
{
I2C1 -> DR = data;
while(!(I2C1->SR1 & I2C_SR1_TXE));
}

Once the last byte of data is sent, stop the communication by sending a STOP bit by setting the I2C_CR1_STOP bit in the I2C1_CR1 register.

Below is the snippet that sends a buffer of data to the slave device:

uint8_t buffer[10];
int length = 10;

uint16_t reg;
I2C1 -> CR1 &= ~I2C_CR1_POS;
I2C1 -> CR1 |= I2C_CR1_START;
while(!(I2C1 -> SR1 & I2C_SR1_SB)){}
I2C1 -> DR = (address << 1) | 0x00;
while(!(I2C1 -> SR1 & I2C_SR1_ADDR)){}
reg = 0x00;
reg = I2C1 -> SR1;
reg = I2C1 -> SR2;
while(!(I2C1 -> SR1 & I2C_SR1_TXE)){}
for(int i = 0 ; i < length; i++)
{
I2C.I2C1 -> DR = buffer[i];
while(!(I2C1 -> SR1 & I2C_SR1_TXE)){}
while(!(I2C1 -> SR1 & I2C_SR1_BTF)){}
reg = 0x00;
reg = I2C1 -> SR1;
reg = I2C1 -> SR2;
}
I2C1 -> CR1 |= I2C_CR1_STOP;
reg = I2C1 -> SR1;
reg = I2C1 -> SR2;

With this, we are done with the I2C Master transmission. Now let's start with Master Receiver mode. It’s almost the same as Master Transmitter with a few changes.

Reset the I2C_CR1_POS bit in the I2C1_CR1 register. Set the I2C_CR1_ACK and the I2C_CR1_START bits. Wait till the I2C_SR1_SB bit is set. Send the slave device address. Wait till the I2C_SR1_ADDR is set. Read I2C1_SR1 and I2C1_SR2 registers. Read the I2C1_DR when the I2C_SR1_RXNE bit is reset. Set I2C_CR1_ACK bit after reading. After the last data is read, send a NACK by resetting the I2C_CR1_ACK bit. Finally, stop the communication by setting the I2C_CR1_STOP bit.

uint16_t reg, i; 
I2C1 -> CR1 &= ~I2C_CR1_POS;
I2C1 -> CR1 |= I2C_CR1_ACK;
I2C1 -> CR1 |= I2C_CR1_START;
while(!(I2C1 -> SR1 & I2C_SR1_SB)){}
I2C1 -> DR = (address << 1) | 0x01;
while(!(I2C1 -> SR1 & I2C_SR1_ADDR)){}
reg = 0x00; reg = I2C.I2C1 -> SR1;
reg = I2C1 -> SR2;
for( i =0; i < (length - 1); i++)
{
while(!(I2C1 -> SR1 & I2C_SR1_RXNE)){}
buffer[i] = I2C1 -> DR;
I2C1 -> CR1 |= I2C_CR1_ACK;
}
i++;
buffer[i] = I2C.I2C1 -> DR;
I2C1 -> CR1 &= ~I2C_CR1_ACK;
I2C1 -> CR1 |= I2C_CR1_STOP;

I’ll write a part 2 on Slave Transmitter and Receiver modes so stay tuned for that.

Follow me on Medium at Kunal Salvi.

Instagram at Ziran_Daruwala and Blackshield Engineering.

For complete code and the latest version, check out my Github Page.

--

--

Kunal Salvi

Embedded Systems Engineer | Roboticist | Researcher | Innovator