I²C Programming

Earlier, we’ve added an I²C connector to our Maple Mini prototype board. Now let’s use it.

1.1.        Test Slave Device

For the purposes of testing or demonstrating I²C it’s always a good idea to have a simple I²C slave device that can be used to verify that everything works without having to code a lot. The more code you need to write in order to test something, the more likely you are to write bugs that you’ll then mistake for I²C issues.

Luckily, one of the earliest I²C chips out there still enjoys a lot of popularity : the venerable PCF8591.

This chip is a combined ADC and DAC that requires almost no external components. Even better, it’s very easy to find simple modules based on it. Modules such as this one :

You can find it online for around one Euro, give or take. It’s pretty much perfect as a tool to learn I²C and verify a bus implementation.

Naturally, you may want to use a different slave. Perhaps something you already have and/or are familiar with. No problem. After all, I’m here to make sure everything on the STM32 side of your I²C bus works as it should. But for the rest of this page I’ll assume you’re using this PCF8591 module.

Let’s take a quick look around :

  •         On the left, you’ve got your analog pins. One DAC output, four ADC inputs.
  •         On the bottom left, the potentiometer is connected to ADC input AIN3, allowing you to quickly verify that the ADC works.
  •         On the bottom right, three jumpers allow you to set the I²C address of the PCF8591. When they are all mounted, that address is 0x90.
  •         On the right, the VCC, GND, SDA and SCL pins. Couldn’t be simpler.
  •         In the middle, a red LED indicates the module is powered, a green LED is connected to the DAC output, allowing you to quickly verify that the DAC works. There’s also a thermistor and an LDR (Light-Dependent Resistor) connected to two other ADC inputs for good measure (pun intended).

There’s no regulator on this module, as a result it’ll work across the entire range of voltage supported by the PCF8591 : 2.5 to 6 V. It’ll play nice with 3.3 V STM32.

The module has pull-up resistors on SDA and SCL. It’s really as “plug and play” as it can possibly get.

I won’t go into any details of how the PCF8591 works. That’s beyond the scope of this page and it’s also something very easy to find out. It’s an incredibly easy chip to find and learn.

In the course of this page, we’re going to program the STM32F103 to read the potentiometer channel and write the result to the DAC. Thus, if everything works, turning the potentiometer will dim the green LED.

1.2.        STM32 Configuration

I’m going to skip wiring : there’s only four wires to connect, I’m confident I don’t need to hold your hand. I’ll assume you got it done on the first try. Let’s move straight to coding.

Launch STM32CubeIDE, and then open an existing project or start a new one. If you’ve been following previous examples on this website you know how to do all that. If you haven’t, now you know where to look.

Open your project’s .ioc file to start the graphical configuration tool.

The pins we’ve wired to an I²C connector are PB10 (SCL) and PB11 (SDA). Those are the pins for the I2C2 controller. Expand the Connectivity category and click on I2C2, then select “I2C” in the Mode pane. Pins PB10 and PB11 should turn green and change name to I2C2_SCL and I2C2_SDA. If your device allows for muxing I2C2 to more than one pair of pins, you may need to force the one you need :

In the Configuration pane, make sure the speed is set to Standard Mode (100 KHz). Ignore the slave features : we’re using the STM32 as a bus master.

Generate the code for your project, from the Project menu. This will add all the necessary initialization code to your project.

1.3.        Fixing a Bug

As of this writing, and apparently for over 6 years now, there’s been a crippling bug in the I²C code generated by ST’s own tools. I know, “shocking”… but never fear, I am here.

ST’s code, which apparently nobody at ST actually tests before shipping, makes a really dumb mistake : it attempts to configure the I²C controller before sending it a clock signal. Digital logic doesn’t work that way. Luckily, this is easy enough to fix.

In your project’s Core/Src folder there’s a source file named stm32yyxx_hal_msp.c (where yy denotes the family of your target STM32. “f1” in the case of the Maple Mini.)

This file contains the initialization functions for each peripheral used in your project. As such, you’ll find function HAL_I2C_MspInit inside. This is where the problem lie. Copy the clock-enable macro it contains and paste it into the USER CODE block at the start of the function. The result should look like this :

void HAL_I2C_MspInit(I2C_HandleTypeDef* hi2c)
  GPIO_InitTypeDef GPIO_InitStruct = {0};
  /* USER CODE BEGIN I2C2_MspInit 0 */
	  // Problem : the generated function enables the I2C block's clock too late.
	  // Solution : do it here, before the I/O pins are configured.
  /* USER CODE END I2C2_MspInit 0 */
    /**I2C2 GPIO Configuration    
    PB10     ------> I2C2_SCL
    PB11     ------> I2C2_SDA 
    GPIO_InitStruct.Pin = GPIO_PIN_10|GPIO_PIN_11;
    GPIO_InitStruct.Mode = GPIO_MODE_AF_OD;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
    HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);

    /* Peripheral clock enable */
  /* USER CODE BEGIN I2C2_MspInit 1 */

  /* USER CODE END I2C2_MspInit 1 */

You can remove the original call at the end of the function if it triggers your OCD, but it will be regenerated any time you regenerate the code for your project. Keep in mind that this function is only called once at the start of execution and the macro just toggles a bit in a register. You’re not wasting much. At least while you’re developing. I’d totally remove this once I’m ready to ship.

1.4.        Let’s Code

Our slave is plugged in, the ST library has been fixed, time to sample some voltage and drive an LED…

Simply paste this code in the body of your main function’s infinite loop, build and flash, and marvel at your genius :

	  static uint8_t wr[] = {0x43, 0x00};
	  HAL_I2C_Master_Transmit(&hi2c2, 0x90, wr, 2, HAL_MAX_DELAY);
	  HAL_I2C_Master_Receive(&hi2c2, 0x90, &wr[1], 1, HAL_MAX_DELAY);
	  sprintf (str, "%i\r", wr[1]);
	  // If you're using an OLED display :
	  print (str, 0, 1);
	  // If you're using USB as a Virtual COM Port :
	  vcp_send (str, strlen(str));

The wr array is a write buffer. It’s static because it needs to keep the ADC sample from one loop to the next. That’s because the ADC is read after the DAC is written : the PCF8591 requires a control byte to select the ADC channel it’ll read next. That’s what the control byte 0x43 is for. It also enables the DAC.

The write is self-explanatory : you’re sending the whole buffer, which is just two bytes. There’s the control byte, followed by the DAC sample.

The read only grabs one sample (one byte) and stores it directly in the wr buffer, ready for transmission on the next loop.

This is quite possibly the simplest, shortest I²C example for STM32 there can be. You don’t even need to output the ADC samples to a display : the green LED on the PCF8591 module should tell you right away if everything works. Play with the potentiometer and see if it dims. If it doesn’t, you have some debugging to do.

1.5.        Further Exploration

I have no doubt that you’ll want to do much more complicated things with I²C once you get it working.

To that end, under the Drivers folder of your project, look for the Cube library’s header file, stm32xxxx_hal_i2c.h. There, you will find the complete API for that library. This includes non-blocking transmit and receive functions using DMA and interrupts. There are also functions dedicated to accessing I²C memory devices.