Skip to content

SPI Protocol and Peripheral Interfacing

SPI Protocol and Peripheral Interfacing hero image
Modified:
Published:

SPI is the fastest standard serial bus on most microcontrollers, and it is the interface of choice for displays, SD cards, and high-speed sensors. In this lesson you will configure the ATmega328P hardware SPI peripheral, understand clock polarity and phase, and write a complete driver for the SSD1306 OLED display. The project is a real-time clock that counts hours, minutes, and seconds on a 128x64 pixel screen, rendered with a custom bitmap font. Every byte that reaches the display passes through code you wrote yourself. #SPI #OLED #SSD1306

What We Are Building

Real-Time Clock on OLED Display

A 128x64 OLED display driven over SPI shows a running clock in large digits (HH:MM:SS format). The clock starts at 00:00:00 on power-up and counts using a Timer1 interrupt for accurate one-second ticks. The SSD1306 driver handles initialization, page addressing, and a 1024-byte framebuffer that gets flushed to the display once per second. A simple 5x7 bitmap font is included for rendering digits and the colon separator.

Project specifications:

ParameterValue
MCUATmega328P (on Arduino Nano or Uno)
DisplaySSD1306 128x64 OLED (SPI variant)
SPI clockF_CPU/4 = 4 MHz
SPI modeMode 0 (CPOL=0, CPHA=0)
Framebuffer1024 bytes (128x64 / 8)
Font5x7 pixel bitmap, digits and colon
Update rate1 Hz
TimekeepingTimer1 CTC, 1 second interrupt

Parts for This Lesson

RefComponentQuantityNotes
1Arduino Nano or Uno (ATmega328P)1From previous lessons
2Breadboard1From previous lessons
3SSD1306 OLED 128x64 (SPI)17-pin SPI variant with D/C pin
4Jumper wires~7Male-to-female or male-to-male

SPI Protocol Fundamentals



SPI uses four signals: MOSI (Master Out Slave In), MISO (Master In Slave Out), SCK (Serial Clock), and SS (Slave Select, active low).

SPI Master-Slave Connection:
ATmega328P (Master) SSD1306 OLED (Slave)
+------------------+ +------------------+
| | | |
| MOSI (PB3) ----+------>| DIN (data in) |
| | | |
| MISO (PB4) <---+-------| (not used) |
| | | |
| SCK (PB5) ----+------>| CLK (clock) |
| | | |
| SS (PB2) ----+------>| CS (chip select)|
| | | |
+------------------+ +------------------+
Data shifts out MOSI on each SCK edge.
SS must be LOW to select the slave.

The master generates the clock and drives MOSI while reading MISO. Data is exchanged simultaneously in both directions, one bit per clock edge. The slave is selected by pulling its SS line low. For a broader comparison of SPI, I2C, and other common bus topologies, see Digital Electronics: Bus Architecture and Interfaces.

SignalATmega328P PinArduino PinDirection
MOSIPB3D11Master to Slave
MISOPB4D12Slave to Master
SCKPB5D13Master output
SSPB2D10Master output (active low)

Clock Polarity and Phase

SPI ModeCPOLCPHAClock IdleData Sampled On
000LowRising edge
101LowFalling edge
210HighFalling edge
311HighRising edge

The SSD1306 uses Mode 0 (CPOL=0, CPHA=0). Always check the peripheral datasheet to determine the correct mode.

SPI Register Configuration



The ATmega328P SPI peripheral is controlled by two registers. SPCR (SPI Control Register) enables the peripheral, sets master/slave mode, clock polarity/phase, and the prescaler. SPSR (SPI Status Register) contains the transfer-complete flag (SPIF) and an optional double-speed bit (SPI2X).

static void spi_init(void)
{
/* Set MOSI, SCK, SS as outputs */
DDRB |= (1 << PB3) | (1 << PB5) | (1 << PB2);
/* Enable SPI, Master mode, clock = F_CPU/4 (4 MHz) */
SPCR = (1 << SPE) | (1 << MSTR);
/* Mode 0 is default (CPOL=0, CPHA=0) */
}
static uint8_t spi_transfer(uint8_t data)
{
SPDR = data;
while (!(SPSR & (1 << SPIF))); /* Wait for transfer complete */
return SPDR;
}

SSD1306 OLED Wiring



The 7-pin SPI OLED module has the following connections:

OLED PinFunctionArduino Nano PinATmega328P Pin
GNDGroundGNDGND
VCCPower (3.3V or 5V)5VVCC
D0 (CLK)SPI ClockD13PB5 (SCK)
D1 (MOSI)SPI DataD11PB3 (MOSI)
RESResetD8PB0
DCData/CommandD9PB1
CSChip SelectD10PB2 (SS)

SSD1306 Driver



The SSD1306 has a 128x64 pixel display organized into 8 pages. Each page is 8 pixels tall, and each byte in a page represents a vertical column of 8 pixels.

SSD1306 display memory layout:
128 columns
<------------------------------>
+-----+-----+-----+-- --+-----+ Page 0
|byte0|byte1|byte2| ... |b127 | (rows 0-7)
+-----+-----+-----+-- --+-----+
+-----+-----+-----+-- --+-----+ Page 1
|b128 |b129 |b130 | ... |b255 | (rows 8-15)
+-----+-----+-----+-- --+-----+
... ...
+-----+-----+-----+-- --+-----+ Page 7
|b896 |b897 |b898 | ... |b1023| (rows 56-63)
+-----+-----+-----+-- --+-----+
Each byte = 8 vertical pixels:
bit 0 = top pixel, bit 7 = bottom
Total: 128 x 8 = 1024 bytes

The SSD1306 distinguishes between commands and data using the D/C pin: low for commands, high for pixel data. The initialization sequence configures the display for 128x64 resolution with horizontal addressing mode. After initialization, you write pixel data as a 1024-byte stream (128 columns by 8 pages, each page is 8 pixels tall).

#define OLED_DC PB1
#define OLED_RST PB0
#define OLED_CS PB2
static void oled_cmd(uint8_t cmd)
{
PORTB &= ~(1 << OLED_DC); /* Command mode */
PORTB &= ~(1 << OLED_CS); /* Select */
spi_transfer(cmd);
PORTB |= (1 << OLED_CS); /* Deselect */
}
static void oled_data(const uint8_t *buf, uint16_t len)
{
PORTB |= (1 << OLED_DC); /* Data mode */
PORTB &= ~(1 << OLED_CS);
for (uint16_t i = 0; i < len; i++) {
spi_transfer(buf[i]);
}
PORTB |= (1 << OLED_CS);
}
static void oled_init(void)
{
/* Configure control pins */
DDRB |= (1 << OLED_DC) | (1 << OLED_RST) | (1 << OLED_CS);
/* Hardware reset */
PORTB &= ~(1 << OLED_RST);
_delay_ms(10);
PORTB |= (1 << OLED_RST);
_delay_ms(10);
/* Initialization sequence */
oled_cmd(0xAE); /* Display off */
oled_cmd(0xD5); /* Set clock divide */
oled_cmd(0x80);
oled_cmd(0xA8); /* Set multiplex ratio */
oled_cmd(0x3F); /* 64 rows */
oled_cmd(0xD3); /* Display offset */
oled_cmd(0x00);
oled_cmd(0x40); /* Start line 0 */
oled_cmd(0x8D); /* Charge pump */
oled_cmd(0x14); /* Enable */
oled_cmd(0x20); /* Memory addressing mode */
oled_cmd(0x00); /* Horizontal */
oled_cmd(0xA1); /* Segment remap */
oled_cmd(0xC8); /* COM scan direction */
oled_cmd(0xDA); /* COM pins config */
oled_cmd(0x12);
oled_cmd(0x81); /* Contrast */
oled_cmd(0xCF);
oled_cmd(0xD9); /* Pre-charge period */
oled_cmd(0xF1);
oled_cmd(0xDB); /* VCOMH deselect */
oled_cmd(0x40);
oled_cmd(0xA4); /* Display from RAM */
oled_cmd(0xA6); /* Normal (not inverted) */
oled_cmd(0xAF); /* Display on */
}

Framebuffer and Font Rendering



The framebuffer is a 1024-byte array that mirrors the OLED memory layout: 128 columns by 8 pages. Each byte represents a vertical strip of 8 pixels with bit 0 at the top. To draw a character, you copy its bitmap data into the framebuffer at the correct column and page offset. After updating the buffer, flush the entire thing to the display in one SPI burst.

static uint8_t framebuf[1024];
/* 5x7 font for digits '0'-'9' and ':' */
static const uint8_t font_5x7[][5] = {
{0x3E,0x51,0x49,0x45,0x3E}, /* 0 */
{0x00,0x42,0x7F,0x40,0x00}, /* 1 */
{0x42,0x61,0x51,0x49,0x46}, /* 2 */
{0x21,0x41,0x45,0x4B,0x31}, /* 3 */
{0x18,0x14,0x12,0x7F,0x10}, /* 4 */
{0x27,0x45,0x45,0x45,0x39}, /* 5 */
{0x3C,0x4A,0x49,0x49,0x30}, /* 6 */
{0x01,0x71,0x09,0x05,0x03}, /* 7 */
{0x36,0x49,0x49,0x49,0x36}, /* 8 */
{0x06,0x49,0x49,0x29,0x1E}, /* 9 */
{0x00,0x36,0x36,0x00,0x00}, /* : */
};
static void fb_clear(void)
{
for (uint16_t i = 0; i < 1024; i++) framebuf[i] = 0;
}
static void fb_draw_char(uint8_t col, uint8_t page, char c)
{
uint8_t idx;
if (c >= '0' && c <= '9') idx = c - '0';
else if (c == ':') idx = 10;
else return;
uint16_t offset = page * 128 + col;
for (uint8_t i = 0; i < 5; i++) {
if (offset + i < 1024) {
framebuf[offset + i] = font_5x7[idx][i];
}
}
}
static void fb_draw_string(uint8_t col, uint8_t page, const char *s)
{
while (*s) {
fb_draw_char(col, page, *s++);
col += 6; /* 5 pixels + 1 space */
}
}
static void oled_flush(void)
{
oled_cmd(0x21); oled_cmd(0); oled_cmd(127); /* Column range */
oled_cmd(0x22); oled_cmd(0); oled_cmd(7); /* Page range */
oled_data(framebuf, 1024);
}

Complete Firmware



#define F_CPU 16000000UL
#include <avr/io.h>
#include <avr/interrupt.h>
#include <util/delay.h>
#include <util/atomic.h>
/* Include all the SPI, OLED, and font code from above */
volatile uint32_t seconds = 0;
ISR(TIMER1_COMPA_vect)
{
static uint16_t subsec = 0;
subsec++;
if (subsec >= 1000) {
subsec = 0;
seconds++;
}
}
static void timer1_init(void)
{
TCCR1A = 0;
TCCR1B = (1 << WGM12) | (1 << CS11) | (1 << CS10);
OCR1A = 249; /* 1 ms tick */
TIMSK1 = (1 << OCIE1A);
}
static void format_time(uint32_t sec, char *buf)
{
uint8_t h = (sec / 3600) % 24;
uint8_t m = (sec / 60) % 60;
uint8_t s = sec % 60;
buf[0] = '0' + h / 10;
buf[1] = '0' + h % 10;
buf[2] = ':';
buf[3] = '0' + m / 10;
buf[4] = '0' + m % 10;
buf[5] = ':';
buf[6] = '0' + s / 10;
buf[7] = '0' + s % 10;
buf[8] = '\0';
}
int main(void)
{
spi_init();
oled_init();
timer1_init();
sei();
uint32_t last_sec = 0xFFFFFFFF;
while (1) {
uint32_t now;
ATOMIC_BLOCK(ATOMIC_RESTORESTATE) {
now = seconds;
}
if (now != last_sec) {
last_sec = now;
char time_str[9];
format_time(now, time_str);
fb_clear();
fb_draw_string(30, 3, time_str); /* Centered on page 3 */
oled_flush();
}
}
}

SPI Clock Speed and Prescaler



The SPI transfer timing in Mode 0 (CPOL=0, CPHA=0) samples data on the rising edge of SCK. The SSD1306 uses this mode. The SPIF flag sets after 8 clocks, signaling the transfer is complete.

SPI Mode 0 byte transfer:
SCK: _ _ _ _ _ _ _ _
| | | | | | | | | | | | | | | |
_____| |_| |_| |_| |_| |_| |_|__
MOSI: X D7 X D6 X D5 X D4 X D3 X D2 X D1 X D0
(data changes on falling edge,
sampled on rising edge)
CS: ____ ____
|________________________|
SPIF: ______________________________
|__| set

The SPI clock speed is set by the SPI2X bit in SPSR and the SPR1/SPR0 bits in SPCR. The SSD1306 supports SPI clocks up to about 10 MHz, so the default F_CPU/4 (4 MHz) works well. If you need faster transfers, enable SPI2X for F_CPU/2 (8 MHz).

SPI2XSPR1SPR0Clock DividerSpeed (16 MHz)
000/44 MHz
001/161 MHz
010/64250 kHz
011/128125 kHz
100/28 MHz
101/82 MHz
110/32500 kHz
111/64250 kHz

Exercises



  1. Scale the font to 2x or 3x size for larger digits on the display. Multiply each bit in the font data to create bigger pixels.
  2. Add a start/stop button: press to pause the clock, press again to resume. Show a “PAUSED” indicator on the display.
  3. Implement a simple graphic: draw a horizontal progress bar that fills across the bottom of the screen each second, then resets.
  4. Measure the time it takes to flush the entire framebuffer to the display. Calculate the theoretical minimum based on the SPI clock speed and 1024 bytes.

Summary



You now understand the SPI protocol at the register level: SPCR configuration, clock polarity and phase, master mode operation, and the transfer-complete flag. You wrote a complete SSD1306 OLED driver that handles initialization, command/data switching via the D/C pin, and framebuffer flushing. The real-time clock demonstrates how to combine SPI display output with timer-based timekeeping. SPI is the fastest on-chip serial bus and the foundation for interfacing with many embedded peripherals.

Comments

Loading comments...


© 2021-2026 SiliconWit®. All rights reserved.