Classroom Glossary Public page

Lab 12.1: SSD1306 OLED Driver via Bit-Bang I2C

799 words

Total points: 25
Estimated time: 4 hours
Prerequisites: Lab 11.1 complete; DE10-Nano GPIO header accessible; SSD1306 OLED module connected (VCC, GND, SDA, SCL wired to GPIO header)


Overview

This lab implements a complete SSD1306 OLED display driver in Virtus OS v2. You will build the I2C bit-bang transport layer from scratch (no hardware I2C controller), implement the SSD1306 command and data protocol, and demonstrate a working character display. Along the way you will measure the I2C transaction timing and verify it with a logic analyzer or SignalTap.


Part A: Bit-bang I2C transport layer (8 pts)

A1: GPIO bit-bang primitives (3 pts)

The DE10-Nano GPIO header exposes pins at physical address 0xFF203000 (GPIO1 data register). Wire two pins for I2C: one for SCL (clock) and one for SDA (data). Both lines are open-drain: your CPU drives them low by writing 0; it releases them (letting the pull-up resistor drive high) by writing 1.

Define the register layout and low-level primitives:

#define GPIO1_DATA   0xFF203000
#define GPIO1_DIR    0xFF203004
#define SCL_BIT      (1 << 0)    // GPIO1_0
#define SDA_BIT      (1 << 1)    // GPIO1_1

static inline void scl_low(void)  { *(volatile uint32_t*)GPIO1_DATA &= ~SCL_BIT; }
static inline void scl_high(void) { *(volatile uint32_t*)GPIO1_DATA |=  SCL_BIT; }
static inline void sda_low(void)  { *(volatile uint32_t*)GPIO1_DATA &= ~SDA_BIT; }
static inline void sda_high(void) { *(volatile uint32_t*)GPIO1_DATA |=  SDA_BIT; }
static inline int  sda_read(void) { return (*(volatile uint32_t*)GPIO1_DATA >> 1) & 1; }

Configure GPIO1_DIR to set both pins as outputs (bit=1 in direction register). At startup, leave both lines high (idle state).

Implement timing: I2C standard-mode requires each half-bit period to be at least 5 microseconds (500 kHz bit rate is the upper bound for bit-bang on a 50 MHz core). At 50 MHz, 250 cycles per half-bit = 5 us. Add a delay loop:

static void i2c_half_bit_delay(void) {
    for (volatile int i = 0; i < 235; i++);  // ~250 cycles at 50 MHz
}

A2: I2C start, stop, and byte transmission (3 pts)

Implement the I2C protocol primitives:

void i2c_start(void) {
    sda_high(); scl_high(); i2c_half_bit_delay();
    sda_low();  i2c_half_bit_delay();   // SDA falls while SCL high: START
    scl_low();  i2c_half_bit_delay();
}

void i2c_stop(void) {
    sda_low();  scl_high(); i2c_half_bit_delay();
    sda_high(); i2c_half_bit_delay();   // SDA rises while SCL high: STOP
}

int i2c_write_byte(uint8_t byte) {
    for (int i = 7; i >= 0; i--) {
        if ((byte >> i) & 1) sda_high(); else sda_low();
        i2c_half_bit_delay();
        scl_high(); i2c_half_bit_delay();
        scl_low();  i2c_half_bit_delay();
    }
    // ACK bit: release SDA, let device pull low for ACK
    sda_high(); i2c_half_bit_delay();
    scl_high();
    int ack = !sda_read();   // ACK = SDA low (device pulls down)
    i2c_half_bit_delay();
    scl_low(); i2c_half_bit_delay();
    return ack;  // 1 = ACK received, 0 = NACK
}

Verify: with a logic analyzer or oscilloscope, capture the SDA and SCL waveforms for i2c_start() followed by i2c_write_byte(0xAA). Verify START condition timing and bit positions.

A3: I2C transaction wrapper (2 pts)

The SSD1306 uses 7-bit I2C addressing. Its factory address is 0x3C (or 0x3D if the SA0 pin is pulled high). Implement a complete I2C write transaction:

int i2c_write(uint8_t dev_addr, uint8_t* data, int len) {
    i2c_start();
    // address byte: 7-bit addr in bits [7:1], bit [0] = 0 (write)
    if (!i2c_write_byte((dev_addr << 1) | 0)) {
        i2c_stop();
        return -1;  // address NACK: device not present
    }
    for (int i = 0; i < len; i++) {
        if (!i2c_write_byte(data[i])) {
            i2c_stop();
            return -1;  // data NACK
        }
    }
    i2c_stop();
    return 0;
}

Test: call i2c_write(0x3C, ...) with a single byte. If the SSD1306 is connected and powered, you should receive an ACK. If you receive NACK, check your wiring and pull-up resistors.


Part B: SSD1306 command and initialization sequence (10 pts)

B1: Command vs data framing (3 pts)

The SSD1306 distinguishes between commands (configuration bytes) and data (pixel bytes) using a control byte that precedes each payload:

  • Control byte 0x00: the following byte is a command
  • Control byte 0x40: the following byte(s) are data (GDDRAM pixel bytes)
  • Control byte 0x80: the following byte is a command; more commands follow

Implement command and data send wrappers:

#define SSD1306_ADDR 0x3C

void oled_cmd(uint8_t cmd) {
    uint8_t buf[2] = {0x00, cmd};   // control byte 0x00 + command
    i2c_write(SSD1306_ADDR, buf, 2);
}

void oled_data(uint8_t* bytes, int len) {
    // first byte is control byte 0x40, rest are pixel data
    uint8_t buf[len + 1];
    buf[0] = 0x40;
    for (int i = 0; i < len; i++) buf[i + 1] = bytes[i];
    i2c_write(SSD1306_ADDR, buf, len + 1);
}

B2: Initialization sequence (4 pts)

The SSD1306 requires a specific initialization sequence to exit power-on state. After the display is powered on, send these commands in order:

void oled_init(void) {
    oled_cmd(0xAE);          // display off
    oled_cmd(0x20); oled_cmd(0x00);  // memory mode: horizontal addressing
    oled_cmd(0xB0);          // page start: page 0
    oled_cmd(0xC8);          // COM scan direction: remapped (flip vertical)
    oled_cmd(0x00);          // lower column start = 0
    oled_cmd(0x10);          // upper column start = 0
    oled_cmd(0x40);          // display start line = 0
    oled_cmd(0x81); oled_cmd(0xFF);  // contrast = max
    oled_cmd(0xA1);          // segment remap: column 127 = SEG0
    oled_cmd(0xA6);          // normal display (not inverted)
    oled_cmd(0xA8); oled_cmd(0x3F);  // multiplex: 64 rows
    oled_cmd(0xA4);          // display follows GDDRAM (not all-on)
    oled_cmd(0xD3); oled_cmd(0x00);  // display offset = 0
    oled_cmd(0xD5); oled_cmd(0xF0);  // display clock: divide by 1, osc freq max
    oled_cmd(0xD9); oled_cmd(0x22);  // pre-charge: phase 1 = 2, phase 2 = 2
    oled_cmd(0xDA); oled_cmd(0x12);  // COM pins: sequential, not remapped
    oled_cmd(0xDB); oled_cmd(0x20);  // Vcomh deselect: 0.77 * Vcc
    oled_cmd(0x8D); oled_cmd(0x14);  // charge pump: enable
    oled_cmd(0xAF);          // display on
}

After calling oled_init(), the display should light up. Verify by calling oled_fill(0xFF) (fills the display with white pixels).

B3: Page-cursor and clear functions (3 pts)

The SSD1306 128x64 display is organized as 8 pages of 8 rows each. Each byte in horizontal addressing mode sets one column of 8 pixels (bit 0 = top row in the page, bit 7 = bottom).

Implement display clear and cursor positioning:

void oled_set_cursor(uint8_t page, uint8_t col) {
    oled_cmd(0xB0 | (page & 0x07));       // page address
    oled_cmd(0x00 | (col & 0x0F));        // lower nibble of column
    oled_cmd(0x10 | ((col >> 4) & 0x0F)); // upper nibble of column
}

void oled_fill(uint8_t pattern) {
    for (int page = 0; page < 8; page++) {
        oled_set_cursor(page, 0);
        uint8_t row[128];
        for (int i = 0; i < 128; i++) row[i] = pattern;
        oled_data(row, 128);
    }
}

void oled_clear(void) { oled_fill(0x00); }

Part C: Character rendering and display verification (7 pts)

C1: 5x7 character font (3 pts)

Embed a minimal 5x7 font covering ASCII 32-127. Each character is 5 bytes wide (5 columns, each byte is an 8-pixel column). The standard font data is available in the reference implementation at vca-csa-201/firmware/drivers/ssd1306_font.h.

extern const uint8_t font5x7[][5];  // indexed by (char - 32)

void oled_draw_char(uint8_t page, uint8_t col, char c) {
    if (c < 32 || c > 127) c = '?';
    const uint8_t* glyph = font5x7[c - 32];
    uint8_t buf[6];
    for (int i = 0; i < 5; i++) buf[i] = glyph[i];
    buf[5] = 0x00;   // 1-pixel gap between characters
    oled_set_cursor(page, col);
    oled_data(buf, 6);
}

void oled_draw_string(uint8_t page, uint8_t col, const char* str) {
    while (*str) {
        oled_draw_char(page, col, *str++);
        col += 6;
        if (col >= 128) break;
    }
}

C2: Display verification test (2 pts)

Write a test that:

  1. Calls oled_init().
  2. Calls oled_clear().
  3. Writes "CSA-201" on page 0.
  4. Writes the current value of the mcycle CSR (as a hex string) on page 1.
  5. Writes the running PID (from the process table) on page 2.

All three lines must be visible on the physical OLED simultaneously. Capture a photo of the display and include it in the lab submission.

C3: I2C transaction timing measurement (2 pts)

Instrument the i2c_write() function with mcycle:

uint32_t before = read_mcycle();
oled_draw_string(0, 0, "TIMING TEST");
uint32_t after = read_mcycle();
uint32_t cycles = after - before;

This call sends: 1 START + 1 address byte + 1 control byte + 11 data bytes (one per character) + 1 STOP = 13 I2C bytes total = 13 * 9 bits = 117 bits.

Fill in:

Measurement Cycles
oled_draw_string("TIMING TEST") total
Cycles per I2C byte (total / 13)
Cycles per bit (per-byte / 9)
Measured bit period (cycles / 50MHz clock)

The bit period should be approximately 2 * i2c_half_bit_delay() cycles. If it is significantly larger, identify the overhead (function call, GPIO read latency, etc.).


Grading

Part Criteria Points
A1 GPIO primitives correct; direction register configured; SDA/SCL waveform captured 3
A2 START/STOP conditions correct; byte transmission verified with logic analyzer or oscilloscope 3
A3 i2c_write wrapper works; ACK received from SSD1306; NACK handled 2
B1 Command/data framing correct (control byte 0x00 vs 0x40) 3
B2 Init sequence fires; display powers on after sequence; oled_fill(0xFF) shows white 4
B3 oled_set_cursor and oled_fill work; oled_clear leaves blank display 3
C1 Font data present; oled_draw_char renders recognizable characters 3
C2 Three-line display test passes; photo submitted 2
C3 Timing measurement table complete; per-bit period measured 2
Total 25