MicroPython is an implementation of Python 3, written in C, that is small enough to run on a microcontroller with a few hundred kilobytes of RAM. It's the Python you get on the Raspberry Pi Pico, the BBC micro:bit and the ESP32, and on a long list of other boards.
I keep a reference to it at micropython.com, covering the language, the boards, worked examples and the tools, and I'll link into it as we go. Let's look at what MicroPython is, how much of Python it keeps, how it works inside, and what it runs on.
About MicroPython
MicroPython was created by Damien George, who launched it on Kickstarter together with its first board, the pyboard. It's open source under the MIT licence, and the project's own site, micropython.org, has the firmware downloads and the full library reference.
The idea is simple. You copy the MicroPython firmware onto the board, plug it into USB, and you get a Python prompt running on the chip itself. There's no operating system underneath and no computer doing the work: you type a line and the microcontroller compiles it and runs it.
>>> from machine import Pin
>>> led = Pin("LED", Pin.OUT)
>>> led.on()
That prompt is the REPL, and it's a big part of why MicroPython is so pleasant to use. Instead of the usual cycle of compiling C, flashing the board and seeing whether it worked, you can try things on the real hardware a line at a time: read a sensor, flip a pin, scan an I2C bus for devices.
For a program that runs by itself, you save it to the board's flash. The board has a small filesystem, and when it starts up it runs boot.py and then main.py.
from machine import Pin
import time
led = Pin("LED", Pin.OUT)
while True:
led.toggle()
time.sleep(0.5)
The official tool for talking to a board is mpremote, which opens the REPL, copies files and runs scripts straight from your computer. Editors like Thonny do the same from a window.
pip install mpremote
mpremote cp main.py :main.py # Copy main.py onto the board
mpremote run test.py # Run a script without copying it
mpremote repl # Open the prompt
You don't even need a board to try it. The playgrounds on micropython.com run the real firmware on emulated boards in the browser, and the learn page walks through flashing a real one.
The language
MicroPython is Python 3. Indentation, functions, classes, exceptions, generators, closures, comprehensions, decorators, f-strings and async/await all look and behave the way you're used to, and most short programs run unchanged on both. The project describes it as implementing Python 3.4 and some selected features of later versions.
What's cut down is mostly around the language rather than in it. Every feature costs flash for its code and RAM while it runs, and a microcontroller might have 2MB of the first and 264KB of the second, so the parts that few programs use are the first to go. The syntax page on micropython.com has the details, and the official docs keep the full list of differences from CPython.
The standard library
The biggest difference is the library. MicroPython has its own versions of the modules you'd expect to use on a device: json, re, struct, time, os, sys, collections, random, hashlib, asyncio and so on. They're smaller, though, with the common functions rather than all of them.
The desktop-only modules aren't there at all: no subprocess, sqlite3, tkinter or pathlib. Many more modules are available from micropython-lib, which you install onto the board with mip, its small package manager.
mpremote mip install aioble
What's missing from the language
The language itself has a few gaps, mostly in the less used corners. These are the ones you're most likely to bump into.
| Feature | In MicroPython |
|---|---|
__del__ on your own classes | Never called, even after a garbage collection |
| Multiple inheritance | Works, but methods are looked up depth first, and super() only calls one of the parents |
Private names like __count | Not mangled, so a subclass sees the same name |
__init_subclass__ | Not called automatically |
| Exception chaining | Not implemented, so there's no __cause__ or __context__ |
| Attributes on functions | Not supported: f.cache = {} fails |
locals() | Doesn't include the local variables, which live in numbered slots rather than by name |
eval() | Only sees globals |
| Text encodings | Only UTF-8 and ASCII |
| Slices with a step | Not on strings or tuples, and not when assigning to or deleting from a list |
str.ljust(), str.rjust(), int.bit_length() | Missing |
| f-strings | No !a conversion, and the brackets inside must balance |
| Floating point | Optional: some of the smallest builds only have integers |
Errors are shorter too. Messages are briefer than CPython's, and a traceback can leave out the source lines unless the firmware keeps them.
Every board is a little different
Which features are in is decided when the firmware is built, not by MicroPython as a whole. A build for a Pico has more of the language and the library than one squeezed onto an ESP8266, and a board's firmware can leave out things like eval() altogether. Typing help("modules") at the prompt lists the modules your board has.
What's added
In return, MicroPython adds what you need on a microcontroller. The machine module is the hardware: Pin, PWM, ADC, I2C, SPI, UART, Timer, RTC, the watchdog, and the sleep modes. It's designed to be the same on every board, so code written for one moves to another. Boards with a radio add network for Wi-Fi and bluetooth for BLE. The libraries page has the full list.
The micropython module has the controls for the runtime itself. The one you'll use most is const(), which works like #define in C: the compiler puts the value in wherever the name is used, instead of looking up a variable each time. If the name starts with an underscore, the name isn't kept at all, which saves a little RAM.
from micropython import const
_BUFFER_SIZE = const(64)
_ADDRESS = const(0x3C)
How it works
MicroPython is a compiler and a virtual machine written in C. The language lives in one directory of the source, py/, which is the same for every board. Underneath it is a port for each family of chips, which starts the chip up and connects Python to its hardware.
The interesting thing is that the compiler runs on the microcontroller. When you type a line at the prompt or import a .py file, the board itself turns the text into bytecode and then runs it. The internals page on micropython.com is a map of the source.
From source to bytecode
The lexer, in py/lexer.c, turns the text into tokens: names, numbers, strings, keywords, and the INDENT and DEDENT tokens that come from the indentation.
The parser, in py/parse.c, builds a parse tree from the tokens using a grammar written as tables in py/grammar.h. While it does that it folds constant integer expressions, so 1 << 8 is just 256 from here on, and it drops docstrings, since nothing on a microcontroller will read them.
The compiler, in py/compile.c, walks the parse tree four times:
- Scope works out whether each name is a local, a global, or a variable from an enclosing function.
- Stack size works out how deep the value stack can get in each function.
- Code size works out how many bytes of code each function will be.
- Emit writes the code out.
Doing it in passes means the final one already knows where every jump lands and how much memory to set aside, so the code can be written straight into a block of exactly the right size. On a chip with very little RAM, that matters more than compiling quickly.
Most functions are compiled to bytecode. c = a + b inside a function becomes four instructions: load local 0, load local 1, add, and store the result in local 2.
The virtual machine
The VM, in py/vm.c, is a loop that reads one opcode at a time and jumps to the code for it, pushing and popping values on a small stack. This is the same on every board. A port doesn't reimplement any of Python, it only provides the layer the VM and the machine module call into, which is why the language behaves the same on a Pico as on an ESP32.
Compiling ahead of time
Compiling on the board costs RAM, and a big module can need more than a small board has free. So the same compiler is also built as a program for your computer, mpy-cross, which writes the bytecode to a .mpy file. You copy that to the board instead, and the board imports the bytecode directly, skipping the lexer, parser and compiler.
mpy-cross sensors.py # Writes sensors.mpy
mpremote cp sensors.mpy : # import sensors now loads the bytecode
A step further, modules can be frozen into the firmware itself when it's built. Frozen bytecode runs straight out of flash, so it costs almost no RAM at all, and most ports ship their bigger Python libraries that way.
Objects in one word
Every Python value in MicroPython is a single machine word, 32 bits on most microcontrollers. Objects in the heap are always aligned to a multiple of four bytes, so the lowest bits of a pointer to one are always zero, and MicroPython uses those bits as a tag to pack other kinds of value into the same word.
A word ending in a 1 is a small integer, with the integer in the other 31 bits. So x = 1000 doesn't allocate anything: the number is right there in the word. Only when an integer gets too big for that does it turn into a heap object.
Interned strings are the names and short strings that appear in programs, like "led" or toggle. Each one is stored once, in a table, and a value just holds its number in the table. Most of the names the firmware itself uses are compiled into that table in flash, so they don't take any RAM.
None, True and False are immediates, values that are complete in the word. Everything else, lists, dicts, floats on most builds, your own objects, is a pointer to an object in the heap, and every heap object starts with a pointer to its type.
Memory and the garbage collector
The heap is a fixed block of RAM that the port sets aside when the board starts. It's divided into blocks of 16 bytes (four words), and a separate table keeps two bits for each block, saying whether it's free, the start of an object, or part of the one before it.
The garbage collector is mark and sweep. When an allocation can't find room, or you call gc.collect(), it marks every object it can reach from the stack and the other roots, then frees the blocks of every object it didn't reach.
The heap never moves anything, so on a small board the thing to watch is fragmentation: there can be plenty of memory free in total but no single gap big enough for a new buffer. The usual answer is to allocate buffers once and reuse them, with the methods that fill an existing buffer rather than returning a new one.
from machine import UART
uart = UART(0, 115200)
buf = bytearray(64)
while True:
count = uart.readinto(buf) # Fills buf, allocates nothing
if count:
handle(buf, count)
gc.mem_free() tells you how much of the heap is free, and micropython.mem_info() prints a summary of it.
Faster code
Bytecode is quick enough for most things on a microcontroller, but the compiler has other emitters for the parts that aren't. Putting @micropython.native on a function compiles it to machine code instead, which the docs put at roughly twice as fast, for a bigger function.
@micropython.viper goes further. Variables can be plain machine integers and pointers rather than Python objects, so arithmetic is a single instruction, at the cost of no longer being exactly Python: integers wrap round at 32 bits, and pointers aren't checked.
import micropython
@micropython.viper
def checksum(data) -> int:
p = ptr8(data) # Read the buffer a byte at a time
n = int(len(data))
total = 0
for i in range(n):
total += p[i]
return total & 0xFF
On Arm chips there's also an inline assembler, where you write the function's body as Thumb instructions in Python syntax.
Ports and boards
A port is a directory under ports/ for a family of chips: rp2 for the Raspberry Pi chips, esp32, stm32, nrf and so on. It has the start-up code, the heap, a REPL over USB or a serial port, and the machine classes for that chip.
A board is a much smaller thing inside a port: a directory with the board's name, which pins have which names (so Pin("LED") finds the LED), how big the heap is, which features to build in, and which modules to freeze into the image. The ports page on micropython.com shows how to add one.
Hardware
MicroPython runs on a large range of hardware, from boards that cost a few pounds to fast chips with a megabyte of RAM, and on your computer and in the browser too. The firmware is built for each board, so you download the image for yours from the official downloads, but the language is the same everywhere. What changes is the processor, how much RAM you have, and whether there's a radio.
| Device | Chip | Processor | RAM | Radio | Try it |
|---|---|---|---|---|---|
| Raspberry Pi Pico | RP2040 | 2 Cortex-M0+ at 133MHz | 264KB | None | Playground |
| Raspberry Pi Pico W | RP2040 and CYW43439 | 2 Cortex-M0+ at 133MHz | 264KB | Wi-Fi, Bluetooth | Playground |
| Raspberry Pi Pico 2 | RP2350 | 2 Cortex-M33 or 2 Hazard3 RISC-V at 150MHz | 520KB | None, Wi-Fi and Bluetooth on the 2 W | Playground |
| ESP32 | ESP32, S2, S3, C3, C6 and others | Xtensa or RISC-V, up to 240MHz | 320KB to 520KB | Wi-Fi, and Bluetooth on most | Playground |
| ESP8266 | ESP8266 | Xtensa L106 at 80MHz | About 80KB | Wi-Fi | Playground |
| pyboard | STM32F405 | Cortex-M4 at 168MHz | 192KB | None | |
| STM32 boards | STM32 F4, F7, H7, U5 and others | Cortex-M | Varies | Varies | |
| BBC micro:bit | nRF51822 (v1), nRF52833 (v2) | Cortex-M0 at 16MHz, Cortex-M4 at 64MHz | 16KB, 128KB | Bluetooth | Playground |
| nRF boards | nRF52 | Cortex-M4 | Varies | Bluetooth | |
| Adafruit Feather M4 | SAMD51 | Cortex-M4 at 120MHz | 192KB | None | |
| Teensy 4.1 | i.MX RT1062 | Cortex-M7 at 600MHz | 1MB | None | |
| Your computer | Linux, macOS, Windows | Any | Plenty | ||
| A web page | WebAssembly | Any | Plenty | ||
| Other ports | Zephyr, Renesas RA, Alif and more | Varies | Varies | Varies |
The Raspberry Pi Pico is the usual first board: it's cheap, it has no drivers to install, and you flash it by holding down its button, plugging it in and copying the firmware file onto the drive that appears. The ESP32 is the one to pick when you want Wi-Fi, and the ESP8266 before it is what made wireless MicroPython cheap, with a tight heap to match.
The pyboard is the original MicroPython board, and the STM32 port that grew from it is still one of the most complete. At the other end of the scale, the first micro:bit gives MicroPython just 16KB of RAM, and it still runs well enough for classrooms full of children to learn Python on it. The micro:bit has its own firmware with a microbit module for its display and buttons, so its tutorials don't use machine.Pin.
The unix, Windows and WebAssembly ports have no pins at all. They're for testing code on your computer, running the test suite, and putting Python into a web page.
The same program moves between all of these with very few changes. The blink program at the top works on any of the Picos, and on most ESP32 boards it only needs the LED's pin number in place of "LED". The devices section of micropython.com has a page for each family, with how to flash it and what its port gives you, and the examples go from blinking an LED to Wi-Fi, MQTT and small projects.