Carlos Pereira Atencio
#PyConES2026

❤️🐍
Scan the QR to follow along during the talk,
or at your own pace.
https://pycones2026.embeddedlog.com
This is an example of slides that sit below the main track.
These contain additional content with more depth, resources, and links.
What is it?
How is it different from normal Python?
Pyboard D
microcontroller
import pyb
# On board green LED on pin 2
led = pyb.LED(2)
while True:
led.toggle()
pyb.delay(500)
Feather RP2040
microcontroller
import time
import board
import digitalio
led = digitalio.DigitalInOut(board.LED)
led.direction = digitalio.Direction.OUTPUT
while True:
led.value = True
time.sleep(0.5)
led.value = False
time.sleep(0.5)
Raspberry Pi 5
Single Board Computer
from time import sleep
from gpiozero import LED
# An LED wired to GPIO17
led = LED(17)
while True:
led.toggle()
sleep(0.5)
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.
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
| MicroPython | CircuitPython | |
|---|---|---|
| Hardware | machine, plus a module per port | board, digitalio, busio on every board |
| Pins | The port ids: Pin(15), Pin("GP15") | The board's labels: board.D5, board.LED |
| Concurrency | Pin interrupts, timers, _thread, asyncio | No interrupts or threads; asyncio |
| Workflow | Copy files with mpremote or Thonny | A USB drive: save code.py and it reruns |
| Files | Your code reads and writes them | Read-only to code, unless boot.py remounts |
| Libraries | mip, from micropython-lib | circup, from a bundle |
Sources: CircuitPython README, FAQ, storage | MicroPython docs
On your computer
On a microcontroller (MCU)
machine| MicroPython | CircuitPython | |
|---|---|---|
1) boot.py | Runs first, and should exit | Runs once, before USB starts |
| 2) your code | main.py; it may loop forever | code.py in a fresh VM; reruns on save |
| 3) when it ends | The REPL, with main.py's variables | The REPL, in another fresh VM |
| Ctrl-D | Soft reset: boot.py, then main.py | Reload: code.py again |
Sources: MicroPython reset and boot · CircuitPython README
640 KB ought to be enough for anybody!
micro:bit V2128 KB
Feather RP2040264 KB
Pico 2520 KB
Nano ESP328 MB PSRAM
16 MB PSRAM
ESP32-S3-DevKitC-116 MB PSRAM
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
hard=True: runs at once, heap locked: no floats or append()micropython.schedule(f, arg)f runs soft, soon afterprint(), sleep() or filesystem actionsRead: Writing interrupt handlers | Pin.irq() | Timer | schedule()
MemoryError when an allocation didn't find enough contiguous free memory.One list/array, buffer or string larger than available memory.
When we get tracebacks on an import line.
Free memory goes down over time or gets fragmented.
import gc provides functions to check and manage memory.>>> 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
. is freemem_info(); gc.mem_free() gives totals.espidf.heap_caps_get_largest_free_block()import, def, classs + t, f"{t}", data[4:20]uart.read(64)n / 2*if, for, while, tryn += 1pin.value()buf[i] = bControl flow is free. Each new object takes at least one 16-byte block.
From Damien George, PyCon AU 2018 | *MicroPython only
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
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
import bme280 # a 10 KB .py driver
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.
A .mpy is a .py already compiled by mpy-cross on your computer. Different locations provide different trade-offs.
| Where | ✓ Good | ✕ Watch out | RAM* |
|---|---|---|---|
Filesystemmpremote 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 |
Frozenmanifest.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.
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!
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
gc.collect() + gc.mem_free() shows less free mem over time.dict, callbacks added on every reconnectarray as a ring bufferRead: MicroPython collections.deque · MicroPython on microcontrollers
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
Measurements from Adafruit
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
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
ticks_diff().async def it only times creating the coroutine.time.monotonic_ns().Every light, motor and sensor needs one.











Different parts might use different signals, protocols, and timings.A driver abstracts that to provide their functionality.
BME280).machine.I2C is MicroPython; busio is CircuitPython.
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
MicroPython
import mip, then mip.install("ssd1306")package.json: mpremote mip install github:org/repompremote cp bme280_float.py :, where : is the boardCircuitPython
.mpy and its dependencies into CIRCUITPY/libcircup install --auto installs what code.py importsMicroPython packages · mpremote · circup · CircuitPython libraries
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.
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
GPIO_OUT_SET is at 0xD0000018.mem32 only when needed.
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
const()I2C, SoftI2C or a fake one for tests
A register, in a sensor, is a memory address connected to the hardware.
The datasheet's register map says what each register does.
| Register | The datasheet says | In the driver |
|---|---|---|
0xD0 id | Always reads 0x60 | != b"\x60": not a BME280 |
0xF4 ctrl_meas | Configure how to measure the temperature in bits 7-5, the pressure (bits 4-2), and mode (1-0) | 0x27 = 001 001 111 sample, 1 sample, continuous |
0xFA…0xFC temp | Temperature in 20 bits over 3 bytes | b[0] << 12 | b[1] << 4 | b[2] >> 4 |
BME280 datasheet, 5.3 and 4.2.3 · Adafruit's CircuitPython driver
i2c = I2C(0, scl=Pin(5), sda=Pin(4))
bme = bme280.BME280(i2c=i2c)
| UART | I²C | SPI | |
|---|---|---|---|
| Used for | GPS, radios, another board | sensors, small OLEDs | colour screens, SD cards |
| Wires | TX → RX, crossed | SDA, SCL | SCK, COPI, CIPO + CS each |
| Devices | two, one at each end | many, each at an address | many, one CS pin each |
| Speed | 9,600 - 115,200 baud (1M+) | 100 Khz or 400 kHz | 1–10+ MHz |
| Extra parts | none | pull-up resistors | often pull-ups on Chip Select |
| Watch out for | wrong baud configured, reads garbage | two chips with the same address clash | wrong SPI mode: reads data incorrectly, no errors shown |
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
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.
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
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
SCL is the clock, SDA the data, both shared. The microcontroller (MCU) reads the chip ID:
i2c.readfrom_mem(0x76, 0xD0, 1) → b'\x60'
S start · W write · R read · A✓ ACK · Sr repeated start · N NACK · P stop
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.
Same skillset in a different domain.
...but nothing happens, not even an error. Could it be the...
Where do we start?
"Remember kids, the only difference between screwing around and science is writing it down." Adam Savage, MythBusters
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
| Pico, Pico 2 | under 300 mA |
| micro:bit V1 · V2 | 90 · 190 mA |
| Adafruit Feather | 500 mA peak |
| USB 2.0 port, in total | 500 mA |
On an ESP32, Wi-Fi alone can take 240 mA of it.
| RP2040/2350 · Pico 1 & 2 | 4 mA, up to 12 |
| ESP32 | 20 mA, up to 40 |
| micro:bit V2 | 1–4 mA |
Fine for an LED and a 330 Ω resistor, about 4 mA.
Not enough for a motor.
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.
machine.reset_cause() says why the board last started. Ask first thing in main.py.
machine.reset(), or a crash in Cmachine.deepsleep()Not every board tells them all apart: what it can't, it reports as one of the others.
You saved a main.py and now your editor can't connect.
A common issue is an endless loop in the code.
| MicroPython | CircuitPython | |
|---|---|---|
| Stop your code | Ctrl-C, then os.remove("main.py") | Ctrl-C, then fix code.py |
| Skip your code | Safe mode, on some boards | Double reset within 1 s |
| Start clean | Erase the flash, reflash | The board's erase file, reinstall |
Erasing deletes your files too. Back them up, don't keep them only on the board.
Tracebacks, print() | Stdout is serial via USB cable: keep a terminal open |
logging | With filesystem access you can log into a file |
| REPL | An interactive shell can poke the live hardware |
| Time/profile | time it on the chip, or on a spare pin |
unittest | run 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
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: your pin is high while your code runs, once per interrupt.
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.
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}
main.py and the REPL share their globals.Python can't see outside the microcontroller,
the right tool at the right time is essential.








Tools can be expensive, but we'll focus on good value options.
| Voltage | Volts over time | Bits & protocols | Current | CPU state | |
|---|---|---|---|---|---|
| Multimeter | ✓ | ✓ | |||
| USB power meter | ✓ | ✓ | |||
| Oscilloscope | ✓ | ✓ | |||
| Logic analyser | ✓ | ||||
| Power profiler | ✓ | ||||
| Debug probe | ✓ |
Raspberry Pi Debug Probe: cables and a case included. For the Pico and other Arm boards at 3.3 V.
.py.| Probe | About | For | Worth knowing |
|---|---|---|---|
| Black Magic Probe | €66 | Arm chips | GDB server built in: no OpenOCD |
| ESP-Prog | €17 | Original ESP32 | Espressif's own: JTAG, flashing and serial |
| J‑Link EDU Mini | $76 | Most Arm chips | Education version of SEGGER's professional J‑Link: non-commercial only |
| NXP MCU-Link | €17 | Arm chips | CMSIS-DAP, as DAPLink probes speak |
| DAP Cat | $5 | Arm chips | Open source CMSIS-DAP, with serial |
Not every probe can debug a Pico's chip: check it supports the RP2040 or RP2350.
The exception: a Black Magic Probe runs the GDB server on the probe itself.
Aneng AN8008 or similar: auto-ranging, with a continuity beep. For low-voltage electronics, not mains.
RD TC66C or any meter that reads to 0.1 mA. It plugs in between the computer and the cable.
Search for a 24 MHz 8-channel USB analyser in AliExpress or Amazon.
Use it the open source app PulseView.
A good read: chip ID 60
Wrong address: no answer
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
Your agent is an expert in Python,
but what about your hardware?
“Claude forgets that my ESP32 isn’t a full computer” Nick Lewis, How-To Geek, September 2026
EmbedBench: 126 small tasks on Arduino, ESP32 and a Pico running MicroPython. It tested 2025 models; around last December/January.
AGENTS.md contains info about the hardware and reference code/projects./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 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.
Owlio, by Jonathan Sanderson, Northumbria University, and Tanwen Sanderson, age 7
Together we've covered:
Questions?