Built-in project · ESP32-C3-DevKitM-1

OLED on an ESP32-C3

A 128x64 SSD1306 OLED on an ESP32-C3 over bit-banged I2C: titles, shapes, a bouncing ball and a counter. The whole build, an ESP32-C3 and 1 more part, runs here in your browser on the firmware below; open it in the editor to change the wiring or the code and run it again.

Teaching? Assign it to your class in one click.

Intermediate About 20 minutes to build and run. Runs in your browser. Free, and no account needed.

The parts retail for about $17. On Mokxi they're free.

The circuit runs here once the page has loaded.

What you'll learn

  • Talking to an OLED over I2C
  • Bit-banged I2C on any two pins
  • Drawing text and shapes into a frame buffer
  • Animating with a fixed frame delay
  • Sending only the parts of the screen that changed
  • Difficulty: Intermediate
  • Time: About 20 minutes
  • Board: ESP32-C3-DevKitM-1
  • Parts: 3

How it works

The ESP32-C3 has no I2C hardware in this model, so the sketch bit-bangs it: it pulls the SDA and SCL lines low and lets them go. The pull-ups on the OLED module bring them back up. Any two GPIOs work, here 4 and 5.

The sketch draws a title, some shapes and a boxed arena once, then loops at about ten frames a second. Each frame it moves a bouncing ball, and every few frames it updates a counter, a bar and a sliding block.

Parts list

3 parts, plus the jumper wires. They retail for about $17; on Mokxi they're free.

Wiring table

Every connection, pin by pin, read from the circuit itself. Each row joins the pin on the left to the pin on the right, by a jumper wire or a breadboard strip.

Part and pinConnects to
OLED display, 128x64 pin VCCESP32-C3-DevKitM-1 pin 3V3
OLED display, 128x64 pin GNDESP32-C3-DevKitM-1 pin GND
OLED display, 128x64 pin SDAESP32-C3-DevKitM-1 pin 4
OLED display, 128x64 pin SCLESP32-C3-DevKitM-1 pin 5

The code

The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.

sketch.ino · ESP32-C3-DevKitM-1
// OLED: text and shapes on a 128x64 SSD1306, over bit-banged I2C.
//
// Wiring, module to board:
//   SCL -> GPIO 5      SDA -> GPIO 4      VCC -> 3V3      GND -> GND
//
// There is no I2C peripheral in the ESP32-C3 model (docs/esp32c3.md section 3),
// so mokxi_i2c.h pulls the two lines down and lets them go, and the module's
// own 4k7 pull-ups bring them back up. Any two GPIOs will do.
//
// This is the same sketch as the Uno's `oled` example with two pin numbers
// changed, which is the point: the driver is the same header on both boards.
// What differs is the money: 400 KB of RAM here against 2 KB, so the 1 KB
// frame buffer that is half of an Uno's memory is a rounding error on this one.
//
// Each display() sends only the eight-column blocks of each page that actually
// changed, so a moving ball is a few dozen bytes rather than a kilobyte. This
// board bit-bangs the bus at about 196 kbit/s against the Uno's 32, and yet it
// runs this demo at ten frames a second where the Uno runs it at twenty-five,
// because the cost in the browser is the kernel carrying each bus edge, not the
// board making it, and this one makes them six times faster.

#include "mokxi_i2c.h"
#include "mokxi_ssd1306.h"

const uint8_t SCL_PIN = 5;
const uint8_t SDA_PIN = 4;

SoftI2c bus(SCL_PIN, SDA_PIN, 400);
Ssd1306 oled(bus, SSD1306_ADDRESS);

// The arena the ball bounces in, and the ball.
const int16_t AX = 2;
const int16_t AY = 21;
const int16_t AW = 60;
const int16_t AH = 41;
const int16_t R = 4;

int16_t bx = AX + R + 6;
int16_t by = AY + R + 4;
int16_t bdx = 2;
int16_t bdy = 1;

uint16_t ticks = 0;

const int FRAME_MS = 100;

// Everything that never moves, drawn once into the buffer.
static void furniture() {
  oled.clear();
  oled.text(3, 2, "MOKXI", 2);
  oled.text(88, 8, "ESP32", 1);
  oled.hLine(0, 18, 128);
  oled.rect(AX - 1, AY - 1, AW + 2, AH + 2);

  oled.rect(68, 22, 24, 16);
  oled.circle(110, 30, 7);
  oled.rect(106, 44, 20, 16);
}

void setup() {
  Serial.begin(115200);
  Serial.println("Mokxi ESP32-C3: SSD1306 over bit-banged I2C");
  bus.begin();
  if (!oled.begin()) {
    Serial.println("no OLED answered 0x3C; check SCL, SDA and power");
    return;
  }
  furniture();
  oled.display();
}

void loop() {
  oled.fillCircle(bx, by, R, false);

  bx = (int16_t)(bx + bdx);
  by = (int16_t)(by + bdy);
  if (bx - R <= AX) {
    bx = (int16_t)(AX + R);
    bdx = (int16_t)-bdx;
  } else if (bx + R >= AX + AW - 1) {
    bx = (int16_t)(AX + AW - 1 - R);
    bdx = (int16_t)-bdx;
  }
  if (by - R <= AY) {
    by = (int16_t)(AY + R);
    bdy = (int16_t)-bdy;
  } else if (by + R >= AY + AH - 1) {
    by = (int16_t)(AY + AH - 1 - R);
    bdy = (int16_t)-bdy;
  }
  oled.fillCircle(bx, by, R, true);

  // The counter and the bar move a good deal more picture than the ball does,
  // so they tick at a sixth of the frame rate and the lozenge at a third. The
  // driver only sends the eight-column blocks that changed, and a frame where
  // only the ball moved is a few dozen bytes on the bus instead of four hundred.
  ticks++;
  if (ticks % 6u == 0u) {
    oled.number(68, 44, (long)((ticks / 6u) % 1000u), 2, true, true);
    int16_t fill = (int16_t)(1 + ((ticks / 6u) % 16u));
    oled.fillRect(108, 46, 16, 12, false);
    oled.fillRect(108, 46, fill, 12, true);
  }
  if (ticks % 3u == 0u) {
    // The lozenge in the top box slides, so the shapes panel is not quite still.
    oled.fillRect(70, 24, 20, 12, false);
    oled.fillRect((int16_t)(70 + ((ticks / 3u) % 14u)), 24, 6, 12, true);
  }

  oled.display();
  delay(FRAME_MS);
}

Code walkthrough

The key functions of the sketch, and what each one does.

furniture sketch.ino

Clears the buffer and draws everything that never moves: the title, the ESP32 label, the line, the arena border and the shape outlines. It runs once, so the loop only moves the animated parts.

// Everything that never moves, drawn once into the buffer.
static void furniture() {
  oled.clear();
  oled.text(3, 2, "MOKXI", 2);
  oled.text(88, 8, "ESP32", 1);
  oled.hLine(0, 18, 128);
  oled.rect(AX - 1, AY - 1, AW + 2, AH + 2);

  oled.rect(68, 22, 24, 16);
  oled.circle(110, 30, 7);
  oled.rect(106, 44, 20, 16);
}

setup sketch.ino

Starts the serial port and the bus. If the OLED answers, it draws the fixed parts and sends them to the screen. If not, it prints a message telling you to check SCL, SDA and power.

void setup() {
  Serial.begin(115200);
  Serial.println("Mokxi ESP32-C3: SSD1306 over bit-banged I2C");
  bus.begin();
  if (!oled.begin()) {
    Serial.println("no OLED answered 0x3C; check SCL, SDA and power");
    return;
  }
  furniture();
  oled.display();
}

loop sketch.ino

Erases the ball, moves it, and flips its direction when it hits a wall. Then it draws it again. Every sixth tick it updates the counter and bar, and every third it slides the block. It sends the changes and waits 100 ms.

void loop() {
  oled.fillCircle(bx, by, R, false);

  bx = (int16_t)(bx + bdx);
  by = (int16_t)(by + bdy);
  if (bx - R <= AX) {
    bx = (int16_t)(AX + R);
    bdx = (int16_t)-bdx;
  } else if (bx + R >= AX + AW - 1) {
    bx = (int16_t)(AX + AW - 1 - R);
    bdx = (int16_t)-bdx;
  }
  if (by - R <= AY) {
    by = (int16_t)(AY + R);
    bdy = (int16_t)-bdy;
  } else if (by + R >= AY + AH - 1) {
    by = (int16_t)(AY + AH - 1 - R);
    bdy = (int16_t)-bdy;
  }
  oled.fillCircle(bx, by, R, true);

  // The counter and the bar move a good deal more picture than the ball does,
  // so they tick at a sixth of the frame rate and the lozenge at a third. The
  // driver only sends the eight-column blocks that changed, and a frame where
  // only the ball moved is a few dozen bytes on the bus instead of four hundred.
  ticks++;
  if (ticks % 6u == 0u) {
    oled.number(68, 44, (long)((ticks / 6u) % 1000u), 2, true, true);
  // ... 13 more lines in the editor

Troubleshooting

The usual mistakes with this circuit, and the fix for each.

  • The screen stays blank This project
    Check SDA on pin 4, SCL on pin 5, and VCC on 3V3. The Serial Monitor says if the OLED did not answer at 0x3C.
  • The ball leaves a trail This project
    The loop must erase the old ball before drawing the new one. Keep the fillCircle call with false first.
  • The OLED stays blank. OLED display
    Check SDA and SCL are not swapped, and power it with 3.3 V or 5 V as its board allows. Most use address 0x3C, some 0x3D. Call display.begin() with the matching address.
  • Text never shows up even though the code runs. OLED display
    Drawing only writes to a buffer. Call display.display() after drawing to push it out. Also set a text color with setTextColor(WHITE) and clear the buffer between frames.
  • Upload fails or the board is not found by the computer. ESP32-C3-DevKitM-1
    Pick the ESP32-C3 board in your tools and the right port. If it still fails, hold BOOT, tap RESET, then release BOOT to enter download mode and try again.
  • GPIO pins act strangely or the board resets. ESP32-C3-DevKitM-1
    GPIO is 3.3 V only, so never feed it 5 V. GPIO 18 and 19 are USB, and GPIO 2, 8 and 9 are boot strapping pins. Avoid loading them at reset.

Change it and keep it

Open it in the editor, change the circuit or the code, and keep your version in a free account.