This guide note is a work in progress
I've been wanting to output video from my microcontroller projects for ages. But I didn't want to jump through obscure hoops and I really wanted multiple video outputs on one board.
Well, the Adafruit Metro RP2350 has a Raspberry Pi RP2350B microcontroller with lots of pins, memory and programmable PIO state machines.
There isn't a great deal on VGA video for the RP2350, only on the RP2040. And certainly no other code for using CircuitPython + PIO. So hopefully this may help some folks.
Here is my work just on VGA. Certain design decisions were made which might be changed by others. Yes I vibe coded it as I don't really know PIO syntax.
Design Considerations
- The clock of the RP2350B remains standard at 150MHz in this implementation
- Given the CPU frequency, certain video output compromises are in the code to do the best with the processing and time budgets. This may effect certain monitors differently.
- Using CircuitPython introduces PIO SM design rules if HSYNC and VSYNC were both driven in PIO. The alternative was to do HSYNC in PIO, VSYNC in CircuitPython. The algorithm syncs the two but is not ideal.
MIT License Copyright (c) 2026 Anne Barela
Files
See this project's GitHub Repository for all the files.
- LICENSE
- PINS files show the pins used, although the Red and Blue pins are switched by Claude for strange reasons. Red has GPIO 2, 3, and 4. Blue has GPIO 8, and 9.
- SCHEMATIC, in several file formats
- vga_color_bars.py, the code
- RP2350 BASIC VGA.zip, all the files prezipped
# SPDX-FileCopyrightText: 2026 Anne Barela
# SPDX-License-Identifier: MIT
"""
Metro RP2350 VGA RGB332 — SMPTE colour bars.
Displays SMPTE standard colour bars on a VGA monitor using an Adafruit
Metro RP2350 and a resistor DAC. See PINS.md for wiring and SCHEMATIC.md
for the resistor DAC circuit.
Hardware:
GPIO2–9: RGB332 resistor DAC (8-bit colour, MSB first)
GPIO10: HSYNC (PIO sideset)
GPIO11: VSYNC (CPU digitalio, timed spin loop)
Output: 640×480 @ ~59.9 Hz using a 320×240 framebuffer, pixel- and
line-doubled in DMA.
After connecting the monitor, press Auto-Adjust on the monitor OSD.
If the right edge is clipped, increase the monitor Clock setting 2–3 steps.
Colour encoding (hardware-specific DAC wiring):
bits[7:5] → GPIO2–4 → VGA Blue (3-bit, 0–7)
bits[4:2] → GPIO5–7 → VGA Green (3-bit, 0–7)
bits[1:0] → GPIO8–9 → VGA Red (2-bit, 0–3)
byte = (blue3 << 5) | (green3 << 2) | red2
"""
import board
import rp2pio
import adafruit_pioasm
import digitalio
import gc
import time
gc.collect()
# ── Pin definitions ──────────────────────────────────────────────────────────
VGA_RGB_BASE = board.D2 # GPIO2 — first of 8-bit RGB332 bus (D2–D9)
VGA_HSYNC = board.D10 # GPIO10 — HSYNC (PIO sideset)
VGA_VSYNC = board.D11 # GPIO11 — VSYNC (CPU digitalio)
# ── VGA timing ───────────────────────────────────────────────────────────────
VGA_PIXEL_CLOCK = 25_175_000 # Hz
VGA_V_VISIBLE = 480
VGA_V_FRONT = 10
VGA_V_SYNC = 2
VGA_V_BACK = 33
VGA_V_TOTAL = 525 # 480 + 10 + 2 + 33
VGA_BUF_W = 320 # logical framebuffer width (pixel-doubled to 640)
VGA_BUF_H = 240 # logical framebuffer height (line-doubled to 480)
# ── PIO program: pixel output + HSYNC ────────────────────────────────────────
#
# Timing per line at 25.175 MHz (800 cycles total):
# Visible + overhead: 657 cycles (16 iter × 20 px × 2 clk + 17 loop overhead)
# Front porch: 8 cycles
# HSYNC pulse: 96 cycles (spec 96)
# Back porch: 39 cycles
#
# .side_set 1 uses one bit from the 5-bit delay field; max delay = [15].
# Instruction count: 29 of 32 maximum.
vga_program = adafruit_pioasm.assemble("""
.program vga_rgb_hsync
.side_set 1
.wrap_target
; Visible: 16 iterations × 20 pixels × 2 cycles + 17 overhead = 657 cycles
set y, 15 side 1 ; 16 iterations (y = 15 down to 0)
pixel_loop:
out pins, 8 side 1 [1] ; pixels 1–20, 2 cycles each
out pins, 8 side 1 [1]
out pins, 8 side 1 [1]
out pins, 8 side 1 [1]
out pins, 8 side 1 [1]
out pins, 8 side 1 [1]
out pins, 8 side 1 [1]
out pins, 8 side 1 [1]
out pins, 8 side 1 [1]
out pins, 8 side 1 [1]
out pins, 8 side 1 [1]
out pins, 8 side 1 [1]
out pins, 8 side 1 [1]
out pins, 8 side 1 [1]
out pins, 8 side 1 [1]
out pins, 8 side 1 [1]
out pins, 8 side 1 [1]
out pins, 8 side 1 [1]
out pins, 8 side 1 [1]
out pins, 8 side 1 [1]
jmp y-- pixel_loop side 1 ; loop (16 cycles overhead total)
; Front porch: 8 cycles, HSYNC high
nop side 1 [7]
; HSYNC pulse: 96 cycles, HSYNC low (16 + 5×16 - 5×1 = 16 + 75 + 5 = 96)
set x, 4 side 0 [15] ; 16 cycles
hsync_loop:
nop side 0 [14] ; 15 cycles × 5 iterations = 75
jmp x-- hsync_loop side 0 ; 1 cycle × 5 iterations = 5
; Back porch: 39 cycles, HSYNC high
nop side 1 [15]
nop side 1 [15]
nop side 1 [6]
.wrap
""")
# ── Colour definitions ───────────────────────────────────────────────────────
# SMPTE standard colour bar order (left to right).
# Byte encoding: bits[7:5]=Blue, bits[4:2]=Green, bits[1:0]=Red
_COLOR_BARS = [
0xFF, # White B=7 G=7 R=3
0x1F, # Yellow B=0 G=7 R=3
0xFC, # Cyan B=7 G=7 R=0
0x1C, # Green B=0 G=7 R=0
0xE3, # Magenta B=7 G=0 R=3
0x03, # Red B=0 G=0 R=3
0xE0, # Blue B=7 G=0 R=0
0x00, # Black B=0 G=0 R=0
]
# ── VGA output ───────────────────────────────────────────────────────────────
class VGAOutput:
"""320×240 RGB332 framebuffer with DMA-ready full_frame buffer.
Call draw_color_bars() then sync_frame() to populate full_frame.
Pass full_frame to sm.background_write(loop=...) for continuous DMA output.
"""
def __init__(self):
self.width = VGA_BUF_W
self.height = VGA_BUF_H
self.framebuffer = bytearray(self.width * self.height)
# DMA source: VGA_V_TOTAL lines × width bytes.
# Visible lines 0–479 are filled by sync_frame().
# Blanking lines 480–524 remain zero (bytearray default).
self.full_frame = bytearray(self.width * VGA_V_TOTAL)
def sync_frame(self):
"""Copy framebuffer into full_frame with pixel- and line-doubling."""
w = self.width
fb = memoryview(self.framebuffer)
ff = self.full_frame
for y in range(self.height):
row = fb[y * w:(y + 1) * w]
ff[(2 * y) * w:(2 * y + 1) * w] = row
ff[(2 * y + 1) * w:(2 * y + 2) * w] = row
def draw_color_bars(self):
"""Fill framebuffer with SMPTE colour bars."""
fb = self.framebuffer
w = self.width
h = self.height
n = len(_COLOR_BARS)
bw = w // n
for i, color in enumerate(_COLOR_BARS):
x0 = i * bw
seg_w = bw if i < n - 1 else w - x0
seg = bytes([color]) * seg_w
for row in range(h):
start = row * w + x0
fb[start:start + seg_w] = seg
# ── Main ─────────────────────────────────────────────────────────────────────
def main():
print("Metro RP2350 VGA — SMPTE colour bars A. B. 2026")
vga = VGAOutput()
vga.draw_color_bars()
vga.sync_frame()
gc.collect()
print(f"Free memory: {gc.mem_free()} bytes")
# VSYNC output — idle high (VGA negative polarity)
vsync = digitalio.DigitalInOut(VGA_VSYNC)
vsync.direction = digitalio.Direction.OUTPUT
vsync.value = True
# PIO state machine: RGB332 pixel data + HSYNC sideset
sm = rp2pio.StateMachine(
vga_program,
frequency=VGA_PIXEL_CLOCK,
first_out_pin=VGA_RGB_BASE,
out_pin_count=8,
first_sideset_pin=VGA_HSYNC,
sideset_pin_count=1,
auto_pull=True,
pull_threshold=8,
out_shift_right=False, # MSB first: bits[7:5]=Blue → GPIO2–4
)
# Frame timing in integer nanoseconds (avoids float drift)
actual = sm.frequency
_NS = 1_000_000_000
frame_ns = VGA_V_TOTAL * 800 * _NS // actual
vsync_low_ns = (VGA_V_VISIBLE + VGA_V_FRONT) * 800 * _NS // actual
vsync_high_ns = (VGA_V_VISIBLE + VGA_V_FRONT + VGA_V_SYNC) * 800 * _NS // actual
# Start continuous DMA. Record t0 immediately before background_write()
# so VSYNC can be aligned to the DMA frame boundary on the first frame.
# Without alignment, the VSYNC anchor lands at a random phase each boot,
# causing vertical image shift or bottom clipping.
t0 = time.monotonic_ns()
sm.background_write(loop=vga.full_frame)
time.sleep(0.1)
print(f"DMA looping: {sm.writing}")
now = time.monotonic_ns()
phase = (now - t0) % frame_ns # position within current DMA frame
next_low = now + (frame_ns - phase) + vsync_low_ns
next_high = now + (frame_ns - phase) + vsync_high_ns
_LEAD_NS = 1_000_000 # 1 ms fine-spin window before pulse
print("Running. Press Auto-Adjust on monitor OSD if image needs centering.")
while True:
# Sleep for bulk of frame (no heap allocations), then fine-spin for
# precise VSYNC timing. GC cannot fire during the ~64 µs pulse window.
sleep_ns = next_low - time.monotonic_ns() - _LEAD_NS
if sleep_ns > 1_000_000:
time.sleep(sleep_ns / _NS)
while time.monotonic_ns() < next_low:
pass
vsync.value = False
while time.monotonic_ns() < next_high:
pass
vsync.value = True
next_low += frame_ns
next_high += frame_ns
main()
This page (RP2350B VGA with CircuitPython) was last updated on March 03, 2026.
Text editor powered by tinymce.