Python meets
Hardware
Survival Guide

Carlos Pereira Atencio
#PyConES2026

  • Python
  • MicroPython
  • CircuitPython

Intro

A bit about me

Carlos Pereira Atencio

  • Embedded Software in Aerospace, Telecoms and EdTech
  • Embedded Lead @ Micro:bit Educational Foundation
  • 10 years of BBC micro:bit + MicroPython microbit❤️🐍

What to expect from this talk?

1. Blink an LED

A MicroPython board on a breadboard, wired to one blinking LED 11551010151520202525303035354040abcdefghij−++−−++−

2. Build the rest of the owl

The "How to draw an owl" meme: 1. two circles; 2. the owl, traced and built as an owl-shaped circuit board perched on a pin-header branch OWL-1U2Y1J1J2J3
  • Device Resources
  • Drivers
  • Debugging
  • Tooling
  • AI

But there is a lot more!

The talk runs left to right along the horizontal slides; some have vertical slides below them The talk → Extra details ↓ resources   links  

Scan the QR to follow along during the talk,
or at your own pace. https://pycones2026.embeddedlog.com

This is a vertical slide!

This is an example of slides that sit below the main track.
These contain additional content with more depth, resources, and links.

  • The arrows in the corner show which ways you can go.
  • Arrow down appears? press ↓, or swipe up to view additional slides.
  • Back to the talk? press →, or swipe left.

Python
on Hardware

What is it?
How is it different from normal Python?

MicroPython, CircuitPython, all the Pythons

Boards placed by RAM and by flash on one log scale: microcontrollers have kilobytes to megabytes, single-board computers gigabytes SINGLE-BOARDCOMPUTERS MICROCONTROLLERS RAM FLASH micro:bit V1 16 MHz micro:bit V2 64 MHz Pyboard D 216 MHz Pico 2 150 MHz Nano ESP32 240 MHz Pi Zero 2 W 1 GHz Raspberry Pi 5 2.4 GHz 10 KB 10 KB 100 KB 100 KB 1 MB 1 MB 10 MB 10 MB 100 MB 100 MB 1 GB 1 GB 10 GB 10 GB

MicroPython, CircuitPython, all the Pythons

MicroPython and CircuitPython

MicroPython

A lean Python 3 for microcontrollers, by Damien George.

The most from each chip. machine works on every board, and each port (the build for one chip family) adds modules for what its chip can do.

CircuitPython

Adafruit's fork of MicroPython, for beginners and education.

The same code everywhere. One API on every board; a board that can't support a module leaves it out.

Blinka brings that API and Adafruit's drivers to CPython on single-board computers like the Raspberry Pi.

Sources: MicroPython libraries · CircuitPython core modules

Where they differ

MicroPythonCircuitPython
Hardwaremachine, plus a module per portboard, digitalio, busio on every board
PinsThe port ids: Pin(15), Pin("GP15")The board's labels: board.D5, board.LED
ConcurrencyPin interrupts, timers, _thread, asyncioNo interrupts or threads; asyncio
WorkflowCopy files with mpremote or ThonnyA USB drive: save code.py and it reruns
FilesYour code reads and writes themRead-only to code, unless boot.py remounts
Librariesmip, from micropython-libcircup, from a bundle

Sources: CircuitPython README, FAQ, storage | MicroPython docs

What's under your code

On your computer

your_script.py
pip + site-packages, from PyPI
CPython
OS: processes, files, catches crashes
Kernel + drivers
Hardware: CPU, RAM, disk

On a microcontroller (MCU)

main.py
mip + micropython-lib, not PyPI
MicroPython VM + machine
No OS: all handled by MicroPython
Vendor SDK: the chip maker's C library
The MCU, with built-in RAM and flash

What happens at power-on

MicroPythonCircuitPython
1) boot.pyRuns first, and should exitRuns once, before USB starts
2) your codemain.py; it may loop forevercode.py in a fresh VM; reruns on save
3) when it endsThe REPL, with main.py's variablesThe REPL, in another fresh VM
Ctrl-DSoft reset: boot.py, then main.pyReload: code.py again

Sources: MicroPython reset and boot · CircuitPython README

Device Resources

640 KB ought to be enough for anybody!

What's different to "normal" Python

  • Memory! KBs, maybe MBs, of RAM & flash
  • CPU frequencies in MHz, not GHz
  • Python standard library is limited
  • Same language we love, just with tighter constraints
BBC micro:bit V2micro:bit V2128 KB
Adafruit Feather RP2040Feather RP2040264 KB
Raspberry Pi Pico 2Pico 2520 KB
Arduino Nano ESP32Nano ESP328 MB PSRAM
16 GB laptop
Espressif ESP32-S3-DevKitC-1

16 MB PSRAM

ESP32-S3-DevKitC-116 MB PSRAM

Something very different: Interrupts

A pin edge or a timer can pause your programme and run an interrupt.


from machine import Pin
presses = 0

def on_press(pin):  # the interrupt handler
    global presses
    presses += 1    # an int: no new object

button = Pin(BUTTON_PIN, Pin.IN, Pin.PULL_UP)
# a press pulls the pin low: a falling edge
button.irq(on_press, Pin.IRQ_FALLING)
        

Not on CircuitPython: use countio, keypad

  • Soft Interruptdefault for pins: queued, so it can lag. May allocate
  • Hard Interrupthard=True: runs at once, heap locked: no floats or append()
  • Hard → softmicropython.schedule(f, arg)
    f runs soft, soon after
  • Be briefyour program waits, no print(), sleep() or filesystem actions

Read: Writing interrupt handlers | Pin.irq() | Timer | schedule()

Common ways to run out of RAM

  • MemoryError when an allocation didn't find enough contiguous free memory.

Large allocations

One list/array, buffer or string larger than available memory.

Importing modules

When we get tracebacks on an import line.

Fragmentation & leaks

Free memory goes down over time or gets fragmented.

  • import gc provides functions to check and manage memory.

Read your own heap

>>> import micropython
>>> micropython.mem_info(1)
GC: total: 64000, used: 9824, free: 54176
 No. of 1-blocks: 112, 2-blocks: 31, max blk sz: 64, max free sz: 3317
00000000: MDhhBTT=h===DhBBhSShhLh=hF.FF.S=..hhh=....Ah=======.....S=S=....
          S str · L list · D dict · F float · h other · = rest of one object
  • max free szthe largest hole: 3317 blocks × 16 = 53,072 bytes
  • the mapyour heap, one character per 16-byte block; . is free
  • MemoryErrorasks for < free but > max free sz: memory is in pieces
  • CircuitPythonno mem_info(); gc.mem_free() gives totals.
    ESP32: espidf.heap_caps_get_largest_free_block()

Which lines cost memory?

  • import, def, class
  • a new list, dict, str or bytes
  • s + t, f"{t}", data[4:20]
  • uart.read(64)
  • float maths, even n / 2*
  • if, for, while, try
  • locals, small-int maths: n += 1
  • calling a function or a method
  • reading a pin: pin.value()
  • writing into a buffer: buf[i] = b

   Control flow is free. Each new object takes at least one 16-byte block.

   From Damien George, PyCon AU 2018 | *MicroPython only

Small tweaks to common habits

packet[4:20]→a memoryview does not copy data
uart.read(64)→uart.readinto(buf) reuses one buffer
magic numbers as globals→const()
1,000 floats in a list→array("f"): ~4 KB, not ~20 KB

header = packet[4:20]     # ✕ From a bytearray, copies data
mv = memoryview(packet)   # ✓ create memoryview
header = mv[4:20]         # ✓ view into packet, no copy
      

data = uart.read(64)      # ✕ new bytes object each time
buf = bytearray(64)       # ✓ create once
n = uart.readinto(buf)    # ✓ reuse when reading data
      

REG_ID = 0xD0           # ✕ a global, looked up by name
from micropython import const
_REG_ID = const(0xD0)   # ✓ in the bytecode, no global
      

temps.append(sensor.temperature)  # ✕ ~20 KB of floats
temps = array("f", [0] * 1000)    # ✓ raw floats: ~4 KB
temps[i] = sensor.temperature
      

Keeping memory under control

Falling over hours? Something grows: a leak.


gc.collect()
print(gc.mem_free())  # once a minute
        

Not when an allocation fails mid-task.


while True:
    read_sensor()
    gc.collect()  # the quiet point
        

While locked, any allocation raises an error.


# MicroPython only
micropython.heap_lock()
handle(packet)  # allocates? MemoryError
micropython.heap_unlock()
        

Log the error, then reset to an empty heap.


try:
    app.run()
except MemoryError as e:
    log_crash(e)     # to crash.log
    machine.reset()  # a fresh heap
        

Read: MicroPython gc · heap_lock() · MicroPython on microcontrollers

Your code lives in RAM too


import bme280  # a 10 KB .py driver
      
RAM used while importing bme280: as .py it climbs to 28 KB while parsing and compiling, then drops to 6.5 KB, and with only 20 KB free it fails on the way up. As .mpy it never climbs past 6.7 KB; from ROMFS it holds 4.1 KB and frozen 1.2 KB. RAM used → 0 10 KB 20 KB 30 KB time → parse compile peak 28 KB stays 6.5 KB only 20 KB free ✕ MemoryError .mpy ROMFS 4.1 KB frozen 1.2 KB

Parse: the whole file becomes a tree of nodes, in RAM.

Compile: the tree becomes bytecode, while the tree is still there.

Then the tree is freed. The bytecode stays.

The import fails on the peak, not on what stays.

.mpy: file compiled on your machine via mpy-cross. The bytecode still loads into RAM.

ROMFS: bytecode saved into a special filesystem in flash.
Frozen: bytecode compiled into the MicroPython build.

Where your .mpy lives

A .mpy is a .py already compiled by mpy-cross on your computer. Different locations provide different trade-offs.

Where✓ Good✕ Watch outRAM*
Filesystem
mpremote cp
Any firmware; update in seconds Bytecode and names load into RAM. Needs a matching mpy-cross 6.3 KB
ROMFS v1.25+
mpremote romfs
Runs from flash; no firmware rebuild Needs a ROMFS partition: few firmware builds have one 4.1 KB
Frozen
manifest.py
Least RAM; no filesystem needed Rebuild and reflash the firmware for every change 1.2 KB

*RAM after importing a 10 KB driver, unix port.

Memory fragmentation


readings = []
for _ in range(9):
    readings.append(read())        # kept: a float, 1 block
    msg = "T=%.1f" % readings[-1]  # dropped next time: a str, 2 blocks
big_array = bytearray(64)          # 64 bytes: 4 blocks in a row
      

big_array = bytearray(64)          # first, while the heap is empty
readings = []
for _ in range(9):
    readings.append(read())
    msg = "T=%.1f" % readings[-1]
      

1 square = 1 block = 16 bytes

FS=FS=FS=FS=FS=FS=FS=FS=FS=
      
F..F..F..F..F..F..F..F..FS=
      
A===FS=FF.FS=F..F..F..FS=F.
      

The loop fills the heap: each float kept, each string dropped

No room for big_array, so the collector frees the strings:
We have 256 B free, but the biggest hole is 2 blocks = 32 B

✓ the loop fills and frees the space after it; big_array keeps its room

A===
        

✕ MemoryError: memory allocation failed, allocating 64 bytes

Allocate big buffers early, right after your imports.
And reuse them!

Leaks: bound what grows


readings = []
while True:
    readings.append(read())  # ✕ forever
      

from collections import deque
readings = deque((), 6)      # ✓ the last 6
while True:
    readings.append(read())
      

1 square = 1 reading · gold: the newest

FFFFFFFFFFFFFFFFFFFFFFFFFFF
      

the list keeps growing ✕ MemoryError

FFFFFF.....................
      

✓ the deque: newest replaces oldest

  • Whythe collector can't free what the list still points to
  • Spot itif no other allocations happening and gc.collect() + gc.mem_free() shows less free mem over time.
  • Also growsa log string, a cache dict, callbacks added on every reconnect
  • No deque?use an array as a ring buffer

Read: MicroPython collections.deque · MicroPython on microcontrollers

Fewer cycles, so make them count


def checksum(buf):
    s = 0
    for b in buf:
        s += b
    return s & 0xFFFF
        

@micropython.native
def checksum(buf):
    s = 0
    for b in buf:
        s += b
    return s & 0xFFFF
        

@micropython.viper
def checksum(buf) -> int:
    p = ptr8(buf)
    s = 0
    for i in range(int(len(buf))):
        s += p[i]
    return s & 0xFFFF
        
Adafruit's fixed-point Mandelbrot on a Metro RP2040: bytecode 8,335 ms, native 4,778 ms, viper 423 ms. bytecode 8,335 ms native 4,778 ms · 1.7× viper 423 ms · 19.7×

Measurements from Adafruit

  • nativeyour Python compiled to machine code, not bytecode
  • viperraw ints and pointers: no floats, no overflow checks
  • Turbonative & viper for CircuitPython, built on a computer

Small tweaks for speed

Time it first, then change only the slow part.

code at module level→inside a function (def main():) code in global space is slow
a method call in a hot loop→toggle = led.toggle save it locally, before the loop
a Python loop over a buffer→Use built-in functions like sum(buf), more efficient
float maths in a fast loop→Use integers with a larger scale (int millivolts instead of float volts)
parsing sensor bytes by hand→struct.unpack_from("<hhh", buf): one call, signs right

Read: Maximising MicroPython speed · Damien George's 2018 talk slides

Time every call with a decorator

Put @timed above a function, and each call prints how long it took.


import gc, time

def timed(f):
    name = str(f).split()[1]  # portable name
    def wrapper(*args, **kwargs):
        gc.collect()  # no GC mid-timing
        t0 = time.ticks_us()
        result = f(*args, **kwargs)
        dt = time.ticks_diff(time.ticks_us(), t0)
        print(name, dt, "us")
        return result
    return wrapper
      
  • Time a few calls: the first is often slower.
  • Ticks wrap: use ticks_diff().
  • Not for async: on async def it only times creating the coroutine.
  • CircuitPython: use time.monotonic_ns().

MicroPython docs: maximising speed

Drivers

Every light, motor and sensor needs one.

Your Python controlling the real world

A lit NeoPixel LED ring
light · NeoPixel ring
A micro servo
move · micro servo
A small OLED screen
show · OLED screen
A BME280 sensor breakout
sense · temperature
An e-ink display
show · e-ink screen
A small stepper motor and its driver board
move · stepper motor
An ultrasonic distance sensor
sense · distance
A PIR motion sensor
sense · motion
A small camera module
see · camera
SparkFun's LIS3DH accelerometer breakout
sense · acceleration
Adafruit's I2C rotary encoder board, with a knob and a lit LED
control · rotary knob

Different parts might use different signals, protocols, and timings.A driver abstracts that to provide their functionality.

First step: find an existing driver

  1. Read the official docs. Vendors might provide all you need.
  2. Find the chip name. Board name might not be the same as the chip (e.g. BME280).
  3. Start with Google, but also use GitHub's search with filters.
  4. Check micropython-lib, awesome-micropython, awesome-circuitpython, vendor's repos (Adafruit, SparkFun, Pimoroni, Arduino).
  5. Check it's for your Python.
    E.g. machine.I2C is MicroPython; busio is CircuitPython.
  6. Only in C or Arduino? Port it, it's a great reference implementation.
Adafruit's BME280 breakout, with BME280 printed on it

Getting the driver onto your board

MicroPython

$ pip install mpremote
$ mpremote mip install \
    github:robert-hh/BME280

import bme280_float as bme280
bme = bme280.BME280(i2c=i2c)
        

By hand: mpremote cp bme280_float.py :

CircuitPython

$ pip install circup
$ circup install adafruit_bme280

from adafruit_bme280 import basic as bme280
bme = bme280.Adafruit_BME280_I2C(i2c)
        

By hand: drag the .mpy into CIRCUITPY/lib

Other ways to install a driver

MicroPython

  • Board with Wi-Fi (ESP32, Pico W): import mip, then mip.install("ssd1306")
  • Any repo with a package.json: mpremote mip install github:org/repo
  • By hand: mpremote cp bme280_float.py :, where : is the board

CircuitPython

  • By hand: copy the .mpy and its dependencies into CIRCUITPY/lib
  • Match the bundle to your firmware: 10.x for CircuitPython 10
  • circup install --auto installs what code.py imports

MicroPython packages · mpremote · circup · CircuitPython libraries

No driver? You can write it in Python!

The layers under one sensor read: your code, the driver, MicroPython and the silicon vendor SDK in software; the microcontroller and its I2C, SPI, UART, PWM and GPIO pins in hardware, wired to a BME280, an OLED, an SD card, a GPS, a servo and a NeoPixel ring SOFTWARE ↑ HARDWARE ↓ YOUR CODEmain.py DRIVERbme280.py MICROPYTHONmachine.I2C SILICON VENDOR SDK MICROCONTROLLERI²CSPIUARTPWMGPIO BME280SSD1306 OLEDSD cardGPSSG90 servoNeoPixel ring Its I²C block drives the wires

from machine import I2C
from bme280 import BME280

sensor = BME280(I2C(0))
print(sensor.temperature)
          

Your code asks for the temperature; the driver handles the details.


class BME280:
    def __init__(self, i2c, addr=0x76):
        chip_id = i2c.readfrom_mem(addr, 0xD0, 1)
        if chip_id != b"\x60":
            raise OSError("BME280 not found")
          

The driver: Python code that sets up and reads the sensor.

MicroPython, written in C, gives your Python an API for the microcontroller.

The chip maker's C library: low-level access to the microcontroller.

One level down: registers

A register is a memory address wired to the hardware.
Write a bit there, and a pin changes.


from machine import Pin, mem32

led = Pin(25, Pin.OUT)  # MicroPython sets the pin up

# RP2040 addresses, from its datasheet
GPIO_OUT_SET = 0xD0000014
GPIO_OUT_CLR = 0xD0000018

mem32[GPIO_OUT_SET] = 1 << 25  # bit 25: "set" LED on
mem32[GPIO_OUT_CLR] = 1 << 25  # "clear" LED off
      
  • The microcontroller datasheets explains all registers.
  • Each chip has its own register memory map: on an RP2350, GPIO_OUT_SET is at 0xD0000018.
  • This is what drivers and SDKs do. Use mem32 only when needed.

machine.mem32 · RP2040 datasheet

A driver is a small class


from micropython import const
_ID = const(0xD0)
_CTRL = const(0xF4)
_TEMP = const(0xFA)

class BME280:
    def __init__(self, i2c, addr=0x76):
        self.i2c, self.addr = i2c, addr
        self._buf = bytearray(3)
        if i2c.readfrom_mem(addr, _ID, 1) != b"\x60":
            raise OSError("BME280 not found")
        i2c.writeto_mem(addr, _CTRL, b"\x27")  # wake

    def raw_temperature(self):
        b = self._buf
        self.i2c.readfrom_mem_into(self.addr, _TEMP, b)
        return b[0] << 12 | b[1] << 4 | b[2] >> 4
      
  1. Registers as const()
  2. Take the bus, don't make it: I2C, SoftI2C or a fake one for tests
  3. Check the ID, wake it up
  4. One buffer, reused

Where the driver numbers come from

A register, in a sensor, is a memory address connected to the hardware.
The datasheet's register map says what each register does.

RegisterThe datasheet saysIn the driver
0xD0 idAlways reads 0x60!= b"\x60": not a BME280
0xF4 ctrl_measConfigure how to measure the temperature in bits 7-5, the pressure (bits 4-2), and mode (1-0)0x27 = 001 001 11
1 sample, 1 sample, continuous
0xFA…0xFC tempTemperature in 20 bits over 3 bytesb[0] << 12 | b[1] << 4 | b[2] >> 4

BME280 datasheet, 5.3 and 4.2.3 · Adafruit's CircuitPython driver

Which communication bus?

  • A bus is a set of signals between chips & rules for exchanging data (the protocol).
  • Microcontrollers have built-in hardware for common ones like: UART, I²C and SPI.
  • You rarely choose: the part's datasheet or product page specifies the bus.
  • You still need to know it: it decides which pins you wire, and what bus you hand the driver.

i2c = I2C(0, scl=Pin(5), sda=Pin(4))
bme = bme280.BME280(i2c=i2c)
    
  • Not every part needs a bus: buttons use a plain pin (GPIO), servos use pulses (PWM), analogue sensors a voltage (ADC).
  • Some buses might not be built into the microcontroller, but can be implemented in software.

UART, I²C and SPI side by side

UARTI²CSPI
Used forGPS, radios, another boardsensors, small OLEDscolour screens, SD cards
WiresTX → RX, crossedSDA, SCLSCK, COPI, CIPO + CS each
Devicestwo, one at each endmany, each at an addressmany, one CS pin each
Speed9,600 - 115,200 baud (1M+)100 Khz or 400 kHz1–10+ MHz
Extra partsnonepull-up resistorsoften pull-ups on Chip Select
Watch out forwrong baud configured, reads garbagetwo chips with the same address clashwrong SPI mode: reads data incorrectly, no errors shown
UART: two devices, each one's TX wired to the other's RX board GPS TX RX TX RX I2C: one controller and three targets, all on the same two wires, SDA and SCL controller SDA SCL target target target SPI: a controller on the left drives SCK, COPI and CIPO out of its right side to two peripherals stacked on the right, with a chip-select wire to each controller CS SCK COPI CIPO CS peripheral peripheral

UART: no clock, so agree in advance

UART sends bytes one bit at a time from TX to the other end's RX. There is no clock wire, so both ends agree on the speed, the baud rate, in advance.

'A' = 0x41 = 0100 0001 · sent least significant bit (LSB) first

UART frame for the letter A: start bit, 1 0 0 0 0 0 1 0, stop bit TX idle START 10000010D0D1D2D3D4D5D6D7 STOPidle

Idle: the line sits HIGH while nothing is sent.

START: the line drops for one bit. The receiver starts timing from this edge.

Eight data bits, least significant first: 'A' goes out backwards.

STOP: back to HIGH for at least one bit, ready for the next byte.

The receiver samples the middle of each bit at its baud rate. Send at 9600, read at 19200: garbage, and no exception.

machine.UART · SparkFun: Serial Communication

SPI: to read, you must write

SPI has a clock, a wire each way and a chip-select (CS) per device. Every tick moves one bit each way.

cs(0); spi.write_readinto(b'\xD0\x00', buf); cs(1) → buf[1] == 0x60

SPI mode 0: controller sends 0xD0 then a dummy byte while the BME280 returns 0x60 CS SCK COPI CIPO 110100000xD0 · bit 7 = read don't care 011000000x60 · chip id

Idle: CS is HIGH, so the sensor ignores the bus.

cs(0): CS goes LOW and selects the BME280.

Byte 1: the microcontroller sends 0xD0, the ID register (bit 7 = read). The reply is ignored.

Byte 2: the microcontroller sends a dummy 0x00. Its 8 clocks shift 0x60 back on CIPO.

cs(1): done. No ACK, so no scan(): reading a known ID register is SPI's scan().

COPI/CIPO: MOSI/MISO on many boards · machine.SPI · BME280 datasheet

I²C: reading the chip ID, bit by bit

SCL is the clock, SDA the data, both shared. The microcontroller (MCU) reads the chip ID:

i2c.readfrom_mem(0x76, 0xD0, 1) → b'\x60'

I2C waveform of a BME280 chip-ID read, with who sends each field: the MCU sends S, 0x76 and W, the BME280 ACKs, the MCU sends 0xD0, the BME280 ACKs, the MCU sends Sr, 0x76 and R, the BME280 ACKs and sends 0x60, the MCU sends NACK and P SCL SDA MCU BME280 SCL SDA MCU BME280 11101100 0 110100000 111011010 01100000 1 S 0x76 W A✓ 0xD0 register A✓ Sr 0x76 R A✓ 0x60 chip id N P MCU BME280 Idle: both linespulled HIGH START: SDA fallswhile SCL is HIGH Address 0x76+ write bit Sensor pulls SDALOW: ACK Register 0xD0,then ACK Repeated START Address 0x76+ read bit, ACK Sensor sends0x60 NACK: that isall I need STOP: SDA riseswhile SCL is HIGH

S start · W write · R read · A✓ ACK · Sr repeated start · N NACK · P stop

I²C: resistors pull HIGH, chips pull LOW

  • Pull-up resistors hold SDA (data) and SCL (clock) HIGH, at 3.3 V.
  • The microcontroller and the I²C device pull the lines LOW to communicate.
Three breakouts on one bus, each with a 4.7 kilo-ohm pull-up from 3V3 to SDA; together they act as 1.6 kilo-ohms 3V3 SDA board 1 4.7 kΩ board 2 4.7 kΩ board 3 4.7 kΩ Together, in parallel, they lower the resistance: 1.6 kΩ
  • Some breakouts/modules bring their own resistors.
  • Pull too strong (low resistance), under ~1 kΩ at 3.3 V: chips can't pull LOW. Disable pull-ups on some boards.
  • Pull too weak (or long wires): signal takes too long to rise to 3V3. Reduce total resistance, or reduce bus frequency.

Reset the board mid-read and the sensor can keep holding SDA LOW, still waiting to finish its byte.

scan() fails until you unplug it.

Fix: drive GPIO to pulse SCL up to nine times, until SDA goes HIGH, then start I²C again. Or power-cycle the sensor.

Debugging

Same skillset in a different domain.

New sensor ready to go, but...

...but nothing happens, not even an error. Could it be the...

  • Code? the wrong pins, the wrong config, the wrong constructor?
  • Driver? the wrong defaults, similar but different chip, incomplete implementation?
  • Wiring? signals swapped, a loose jumper, a bad breadboard or solder joint?
  • Power? no power to the sensor, wrong voltage, grounds aren't joined?
  • Part? is it dead, a different version, or a known chip bug (errata)?

 Where do we start?

Stop guessing, start debugging

  1. Look first: Disconnect power, inspect for damage, heat, a burnt smell? Check cables
  2. Check power: Measuring voltage & ground, check signal directions and pins.
  3. Make it fail: the smallest test that consistently reproduces the issue.
  4. Change one thing at a time: and write down what you changed.
    Adam Savage on MythBusters, holding up a clipboard
    "Remember kids, the only difference between screwing around and science is writing it down." Adam Savage, MythBusters
  5. Get a fresh view: explain it, or ask online/ai. Include code, wiring & the error.

Nine rules for debugging

  1. Understand the system
  2. Make it fail
  3. Quit thinking and look
  4. Divide and conquer
  5. Change one thing at a time
  1. Keep an audit trail
  2. Check the plug
  3. Get a fresh view
  4. If you didn't fix it, it ain't fixed

David J. Agans, Debugging: The 9 Indispensable Rules for Finding Even the Most Elusive Software and Hardware Problems · Embedded Artistry summary · Interrupt: debugging ill-defined problems

Check power & cables

  • Cables: Some USB cable only charge. Use a known‑good data cable.
  • Battery banks: Some switch off when the board draws too little current.
  • Voltage: Ensure the right voltage level is used for your device and any components.
  • Ground: Has to be shared by every board and supply. Breadboard rails can be split.
  • Under load: Resets when a motor or Wi-Fi starts? A brownout. A meter misses it.
Supply voltage dips below the brownout threshold when a load starts; the chip resets and reports a power-on reset 3.3 V time (ms) brownout Wi-Fi TX burst, motor starts, etc ✕Brownout detector was triggered reset_cause() == PWRON_RESET

Power budget

  • Every part draws current, normally in milliamps (mA).
  • Board power supply only provides only so much; microcontroller pins are more limited.
Pico, Pico 2under 300 mA
micro:bit V1 · V290 · 190 mA
Adafruit Feather500 mA peak
USB 2.0 port, in total500 mA

On an ESP32, Wi-Fi alone can take 240 mA of it.

RP2040/2350 · Pico 1 & 24 mA, up to 12
ESP3220 mA, up to 40
micro:bit V21–4 mA

Fine for an LED and a 330 Ω resistor, about 4 mA.
Not enough for a motor.

  • Add up the datasheets: BME280 under 1 mA · OLED ~20 mA · NeoPixel up to 60 mA · servo, hundreds.
  • Measure with a USB power meter, or a multimeter in series.

Wiring: look, wiggle, slow down, swap

Fine sometimes, and flaky others? Maybe something moved.

A good solder joint is smooth and concave. Lumpy or grainy may not connect at all. Headers must be soldered, not just pushed in.

Press on wires and parts as it runs. Comes and goes as you move? A connection. Changes with just a hand nearby? A floating input: add Pin.PULL_UP/DOWN.

Lower the bus speed, I2C(0, freq=100000), or the baud rate. Works now? Suspect the wires or pull-ups, not your code.

Another wire, another sensor, another board. A part you know works tells you which half is broken.

Why did it reset?

machine.reset_cause() says why the board last started. Ask first thing in main.py.

  • PWRON_RESETpower came on: plugged in, or the supply dipped (a brownout)
  • HARD_RESETthe reset button, machine.reset(), or a crash in C
  • WDT_RESETthe watchdog: your code hung and stopped feeding it
  • DEEPSLEEP_RESETit woke from machine.deepsleep()
  • SOFT_RESETCtrl-D or your editor restarted MicroPython, not the chip

Not every board tells them all apart: what it can't, it reports as one of the others.

When the board won't talk to you

You saved a main.py and now your editor can't connect.
A common issue is an endless loop in the code.

MicroPythonCircuitPython
Stop your codeCtrl-C, then os.remove("main.py")Ctrl-C, then fix code.py
Skip your codeSafe mode, on some boardsDouble reset within 1 s
Start cleanErase the flash, reflashThe board's erase file, reinstall

Erasing deletes your files too. Back them up, don't keep them only on the board.

Most of your debugging habits still work

Tracebacks, print()Stdout is serial via USB cable: keep a terminal open
loggingWith filesystem access you can log into a file
REPLAn interactive shell can poke the live hardware
Time/profiletime it on the chip, or on a spare pin
unittestrun it on-device, or use micropython-mock-machine on PC
Traceback (most recent call last):
  File "main.py", line 14, in <module>
OSError: [Errno 5] EIO
>>>

except Exception as e:
    log.exception("failed", exc_info=e)  # or no traceback
    handler.stream.flush()               # or lost on a reset
      
>>> i2c.scan()        # ask the live bus
[118]
>>> led.value(1)      # and the LED lights up

t0 = time.ticks_us()
read_sensor()
print(time.ticks_diff(time.ticks_us(), t0), "µs")
      
$ mpremote mip install unittest
$ mpremote mount . run test_readings.py
test_in_range (TestBME280) ... ok
OK

Make time visible

No debugger on the board? Time your code with a spare pin, or measure with ticks.


dbg = Pin(DEBUG_PIN, Pin.OUT)   # any free pin
dbg.on(); read_sensor(); dbg.off()
      

t0 = time.ticks_us(); read_sensor()
print(time.ticks_diff(time.ticks_us(), t0))
      
Logic analyser, drawn: the debug pin pulses once per interrupt; one pulse runs long, then the pulses stop while the interrupts keep coming DEBUG PIN INTERRUPT ran long: slow or blocking code ✕ handler raised: interrupt turned off

Logic analyser: your pin is high while your code runs, once per interrupt.

Breakpoints on the board

GHI Electronics has recently released a MicroPython Debugger.

Breakpoints, stepping, variables and watch in the Python code.

Via USB cable, no debug probe needed.

Runs as a VS Code extension.

Developed as a MicroPython fork.

Only runs on Pico, Pico 2 and ESP32 boards so far.

Multiple limitations listed on their documentation.

REPL as a makeshift breakpoint

Wait in your main.py with an infinite loop + Ctrl-C: globals stay at the REPL.


# main.py
debug = {}               # global: kept
def main():
    while True:
        reading = sensor.read()  # local: lost
        debug['reading'] = reading  # copy it
        time.sleep(1)
main()                   # Ctrl-C any time
      
KeyboardInterrupt:
>>> reading
NameError: name 'reading' isn't defined
>>> debug
{'reading': 4095}
  • An uncaught exception keeps them too: main.py and the REPL share their globals.
  • You can look, and manually execute code, but not step through.
  • Only for MicroPython, CircuitPython starts a fresh REPL each time.

Tooling

Python can't see outside the microcontroller,
the right tool at the right time is essential.

Getting down to the wire: which tool?

A yellow handheld multimeter with its test leads
Multimeter
A small USB-C power meter with a colour screen showing volts and amps
USB power meter
Nordic's Power Profiler Kit II, a blue board in a clear case
Power Profiler
A bench power supply with voltage and current displays
Bench power supply
A Saleae logic analyser, a small red aluminium box
Logic analyser
A bench oscilloscope with waveforms on its screen
Oscilloscope
A SEGGER J-Link debug probe
Debug probe
A soldering station with its iron in the stand
Soldering iron

Tools can be expensive, but we'll focus on good value options.

What each tool sees

VoltageVolts over timeBits & protocolsCurrentCPU state
Multimeter✓✓
USB power meter✓✓
Oscilloscope✓✓
Logic analyser✓
Power profiler✓
Debug probe✓

Debug probe

The Raspberry Pi Debug Probe board
  • Gives you a serial console on the board's UART pins, and SWD: the chip's own debug port.
  • Use it for: flashing or rescuing a board without its boot button, and logs when USB is busy.

Raspberry Pi Debug Probe: cables and a case included. For the Pico and other Arm boards at 3.3 V.

The probe and your Python

  • A console: bridges UART from the board to a different USB port. MicroPython can copy its REPL there.
  • Flashing: SWD writes new firmware with no button to press.
  • Debugging means the C firmware running the Python code, not your .py.

You may already have a probe

  • A spare Pico: flash Raspberry Pi's debugprobe firmware and it works as a Debug Probe, for about €4.
  • ESP32-S3, C3 or C6: USB-JTAG is built into the chip. One USB cable, then OpenOCD.
  • BBC micro:bit: its second chip is a probe and a serial port, called DAPLink.

Other low-cost probes

ProbeAboutForWorth knowing
Black Magic Probe€66Arm chipsGDB server built in: no OpenOCD
ESP-Prog€17Original ESP32Espressif's own: JTAG, flashing and serial
J‑Link EDU Mini$76Most Arm chipsEducation version of SEGGER's professional J‑Link: non-commercial only
NXP MCU-Link€17Arm chipsCMSIS-DAP, as DAPLink probes speak
DAP Cat$5Arm chipsOpen source CMSIS-DAP, with serial

Not every probe can debug a Pico's chip: check it supports the RP2040 or RP2350.

A probe needs OpenOCD or pyOCD

VS Code
OpenOCD or pyOCD
CMSIS-DAP
Microcontroller
  • The probe only moves bits between USB and the chip's debug port.
  • The tool knows your chip: its memory, its cores, and a small program it loads into RAM to write the flash.
  • It serves GDB and VS Code: breakpoints, single steps, registers.

The exception: a Black Magic Probe runs the GDB server on the probe itself.

USB-UART adapters: about €5

A red USB to serial adapter stick
  • Set it to 3.3 V: 5 V can damage a 3.3 V board.
  • Cross the wires: TX to RX, RX to TX, and GND to GND.
  • Often built in: most ESP32 boards and the micro:bit have one. Some, like the ESP32-CAM, don't.

Multimeter

A yellow handheld multimeter with its test leads
  • Shows volts, ohms and amps, and a beep when two points connect.
  • Use it for: checking power, a wire that isn't connected, a short.

Aneng AN8008 or similar: auto-ranging, with a continuity beep. For low-voltage electronics, not mains.

Mark Hennessy: budget multimeters, reviewed

Multimeter: three checks and a trap

  1. Voltage, power on: black on GND, red on 3V3. Expect 3.3 V.
  2. Continuity, power off: a beep means connected. 3V3 to GND must not beep.
  3. Current: the meter goes in the circuit, red lead in the mA jack.

USB power meter

A small USB-C power meter with a colour screen showing volts and amps
  • Shows the volts and amps going into your board over USB, as it runs.
  • Use it for: resets when a motor starts, flat batteries, your power budget and calculate battery life.

RD TC66C or any meter that reads to 0.1 mA. It plugs in between the computer and the cable.

What a USB meter misses

  • Short spikes: it reads a few times a second, so a spike can fall between readings.
  • Sleep current: microamps, too small for most USB meters. That's a power profiler's job, such as Nordic's PPK2.
  • On USB, CircuitPython only pretends to sleep. Measure deep sleep on a battery.

When you need a power profiler

  • Shows current as a graph, 100,000 readings a second: microamps (µA) asleep, milliamps (mA) when running.
  • Provides power can also be used as a power source for testing.
  • Use it for: battery life. Toggle a pin from Python to mark your code on the graph.
  • Nordic's Power Profiler Kit II (PPK2), I generally recommend this product, only downside is price ~€100.
Nordic's Power Profiler Kit II, a blue board in a clear case

Logic analyser: a real view into the signals

A small USB logic analyser with its wires PulseView decoding an I2C capture: SCL and SDA lanes, then the bits, an address write to 0x68 and a data write of 00
  • Shows the 0s and 1s on the bus signals, decodes the data of I²C, UART, SPI, and more.
  • Use it for: did the sensor answer? What did your code send?

Search for a 24 MHz 8-channel USB analyser in AliExpress or Amazon.
Use it the open source app PulseView.

What you'll see

A good read: chip ID 60

Decoded capture: start, address write 76, ACK, data write D0, ACK, start repeat, address read 76, ACK, data read 60, NACK, stop D1 SCL D0 SDA S Address write: 76 A Data write: D0 A Sr Address read: 76 A Data read: 60 N P

Wrong address: no answer

Decoded capture: start, address write 77, NACK, stop D1 SCL D0 SDA S Address write: 77 N ✕ P

OSError: [Errno 5] EIO · Python says that. The wire says why.

D0, D1: channels · S start · Sr restart · A ACK · N NACK · P stop · hex

Logic analyser: getting started

  • GND first, then a channel per signal: SDA, SCL.
  • Sample at least 4× faster than the signal: 24 MHz is plenty for I²C and UART.
  • Add a decoder for I²C, UART or SPI. PulseView's are written in Python.
  • A bare Pico is 3.3 V only; the €10 analyser copes with 5 V too.

When you need an oscilloscope

A bench oscilloscope with waveforms on its screen
  • An analyser sees 0s and 1s. A scope sees the shape of the voltage: a sagging supply, slow I²C edges, PWM.
  • Not a first buy. Later: a FNIRSI 2C53T handheld, about €85, or a Rigol DHO802 bench scope, about €390.

AI

Your agent is an expert in Python,
but what about your hardware?

Your agent is new to hardware too

“Claude forgets that my ESP32 isn’t a full computer” Nick Lewis, How-To Geek, September 2026
  • But it is rapidly learning
  • It still reaches for CPython modules, and makes bad assumptions
  • But with enough guidance, it does significantly better
OpenAI's animation: a circuit board is put together, part by part, into a finished handheld device
Designed by OpenAI Astra

What the benchmarks measured

EmbedBench: 126 small tasks on Arduino, ESP32 and a Pico running MicroPython. It tested 2025 models; around last December/January.

55.6%
Best model, first try, circuit given
65.1%
Docs and compiler errors fed back
73.8%
Porting Arduino code to MicroPython (ESP-IDF: 29.4%)
  • Typical misses: voltage logic, debouncing and wrong APIs.
  • Second benchmark, PCEval, adds breadboard layout and pins.

EmbedBench, arXiv 2506.11003 · PCEval, arXiv 2601.02404

Help your agent

  • A good AGENTS.md contains info about the hardware and reference code/projects.
  • Give it skills
  • Tell it how to interact with the hardware.
  • Give it a goal and a way to measure it.

/deep-research MicroPython on [your board] with [your modules]: pins, drivers and known problems. Generate a AGENTS.md, with links and references to similar projects using this hardware.

mpremote: runs code, copies files

by Andrew Leech

circuitpython-runner: runs code

hw-tests-runner: tests on the board

by Adafruit

more skills below ↓

My badge's AGENTS.md, abridged


## The unit

- Know the device's port before opening
  any serial port. Always pass the port.
- mpremote reset doesn't reset this board:
  make reset pulses EN, with esptool.
- mpremote's Ctrl-C stops the programme
  until a reset: leave the unit reset.
          
A webcam on an arm points down at the e-paper badge on a desk beside a laptop, with a USB-C power meter in the badge's cable
Give you agent eyes
YOU SET THE GOAL Agent iterates on the code Runs it on the board Measures it Compares with the goal ✕ not yet ✓ met AGENT DONE & PROVIDES EVIDENCE

Skills that count

A skill is a folder with a SKILL.md and scripts. Read them before installing them.

mpremote: runs code on the board, copies files, keeps one session open.

circuitpython-runner runs code; hw-tests-runner runs tests on the board.

upy-gen-driver writes a driver from a datasheet; upy-slim-driver cuts its RAM.

Conclusion

The rest of the owl

The "How to draw an owl" meme: 1. two circles; 2. the owl, traced and built as an owl-shaped circuit board perched on a pin-header branch OWL-1U2Y1J1J2J3

Owlio, by Jonathan Sanderson, Northumbria University, and Tanwen Sanderson, age 7

Together we've covered:

  • Device Resources
  • Drivers
  • Debugging
  • Tooling
  • AI

Thank you

Questions?