Python MicroPython for Embedded Systems — Deep Dive

MicroPython Architecture

MicroPython compiles Python source code to bytecode, then executes that bytecode on a custom virtual machine. The entire runtime — compiler, VM, garbage collector, and core library — fits into roughly 256 KB of flash and can operate with as little as 16 KB of RAM.

The compilation happens on-device. When you import a module or run a script, MicroPython parses and compiles it right on the microcontroller. This is different from CPython’s approach of caching .pyc files — MicroPython recompiles from source each boot unless you pre-compile to .mpy bytecode files.

Pre-compilation with mpy-cross

For production deployments, pre-compiling saves boot time and RAM:

# On your development machine
mpy-cross my_module.py  # produces my_module.mpy

# Copy .mpy to the board's filesystem
# MicroPython loads it without recompilation

Pre-compiled .mpy files load faster and use less RAM because the compiler does not need to build the abstract syntax tree in memory.

Memory Management on Constrained Devices

The garbage collector in MicroPython uses a simple mark-and-sweep algorithm. You can inspect and control it:

import gc
import micropython

gc.collect()
print(gc.mem_free())    # bytes of free RAM
print(gc.mem_alloc())   # bytes allocated

micropython.mem_info(1)  # detailed memory map

Memory Optimization Strategies

1. Use constants. MicroPython’s const() function stores integer values in ROM instead of creating heap objects:

from micropython import const

_LED_PIN = const(2)
_BAUD_RATE = const(115200)
_BUFFER_SIZE = const(1024)

2. Prefer bytes over strings. String operations create new objects on each operation. For protocol work, use bytes and bytearray:

# Memory-wasteful
msg = "TEMP:" + str(temperature) + "\n"

# Memory-efficient
buf = bytearray(32)
buf[:5] = b"TEMP:"
# Format directly into buffer

3. Avoid global imports of large modules. Import inside functions when the module is only used occasionally:

def connect_wifi():
    import network  # only loaded when needed
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    wlan.connect('SSID', 'password')

4. Use generators for data streams. Instead of collecting sensor readings into a list, yield them:

def read_sensors(count):
    from machine import ADC
    adc = ADC(0)
    for _ in range(count):
        yield adc.read()

Hardware Interface Patterns

GPIO with Interrupts

Polling pins wastes CPU cycles. Use hardware interrupts for responsive input handling:

from machine import Pin

button = Pin(14, Pin.IN, Pin.PULL_UP)

def button_handler(pin):
    print("Button pressed on", pin)

button.irq(trigger=Pin.IRQ_FALLING, handler=button_handler)

Interrupt handlers should be short. Set a flag in the handler and process it in the main loop:

import micropython

button_pressed = False

def button_isr(pin):
    global button_pressed
    button_pressed = True

button.irq(trigger=Pin.IRQ_FALLING, handler=button_isr)

while True:
    if button_pressed:
        button_pressed = False
        micropython.schedule(process_press, None)

I2C Communication

I2C is the most common protocol for connecting sensors. MicroPython provides both hardware and software I2C:

from machine import I2C, Pin

# Hardware I2C (faster, uses dedicated peripheral)
i2c = I2C(0, scl=Pin(22), sda=Pin(21), freq=400000)

# Scan for connected devices
devices = i2c.scan()
print("Found devices at addresses:", [hex(d) for d in devices])

# Read 2 bytes from device at address 0x48 (common temp sensor)
data = i2c.readfrom(0x48, 2)
temp = (data[0] << 8 | data[1]) >> 4
temp_celsius = temp * 0.0625

PWM for Motor and LED Control

Pulse Width Modulation controls brightness and motor speed:

from machine import Pin, PWM

pwm = PWM(Pin(2))
pwm.freq(1000)      # 1 kHz frequency

# Fade LED from off to full brightness
for duty in range(0, 1024, 8):
    pwm.duty(duty)
    time.sleep_ms(10)

Networking on ESP32

The ESP32’s built-in Wi-Fi makes it a popular IoT platform:

import network
import urequests
import ujson

def connect_wifi(ssid, password, timeout=10):
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    wlan.connect(ssid, password)
    
    start = time.time()
    while not wlan.isconnected():
        if time.time() - start > timeout:
            raise OSError("Wi-Fi connection timeout")
        time.sleep(0.5)
    
    print("Connected:", wlan.ifconfig())
    return wlan

def post_sensor_data(url, temperature, humidity):
    data = ujson.dumps({
        "temp": temperature,
        "humidity": humidity
    })
    response = urequests.post(url, data=data,
                               headers={"Content-Type": "application/json"})
    response.close()  # critical: free the socket

Always close response objects. MicroPython has limited sockets, and leaked connections cause hard-to-debug crashes.

Filesystem and Data Persistence

MicroPython provides a small filesystem on the board’s flash memory:

# Write configuration
import ujson

config = {"interval": 30, "threshold": 25.0}
with open("config.json", "w") as f:
    ujson.dump(config, f)

# Read configuration
with open("config.json", "r") as f:
    config = ujson.load(f)

Flash memory has limited write cycles (typically 10,000 to 100,000). For frequently updated data like sensor logs, consider buffering in RAM and writing in batches, or using an external SD card.

Production Deployment Patterns

Watchdog Timer

Microcontrollers can hang due to hardware glitches or software bugs. The watchdog timer resets the board if your code stops responding:

from machine import WDT

wdt = WDT(timeout=8000)  # 8 second timeout

while True:
    read_sensors()
    send_data()
    wdt.feed()  # reset the watchdog timer
    time.sleep(1)

If wdt.feed() is not called within 8 seconds, the board reboots automatically.

Deep Sleep for Battery Projects

Battery-powered devices need aggressive power management:

import machine

def run_cycle():
    connect_wifi("SSID", "pass")
    temp = read_temperature()
    post_data(temp)
    
    # Sleep for 5 minutes (300 seconds)
    machine.deepsleep(300_000)

run_cycle()

During deep sleep, the ESP32 draws around 10 microamps compared to 80 milliamps when active. A single 18650 battery can last months with hourly readings.

Over-the-Air Updates

For deployed devices, updating firmware remotely is essential:

import urequests

def check_update(current_version):
    response = urequests.get("https://my-server.com/firmware/latest")
    info = response.json()
    response.close()
    
    if info["version"] > current_version:
        download_and_apply(info["url"])
        machine.reset()

Production OTA systems need integrity checks (hash verification), rollback mechanisms, and staged rollouts.

Performance Comparison

Typical benchmarks on ESP32 at 240 MHz:

OperationMicroPythonC (Arduino)
GPIO toggle100 kHz10 MHz
Float multiply (1M ops)1.2 seconds0.02 seconds
JSON parse (1 KB)15 ms2 ms
I2C sensor read0.5 ms0.3 ms

MicroPython is roughly 10-100x slower than C for computation-heavy tasks, but for I/O-bound work (sensor reading, network communication), the difference is negligible because the bottleneck is the hardware, not the interpreter.

Debugging Techniques

REPL-driven development is MicroPython’s strongest debugging tool. Connect to the board, import your module, and test functions interactively.

Hardware debugging often means adding print statements and watching serial output. For more complex issues:

import micropython

# Schedule a heap dump on exception
micropython.alloc_emergency_exception_buf(100)

# Detailed memory layout
micropython.mem_info(1)

# Stack usage
micropython.stack_use()

When MicroPython Is Not the Right Choice

Avoid MicroPython when you need sub-microsecond timing (bit-banging fast protocols), when RAM usage exceeds 80% of available memory, or when you need hard real-time guarantees. In these cases, C, Rust (via Embassy), or a hybrid approach where MicroPython handles high-level logic while C handles timing-critical code is more appropriate.

One thing to remember: MicroPython trades execution speed for development velocity — its real power lies in rapid prototyping, interactive debugging, and making embedded programming accessible to anyone who knows Python.

pythonmicropythonembeddediot

See Also

  • Python Behavior Trees Robotics How robots make decisions using a tree-shaped rulebook that keeps them organized, like a flowchart that tells a robot what to do in every situation.
  • Python Bluetooth Ble How Python connects to fitness trackers, smart locks, and wireless sensors using the invisible radio signals all around you.
  • Python Circuitpython Hardware Why CircuitPython makes wiring up LEDs, sensors, and motors as easy as plugging in a USB drive.
  • Python Computer Vision Autonomous How self-driving cars use cameras and Python to see the road, spot pedestrians, read signs, and understand traffic — like giving a car human eyes and a brain.
  • Python Home Assistant Automation How Python turns your home into a smart home that reacts to you automatically, like a helpful invisible butler.