RP2040-Zero mit MicroPython programmieren: Thonny, GPIO & I2C-Sensoren ohne Compiler

Beim klassischen Arduino-Workflow tippst du eine Zeile, drückst Upload, wartest zwanzig Sekunden auf den Compiler – und stellst fest, dass du einen Tippfehler hattest. Mit MicroPython auf dem RP2040-Zero tippst du led.value(1) in die Konsole und die LED leuchtet. Sofort. Kein Compiler, kein Flash-Zyklus, kein Warten. Dieses Tutorial bringt dich vom leeren Board zum laufenden Sensorprojekt – mit Thonny, GPIO, PWM, I2C, Interrupts und sogar dem zweiten Prozessorkern.

Warum MicroPython auf dem RP2040 besonders gut funktioniert

MicroPython gibt es für viele Boards, aber auf dem RP2040 fühlt es sich am rundesten an. Das liegt an drei Dingen: Der Chip hat 264 KB RAM – viel für einen Mikrocontroller, gerade genug für einen Python-Interpreter mit Luft nach oben. Das Board meldet sich über USB als Massenspeicher, du kopierst die Firmware also einfach per Drag-and-Drop. Und die MicroPython-Portierung für den RP2040 wird direkt vom Raspberry-Pi-Team gepflegt.

Kriterium MicroPython Arduino C++
Erste LED blinkt nach ca. 5 Minuten ca. 15 Minuten (Boardpaket laden)
Änderung testen sofort, Zeile in der Konsole 10–30 s Compile + Upload
Geschwindigkeit ca. 30–100x langsamer volle Chipleistung
Freier RAM ca. 190 KB nach dem Start praktisch alles
Bibliotheken weniger, dafür lesbar riesiges Ökosystem
Gut für Sensoren, Logik, Prototypen, Lernen Timing im Mikrosekundenbereich, Serienreife

ℹ️ Wie langsam ist „langsam“ wirklich? Ein machine.Pin-Toggle in einer Python-Schleife schafft auf dem RP2040 grob 100.000 Umschaltungen pro Sekunde, in C++ sind es einige Millionen. Für einen Temperatursensor, der alle zwei Sekunden misst, ist das völlig egal. Für eine Software-PWM oder ein Bitbanging-Protokoll wird es eng – dafür hat der RP2040 aber die PIO-Blocks, die genau solche Aufgaben in Hardware erledigen.

Das brauchst du

RP2040-Zero Board – Dual-Core Cortex-M0+, USB-C, winzig genug fürs Breadboard
0,96 Zoll OLED SSD1306 – die Standardanzeige, läuft mit dem eingebauten ssd1306-Treiber
BMP280 Sensor (I2C) – Temperatur und Luftdruck, ideal zum Üben mit Registern
DS18B20 Temperatursensor – OneWire ist in MicroPython eingebaut, kein Treiber nötig
DS3231 RTC-Modul – Uhrzeit, die auch ohne Strom weiterläuft
Rotary Encoder KY-040 – perfektes Beispiel für Interrupts in Python

Software: Thonny (kostenlos, läuft unter Windows, macOS und Linux) und die aktuelle MicroPython-UF2-Datei für den Raspberry Pi Pico beziehungsweise RP2040.

1Firmware aufspielen – einmal und nie wieder

Der RP2040 hat einen Bootloader im ROM, den du nicht kaputtmachen kannst. Selbst wenn du dich komplett aussperrst, bringen dich diese vier Schritte immer zurück:

  1. USB-Kabel abziehen.
  2. BOOT-Taster auf dem RP2040-Zero gedrückt halten.
  3. Bei gedrücktem Taster das USB-Kabel einstecken, dann loslassen.
  4. Es erscheint ein Laufwerk namens RPI-RP2. Die UF2-Datei darauf kopieren – das Board startet automatisch neu.

Danach ist kein Laufwerk mehr da, sondern ein serieller Port. In Thonny wählst du unten rechts MicroPython (Raspberry Pi Pico) und den passenden Port. Im unteren Fenster erscheint der Prompt:

MicroPython v1.24.0 on 2024-11-29; Raspberry Pi Pico with RP2040
Type "help()" for more information.
>>> import machine
>>> machine.freq()
125000000
>>> import gc
>>> gc.mem_free()
192512

💡 Das ist der ganze Punkt an MicroPython. Du hast eine lebende Konsole auf dem Mikrocontroller. Tippe machine.freq(250000000) und der Chip läuft ab sofort mit 250 MHz. Tippe help(machine.Pin) und du bekommst die Dokumentation direkt vom Board. Kein Datenblatt, kein Neustart, kein Compiler dazwischen.

⚠️ Zwei Dateinamen sind besonders: boot.py läuft einmal beim Einschalten, main.py direkt danach. Alles andere ist ein normales Modul, das du selbst importieren musst. Wenn dein Programm nach dem Speichern nicht automatisch startet, heißt die Datei mit hoher Wahrscheinlichkeit nicht main.py – oder sie liegt auf dem PC statt auf dem Board. Thonny zeigt beim Speichern beide Ziele zur Auswahl an.

2GPIO, PWM und der ADC

Alles Hardwarenahe steckt im Modul machine. Die drei wichtigsten Klassen decken 90 Prozent aller Projekte ab:

from machine import Pin, PWM, ADC
import time

# --- Digitaler Ausgang ---
led = Pin(25, Pin.OUT)          # beim RP2040-Zero: eigene GPIO pruefen
led.value(1)
time.sleep(0.5)
led.value(0)
led.toggle()                    # umschalten, ohne den Zustand zu kennen

# --- Digitaler Eingang mit internem Pullup ---
taster = Pin(14, Pin.IN, Pin.PULL_UP)
print("Taster:", taster.value())        # 1 = offen, 0 = gedrueckt

# --- PWM zum Dimmen ---
dimmer = PWM(Pin(15))
dimmer.freq(1000)                       # 1 kHz, flimmerfrei fuers Auge

def fade():
    # duty_u16 nimmt 0 bis 65535 - 16 Bit Aufloesung
    for wert in range(0, 65536, 512):
        dimmer.duty_u16(wert)
        time.sleep_ms(5)
    for wert in range(65535, -1, -512):
        dimmer.duty_u16(wert)
        time.sleep_ms(5)

# --- Analogeingang ---
# Der RP2040 hat ADC auf GP26, GP27 und GP28 - sonst nirgends
poti = ADC(Pin(26))

def spannung():
    roh = poti.read_u16()               # 0..65535, intern 12 Bit hochskaliert
    return roh * 3.3 / 65535

# --- Interne Chiptemperatur, ohne externen Sensor ---
sensor_temp = ADC(4)

def chip_temperatur():
    spannung_adc = sensor_temp.read_u16() * 3.3 / 65535
    return 27 - (spannung_adc - 0.706) / 0.001721

while True:
    print("Poti: {:.2f} V   Chip: {:.1f} C".format(
        spannung(), chip_temperatur()))
    time.sleep(1)

ℹ️ duty_u16() statt analogWrite(). Arduino kennt 0–255, MicroPython auf dem RP2040 arbeitet mit 0–65535. Das ist kein Schreibfehler, sondern echte 16-Bit-Aufläsung – gerade beim Dimmen von LEDs im unteren Bereich sieht man den Unterschied deutlich. Willst du in Arduino-Werten denken: duty_u16(arduino_wert * 257).

⚠️ Der ADC des RP2040 liegt nur auf GP26, GP27 und GP28. Jeder andere Pin liefert bei ADC(Pin(x)) einen Fehler oder Unsinn. Und: Die Referenz sind 3,3 V. Ein Sensor mit 5 V Ausgang zerstört den Eingang – hier brauchst du zwingend einen Spannungsteiler aus dem Widerstands-Sortiment.

3I2C: Sensoren und Display am selben Bus

Der RP2040 hat zwei I2C-Einheiten, und du legst frei fest, auf welchen Pins sie liegen. Erst scannen, dann ansprechen – das erspart die Hälfte aller Fehlersuchen:

from machine import Pin, I2C
import time

# I2C-Einheit 0 auf GP0 (SDA) und GP1 (SCL)
i2c = I2C(0, sda=Pin(0), scl=Pin(1), freq=400000)

print("Gefundene Adressen:")
for adresse in i2c.scan():
    print("  0x{:02X}".format(adresse))

# Typische Adressen:
#   0x3C = OLED SSD1306
#   0x68 = DS3231 RTC (und MPU-6050)
#   0x76 / 0x77 = BMP280
#   0x57 = AT24C32 EEPROM auf dem DS3231-Modul

# --- BMP280 direkt ueber Register auslesen ---
BMP_ADDR = 0x76

def bmp_id():
    # Register 0xD0 enthaelt die Chip-ID: 0x58 = BMP280, 0x60 = BME280
    return i2c.readfrom_mem(BMP_ADDR, 0xD0, 1)[0]

def bmp_start():
    # ctrl_meas 0xF4: Oversampling x1 fuer T und P, Normal Mode
    i2c.writeto_mem(BMP_ADDR, 0xF4, bytes([0x27]))
    # config 0xF5: Standby 500 ms, IIR-Filter aus
    i2c.writeto_mem(BMP_ADDR, 0xF5, bytes([0x90]))

def bmp_rohwerte():
    # 6 Bytes ab 0xF7: Druck (3) und Temperatur (3)
    daten = i2c.readfrom_mem(BMP_ADDR, 0xF7, 6)
    druck_roh = (daten[0] << 12) | (daten[1] << 4) | (daten[2] >> 4)
    temp_roh  = (daten[3] << 12) | (daten[4] << 4) | (daten[5] >> 4)
    return temp_roh, druck_roh

print("Chip-ID: 0x{:02X}".format(bmp_id()))
bmp_start()
time.sleep(0.2)
print("Rohwerte:", bmp_rohwerte())

# --- OLED SSD1306 ---
# ssd1306.py einmal auf das Board kopieren:
# Thonny -> Werkzeuge -> Pakete verwalten -> "micropython-ssd1306"
from ssd1306 import SSD1306_I2C

oled = SSD1306_I2C(128, 64, i2c)

def zeige(zeilen):
    oled.fill(0)
    for nr, text in enumerate(zeilen):
        oled.text(text, 0, nr * 10)
    oled.show()

zeige(["RP2040-Zero", "MicroPython", "laeuft."])

💡 readfrom_mem und writeto_mem sind die zwei Befehle, mit denen du jeden I2C-Sensor bedienen kannst – auch einen, für den es keinen MicroPython-Treiber gibt. Du brauchst nur das Datenblatt: Registeradresse, wie viele Bytes, wie zusammensetzen. Genau das macht auch jede Arduino-Bibliothek, nur versteckt. Die Grundlagen zum Bus stehen im Artikel I2C-Bus mit Arduino verstehen.

ℹ️ Die vollständige Kompensation des BMP280 braucht die Kalibrierdaten aus den Registern 0x88 bis 0xA1 und eine Rechenformel aus dem Datenblatt. Für den Einstieg reicht der fertige Treiber bmp280.py aus dem Paketmanager. Aber einmal selbst die Rohwerte zu holen, zeigt dir genau, was eine Sensorbibliothek eigentlich tut – und macht dich unabhängig von der Frage, ob es sie für deinen Chip gibt.

4Interrupts, Timer und der DS18B20

Auch in MicroPython musst du nicht in einer Endlosschleife pollen. Pin-Interrupts und Hardware-Timer gibt es fertig, und OneWire ist sogar eingebaut:

from machine import Pin, Timer
import time
import onewire, ds18x20

# --- Taster-Interrupt mit Entprellung ---
taster = Pin(14, Pin.IN, Pin.PULL_UP)
zaehler = 0
letzte_flanke = 0

def taster_gedrueckt(pin):
    global zaehler, letzte_flanke
    jetzt = time.ticks_ms()
    # ticks_diff rechnet ueberlaufsicher
    if time.ticks_diff(jetzt, letzte_flanke) < 200:
        return                       # prellt noch, ignorieren
    letzte_flanke = jetzt
    zaehler += 1

taster.irq(trigger=Pin.IRQ_FALLING, handler=taster_gedrueckt)

# --- Hardware-Timer statt sleep ---
herzschlag = Pin(16, Pin.OUT)

def blinken(timer):
    herzschlag.toggle()

tim = Timer()
tim.init(freq=2, mode=Timer.PERIODIC, callback=blinken)

# --- DS18B20 ueber OneWire, ohne externe Bibliothek ---
ow_pin = Pin(18)
bus = onewire.OneWire(ow_pin)
sensoren = ds18x20.DS18X20(bus)

adressen = sensoren.scan()
print("Gefundene DS18B20:", len(adressen))
for a in adressen:
    print("  ", "".join("{:02X}".format(b) for b in a))

def temperaturen():
    sensoren.convert_temp()
    time.sleep_ms(750)               # Wandlungszeit bei 12 Bit
    return [sensoren.read_temp(a) for a in adressen]

while True:
    werte = temperaturen()
    for i, t in enumerate(werte):
        print("Sensor {}: {:.2f} C".format(i, t))
    print("Tastendruecke:", zaehler)
    time.sleep(2)

⚠️ In einer Interrupt-Routine keinen Speicher anfordern. print(), String-Formatierung, Listen anlegen – all das kann den Garbage Collector auslösen und wirft dann MemoryError: memory allocation failed, allocating N bytes mitten im Betrieb. In der ISR nur Zähler hochsetzen oder ein Flag setzen, die Auswertung gehört in die Hauptschleife. Dasselbe Prinzip wie bei Arduino-Interrupts, nur mit anderer Fehlermeldung.

ℹ️ time.ticks_diff() statt einfacher Subtraktion. ticks_ms() läuft in MicroPython nach einer implementierungsabhängigen Zeit über. ticks_diff(neu, alt) liefert auch über diesen Punkt hinweg die korrekte Differenz. Wer stattdessen jetzt - letzte rechnet, baut sich einen Fehler ein, der erst nach Tagen Laufzeit auftritt – und dann kaum reproduzierbar ist.

5Der zweite Kern: _thread in der Praxis

Der RP2040 hat zwei Cortex-M0+-Kerne, und MicroPython gibt dir Zugriff auf den zweiten. Das ist kein echtes Multithreading mit Scheduler – du bekommst genau einen zusätzlichen Thread. Aber der läuft wirklich parallel:

import _thread
import time
from machine import Pin, ADC

led = Pin(16, Pin.OUT)
poti = ADC(Pin(26))

# Gemeinsame Daten und ein Lock, das den Zugriff schuetzt
messwerte = {"roh": 0, "anzahl": 0}
lock = _thread.allocate_lock()
laeuft = True

def kern1_messen():
    """Laeuft auf dem zweiten Kern und misst ununterbrochen."""
    global messwerte
    while laeuft:
        wert = poti.read_u16()
        with lock:                       # Zugriff serialisieren
            messwerte["roh"] = wert
            messwerte["anzahl"] += 1
        time.sleep_ms(10)

_thread.start_new_thread(kern1_messen, ())

# Kern 0 macht die Anzeige - unabhaengig vom Messen
try:
    while True:
        with lock:
            roh = messwerte["roh"]
            n   = messwerte["anzahl"]

        volt = roh * 3.3 / 65535
        print("{:.3f} V   ({} Messungen)".format(volt, n))

        led.toggle()
        time.sleep(1)
except KeyboardInterrupt:
    laeuft = False
    time.sleep_ms(100)
    print("gestoppt")

⚠️ Ohne lock geht das irgendwann schief. Beide Kerne greifen auf dasselbe Dictionary zu. Ohne Absicherung liest Kern 0 im ungünstigsten Moment einen halb geschriebenen Zustand – oder der Interpreter selbst gerät durcheinander und das Board hängt sich auf. Das with lock: kostet fast nichts und erspart dir Fehler, die nur alle paar Stunden auftreten.

⚠️ Nur ein zusätzlicher Thread ist möglich. Ein zweiter start_new_thread wirft OSError: core1 in use. Und der zweite Kern hat keinen eigenen Heap: Legst du dort große Objekte an, geht das vom gemeinsamen Speicher ab.

💡 Wann lohnt der zweite Kern überhaupt? Immer dann, wenn eine Aufgabe blockiert: eine Wandlungspause von 750 ms beim DS18B20, ein langsames Display-Update, ein Sensor mit fester Wartezeit. Kern 1 wartet, Kern 0 bleibt bedienbar. Für reine Rechenlast bringt es dagegen weniger, als man denkt – der Python-Interpreter ist ohnehin der Flaschenhals.

Troubleshooting

⚠️ Thonny findet das Board nicht: Erst prüfen, ob überhaupt MicroPython drauf ist – ein frisches Board meldet sich als USB-Laufwerk, nicht als serieller Port. Zweitens: Ein reines Ladekabel ohne Datenadern sieht genauso aus wie ein Datenkabel. Drittens: Falls noch ein anderes Programm den Port belegt (Arduino IDE, PuTTY), schließ es.

⚠️ Das Board reagiert auf nichts mehr: Wahrscheinlich läuft in main.py eine Endlosschleife ohne Pause. Abhilfe: In Thonny Strg+C drücken, um das laufende Programm zu unterbrechen. Klappt das nicht, hilft Werkzeuge → MicroPython-Gerät löschen oder ein erneuter Firmware-Flash über BOOTSEL – dabei geht der Dateisystem-Inhalt verloren, das Board ist aber nie „kaputt“.

⚠️ i2c.scan() liefert eine leere Liste: SDA und SCL vertauscht, Pullups fehlen (die meisten Module haben sie an Bord), oder das Modul hängt an 5 V statt an 3,3 V. Prüfe auch, ob du wirklich die I2C-Einheit angelegt hast, deren Pins du verkabelt hast – I2C(0, ...) und I2C(1, ...) haben unterschiedliche erlaubte Pin-Paare.

⚠️ MemoryError nach längerer Laufzeit: Fragmentierter Heap. Vermeide es, in Schleifen ständig neue Strings zusammenzusetzen. Ruf gelegentlich gc.collect() auf und beobachte gc.mem_free(). Sinkt der Wert stetig, hältst du irgendwo Referenzen fest, die du nicht mehr brauchst.

⚠️ ImportError: no module named 'ssd1306': Treiber sind nicht Teil der Firmware. In Thonny unter Werkzeuge → Pakete verwalten installieren, oder die .py-Datei manuell auf das Board kopieren. Achte darauf, dass sie im Hauptverzeichnis des Boards landet, nicht auf deinem PC.

⚠️ Die PWM-Frequenz lässt sich nicht frei wählen: Der RP2040 teilt sich acht PWM-Blöcke mit je zwei Kanälen. Zwei Pins am selben Block müssen dieselbe Frequenz haben – nur das Tastverhältnis ist unabhängig. Willst du zwei sehr unterschiedliche Frequenzen, wähle Pins aus verschiedenen Blöcken.

Praxistipps

  • Erst in der Konsole, dann in die Datei. Jede neue Zeile Hardwarecode zuerst live im REPL testen. Erst wenn sie funktioniert, wandert sie in main.py. Das ist der eigentliche Geschwindigkeitsvorteil gegenüber C++.
  • Eine safe.py als Notausgang. Leg dir eine boot.py an, die beim Start einen Taster abfragt und main.py überspringt, wenn er gedrückt ist. So sperrst du dich nie mit einer Endlosschleife aus.
  • const() für Konstanten. from micropython import const und dann _ADDR = const(0x76) – der Wert wird beim Kompilieren eingesetzt statt zur Laufzeit nachgeschlagen. Spart RAM und ist messbar schneller.
  • Speicher im Blick behalten. import gc; gc.mem_free() gehört bei jedem größeren Projekt in die Statusausgabe. Der Trend über Stunden verrät mehr als jeder Einzelwert.
  • Uhrzeit vom DS3231 holen. Der RP2040 hat keine batteriegepufferte Uhr. Ein DS3231 über I2C liefert nach jedem Neustart sofort das korrekte Datum – siehe DS3231 RTC mit Arduino.
  • Wenn es schneller sein muss: PIO. Die programmierbaren I/O-Blocks des RP2040 lassen sich aus MicroPython heraus mit rp2.asm_pio befüllen und erledigen zeitkritische Protokolle in echter Hardware – unabhängig davon, wie langsam der Interpreter gerade ist.
  • Kein WLAN an Bord. Der RP2040-Zero ist ein reiner Mikrocontroller ohne Funk. Für vernetzte Projekte ist ein ESP32-C3 die bessere Wahl – auf dem läuft MicroPython übrigens genauso.
  • Vom Breadboard auf die Platine. Wenn das Projekt steht, wandert es auf Lochrasterplatine – wie das sauber gelingt, steht im Artikel Vom Breadboard zur Lochrasterplatine.

🚀 Bauteile für deinen MicroPython-Einstieg

Fazit

MicroPython auf dem RP2040-Zero ändert vor allem eines: die Geschwindigkeit, mit der du Ideen ausprobierst. Ein Sensor, der sich nicht meldet, ist kein Nachmittag Fehlersuche mehr, sondern drei Zeilen im REPL. Genau das macht das Board zum besten Einstiegswerkzeug, wenn du Hardware verstehen willst, statt Bibliotheken zu bedienen.

Die Grenzen sind klar: kein Mikrosekunden-Timing, weniger fertige Treiber und kein WLAN auf diesem Board. Aber für Sensorik, Anzeigen, Steuerungslogik und Prototypen ist der Kompromiss fast immer der richtige. Und falls du irgendwann an eine Wand läufst – derselbe Chip nimmt auch C++ entgegen, wie im Artikel RP2040-Zero programmieren beschrieben. Du musst dich nicht entscheiden, sondern nur die Firmware wechseln.

Zurück zum Blog