On this page
The previous article added UART logging so firmware can report what it is doing while it runs.
That becomes especially useful for I2C. Unlike an LED, an I2C module may fail because of wiring, pull-ups, voltage levels, address confusion, timing, or a device-specific command sequence. A serial log gives you a way to see scan results and HAL return paths before writing a full driver.
This article brings up STM32 I2C for real breakout modules using a practical, blocking HAL workflow.
What This Article Covers
This article covers:
- The I2C signals and electrical assumptions that matter on a breadboard or module.
- Pull-up resistors and open-drain signaling.
- 7-bit addresses and STM32 HAL address formatting.
- Basic I2C initialization concepts.
- A simple address scanner using
HAL_I2C_IsDeviceReady. - How UART logging helps debug I2C bring-up.
It does not build a complete sensor, EEPROM, OLED, or display driver. The goal is to prove that the STM32 can see real I2C devices on the bus and that the project has a reliable debugging path for later module-specific code.
I2C Mental Model
I2C uses two shared signal lines:
SCL: serial clock.SDA: serial data.
The bus is open-drain. Devices pull the lines low, but they do not actively drive the lines high. Pull-up resistors bring the lines back high when no device is pulling them down.
That means an idle I2C bus should normally have both SCL and SDA high.
I2C devices use addresses. Most module documentation gives a 7-bit address, commonly written like:
0x3C
0x48
0x68
The read/write bit is not part of that 7-bit address. STM32 HAL I2C APIs commonly expect the address shifted left by one bit, leaving room for the read/write bit. If your module address is 0x3C, pass 0x3C << 1 to HAL transmit/read/device-ready calls.
That shifted-address detail is one of the most common STM32 HAL I2C mistakes.
Hardware Checklist
Before debugging code, check the bus physically.
Confirm:
- The module is compatible with 3.3 V logic.
- The STM32 board and module share ground.
SCLis connected to the selected STM32 I2C clock pin.SDAis connected to the selected STM32 I2C data pin.- The selected pins support the I2C alternate function for the chosen peripheral instance.
- Pull-up resistors exist on
SCLandSDA. - Pull-ups are not so strong that multiple modules overload the bus.
- The module address is known or discoverable.
Many breakout modules already include pull-up resistors. Some do not. Some include pull-ups to their local supply voltage, which matters if the module can also be powered at 5 V.
Start with one I2C module on the bus. Add more modules only after the first one is detected reliably.
Timing Checklist
I2C timing depends on the peripheral clock and timing register configuration.
For first bring-up, use standard mode:
100 kHz
Do not start with 400 kHz fast mode while debugging wiring and address assumptions. A slower bus gives more margin and is easier to inspect with a logic analyzer.
If CubeMX generated the I2C setup, it will calculate a timing value based on the clock configuration. If you later change the clock tree, regenerate or review the I2C timing setting.
Minimal HAL I2C Setup
The example below uses I2C1 as a concrete example. Adjust the instance, pins, alternate function, and timing value for your board and CubeMX configuration.
#include "stm32l4xx_hal.h"
#include <stdint.h>
#include <stdio.h>
#include <string.h>
I2C_HandleTypeDef hi2c1;
extern UART_HandleTypeDef huart2;
static void log_write(const char *message)
{
HAL_UART_Transmit(
&huart2,
(uint8_t *)message,
(uint16_t)strlen(message),
HAL_MAX_DELAY);
}
static void i2c1_gpio_init(void)
{
__HAL_RCC_GPIOB_CLK_ENABLE();
GPIO_InitTypeDef gpio = {0};
gpio.Pin = GPIO_PIN_8 | GPIO_PIN_9;
gpio.Mode = GPIO_MODE_AF_OD;
gpio.Pull = GPIO_PULLUP;
gpio.Speed = GPIO_SPEED_FREQ_LOW;
gpio.Alternate = GPIO_AF4_I2C1;
HAL_GPIO_Init(GPIOB, &gpio);
}
static void i2c1_init(void)
{
__HAL_RCC_I2C1_CLK_ENABLE();
i2c1_gpio_init();
hi2c1.Instance = I2C1;
hi2c1.Init.Timing = 0x10909CEC;
hi2c1.Init.OwnAddress1 = 0;
hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT;
hi2c1.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE;
hi2c1.Init.OwnAddress2 = 0;
hi2c1.Init.OwnAddress2Masks = I2C_OA2_NOMASK;
hi2c1.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE;
hi2c1.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE;
if (HAL_I2C_Init(&hi2c1) != HAL_OK)
{
while (1)
{
}
}
HAL_I2CEx_ConfigAnalogFilter(&hi2c1, I2C_ANALOGFILTER_ENABLE);
HAL_I2CEx_ConfigDigitalFilter(&hi2c1, 0);
}
The pins and timing value are examples. Use CubeMX or the STM32 timing configuration tools for your actual clock setup.
Scan for Devices
An I2C scanner is a useful first test because it does not require a full device driver.
static void i2c_scan(void)
{
char line[48];
log_write("I2C scan start\r\n");
for (uint8_t address = 1; address < 128; address++)
{
HAL_StatusTypeDef status = HAL_I2C_IsDeviceReady(
&hi2c1,
(uint16_t)(address << 1),
2,
10);
if (status == HAL_OK)
{
snprintf(line, sizeof(line), "found 0x%02X\r\n", address);
log_write(line);
}
}
log_write("I2C scan done\r\n");
}
Call the scanner after UART and I2C initialization:
int main(void)
{
HAL_Init();
SystemClock_Config();
uart2_init();
i2c1_init();
i2c_scan();
while (1)
{
}
}
If the module responds, the log should show at least one address.
If nothing appears, do not jump straight to driver code. Fix bus bring-up first.
Basic Transaction Pattern
Once a device is detected, module-specific communication usually follows one of these patterns:
- Write a command byte.
- Write a register address, then read data.
- Write several configuration bytes.
- Read a block of status or measurement bytes.
For register-style devices, HAL provides memory helpers such as:
HAL_I2C_Mem_Read(
&hi2c1,
(uint16_t)(device_address << 1),
register_address,
I2C_MEMADD_SIZE_8BIT,
buffer,
length,
100);
For simpler command-oriented devices, HAL_I2C_Master_Transmit and HAL_I2C_Master_Receive may be a better fit.
Do not guess the transaction shape. Use the module datasheet or controller datasheet. I2C only defines the bus transfer; the device defines what the bytes mean.
CMake Support
If I2C HAL code was not already compiled, add the I2C HAL source file to the firmware target:
generated/Drivers/STM32L4xx_HAL_Driver/Src/stm32l4xx_hal_i2c.c
Depending on the HAL configuration, extension support may also be needed:
generated/Drivers/STM32L4xx_HAL_Driver/Src/stm32l4xx_hal_i2c_ex.c
If CubeMX generated the MSP setup for I2C pins and clocks, make sure this file is compiled:
generated/Core/Src/stm32l4xx_hal_msp.c
Missing I2C sources usually show up as linker errors for functions such as HAL_I2C_Init or HAL_I2C_IsDeviceReady.
Build and Test
Build and flash the firmware:
cmake --build build
openocd -f interface/stlink.cfg -f target/stm32l4x.cfg -c "program build/firmware.elf verify reset exit"
Open the UART terminal from the previous article and watch the scan output.
A useful first success is simple:
I2C scan start
found 0x3C
I2C scan done
The address will depend on the module.
Debug with UART Logs
UART logs are useful because I2C failures often need more detail than an LED can show.
During bring-up, log:
- Whether
HAL_I2C_Initsucceeded. - Which addresses were scanned.
- Which address responded.
- HAL status values for transactions.
- Timeout paths.
If a transaction fails, capture the returned HAL_StatusTypeDef value before retrying or resetting the bus.
For deeper debugging, inspect hi2c1.ErrorCode in the debugger after a failed call.
Debug with Hardware
If software checks look correct but no device responds, inspect the bus.
With a scope or logic analyzer, check:
SCLandSDAidle high.- Clock pulses appear during a scan.
- SDA changes while SCL is active.
- A device acknowledges the expected address.
- Neither line is stuck low.
If both lines are stuck low, disconnect modules and test again. A wiring error, unpowered module, damaged device, or peripheral misconfiguration can hold the bus.
If lines never move, suspect GPIO alternate-function setup, I2C peripheral clock, or code path execution.
Common Mistakes
The first common mistake is missing pull-up resistors. I2C lines need pull-ups because devices only pull the bus low.
The second is using a 5 V module or pull-up arrangement that is not safe for 3.3 V STM32 pins.
The third is forgetting shared ground between the STM32 board and external module.
The fourth is swapping SDA and SCL.
The fifth is passing an unshifted 7-bit address to STM32 HAL calls that expect the address shifted left by one bit.
The sixth is using the wrong alternate function for the selected pins.
The seventh is configuring I2C timing for a different clock tree than the firmware actually uses.
The eighth is starting at 400 kHz before proving the bus works at 100 kHz.
The ninth is placing two modules with the same fixed address on the same bus.
The tenth is writing a device driver before proving the bus can detect the device at all.
What This Proves
An I2C scan that finds a real module proves several layers are working:
- GPIO alternate-function routing.
- I2C peripheral clocking and initialization.
- Bus pull-ups and idle state.
- Module power and ground.
- Address formatting.
- Basic firmware-to-module communication.
That is the right milestone before writing module-specific driver code.
What This Does Not Prove
This does not prove that a module driver is correct.
It does not validate every register transaction, timing requirement, reset sequence, or data conversion formula. It also does not prove the bus is robust with multiple modules, long wires, high speed, or noisy environments.
The point is to bring up the bus honestly: wiring, clocks, address, ACK, and logs first; device behavior second.
Next Steps
The next article uses SPI on STM32 for real modules.
SPI has a different set of tradeoffs: separate clock and data directions, explicit chip-select behavior, mode settings, and higher-speed signal integrity concerns. The same bring-up discipline still applies.