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:
- Calls
oled_init(). - Calls
oled_clear(). - Writes "CSA-201" on page 0.
- Writes the current value of the mcycle CSR (as a hex string) on page 1.
- 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 |