Skip to content

Lab 4: The OLED Display

Time: ~45 minutes | Prerequisites: Lab 3 | Hardware: Pico 2, SSD1306 OLED, breadboard, jumper wires

Time to give your chip a face

Echo waving welcome Up to now everything has happened in a text console on your laptop. After this lab your Pico can show you things on its own. Every spectrum, meter and tuner we build later lands on this little screen. Time to transform!

What You'll Build

Text, pixels, lines, boxes, and a sweeping animated bar — all on a 128×64 OLED that your Pico drives directly. You'll also meet config.py, the single file that describes your whole kit.

Learning Objectives

  • Wire an SPI display to the Pico 2 correctly
  • Explain what SPI is and what the chip-select line does
  • Draw text and shapes using pixel coordinates
  • Describe why nothing appears until you call show()
  • Use a shared configuration module instead of hard-coded pin numbers

Concepts Introduced

ID Concept
229 Serial Peripheral Interface
230 SPI Clock And Data
231 Chip Select Line
232 Display Driver Chip
233 SSD1306 Controller
234 Framebuffer
235 Monochrome Display
236 Pixel Coordinates
237 Text Rendering
238 Display Refresh
239 Shared Configuration Module

Background

SPI: a conversation with a clock

Your Pico talks to the display over SPI — Serial Peripheral Interface. Three ideas cover it:

  • Clock (SCL) — a pin the Pico wiggles up and down to set the pace. Every tick means "here comes one bit."
  • Data (SDA) — the bits themselves, one per clock tick.
  • Chip select (CS) — "I'm talking to you now." Several devices can share the same clock and data wires; CS decides which one is listening.

Two extra wires here are specific to this display: DC (is this byte a command or picture data?) and RES (reset).

The framebuffer: draw first, show later

The display has its own memory. Your drawing commands don't touch the glass — they fill a framebuffer in the Pico's RAM. Only oled.show() ships that buffer over SPI.

That's not an inconvenience, it's the point. Drawing pixel by pixel directly onto a screen produces flicker and tearing. Building a complete picture and then sending it all at once produces a clean frame.

One bit per pixel

Echo thinking This screen is monochrome — each pixel is on or off, no grey. 128 × 64 = 8,192 pixels, one bit each, so the whole framebuffer is just 1,024 bytes. Your Pico has ~485 KB of RAM. We can afford a lot of frames.

Optional: Build a Wiring Harness

Seven loose jumper wires on a breadboard are the single most common source of "my code stopped working for no reason" in this lab — a wire walks out half a millimeter and the display goes dark, and it looks exactly like a software bug.

If you'd rather not re-seat wires every time you touch the board, follow the Wiring Harness lab from the Learning MicroPython textbook before you wire up the display below. It shows you how to bundle the display's seven wires — power, ground, and the five SPI signal wires — into one connector with a couple of dabs of hot glue, so connecting the display becomes a single plug-in instead of five separate chances to get it wrong. Its pin layout (SCL on GPIO 2, SDA on GPIO 3, RES on GPIO 4, DC on GPIO 5, CS on GPIO 6) matches the wiring table below exactly, so you can follow it as written.

This step is optional — plain jumper wires work fine for this lab — but if a wire ever falls out mid-course, you'll wish you'd built the harness on day one.

Wiring

Display pin Pico 2 GPIO Purpose
VCC 3V3 (pin 36) power
GND GND (pin 38) ground
SCL / CLK GPIO 2 SPI clock
SDA / MOSI GPIO 3 SPI data
RES GPIO 4 reset
DC GPIO 5 data/command
CS GPIO 6 chip select

3.3 V only

Echo warning Connect VCC to 3V3, never to VBUS or the 5 V rail. The Pico's GPIO pins are 3.3 V parts and 5 V can damage them permanently. Double-check this wire before you plug the USB cable in — it's the one mistake that costs money rather than time.

Procedure

Step 1 — Meet config.py

Open config.py on the Pico. It holds every pin number for the entire kit, the display size and color constants, and the init_*() helper functions that build ready-to-use objects like the display driver:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# Hardware configuration for the FFT lab kit.
# Every numbered lab in this course imports this file instead of repeating
# pin numbers, so the whole kit only needs to be described in one place.

from machine import Pin, SPI, I2S
import ssd1306

WIDTH = 128
HEIGHT = 64

# 2.42" SSD1306/SSD1309 SPI display
SCL_PIN = 2
SDA_PIN = 3
RES_PIN = 4
DC_PIN = 5
CS_PIN = 6

# Two momentary push buttons. Each button's other leg goes to GND, and
# PULL_UP holds the pin at 1 until a press pulls it to 0.
BUTTON_A_PIN = 14
BUTTON_B_PIN = 15

# I2S configuration for INMP441 microphone
SCK_PIN = 10  # Serial Clock
WS_PIN = 11   # Word Select
SD_PIN = 12   # Serial Data

# The INMP441 sends 24 real bits of audio inside a 32-bit word, so we read
# 32-bit samples and shift right by 8 to recover the value.
I2S_ID = 0
SAMPLE_BITS = 32
SAMPLE_RATE = 12800
AUDIO_BUFFER_BYTES = 40000

# 512 samples at 12800 Hz is exactly 40 ms of sound per frame.
FFT_SIZE = 512

WHITE = 1
BLACK = 0
NO_FILL = 0
FILL = 1


def init_led():
    # The onboard LED is GPIO 25 on a plain Pico 2, but on a Pico 2 W it is
    # driven by the wireless chip and only reachable by the name "LED".
    # Pin(25) is accepted on a W board but lights nothing, so try "LED" first.
    try:
        return Pin("LED", Pin.OUT)
    except (ValueError, TypeError):
        return Pin(25, Pin.OUT)


def init_display():
    clock = Pin(SCL_PIN)
    data = Pin(SDA_PIN)
    res = Pin(RES_PIN)
    dc = Pin(DC_PIN)
    cs = Pin(CS_PIN)
    spi = SPI(0, sck=clock, mosi=data)
    return ssd1306.SSD1306_SPI(WIDTH, HEIGHT, spi, dc, res, cs)


def init_buttons():
    button_a = Pin(BUTTON_A_PIN, Pin.IN, Pin.PULL_UP)
    button_b = Pin(BUTTON_B_PIN, Pin.IN, Pin.PULL_UP)
    return button_a, button_b


def init_microphone(rate=SAMPLE_RATE):
    sck = Pin(SCK_PIN)
    ws = Pin(WS_PIN)
    sd = Pin(SD_PIN)
    return I2S(I2S_ID, sck=sck, ws=ws, sd=sd, mode=I2S.RX,
               bits=SAMPLE_BITS, format=I2S.MONO, rate=rate,
               ibuf=AUDIO_BUFFER_BYTES)

Why bother? Because the previous version of this course didn't. Pin numbers were copy-pasted into every lab file, and over time they drifted apart. Labs stopped working with no error message — just a blank screen. One file, one truth, no drift.

From here on, every lab starts with import config.

Don't see config.py on your board?

Echo offering a tip Your instructor's upload-code.sh script copies this file to the Pico along with every lab's code (see the Troubleshooting section below). If import config fails, that upload step hasn't run yet on your board — the file above is exactly what should end up at /config.py.

Step 2 — Run the display demo

Open 04-oled-display.py from the Pico and run it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# Lab 4: The OLED Display
#
# Draws text and shapes on the 128x64 OLED.
#
# Notice what is NOT in this file: pin numbers. They live in config.py, so
# this program cannot disagree with the next one about how the display is
# wired. That is the whole point of a shared config module.

import config
import time

oled = config.init_display()

# Everything you draw goes into a framebuffer in RAM first. Nothing appears
# on the glass until show() pushes that buffer out over SPI.
oled.fill(config.BLACK)
oled.text("Lab 4", 0, 0, config.WHITE)
oled.text("Hello, display!", 0, 12, config.WHITE)
oled.show()
time.sleep(2)

# --- pixel coordinates -----------------------------------------------------
# (0, 0) is the TOP-LEFT corner. x goes right, y goes DOWN. That y direction
# surprises everyone who remembers graphs from maths class.
oled.fill(config.BLACK)
oled.text("Corners:", 0, 0, config.WHITE)
oled.pixel(0, 0, config.WHITE)                                   # top-left
oled.pixel(config.WIDTH - 1, 0, config.WHITE)                    # top-right
oled.pixel(0, config.HEIGHT - 1, config.WHITE)                   # bottom-left
oled.pixel(config.WIDTH - 1, config.HEIGHT - 1, config.WHITE)    # bottom-right
oled.show()
time.sleep(2)

# --- lines and boxes -------------------------------------------------------
oled.fill(config.BLACK)
oled.text("Shapes", 0, 0, config.WHITE)
oled.hline(0, 10, config.WIDTH, config.WHITE)          # x, y, width
oled.rect(4, 16, 40, 20, config.WHITE)                 # outline
oled.fill_rect(52, 16, 40, 20, config.WHITE)           # solid
oled.line(0, 63, 127, 40, config.WHITE)                # x1, y1, x2, y2
oled.show()
time.sleep(2)

# --- an animated bar -------------------------------------------------------
# This is the shape of every meter we build later: clear, draw, show, repeat.
print("Drawing a bar sweep. Ctrl-C to stop early.")
try:
    for pass_num in range(3):
        for width in range(0, config.WIDTH + 1, 4):
            oled.fill(config.BLACK)
            oled.text("Bar demo", 0, 0, config.WHITE)
            oled.fill_rect(0, 30, width, 16, config.WHITE)
            oled.text("%d px" % width, 0, 52, config.WHITE)
            oled.show()
            time.sleep(0.02)
except KeyboardInterrupt:
    pass

oled.fill(config.BLACK)
oled.text("Lab 4 done!", 16, 28, config.WHITE)
oled.show()
print("Done. The display keeps showing the last thing you drew,")
print("even after the program ends -- it has its own memory.")

You should see four scenes: a greeting, four corner dots, some shapes, then a sweeping bar.

Step 3 — Understand the coordinate system

Look at the corners scene. (0, 0) is the top-left, and y increases downward.

1
2
3
4
5
6
(0,0) ─────────────► x increases
  │
  │        128 × 64
  │
  ▼
y increases

That downward y trips up everyone who remembers graphs from maths class. It comes from how screens are scanned — top row first.

Step 4 — The draw cycle

Every animation in this course is the same four steps:

1
2
3
4
5
oled.fill(config.BLACK)      # 1. clear the buffer
oled.text("Hi", 0, 0, 1)     # 2. draw into the buffer
oled.fill_rect(0, 30, w, 16, 1)
oled.show()                  # 3. send it to the glass
time.sleep(0.02)             # 4. pause, then repeat

Try deleting the oled.fill(config.BLACK) line from the bar demo and running it again. The bars pile up on top of each other because nothing ever clears. That's what "clear the buffer" buys you.

Text is 8 pixels wide, 8 tall

Echo offering a tip Each character occupies an 8×8 box. So 128 ÷ 8 = 16 characters per line, and 64 ÷ 8 = 8 lines. Want text right-aligned? x = 128 - len(text) * 8. You'll use that formula for the frequency readout in Lab 23.

Expected Output

The Shell prints:

1
2
3
Drawing a bar sweep. Ctrl-C to stop early.
Done. The display keeps showing the last thing you drew,
even after the program ends -- it has its own memory.

…and the display cycles through the four scenes, ending on "Lab 4 done!". Notice the message stays on screen after the program finishes — the display holds its own image.

Troubleshooting

Symptom Likely cause Fix
Screen completely blank Power or wiring Check VCC → 3V3 and GND. Then check all five signal wires
ImportError: no module named 'ssd1306' Driver not on the board Re-run upload-code.sh, which copies the driver to /lib. No git clone or terminal handy? In Thonny, go to Tools → Manage Packages, search for micropython-ssd1306py, and install it — that puts ssd1306.py on the board the same way.
Garbled or shifted image SCL and SDA swapped SCL → GPIO 2, SDA → GPIO 3
Drew something but nothing appeared Forgot show() Drawing fills a buffer; show() sends it
Image from an old program still showing Working as designed The display keeps its last frame. oled.fill(0); oled.show() clears it
Faint or flickering display Loose breadboard connection Press the wires home; jumper ends wear out

Challenges

  1. Bounce. Make a small filled square travel across the screen and reverse when it hits an edge. Hint: keep an x and a dx, and flip dx at the boundaries.
  2. Text alignment. Print a number in the bottom-right corner, right-aligned, so it stays tidy as the number grows from 1 digit to 4.
  3. Frame rate. Count how many show() calls you can do in one second. (Use time.ticks_ms().) Write the number down — in Lab 24 you'll find out whether drawing or the FFT is your bottleneck, and you'll need this.

Check Your Understanding

  1. What are the three essential SPI signals, and what does chip select do?
  2. Why does nothing appear on screen until you call show()?
  3. Where is pixel (0, 0), and which direction does y grow?
  4. How many characters fit on one line of this display, and why?
  5. Why does every lab import config instead of writing pin numbers directly?

Your chip has a face now

Echo celebrating That clear-draw-show loop you just ran? It's exactly the loop that will animate a live audio spectrum in Lab 21. Same four lines. Much better content.


Next: Lab 5: Buttons and Interaction | Previous: Lab 3