STM32 I2C OLED Display: Driving an SSD1306 128x64 with HAL

The 0.96 inch 128x64 OLED module based on the SSD1306 controller is one of the most common displays in student projects. It needs only two signal wires, has excellent contrast, and is cheap. Libraries exist for it, but writing a minimal driver yourself is the best way to learn how I2C works on STM32 and how a display controller is organised.

In this tutorial you will wire the module to a Nucleo-F401RE, scan the I2C bus to find it, send the initialisation commands, and draw pixels, lines and a live bar graph from a 1 KB framebuffer in RAM.

I2C in Brief

I2C uses two open-drain lines: SCL (clock) and SDA (data), each pulled up to the supply by a resistor. The STM32 is the master and generates the clock; each slave device has a 7-bit address. Every transfer starts with the address and a read/write bit, and the slave answers with an acknowledge (ACK). Standard mode runs at 100 kHz and fast mode at 400 kHz; the SSD1306 supports fast mode.

Most SSD1306 modules use address 0x3C, and some can be set to 0x3D with a solder jumper. The HAL functions expect the address already shifted left by one bit, so 0x3C is passed as 0x78. This shifted-address rule is the most common reason for a first I2C program to fail.

After the address, the SSD1306 expects a control byte. 0x00 means the following bytes are commands, and 0x40 means they are display data. HAL_I2C_Mem_Write sends exactly this pattern: address, one 'memory address' byte, then the data, so we use the control byte as the memory address.

Wiring

OLED pinNucleo-F401REBlue Pill
VCC3V33.3
GNDGNDG
SCLPB8 (I2C1_SCL, Arduino D15)PB6 (I2C1_SCL)
SDAPB9 (I2C1_SDA, Arduino D14)PB7 (I2C1_SDA)

Most of these modules already have pull-up resistors on SCL and SDA, so no extra parts are needed for short wires. If you use a bare display or long wires, add 4.7 k pull-ups to 3.3 V on both lines.

CubeMX Configuration

  1. Under Connectivity, I2C1, set the mode to I2C. CubeMX may pick PB6 and PB7; on the Nucleo, click PB8 and choose I2C1_SCL, then PB9 and choose I2C1_SDA, so the pins match the Arduino header.
  2. In Parameter Settings set I2C Speed Mode to Fast Mode and the clock speed to 400000 Hz.
  3. Keep USART2 enabled so the bus scan can print its results. Generate the code.

Minimal SSD1306 Driver

The display memory is organised as 8 pages of 128 columns, where each byte holds 8 vertical pixels. The driver keeps a copy of all 1024 bytes in RAM, draws into that copy, and sends the whole buffer to the display in one I2C transfer using horizontal addressing mode.

main.c (Nucleo-F401RE): I2C scan, SSD1306 init and framebuffer drawing
/* USER CODE BEGIN Includes */
#include <stdio.h>
#include <string.h>
/* USER CODE END Includes */

/* USER CODE BEGIN PV */
#define OLED_ADDR   (0x3C << 1)   /* HAL wants the 8-bit form: 0x78 */
#define OLED_W      128
#define OLED_H      64
#define CTRL_CMD    0x00          /* control byte: commands follow  */
#define CTRL_DATA   0x40          /* control byte: pixel data follows */

static uint8_t fb[OLED_W * OLED_H / 8];   /* 1024-byte framebuffer */
/* USER CODE END PV */

/* USER CODE BEGIN 0 */
int _write(int file, char *ptr, int len)
{
  (void)file;
  HAL_UART_Transmit(&huart2, (uint8_t *)ptr, (uint16_t)len, HAL_MAX_DELAY);
  return len;
}

/* Print every address that answers with an ACK */
static void i2c_scan(void)
{
  printf("I2C scan:\r\n");
  for (uint16_t a = 1; a < 128; a++)
  {
    if (HAL_I2C_IsDeviceReady(&hi2c1, (uint16_t)(a << 1), 2, 5) == HAL_OK)
    {
      printf("  found device at 0x%02X\r\n", a);
    }
  }
}

static HAL_StatusTypeDef oled_cmds(const uint8_t *cmds, uint16_t n)
{
  return HAL_I2C_Mem_Write(&hi2c1, OLED_ADDR, CTRL_CMD, I2C_MEMADD_SIZE_8BIT,
                           (uint8_t *)cmds, n, 100);
}

static HAL_StatusTypeDef oled_init(void)
{
  static const uint8_t init_seq[] = {
    0xAE,             /* display off                         */
    0xD5, 0x80,       /* clock divide ratio / oscillator     */
    0xA8, 0x3F,       /* multiplex ratio = 64 rows           */
    0xD3, 0x00,       /* display offset = 0                  */
    0x40,             /* start line = 0                      */
    0x8D, 0x14,       /* enable internal charge pump         */
    0x20, 0x00,       /* horizontal addressing mode          */
    0xA1,             /* segment remap: column 127 = SEG0    */
    0xC8,             /* COM scan direction remapped         */
    0xDA, 0x12,       /* COM pins configuration for 128x64   */
    0x81, 0xCF,       /* contrast                            */
    0xD9, 0xF1,       /* pre-charge period                   */
    0xDB, 0x40,       /* VCOMH deselect level                */
    0xA4,             /* display follows RAM content         */
    0xA6,             /* normal (not inverted) display       */
    0xAF              /* display on                          */
  };
  HAL_Delay(100);     /* give the module time to power up    */
  return oled_cmds(init_seq, sizeof(init_seq));
}

/* Send the whole framebuffer: set column 0..127 and page 0..7, then data */
static void oled_update(void)
{
  static const uint8_t window[] = { 0x21, 0, 127, 0x22, 0, 7 };
  oled_cmds(window, sizeof(window));
  HAL_I2C_Mem_Write(&hi2c1, OLED_ADDR, CTRL_DATA, I2C_MEMADD_SIZE_8BIT,
                    fb, sizeof(fb), 200);
}

static void oled_pixel(int x, int y, uint8_t on)
{
  if (x < 0 || x >= OLED_W || y < 0 || y >= OLED_H) return;
  uint8_t mask = (uint8_t)(1U << (y & 7));   /* bit inside the byte */
  if (on) fb[x + (y / 8) * OLED_W] |= mask;
  else    fb[x + (y / 8) * OLED_W] &= (uint8_t)~mask;
}

static void oled_rect(int x0, int y0, int x1, int y1, uint8_t fill)
{
  for (int x = x0; x <= x1; x++)
    for (int y = y0; y <= y1; y++)
      if (fill || x == x0 || x == x1 || y == y0 || y == y1)
        oled_pixel(x, y, 1);
}
/* USER CODE END 0 */

int main(void)
{
  HAL_Init();
  SystemClock_Config();
  MX_GPIO_Init();
  MX_USART2_UART_Init();
  MX_I2C1_Init();          /* 400 kHz on PB8 (SCL) / PB9 (SDA) */

  /* USER CODE BEGIN 2 */
  i2c_scan();
  if (oled_init() != HAL_OK)
  {
    printf("OLED not responding at 0x3C\r\n");
  }
  uint8_t level = 0;
  int8_t  dir = 2;
  /* USER CODE END 2 */

  while (1)
  {
    /* USER CODE BEGIN 3 */
    memset(fb, 0, sizeof(fb));               /* clear the buffer       */
    oled_rect(0, 0, 127, 63, 0);             /* border                 */
    oled_rect(10, 24, 117, 39, 0);           /* bar outline            */
    oled_rect(12, 26, 12 + level, 37, 1);    /* filled part, 0..103 px */
    for (int x = 0; x < 128; x += 8)         /* tick marks at the top  */
      oled_pixel(x, 2, 1);
    oled_update();                           /* about 1 KB over I2C    */

    level = (uint8_t)(level + dir);
    if (level >= 103 || level == 0) dir = (int8_t)-dir;
    /* USER CODE END 3 */
  }
}

How the Driver Works

  • HAL_I2C_IsDeviceReady sends only the address and checks for an ACK, which is ideal for a bus scan. Expect to see 0x3C printed.
  • The init sequence turns on the internal charge pump (0x8D, 0x14), which most modules need because they have no separate high-voltage supply for the OLED panel.
  • Commands 0xA1 and 0xC8 flip the image horizontally and vertically. If your text or graphics appear mirrored or upside down, change these to 0xA0 and 0xC0.
  • oled_pixel finds the byte with x + (y / 8) * 128 and the bit with y & 7. This matches the page layout of the controller.
  • A full update sends about 1026 bytes. At 400 kHz, with 9 clock cycles per byte, this takes roughly 23 ms, so a full refresh rate of around 40 frames per second is the practical upper limit.

Reducing the Time Spent on Updates

Because HAL_I2C_Mem_Write is blocking, the CPU waits for the full transfer on every update. Two simple changes help. First, only call oled_update when something on screen has actually changed, for example when a sensor value differs from the last one shown. Second, if only one area changes, set a smaller column and page window with commands 0x21 and 0x22 and send only those bytes. For continuous animation, HAL_I2C_Mem_Write_DMA sends the buffer in the background, but you must not modify the framebuffer until the transfer-complete callback runs.

To print text, you need a font table, usually 5x7 or 8x8 pixels per character, copied into the framebuffer one column at a time. The open-source stm32-ssd1306 library by Aleksander Alekseev (afiskon) provides fonts and drawing functions on top of exactly this HAL approach and is a good next step.

Troubleshooting

  • Scan finds nothing: SDA and SCL swapped, no pull-ups, or the module is powered from the wrong pin.
  • Scan finds 0x3D: change OLED_ADDR to (0x3D << 1).
  • HAL_I2C_Mem_Write returns HAL_ERROR with no ACK: you passed 0x3C instead of 0x78.
  • Display shows random noise: the initialisation failed partway; check the return value of oled_init.
  • Only the top half works or lines are doubled: the module is a 128x32 version, which needs multiplex 0x1F and COM pins value 0x02.

Summary

You connected an SSD1306 OLED to I2C1, found it with a bus scan, initialised it with a command sequence sent through HAL_I2C_Mem_Write, and drew graphics from a 1 KB framebuffer. You also learned the shifted-address rule and the command/data control bytes. Next, you will use the faster SPI bus to talk to a W25Q flash memory chip.