Carlos Pereira Atencio
PyCon ES 2026
Step 1: blink
Step 2: ???
Step 3: profit

❤️🐍
Scan the QR to follow along during the talk,
or at your own pace.
https://pycones2026.embeddedlog.com
What is it?
How is it different than 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
# External LED connected to pin 17
led = LED(17)
while True:
led.toggle()
sleep(0.5)
A lean Python 3 for microcontrollers, by Damien George.
The best of each chip. machine is the
portable core, and each port adds modules for what only 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 a Raspberry Pi.
Sources: MicroPython libraries · CircuitPython core modules
| MicroPython 1.29 | CircuitPython 10.3 | |
|---|---|---|
| Hardware | machine, plus a module per port | board, digitalio, busio on every board |
| Pins | The port's 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 of 300+ drivers |
Sources: CircuitPython README, FAQ, storage · MicroPython docs
On your laptop
On a microcontroller
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 |
| Brownout | Hard reset, back to the start | Safe mode: your code is skipped |
CircuitPython's status LED: 1 green blink, done · 2 red, an exception · 3 yellow, safe mode
Sources:
MicroPython reset and boot ·
CircuitPython README
A micro:bit V2 has 128 KB of RAM. Total.
5 V on a 3.3 V pin is out of spec. Interrupt handlers can't allocate.
No step debugger in the firmware you flashed.
640 KB ought to be enough for anybody!
MemoryError
micro:bit V2128 KB
Feather RP2040264 KB
Pico 2520 KB
Nano ESP328 MB PSRAM
16 MB PSRAM
ESP32-S3-DevKitC-116 MB PSRAM
MemoryError when an allocation didn't find enough contiguous free memory.The traceback ends on an import line.
Allocating large lists or objects
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=....
. is freeIf a MemoryError asks for fewer bytes than free but more than the largest hole, memory is in pieces.
Not in CircuitPython · micropython.mem_info() · py/gc.c
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.
* Peak and ends measured on the unix port
if, for, while, tryn += 1pin.value()buf[i] = bimport, def, classs + t, f"{t}", data[4:20]uart.read(64)n / 2*Control flow is free. Each new object takes at least one 16-byte block.
From Damien George, PyCon AU 2018 | *MicroPython only
uart.read(64) every loop | → | readinto(buf) with one buffer |
packet[4:20] | → | a memoryview: no copy |
| float maths in a fast loop | → | use ints, increase units (e.g. millivolts) |
| magic numbers as globals | → | const() |
data = uart.read(64) # ✕ new bytes every loop
buf = bytearray(64) # ✓ once, before the loop
n = uart.readinto(buf) # ✓ in the loop: no allocation
header = packet[4:20] # ✕ copies 16 bytes
mv = memoryview(packet) # ✓ once
header = mv[4:20] # ✓ a view into packet, no copy
raw = adc.read_u16()
volts = raw * 3.3 / 65535 # ✕ MicroPython: 2 new floats
mv = raw * 3300 // 65535 # ✓ millivolts, an int
REG_ID = 0xD0 # ✕ a global, looked up by name
from micropython import const
_REG_ID = const(0xD0) # ✓ in the bytecode, no global
| allocating in a hard ISR (Interrupt Service Routine) | → | micropython.schedule() |
r.json() on a big reply | → | json.load(r.raw): parsed as it arrives |
f.read() on a log file | → | for line in f: |
| 1,000 floats in a list | → | array("f"): ~4 KB, not ~20 KB |
def on_edge(pin): # hard IRQ, heap locked
events.append(pin.value()) # ✕ MemoryError
micropython.schedule(record, pin) # ✓ may allocate
data = r.json() # ✕ whole body in RAM first
data = json.load(r.raw) # ✓ parsed as it arrives
lines = f.read().split("\n") # ✕ whole file, then a copy
for line in f: # ✓ one line at a time
handle(line)
temps.append(sensor.temperature) # ✕ ~20 KB of floats
temps = array("f", [0] * 1000) # ✓ raw floats: ~4 KB
temps[i] = sensor.temperature
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
1 square = 1 block = 16 bytes
...........................
FS=FS=FS=FS=FS=FS=FS=FS=FS=
F..F..F..F..F..F..F..F..FS=
27 blocks, all free
the loop fills it: each float kept, each string dropped
no room for big_array, so the collector frees the strings:
16 blocks × 16 B = 256 B free, but the biggest hole is 2 blocks, 32 B
A===
✕ MemoryError: memory allocation failed, allocating 64 bytes
A big object needs one hole that big.
big_array = bytearray(64) # first, while the heap is empty
readings = []
for _ in range(9):
readings.append(read())
msg = "T=%.1f" % readings[-1]
A===.......................
A===FS=FF.FS=F..F..F..FS=F.
big_array takes 4 blocks in a row while there are plenty
✓ the loop fills and frees the space after it; big_array keeps its room
Imports, then gc.collect(),
then the big buffers. Then reuse them.
readings = []
while True:
readings.append(read()) # every reading, forever
from collections import deque
readings = deque((), 6) # the last 6, never more
while True:
readings.append(read())
FFF........................
FFFFFFFFFFFFFF.............
FFFFFFFFFFFFFFFFFFFFFFFFFFF
FFFFFF.....................
after a minute: 3 readings
after an hour: every reading is still in the list
after a day: ✕ MemoryError. The collector can't free what you still hold.
readings: 21.1 21.3 21.4 21.2 21.0 20.9
after a day: 21.1 21.3 21.4 21.2 21.0 20.9 21.5 · still 6
The newest pushes the oldest out.
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
Mandelbrot, Metro RP2040 · Adafruit
gc.collect()
print(gc.mem_free()) # once a minute
while True:
read_sensor()
update_display()
gc.collect() # here, never mid-task
time.sleep_ms(100)
micropython.heap_lock() # MicroPython only
handle(packet) # allocates? MemoryError
micropython.heap_unlock()
try:
app.run()
except MemoryError as e:
log_crash(e) # append to crash.log
machine.reset() # start on a fresh heap
| When | Do |
|---|---|
| Before you change anything | Measure: gc.collect(), then gc.mem_free(), before and after |
| At start-up | Imports, biggest first; gc.collect(); then the big buffers |
| Code you don't edit | .mpy with mpy-cross; ROMFS or frozen when tight |
| Big data | Stream it; array and bytearray, not lists of floats |
| Loops | Reuse buffers: readinto(), memoryview, no s += … |
| Anything that grows | A limit: deque((), N), a ring buffer, batches to flash |
| Hard interrupts | Allocate nothing: set a flag, micropython.schedule() |
| Slow loops | Time it with ticks_us(), then @micropython.native or viper |
Read: MicroPython on microcontrollers · Adafruit memory tips
Every light, motor and sensor needs one.











Every part needs its own signals, protocol, and timing.
A driver abstracts all of that for you.
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)
Python user code, requests temperature, 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")
Driver in Python code, initialises and controls the sensor
Written in C, provides Python API to control the Microcontroller
Usually C code, from the chip vendor, provides low-level access to Microcontroller features.
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, clocks, 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 | 100 or 400 kHz | 1–10+ MHz |
| Extra parts | none | pull-ups | often pull-ups on CS |
| Anyone there? | no way to know | scan() lists them | no: you read 0x00 or 0xFF |
| Classic mistake | wrong baud: garbage, no error | two of the same chip clash | wrong mode: wrong bits, no error |
Connect GND between boards · 5 V can kill a 3.3 V pin
Not every pin can do every bus · keep wires short
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.
SCL is the clock, SDA the data, both shared. The microcontroller (MCU) reads the BME280's ID:
i2c.readfrom_mem(0x76, 0xD0, 1) → b'\x60'
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 and CIPO were MOSI and MISO in older docs · machine.SPI · BME280 datasheet
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()For every board and power supply. Signals need the common ground.
Check the part's supply range and its logic level.
Long wires add capacitance & might need stronger pull-ups (↓ resistance). Remember boards/modules might already have pulls!
Servos and LED strips brown out the board. Keep GND shared.
Shift I²C with a MOSFET. A BSS138-style shifter works on I²C. A TXB0108 doesn't.
Boot, input-only, LED and button pins: read the pinout.
scan() lies to youi2c.scan() | Usual cause |
|---|---|
[] | no power or GND, SDA/SCL swapped, wrong pins |
| every address, or hangs | SDA held LOW: no pull-ups, stuck target |
| yours is missing | 8-bit address (0xEC ≠ 0x76), collision |
| found, reads fail | weak pull-ups, long wires |
| found, on 5 V | its pull-ups put 5 V on 3.3 V pins |
The pull-up resistor lifts the line. The weaker it is, the slower the rise: at 400 kHz, 300 ns at most.
Rise time: 4.7 kΩ ~0.2 µs ✓ · 50 kΩ ~2.1 µs ✕ · five 4.7 kΩ in parallel = 940 Ω, under the 967 Ω minimum
The Pico's internal pull-ups are 50–80 kΩ: fine on the desk, flaky on wires · NXP UM10204, the I²C specification
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.
| 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.
Lorem ipsum dolor sit amet, consectetur adipiscing elit sed do eiusmod.
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Lorem ipsum dolor sit amet, consectetur adipiscing elit sed do eiusmod.
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Lorem ipsum dolor sit amet, consectetur adipiscing elit sed do eiusmod.
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Lorem ipsum dolor sit amet, consectetur adipiscing elit sed do eiusmod.
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Questions?