Project 10: I2C Driver for OLED Display
Build a complete I2C driver from scratch that communicates with an SSD1306 OLED display, drawing pixels, text, and simple graphics without using any libraries.
Quick Reference
| Attribute | Value |
|---|---|
| Difficulty | Level 3 - Advanced |
| Time Estimate | 1-2 weeks |
| Language | C (primary), Rust, MicroPython (for comparison) |
| Prerequisites | Project 3 (bare-metal), Project 4 (peripheral experience), bit manipulation |
| Key Topics | I2C Protocol, GPIO Alternate Functions, SSD1306 Controller, Frame Buffers |
1. Learning Objectives
After completing this project, you will:
- Understand the I2C protocol at the bit and byte level
- Master GPIO alternate function configuration for I2C pins
- Implement I2C master mode with proper timing
- Handle ACK/NACK signaling and error conditions
- Initialize and control the SSD1306 OLED controller
- Manage a frame buffer for efficient display updates
- Implement basic graphics primitives (pixels, lines, text)
- Understand clock stretching and multi-master arbitration
- Debug I2C issues with logic analyzers and oscilloscopes
2. Theoretical Foundation
2.1 Core Concepts
The I2C Protocol
I2C (Inter-Integrated Circuit) is a two-wire serial protocol developed by Philips. It uses:
- SDA: Serial Data line (bidirectional)
- SCL: Serial Clock line (driven by master)
Both lines require pull-up resistors because devices use open-drain outputs.
I2C Bus Topology:
VCC
│
┌────┴────┐
│ Pull-up │
│ Resistors│
│ 4.7kΩ ea │
└────┬────┘
│
┌───────────────┼───────────────┐
│ │ │
┌────┴────┐ ┌────┴────┐ ┌────┴────┐
│ Master │ │ Slave │ │ Slave │
│ (MCU) │ │ (OLED) │ │ (Sensor)│
│ │ │ 0x3C │ │ 0x68 │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└───────────────┼───────────────┘
│
GND
Each device has a 7-bit address (0x3C for SSD1306)
I2C Transaction Format
Complete I2C Write Transaction:
┌─────┬──────────────┬─────┬──────────────┬─────┬──────────────┬─────┬─────┐
│START│ Address + W │ ACK │ Data Byte 1 │ ACK │ Data Byte N │ ACK │STOP │
│ │ (8 bits) │ │ (8 bits) │ │ (8 bits) │ │ │
└─────┴──────────────┴─────┴──────────────┴─────┴──────────────┴─────┴─────┘
│ │ │
│ │ └── Slave pulls SDA low to acknowledge
│ │
│ └── 7-bit address + R/W bit (0=Write, 1=Read)
│
└── SDA goes low while SCL is high
Timing Detail:
START STOP
│ │
SCL ────┐ ┌──┐ ┌──┐ ┌──┐ │ ┌────
│ │ │ │ │ │ │ │ │
└─────┘ └──┘ └──┘ └───┴───┘
SDA ──┐ ┌─────────────────┐
│ D7 │ ... │ D0
└───────┴─────────────────┴────────────
│ │
START STOP
SSD1306 OLED Controller
The SSD1306 is a popular OLED driver IC that supports 128x64 or 128x32 pixel displays:
SSD1306 Memory Organization:
┌─────────────────────────────────────────────────────────────────────────────┐
│ 128 columns (0-127) │
├─────────────────────────────────────────────────────────────────────────────┤
│ Page 0 │ Each page is 8 pixels tall │
│ (0-7) │ Each column byte represents 8 vertical pixels │
├────────┤ │
│ Page 1 │ │
├────────┤ │
│ Page 2 │ │
├────────┤ Display: 128x64 = 8 pages x 128 columns = 1024 bytes │
│ Page 3 │ │
├────────┤ │
│ Page 4 │ │
├────────┤ │
│ Page 5 │ │
├────────┤ │
│ Page 6 │ │
├────────┤ │
│ Page 7 │ │
└─────────────────────────────────────────────────────────────────────────────┘
Single Column Byte (Page N, Column M):
MSB LSB
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ 7 │ 6 │ 5 │ 4 │ 3 │ 2 │ 1 │ 0 │ ← Bit position
└───┴───┴───┴───┴───┴───┴───┴───┘
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ └── Row N*8 + 0 (top)
│ │ │ │ │ │ └────── Row N*8 + 1
│ │ │ │ │ └────────── Row N*8 + 2
│ │ │ │ └────────────── Row N*8 + 3
│ │ │ └────────────────── Row N*8 + 4
│ │ └────────────────────── Row N*8 + 5
│ └────────────────────────── Row N*8 + 6
└────────────────────────────── Row N*8 + 7 (bottom)
I2C Command Protocol for SSD1306
The SSD1306 uses a control byte to distinguish commands from data:
I2C Frame Format:
┌───────┬─────────┬───────┬─────────┬───────┬─────────┐
│ START │ 0x3C<<1 │ ACK │ Control │ ACK │ Payload │...
│ │ + W │ │ Byte │ │ │
└───────┴─────────┴───────┴─────────┴───────┴─────────┘
Control Byte:
┌───────┬───────┬───────────────────────────────────────┐
│ Co │ D/C# │ Reserved (0) │
│ bit 7 │ bit 6 │ bits 5-0 │
└───────┴───────┴───────────────────────────────────────┘
Co=0: Continuation, only data follows
Co=1: More control bytes follow
D/C#=0: Next byte is command (0x00)
D/C#=1: Next byte is data (0x40)
Examples:
0x00 = Command mode (single command)
0x40 = Data mode (display data follows)
0x80 = Command mode, more control bytes follow
2.2 Why This Matters
I2C is the most common embedded peripheral protocol. Nearly every sensor, display, EEPROM, and I/O expander uses I2C. Building an I2C driver from scratch teaches:
- Protocol fundamentals: Understanding timing, addressing, and acknowledgment
- GPIO configuration: Mastering alternate functions and open-drain outputs
- Error handling: Dealing with NAKs, bus errors, and arbitration
- Hardware abstraction: Creating clean APIs for peripheral access
Industry usage:
- Every smartphone has dozens of I2C devices (sensors, touch controllers, etc.)
- Automotive systems use I2C for dashboard displays and sensor networks
- Industrial equipment relies on I2C for configuration EEPROMs
- IoT devices use I2C OLED displays for user interfaces
Plus, you get visible output! Nothing beats seeing pixels light up from your own driver code.
2.3 Historical Context
I2C was developed by Philips Semiconductor (now NXP) in 1982 to simplify communication between ICs on a circuit board. Key innovations:
- Two-wire design: Minimized pin count and PCB traces
- Multi-master: Multiple controllers can share the bus
- Addressing: Up to 128 devices (7-bit) or 1024 devices (10-bit)
The protocol has evolved through several speed grades:
- Standard mode: 100 kbit/s (1982)
- Fast mode: 400 kbit/s (1992)
- Fast mode plus: 1 Mbit/s (2007)
- High speed: 3.4 Mbit/s (1998)
The SSD1306 controller was designed by Solomon Systech and became the de facto standard for small OLED displays due to its simple interface and low cost.
2.4 Common Misconceptions
Misconception 1: “I2C is always slow”
- Reality: Fast mode (400 kHz) is sufficient for most displays. The SSD1306 can update its full display at ~60 fps in fast mode.
Misconception 2: “I2C needs external pull-ups”
- Reality: While recommended for reliable operation, many MCUs have internal pull-ups that work for short buses with few devices.
Misconception 3: “The address 0x3C is 8 bits”
- Reality: It’s a 7-bit address. When shifted left and combined with R/W bit, it becomes 0x78 (write) or 0x79 (read).
Misconception 4: “ACK means success”
- Reality: ACK only means the byte was received. It doesn’t guarantee the command was valid or executed correctly.
Misconception 5: “Open-drain means no configuration needed”
- Reality: GPIO must be explicitly configured as open-drain/alternate function, or SDA will be driven high, causing bus conflicts.
3. Project Specification
3.1 What You Will Build
A complete I2C display system that:
- Implements I2C master mode from scratch
- Initializes and controls an SSD1306 OLED display
- Maintains a frame buffer for efficient updates
- Provides graphics primitives (pixels, lines, rectangles, text)
- Works on STM32 or similar Cortex-M boards
3.2 Functional Requirements
- I2C driver: Initialize, write single byte, write multiple bytes
- Device detection: Scan bus for responding devices
- Display initialization: Full SSD1306 init sequence
- Frame buffer: 1024-byte buffer for 128x64 display
- Pixel operations: Set, clear, toggle individual pixels
- Graphics primitives: Lines, rectangles, circles
- Text rendering: 8x8 font, string printing
- Display update: Transfer frame buffer to display
3.3 Non-Functional Requirements
- Speed: Support 400 kHz I2C clock (fast mode)
- Reliability: Handle NAK and bus errors gracefully
- Efficiency: Use page-based updates when possible
- Memory: Frame buffer only uses 1KB RAM
- Portability: Clean separation between I2C driver and display code
3.4 Example Usage / Output
Physical result: 128x64 OLED display shows:
┌──────────────────────────────────────┐
│ ARM I2C Demo │
│ │
│ ╭──────────────────────────╮ │
│ │ CPU: 72MHz │ │
│ │ Temp: 32C │ │
│ │ Time: 14:23:45 │ │
│ ╰──────────────────────────╯ │
│ ___ │
│ * /ARM \ │
│ \_____/ │
└──────────────────────────────────────┘
Serial output:
=== I2C OLED Driver Demo ===
Scanning I2C bus...
Device found at 0x3C (SSD1306 OLED)
I2C initialized at 400kHz
SSD1306 found at address 0x3C
Display initialized (128x64)
Drawing test pattern...
Framebuffer: 1024 bytes (128x64/8)
Frame rate: 62 fps
3.5 Real World Outcome
What success looks like:
- Working I2C communication: Device responds to address
- Display initialization: Screen turns on, shows content
- Graphics output: Pixels, text, and shapes render correctly
- Efficient updates: Full screen updates at reasonable frame rate
- Deep understanding: You can explain I2C timing diagrams
4. Solution Architecture
4.1 High-Level Design
┌─────────────────────────────────────────────────────────────────┐
│ I2C OLED Display System │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Application Layer │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Shapes │ │ Text │ │ Sprites │ │ │
│ │ │ draw_rect │ │ print_str │ │ draw_bitmap │ │ │
│ │ │ draw_circle │ │ print_char │ │ │ │ │
│ │ │ draw_line │ │ │ │ │ │ │
│ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │
│ └─────────│────────────────│────────────────│─────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Frame Buffer Layer │ │
│ │ ┌───────────────────────────────────────────────────┐ │ │
│ │ │ uint8_t framebuffer[1024] │ │ │
│ │ │ │ │ │
│ │ │ set_pixel(x, y) clear_pixel(x, y) │ │ │
│ │ │ get_pixel(x, y) clear_buffer() │ │ │
│ │ └───────────────────────────────────────────────────┘ │ │
│ └────────────────────────────┬────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ SSD1306 Driver Layer │ │
│ │ ┌───────────────────┐ ┌───────────────────┐ │ │
│ │ │ ssd1306_init │ │ ssd1306_display │ │ │
│ │ │ send_command │ │ send_data │ │ │
│ │ │ set_contrast │ │ set_addressing │ │ │
│ │ └─────────┬─────────┘ └─────────┬─────────┘ │ │
│ └────────────│──────────────────────│─────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ I2C Driver Layer │ │
│ │ ┌───────────────────────────────────────────────────┐ │ │
│ │ │ i2c_init() i2c_start() i2c_stop() │ │ │
│ │ │ i2c_write() i2c_write_bytes() │ │ │
│ │ │ i2c_scan() │ │ │
│ │ └───────────────────────────────────────────────────┘ │ │
│ └────────────────────────────┬────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Hardware Layer │ │
│ │ ┌───────────────────────────────────────────────────┐ │ │
│ │ │ I2C1 Peripheral GPIO PB6 (SCL), PB7 (SDA) │ │ │
│ │ │ Clock Configuration Pull-ups (internal/ext) │ │ │
│ │ └───────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
4.2 Key Components
| Component | Purpose | Key Functions |
|---|---|---|
| I2C Driver | Low-level I2C communication | i2c_init(), i2c_write(), i2c_scan() |
| SSD1306 Driver | Display-specific commands | ssd1306_init(), ssd1306_display() |
| Frame Buffer | In-memory display state | set_pixel(), clear_buffer() |
| Graphics Library | Drawing primitives | draw_line(), draw_rect(), draw_circle() |
| Text Renderer | Character and string output | print_char(), print_str() |
| Font Data | 8x8 bitmap font | font8x8[] array |
4.3 Data Structures
// I2C configuration
typedef struct {
I2C_TypeDef *i2c; // I2C peripheral (I2C1, I2C2, etc.)
uint32_t clock_speed; // 100000 or 400000 Hz
uint8_t own_address; // Master address (if needed)
} i2c_config_t;
// Display configuration
typedef struct {
uint8_t width; // 128 pixels
uint8_t height; // 64 pixels
uint8_t i2c_address; // 0x3C or 0x3D
uint8_t *framebuffer; // Pointer to 1024-byte buffer
} ssd1306_t;
// Graphics context
typedef struct {
ssd1306_t *display;
uint8_t fg_color; // 1 = white, 0 = black
uint8_t bg_color;
uint8_t cursor_x;
uint8_t cursor_y;
const uint8_t *font; // Pointer to font data
} gfx_context_t;
// Framebuffer (128x64 / 8 bits per vertical byte = 1024 bytes)
uint8_t framebuffer[128 * 64 / 8];
4.4 Algorithm Overview
FUNCTION initialize_display():
// Phase 1: Enable clocks
enable_gpio_clock()
enable_i2c_clock()
// Phase 2: Configure GPIO for I2C
configure_gpio_alternate_function(SDA_PIN, AF_I2C)
configure_gpio_alternate_function(SCL_PIN, AF_I2C)
set_output_type(SDA_PIN, OPEN_DRAIN)
set_output_type(SCL_PIN, OPEN_DRAIN)
// Phase 3: Configure I2C peripheral
disable_i2c()
set_clock_speed(400000)
enable_i2c()
// Phase 4: Initialize SSD1306
send_init_commands()
// Phase 5: Clear display
clear_framebuffer()
send_framebuffer_to_display()
FUNCTION draw_pixel(x, y, color):
IF x >= 128 OR y >= 64:
RETURN // Out of bounds
page = y / 8
bit = y % 8
offset = page * 128 + x
IF color:
framebuffer[offset] |= (1 << bit)
ELSE:
framebuffer[offset] &= ~(1 << bit)
FUNCTION send_framebuffer_to_display():
// Set addressing mode and column/page addresses
send_command(0x20, 0x00) // Horizontal addressing mode
send_command(0x21, 0, 127) // Column range
send_command(0x22, 0, 7) // Page range
// Send all 1024 bytes
i2c_start()
i2c_write(SSD1306_ADDRESS << 1)
i2c_write(0x40) // Data mode
FOR i = 0 TO 1023:
i2c_write(framebuffer[i])
i2c_stop()
5. Implementation Guide
5.1 Development Environment Setup
# Verify toolchain
$ arm-none-eabi-gcc --version
arm-none-eabi-gcc (GNU Arm Embedded Toolchain) 12.2.1
# Create project directory
$ mkdir -p ~/projects/i2c-oled
$ cd ~/projects/i2c-oled
# Create initial file structure
$ touch main.c i2c.c i2c.h ssd1306.c ssd1306.h gfx.c gfx.h font8x8.h
$ touch startup.s linker.ld Makefile
# Hardware connections:
# STM32 PB6 (SCL) ──── SSD1306 SCL
# STM32 PB7 (SDA) ──── SSD1306 SDA
# STM32 3.3V ───────── SSD1306 VCC
# STM32 GND ────────── SSD1306 GND
# (4.7kΩ pull-ups on SCL and SDA to VCC recommended)
5.2 Project Structure
i2c-oled/
├── main.c # Demo application
├── i2c.c # I2C driver implementation
├── i2c.h # I2C driver interface
├── ssd1306.c # SSD1306 display driver
├── ssd1306.h # Display driver interface
├── gfx.c # Graphics primitives
├── gfx.h # Graphics interface
├── font8x8.h # 8x8 bitmap font data
├── stm32f4xx.h # Register definitions
├── startup.s # Vector table and reset handler
├── linker.ld # Linker script
├── Makefile
└── README.md
5.3 The Core Question You’re Answering
“How does a two-wire protocol enable communication with dozens of different devices, and how do displays organize and receive pixel data?”
Before coding, understand that I2C’s elegance lies in its addressing scheme and open-drain signaling. Every device monitors the bus, but only responds when its address is called. The SSD1306 adds another layer: a command/data protocol on top of I2C bytes.
5.4 Concepts You Must Understand First
Stop and research these before coding:
- Open-Drain Outputs
- Why can’t you drive SDA high directly?
- How do pull-up resistors enable the bus?
- Book Reference: “Making Embedded Systems” Chapter 9 - White
- I2C Timing
- What are setup and hold times?
- How is clock speed determined?
- Book Reference: I2C Specification (NXP)
- GPIO Alternate Functions
- What does “alternate function” mean?
- How do you find the correct AF number?
- Book Reference: STM32 Reference Manual, GPIO chapter
- SSD1306 Command Set
- What’s the difference between commands and data?
- How does addressing mode affect data writes?
- Book Reference: SSD1306 Datasheet
5.5 Questions to Guide Your Design
Before implementing, think through these:
- Bus Configuration
- What pull-up resistance is optimal for your bus length and speed?
- Should you use internal or external pull-ups?
- Error Handling
- What if the device doesn’t ACK?
- How do you recover from a stuck bus?
- Performance
- Should you update the whole display or just changed regions?
- Can you use DMA for large transfers?
- Memory
- Where should the frame buffer live?
- What if you don’t have 1KB for a full buffer?
5.6 Thinking Exercise
Trace an I2C Write
Before coding, trace what happens when you call i2c_write_byte(0x3C, 0xAF):
1. Generate START condition
- SCL high, SDA transitions high→low
- All devices see START, prepare to listen
2. Send address byte (0x78 = 0x3C << 1 | 0)
- Clock out 8 bits: 0111 1000
- Each bit: set SDA, pulse SCL
- Bit 0 = 0 indicates WRITE operation
3. Wait for ACK
- Release SDA (goes high via pull-up)
- Clock SCL
- SSD1306 pulls SDA low = ACK
- If SDA stays high = NACK (device not present)
4. Send data byte (0xAF)
- Clock out 8 bits: 1010 1111
- This is "Display ON" command for SSD1306
5. Wait for ACK
- Same as step 3
6. Generate STOP condition
- SDA transitions low→high while SCL high
- Bus is released
Questions while tracing:
- What voltage levels represent '0' and '1'?
- Who drives each wire at each step?
- What happens if you skip the START?
5.7 Hints in Layers
Hint 1: GPIO Configuration
Configure GPIO pins for I2C before enabling the I2C peripheral:
// Enable GPIO clock
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOBEN;
// Configure PB6 and PB7 for I2C1
// Mode: Alternate Function (10)
GPIOB->MODER &= ~((3 << (6*2)) | (3 << (7*2)));
GPIOB->MODER |= (2 << (6*2)) | (2 << (7*2));
// Output type: Open Drain (1)
GPIOB->OTYPER |= (1 << 6) | (1 << 7);
// Speed: High (11)
GPIOB->OSPEEDR |= (3 << (6*2)) | (3 << (7*2));
// Pull-up: Enable (01)
GPIOB->PUPDR &= ~((3 << (6*2)) | (3 << (7*2)));
GPIOB->PUPDR |= (1 << (6*2)) | (1 << (7*2));
// Alternate function: AF4 for I2C1
GPIOB->AFR[0] &= ~((0xF << (6*4)) | (0xF << (7*4)));
GPIOB->AFR[0] |= (4 << (6*4)) | (4 << (7*4));
Hint 2: I2C Peripheral Setup
// Enable I2C1 clock
RCC->APB1ENR |= RCC_APB1ENR_I2C1EN;
// Reset and configure I2C
I2C1->CR1 = 0; // Disable I2C
I2C1->CR2 = 36; // APB1 clock = 36MHz
I2C1->CCR = 90; // CCR for 400kHz: 36MHz/(2*400kHz)
I2C1->TRISE = 11; // Rise time: 36MHz * 300ns + 1
I2C1->CR1 = I2C_CR1_PE; // Enable I2C
Hint 3: I2C Write Function
bool i2c_write(uint8_t addr, uint8_t *data, uint8_t len) {
// Generate START
I2C1->CR1 |= I2C_CR1_START;
while (!(I2C1->SR1 & I2C_SR1_SB));
// Send address with write bit
I2C1->DR = addr << 1;
while (!(I2C1->SR1 & I2C_SR1_ADDR));
(void)I2C1->SR2; // Clear ADDR flag by reading SR2
// Send data bytes
for (int i = 0; i < len; i++) {
while (!(I2C1->SR1 & I2C_SR1_TXE));
I2C1->DR = data[i];
}
// Wait for last byte to finish
while (!(I2C1->SR1 & I2C_SR1_BTF));
// Generate STOP
I2C1->CR1 |= I2C_CR1_STOP;
return true;
}
Hint 4: SSD1306 Initialization
static const uint8_t init_cmds[] = {
0xAE, // Display off
0xD5, 0x80, // Set display clock divide
0xA8, 0x3F, // Set multiplex ratio (64-1)
0xD3, 0x00, // Set display offset
0x40, // Set start line to 0
0x8D, 0x14, // Enable charge pump
0x20, 0x00, // Horizontal addressing mode
0xA1, // Segment remap (column 127 = SEG0)
0xC8, // COM scan direction (remapped)
0xDA, 0x12, // Set COM pins configuration
0x81, 0xCF, // Set contrast
0xD9, 0xF1, // Set pre-charge period
0xDB, 0x40, // Set VCOMH deselect level
0xA4, // Display from RAM
0xA6, // Normal display (not inverted)
0xAF // Display on
};
void ssd1306_init(void) {
for (int i = 0; i < sizeof(init_cmds); i++) {
ssd1306_command(init_cmds[i]);
}
}
void ssd1306_command(uint8_t cmd) {
uint8_t data[2] = {0x00, cmd}; // 0x00 = command mode
i2c_write(SSD1306_ADDR, data, 2);
}
5.8 The Interview Questions They’ll Ask
Prepare to answer these:
- “Explain how I2C addressing works”
- 7-bit address (128 possible devices)
- Address sent after START, with R/W bit as LSB
- Device with matching address responds with ACK
- Reserved addresses: 0x00 (general call), 0x7F (reserved)
- “Why does I2C use open-drain outputs?”
- Allows multiple devices to drive the same line
- Prevents short circuits (no driver conflict)
- Enables wired-AND logic (any device can pull low)
- Requires pull-up resistors to achieve logic high
- “What is clock stretching?”
- Slave holds SCL low to slow down master
- Used when slave needs more time to process data
- Master must detect and wait for SCL to go high
- Can cause timing issues with strict masters
- “How would you debug an I2C device that’s not responding?”
- Verify address with i2c_scan()
- Check pull-up resistors (measure voltage levels)
- Use oscilloscope/logic analyzer to view signals
- Check GPIO alternate function configuration
- Verify clock enable for GPIO and I2C peripherals
- “Why does the SSD1306 organize memory in pages?”
- Historical: early OLEDs were segmented displays
- Efficient: can update partial screen
- Trade-off: complicates pixel addressing
- 8 vertical pixels per byte matches segment drivers
5.9 Books That Will Help
| Topic | Book | Chapter |
|---|---|---|
| I2C protocol | “Making Embedded Systems” by White | Ch. 9 |
| GPIO configuration | STM32 Reference Manual | GPIO chapter |
| Display controllers | SSD1306 Datasheet | All |
| Graphics primitives | “Computer Graphics from Scratch” by Gambetta | Ch. 1 |
| Protocol analysis | “Debugging with GDB and strace” | I2C section |
5.10 Implementation Phases
Phase 1: I2C Bus Scan (2-3 hours)
- Configure GPIO and I2C peripheral
- Implement i2c_start(), i2c_stop(), i2c_write_byte()
- Write bus scanner to find devices
- Verify SSD1306 responds at 0x3C
Phase 2: Display Initialization (2-3 hours)
- Implement command sending function
- Send SSD1306 initialization sequence
- Display should turn on (may show noise)
Phase 3: Frame Buffer (3-4 hours)
- Allocate 1024-byte frame buffer
- Implement set_pixel(), clear_buffer()
- Implement frame buffer to display transfer
- Clear display should show blank screen
Phase 4: Graphics Primitives (4-6 hours)
- Implement draw_line() using Bresenham’s algorithm
- Implement draw_rect(), fill_rect()
- Implement draw_circle() (optional)
- Test with geometric patterns
Phase 5: Text Rendering (3-4 hours)
- Create or import 8x8 font data
- Implement print_char()
- Implement print_str()
- Display “Hello ARM!” on screen
Phase 6: Demo and Polish (2-3 hours)
- Create impressive demo (animation, info display)
- Optimize frame rate
- Add error handling
- Document API
5.11 Key Implementation Decisions
| Decision | Trade-offs |
|---|---|
| Full vs partial update | Full: simpler. Partial: faster for small changes |
| Internal vs external pull-ups | Internal: fewer components. External: more reliable |
| Blocking vs DMA | Blocking: simpler. DMA: CPU free during transfer |
| Software vs hardware I2C | HW: faster, handles timing. SW: more portable |
| Font storage | Flash: saves RAM. RAM: faster access |
6. Testing Strategy
6.1 Unit Tests
| Test | Description | Expected Result |
|---|---|---|
| GPIO config | Check MODER, OTYPER, AFR values | Correct bit patterns |
| I2C clock | Measure SCL frequency | 400 kHz +/- 10% |
| Bus scan | Scan for devices | 0x3C detected |
| START/STOP | Check signal timing | Correct transitions |
| ACK detection | Send to invalid address | Returns false/NACK |
6.2 Integration Tests
| Test | Command | Expected Result |
|---|---|---|
| Display init | Call ssd1306_init() | Display turns on |
| Clear display | clear_buffer(); update() | Blank screen |
| Single pixel | set_pixel(64, 32, 1) | Center pixel lit |
| Horizontal line | draw_line(0,0,127,0) | Top edge lit |
| Vertical line | draw_line(0,0,0,63) | Left edge lit |
| Text | print_str(“ARM”) | “ARM” visible |
| Full frame | Fill pattern, update | Pattern displayed |
6.3 Verification Commands
# Use logic analyzer to capture I2C traffic
$ sigrok-cli -d fx2lafw -c samplerate=2M -P i2c:scl=D0:sda=D1 -A i2c
# Expected output:
# i2c-1: Address write: 3C
# i2c-1: ACK
# i2c-1: Data write: 00
# i2c-1: ACK
# i2c-1: Data write: AE
# ...
# Measure frame rate
# Add timer code to measure update frequency
# Expected: 30-60 fps depending on optimization
7. Common Pitfalls & Debugging
Problem 1: “No ACK received (device not found)”
- Why: Wrong address, no pull-ups, GPIO not configured
- Fix: Check address (0x3C or 0x3D), add pull-ups, verify GPIO config
- Debug: Measure SDA/SCL with oscilloscope, should see toggling
Problem 2: “Display shows garbage”
- Why: Wrong initialization sequence or addressing mode
- Fix: Verify init commands match datasheet, check addressing mode
- Debug: Compare init sequence byte-by-byte with working example
Problem 3: “I2C hangs (no response)”
- Why: Missed ACK check, stuck bus, clock stretching timeout
- Fix: Add timeout to all waits, implement bus recovery
- Debug: Toggle GPIO manually to unstick bus
Problem 4: “Pixels appear in wrong location”
- Why: Column/row addressing confusion, byte order wrong
- Fix: Remember: each byte is 8 vertical pixels, LSB is top
- Debug: Set single pixels at known coordinates, trace buffer index
Problem 5: “Text is garbled or upside down”
- Why: Font bit order doesn’t match display organization
- Fix: Adjust font rendering to match SSD1306’s column/page layout
- Debug: Print single character, compare visual with font data
Problem 6: “Display flickers or updates slowly”
- Why: Updating entire display for small changes, I2C too slow
- Fix: Use partial updates, increase I2C speed, consider DMA
- Debug: Measure actual frame rate, profile transfer time
8. Extensions & Challenges
8.1 Easy Extensions
| Extension | Description | Learning |
|---|---|---|
| Contrast control | Runtime contrast adjustment | Command parameters |
| Display invert | Toggle inverse video mode | Simple command |
| Scrolling | Hardware scroll commands | SSD1306 scroll features |
| Multiple fonts | Add 16x16 or proportional fonts | Font rendering |
8.2 Advanced Challenges
| Challenge | Description | Learning |
|---|---|---|
| DMA transfer | Use DMA for frame buffer transfer | DMA peripheral |
| Double buffering | Render to back buffer, swap | Animation smoothness |
| Sprites | XOR-based sprite rendering | Game development |
| Touch input | Add I2C touch controller | Multiple I2C devices |
| Animation engine | Frame-based animation system | Graphics programming |
| Display rotation | Support 0/90/180/270 rotation | Coordinate transforms |
8.3 Research Topics
- How do commercial graphics libraries (LVGL, u8g2) optimize for I2C displays?
- What is the maximum practical frame rate for I2C OLED displays?
- How do high-performance displays use parallel interfaces instead of I2C?
- What are the power consumption implications of different update strategies?
9. Real-World Connections
9.1 Production Systems Using This
| System | How It Uses I2C Displays | Notable Feature |
|---|---|---|
| Arduino ecosystem | u8g2 library | Universal display support |
| 3D printers | Marlin firmware | Status displays |
| Smart watches | Low-power OLED | Sleep modes |
| IoT devices | Status indicators | Minimal hardware |
| Lab equipment | Data displays | Real-time updates |
9.2 How the Pros Do It
u8g2 Library:
- Universal HAL for many MCU families
- Multiple buffer strategies (full, page, tile)
- Hardware and software I2C support
- Extensive font library
LVGL:
- Professional embedded GUI library
- Display driver abstraction
- DMA acceleration support
- Complex widget system
Production IoT:
- Power-gated displays for battery life
- Partial update regions for efficiency
- Error recovery for field reliability
10. Self-Assessment Checklist
Before considering this project complete, verify:
- I can explain the I2C protocol including START, STOP, ACK, and addressing
- I understand why open-drain outputs and pull-up resistors are required
- My I2C driver correctly handles device addressing and ACK checking
- I can scan the bus and identify connected devices
- I understand the SSD1306’s memory organization (pages and columns)
- My display initialization sequence correctly turns on the display
- Frame buffer updates correctly transfer to the display
- I can draw pixels at any coordinate
- I implemented at least one drawing primitive (line, rectangle)
- Text rendering works correctly with the 8x8 font
- I can achieve reasonable frame rates (30+ fps for full updates)
- I can debug I2C issues using oscilloscope or logic analyzer
- I can answer all the interview questions listed above
Next Steps
After completing this project, you’ll be well-prepared for:
- Project 11: SPI SD Card Driver - Another essential peripheral protocol
- Project 13: DMA Audio Player - Apply DMA concepts to any peripheral
- Game Console Capstone - Use display for game graphics
The I2C skills you’ve developed are immediately applicable to hundreds of different sensors and peripherals. You now understand the most common embedded protocol!