3.5 MicroPython and CircuitPython: Python on Microcontrollers
MicroPython and CircuitPython represent a radical re-imagining of Python, meticulously engineered to run on microcontrollers (MCUs)—small, low-power computers embedded in devices ranging from thermostats to satellites. Unlike their desktop counterparts, these MCUs have severe constraints: often just a few hundred kilobytes of ROM and RAM, a single core running at a few hundred MHz, and no traditional operating system. To overcome this, MicroPython is a complete, lean re-implementation of the Python 3 language, including a subset of the Python Standard Library. It provides an interactive REPL (Read-Eval-Print Loop), a filesystem, and hardware-specific modules, all while executing not from a file on a disk but directly from flash memory on the chip itself. CircuitPython, a fork of MicroPython developed by Adafruit Industries, focuses specifically on the beginner and education experience, emphasizing ease of use, clear documentation, and a vast library of driver code for sensors and displays.
Core Architecture and Execution Model
The most fundamental difference from CPython is the execution model. CPython compiles Python source code to bytecode, which is then interpreted by a virtual machine. MicroPython also compiles to bytecode, but its compiler (mpy-cross) is often run on the developer’s computer beforehand. The output is .mpy files, a compact and pre-compiled bytecode format. This pre-compilation step drastically reduces the memory and processing power required on the MCU, as the resource-intensive parsing and compilation phases are offloaded. The MicroPython runtime, which includes the bytecode interpreter and core libraries, is itself compiled to machine code and flashed onto the MCU’s ROM. When you deploy your code, you are typically sending pre-compiled .mpy bytecode or even raw Python source (.py) to be stored on the chip’s flash filesystem. The interpreter then executes this bytecode directly, translating it into hardware operations. This is why the REPL is so responsive; you are interacting with the live interpreter on the chip.
Hardware Abstraction and the machine Module
Direct hardware control is the primary reason for using MicroPython. This is abstracted through the machine module, which provides a standardized, portable API for interacting with the MCU’s peripherals. The key to its design is that it abstracts the common features of hardware (like pins, clocks, and communication buses) without hiding their power. For instance, to blink an LED connected to a GPIO pin, you don’t need a complex IDE or low-level register manipulation.
# Example: Blinking an LED on a Raspberry Pi Pico
import machine
import time
# Instantiate a Pin object for GPIO pin 25 as an output
led = machine.Pin(25, machine.Pin.OUT)
while True:
led.value(1) # Set pin high (3.3V) to turn LED on
time.sleep(0.5) # Wait 500ms
led.value(0) # Set pin low (0V) to turn LED off
time.sleep(0.5)
This code works across dozens of supported boards because the machine module translates the high-level value() method into the specific register writes required for that particular MCU. The same principle applies to more complex protocols like I2C or SPI, allowing driver libraries to be written in a portable way.
CircuitPython’s User-Friendly Enhancements
CircuitPython builds upon MicroPython’s foundation with a focus on accessibility. Its most notable feature is its use as a USB Mass Storage Device. When you plug a CircuitPython board into your computer, it appears as a flash drive named CIRCUITPY. You simply drag and drop your Python code, named code.py, onto this drive, and the board automatically executes it upon save or reset. This eliminates the need for any flashing tools or complex IDE setups. Furthermore, CircuitPython’s library ecosystem is immense. Instead of writing code to talk to a specific sensor using I2C, you can simply copy a pre-written driver library onto the CIRCUITPY drive and import it, dramatically reducing development time.
# Example: Reading a temperature from an I2C sensor (e.g., ADT7410) in CircuitPython
import board
import busio
import adafruit_adt7410 # A driver library dropped onto the board
# Set up the I2C bus using the board's default SCL and SDA pins
i2c = busio.I2C(board.SCL, board.SDA)
# Instantiate the sensor object using the I2C bus
sensor = adafruit_adt7410.ADT7410(i2c)
print("Temperature: {:.2f} C".format(sensor.temperature))
Common Pitfalls and Best Practices
Developers coming from a resource-rich environment often face challenges related to these constraints. A major pitfall is assuming unlimited memory. Creating large lists, strings, or using recursion can quickly exhaust the available RAM (which can be as small as 20KB) and cause a crash. It’s crucial to be mindful of object creation, prefer generators, and reuse objects where possible.
Another critical issue is blocking the interpreter. The time.sleep() function blocks the entire core—nothing else can run. For non-trivial applications, this is unacceptable. The solution is to use state machines and non-blocking timing patterns based on time.monotonic().
# Best Practice: Non-blocking delay for a blinking LED
import machine
import time
led = machine.Pin(25, machine.Pin.OUT)
led_state = False
previous_millis = time.ticks_ms()
interval = 500 # milliseconds
while True:
current_millis = time.ticks_ms()
# Check if the interval has elapsed without blocking
if time.ticks_diff(current_millis, previous_millis) >= interval:
previous_millis = current_millis
led_state = not led_state # Toggle the state
led.value(led_state)
# Other tasks can be performed here without interruption
This pattern allows the main loop to continue processing other tasks, like checking sensors or handling network requests, while still maintaining accurate timing for the LED. Finally, directly controlling hardware introduces the risk of electrical damage. Best practices include using resistors to limit current to LEDs, understanding the voltage tolerances of GPIO pins, and being cautious when hot-plugging devices to I2C or SPI buses.