MicroPython for Microcontrollers Module 13 – Ports and boards: RP2040/RP2350, ESP32 and the rest
MicroPython, CircuitPython and other ports
How MicroPython 1.29 compares with CircuitPython 10.3, which other ports MicroPython runs on and in which support tier, and a thin layer for portable code.
What you will learn
- Compare MicroPython with CircuitPython in hardware API, start-up files, editing and concurrency
- Name MicroPython's other ports and what each support tier promises
- Write a thin hardware layer so that application logic runs on both platforms unchanged
- Choose a port and a board for a project from its needs
Before you start
What you’ll learn · Before you start
What you will learn
- Compare MicroPython with CircuitPython in hardware API, start-up files, editing and concurrency
- Name MicroPython's other ports and what each support tier promises
- Write a thin hardware layer so that application logic runs on both platforms unchanged
- Choose a port and a board for a project from its needs
Before you start
On this page
The same Raspberry Pi Pico can run MicroPython 1.29 or CircuitPython: A Python for microcontrollers, based on MicroPython, with its own hardware modules such as board, digitalio and busio, a USB drive for editing, and no interrupts or threads. 10.3.1, and the
two look alike until the first
line of hardware code: Pin("LED", Pin.OUT) in one, digitalio.DigitalInOut(board.LED) in the other. CircuitPython
is based on MicroPython and keeps its language, but changed the hardware API, the way you edit a board and the files
that start a program.
This lesson compares the two, maps the other ports MicroPython itself runs on with their support tiers, and builds the thin hardware layer that lets one application run on both.
MicroPython and CircuitPython share a language and part ways at the hardware
CircuitPython’s own README lists how it differs from MicroPython. The differences that change your code:
| Area | MicroPython 1.29 | CircuitPython 10.3.1 |
|---|---|---|
| Hardware API | machine: Pin, I2C, SPI, UART, PWM |
board pin names with digitalio, busio and others; no machine |
| Program files | boot.py, then main.py, after each reset |
boot.py once at start-up, then code.py after every reload |
| Editing | the REPL, and tools such as mpremote over USB serial | the CIRCUITPY USB drive; saving a file reruns the program |
| Concurrency | Pin.irq(), timer callbacks, asyncio, threads on some ports |
no interrupts or threads; async and await on some boards |
| After a crash | the next reset runs boot.py and main.py again, whatever caused it |
safe mode, which leaves your code unrun until you fix it |
MicroPython gives you the chip’s features through machine and modules such as rp2 and esp32, and leaves the
workflow to tools. CircuitPython gives every board the same higher-level modules, a USB drive to edit on, and error
messages in more than 10 languages, at the price of interrupts and threads. Both run Python 3 code, so logic that
never touches the hardware moves between them unchanged.
The rule of thumb
Choose MicroPython for interrupts, timers, threads or chip features such as PIO; choose CircuitPython for a classroom or a first project, where editing on a USB drive and its device libraries matter most.
Checkpoint
Check: MicroPython against CircuitPython
MicroPython runs on other ports too, in four support tiers
MicroPython is built separately for each family of chips or operating system, and each build is a port: MicroPython adapted to one family of chips or one operating system, such as rp2 for the RP2040 and RP2350, esp32, or unix. Each port builds the firmware for its boards. More in “What MicroPython is and where it runs”. The other ports reach far beyond the Pico and the ESP32. Since 1.27 every port has a support tier: One of MicroPython's four levels of care for a port. Tier 1 ports such as rp2, esp32 and unix are the most actively maintained, tested and documented. More in “What MicroPython is and where it runs”:
| Tier | Ports in 1.29 | What the tier promises |
|---|---|---|
| 1 | esp32, mimxrt, rp2, samd, stm32, unix, windows | active maintenance, a stable Python API, hardware tests for releases, port documentation |
| 2 | alif, embed, nrf, psoc-edge, renesas-ra, webassembly, zephyr | active maintenance, a stable API, hardware tests for releases |
| 3 | cc3200, esp8266, pic16bit | builds and tests pass in CI |
| M | bare-arm, minimal, qemu | maintenance, development and testing of MicroPython itself |
Two of the other ports matter even without a board. The unix and windows ports, in Tier 1, run MicroPython on a
computer, which makes them the test bench for code that will run on a chip. The webassembly port, in Tier 2, runs
MicroPython in a web browser; its npm package, @micropython/micropython-webassembly-pyscript, is its PyScript
variant. The ESP8266, once a favourite board, now sits in Tier 3: it builds, but new features go elsewhere.
Checkpoint
Check: Other ports and their support tiers
A thin hardware layer moves the logic between platforms
To run one application on both platforms, keep it away from the hardware, behind a
board layer: A small module that implements a fixed set of methods, such as led() and button_pressed(), with one platform's hardware API, so that the application above it runs on every platform that has one.. The application below asks a board object three things: to light the
LED, whether the button is pressed, and how many milliseconds have passed. Two small
modules answer them, one with machine.Pin and Signal for MicroPython, one with digitalio for CircuitPython; the
clock comes in as an argument, so a test can pass its own. The program runs the same application on both layers,
with stand-ins for each platform:
ports/run_both.py
"""The same application on the MicroPython layer and the CircuitPython layer, run on stand-ins of both platforms."""
import app
import bench
import hw_circuitpython
import hw_micropython
TIMES = range(0, 2000, 250)
print("ms " + "".join("%5d" % t for t in TIMES))
for name, layer in (("MicroPython ", hw_micropython), ("CircuitPython", hw_circuitpython)):
bench.reset()
board = layer.Board(clock=bench.clock)
seen = []
for t in TIMES:
bench.press(1200 <= t < 1700) # the button is held from 1,200 to 1,700 ms
app.step(board)
seen.append(bench.led().strip())
bench.advance(250)
print(name + " " + "".join("%5s" % s for s in seen)) Output
ms 0 250 500 750 1000 1250 1500 1750 MicroPython on on off off on on on off CircuitPython on on off off on on on off
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 run_both.py
ports/app.py
"""The application: blink once a second while idle, stay lit while the button is held.
It talks only to a board object, never to machine or digitalio, so it runs on either platform."""
def step(board):
if board.button_pressed():
board.led(True)
else:
board.led(board.now_ms() // 500 % 2 == 0) ports/hw_micropython.py
"""The board layer for MicroPython: machine.Pin and Signal, with ticks that survive the wrap-around."""
import time
from machine import Pin, Signal
class Board:
def __init__(self, led_pin="LED", button_pin=14, clock=None):
self._led = Signal(Pin(led_pin, Pin.OUT, value=0))
self._button = Signal(Pin(button_pin, Pin.IN, Pin.PULL_UP), invert=True) # pressed pulls low
if clock is None:
t0 = time.ticks_ms()
def clock():
return time.ticks_diff(time.ticks_ms(), t0)
self.now_ms = clock # milliseconds since start; a test passes its own clock
def led(self, on):
self._led.value(1 if on else 0)
def button_pressed(self):
return bool(self._button.value()) ports/hw_circuitpython.py
"""The board layer for CircuitPython: board pin names and digitalio, with time.monotonic_ns()."""
import time
import board
import digitalio
class Board:
def __init__(self, led_pin=board.LED, button_pin=board.GP14, clock=None):
self._led = digitalio.DigitalInOut(led_pin)
self._led.switch_to_output(value=False)
self._button = digitalio.DigitalInOut(button_pin)
self._button.switch_to_input(pull=digitalio.Pull.UP)
if clock is None:
t0 = time.monotonic_ns()
def clock():
return (time.monotonic_ns() - t0) // 1_000_000
self.now_ms = clock # milliseconds since start; a test passes its own clock
def led(self, on):
self._led.value = bool(on)
def button_pressed(self):
return not self._button.value # pressed pulls low machine.py (stand-in) · ports/machine.py
"""A stand-in for machine.Pin and machine.Signal, enough for the board layer beside it. Never copy it to a board."""
import bench
FAKE = True
class Pin:
IN, OUT, PULL_UP = 0, 1, 1
def __init__(self, id, mode=-1, pull=None, value=None):
self.id = id
if value is not None:
bench.write(id, value)
def value(self, v=None):
if v is None:
return bench.read(self.id)
bench.write(self.id, v)
class Signal:
def __init__(self, pin, invert=False):
self.pin, self.invert = pin, invert
def value(self, v=None):
if v is None:
return self.pin.value() ^ self.invert
self.pin.value(v ^ self.invert)
class I2C:
"""machine.I2C with MSC-ENV1 (made up) at 0x41: TEMP in registers 3 and 4, 21.5 °C."""
def __init__(self, id=0, *, scl=None, sda=None, freq=400_000):
self.regs = bytes([0xE1, 0, 0, 0x0A, 0xC0])
def readfrom_mem_into(self, addr, memaddr, buf):
buf[:] = self.regs[memaddr:memaddr + len(buf)] board.py (stand-in) · ports/board.py
"""A stand-in for CircuitPython's board module on a Pico: pin names as objects. Never copy it to a board."""
FAKE = True
class _Pin:
def __init__(self, name):
self.name = name
LED = _Pin("LED")
GP14 = _Pin("GP14")
GP4 = _Pin("GP4")
GP5 = _Pin("GP5") digitalio.py (stand-in) · ports/digitalio.py
"""A stand-in for CircuitPython's digitalio, enough for the board layer beside it. Never copy it to a board."""
import bench
FAKE = True
_IDS = {"LED": "LED", "GP14": 14} # the names the bench uses
class Pull:
UP, DOWN = "UP", "DOWN"
class DigitalInOut:
def __init__(self, pin):
self._id = _IDS[pin.name]
def switch_to_output(self, value=False, drive_mode=None):
bench.write(self._id, int(value))
def switch_to_input(self, pull=None):
pass
@property
def value(self):
return bool(bench.read(self._id))
@value.setter
def value(self, v):
bench.write(self._id, int(v)) ports/bench.py
"""The test bench for both board layers: a clock you move, a button you press, and the LED's level."""
_levels = {14: 1} # GP14 idles high through its pull-up
_now = [0]
def reset():
_levels.clear()
_levels[14] = 1
_now[0] = 0
def clock():
return _now[0]
def advance(ms):
_now[0] += ms
def press(down):
_levels[14] = 0 if down else 1
def read(pin):
return _levels.get(pin, 0)
def write(pin, value):
if pin != 14: # the button is an input; writes to it change nothing
_levels[pin] = 1 if value else 0
def led():
return "on " if _levels.get("LED") else "off" Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Both rows are the same: the LED blinks once a second and stays lit while the button is held from 1,200 to 1,700 ms.
app.py imports neither platform, so a change to the application is made once, and each layer keeps what really
differs: pin names, the button’s polarity, the clock.
Checkpoint
Check: A thin hardware layer
Check yourself
Test what you know of the two platforms, the ports and the layer between them, one question at a time.
Practice that adapts to you
Practise this lesson
6 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Porting MicroPython code to CircuitPython, call by call Pro: opens with a Pro, Premium or Ultimate pass
Most MicroPython code moves to CircuitPython in a handful of patterns, and each has a trap that fails quietly or late. This part maps the everyday calls side by side, then reproduces the trap that stops most I2C ports.
In this part:
- The everyday calls side by side, with the traps
- Start-up, files and state behave differently
- The same register read on both, reproduced
1 runnable example (board.py (stand-in), busio.py (stand-in), i2c_port.py, machine.py (stand-in)) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Board layers with a contract, tested on both platforms Pro: opens with a Pro, Premium or Ultimate pass
A board layer is only as portable as its promises. This part writes those promises down as a contract, tests every layer against it, and draws the line between what goes in the layer and what stays out.
In this part:
- Writing the contract down
- One set of tests for every layer
- Two board layers behind one contract
- Results of the sample tests
- What stays outside the layer
1 exercise with sample tests · about 4 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Choosing a port and a board, case by case Pro: opens with a Pro, Premium or Ultimate pass
The right platform follows from a project’s needs, and four questions settle most cases. This part puts them in a table that answers back, then walks through four projects.
In this part:
- Four questions that settle most choices
- Four projects, four choices
about 6 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Interview questions
Warm-up (fresher): What is CircuitPython, and how does it differ from MicroPython?
CircuitPython is a version of Python for microcontrollers that is based on MicroPython and keeps its language core.
It replaces MicroPython’s machine module with higher-level modules such as board, digitalio and busio that
look the same on every board, and makes the board appear as a USB drive called CIRCUITPY, where saving code.py
reruns the program. It leaves out interrupts and threads, which MicroPython has, and adds a safe mode after crashes
and translated error messages. Code that never touches the hardware runs on both.
Three longer questions about ports follow; the link under each leads to what it tests and a first step:
-
Mid to senior
A MicroPython logger on a Pico reads an I2C sensor with readfrom_mem_into(), blinks a status LED from a Timer callback and appends readings to a file on the board. It must move to a CircuitPython board. What breaks, in which order do you fix it, and what do you test?
-
Mid to senior
A gadget ships on a Pico with MicroPython and, for schools, on a CircuitPython board. Design one codebase for both: its structure, the contract of the hardware layer, and how CI proves that both builds work.
-
Senior to staff
A startup will sell a battery-powered Wi-Fi sensor and update it for five years. Its founders liked CircuitPython in a workshop. Which platform do you recommend, and how do you justify it with support tiers and the features each platform has?
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How does CircuitPython's hardware API differ from MicroPython's?
CircuitPython has no machine module: it uses board pin names with digitalio, busio and other modules, such as digitalio.DigitalInOut(board.LED) in place of Pin("LED", Pin.OUT).
-
Which files start a program in CircuitPython and in MicroPython?
CircuitPython runs boot.py once at start-up, then code.py after every reload. MicroPython runs boot.py, then main.py, after each reset.
-
How do you change a program on each platform?
CircuitPython shows the board as the CIRCUITPY USB drive, and saving a file reruns the program. MicroPython uses the REPL and tools such as mpremote over USB serial.
-
Which concurrency tools does CircuitPython leave out?
Interrupts and threads, though some boards have async and await. MicroPython has Pin.irq(), timer callbacks, asyncio and, on some ports, threads.
-
Which support tier is the esp8266 port in, and what does that promise?
Tier 3: it is built and tested by continuous integration but not actively maintained, so new features go to other ports.
-
Which MicroPython ports run without a microcontroller?
The unix and windows ports, Tier 1, run on a computer as a test bench; the webassembly port, Tier 2, runs in a browser, and its PyScript variant is on npm.
-
What lets one application run on MicroPython and CircuitPython unchanged?
A thin board layer for each platform with the same small set of methods, such as led() and button_pressed(), and the clock passed in, so the application imports neither platform.
Key takeaways
- MicroPython and CircuitPython run the same Python language, but CircuitPython replaces
machinewithboard,digitalioandbusio, starts fromcode.pyon a USB drive, and has no interrupts or threads. - MicroPython’s ports are sorted into four support tiers: rp2, esp32 and five others in Tier 1, webassembly in Tier 2, the ESP8266 in Tier 3.
- A thin board layer, one per platform, with the clock passed in, lets one application run unchanged on both.
Terms in this lesson
- board layer
- A small module that implements a fixed set of methods, such as led() and button_pressed(), with one platform's hardware API, so that the application above it runs on every platform that has one.
- CircuitPython
- A Python for microcontrollers, based on MicroPython, with its own hardware modules such as board, digitalio and busio, a USB drive for editing, and no interrupts or threads.
References
- MicroPython Support Tiers (MicroPython project)
- MicroPython v1.27.0 release notes (port tier levels) (MicroPython project)
- MicroPython WebAssembly (ports/webassembly/README.md, v1.29.0) (MicroPython project)
- @micropython/micropython-webassembly-pyscript (npm package) (MicroPython project)
- class Pin and class Signal (machine) (MicroPython project)
- CircuitPython README (differences from MicroPython) (Adafruit Industries)
- CircuitPython 10.3.1 release (Adafruit Industries)
- digitalio.DigitalInOut (shared-bindings/digitalio/DigitalInOut.c, 10.3.1) (Adafruit Industries)
- busio.I2C and its lock (shared-bindings/busio/I2C.c, 10.3.1) (Adafruit Industries)
- time.monotonic() and time.monotonic_ns() (shared-bindings/time/__init__.c, 10.3.1) (Adafruit Industries)
- storage.remount() (shared-bindings/storage/__init__.c, 10.3.1) (Adafruit Industries)
- keypad, countio and rotaryio (shared-bindings, 10.3.1) (Adafruit Industries)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress