technique

MicroPython on the ESP32

Writing and running MicroPython on an ESP32: the REPL, boot.py and main.py, the board's filesystem, the machine module, and the gotchas that bite.

Before this

This page assumes you are comfortable with:

Why you need this

MicroPython is the fastest way from a new board to a working gadget: you type a line at the board and it runs. Most of the author's CYD projects, from a sensor dashboard to a teaching machine, are written in it. It also has a handful of habits, such as freeing hardware you thought you still held, that turn into mystery bugs unless you know them.

The idea

MicroPython is a reimplementation of Python 3 that runs directly on the chip. It has two parts: a firmware image (the interpreter itself, written in C) that you flash once, and your Python files, which live in a small filesystem inside the board's flash.

Flash the firmware once

The MicroPython project publishes a build per chip: ESP32_GENERIC for the classic ESP32, plus ESP32_GENERIC_S3, ESP32_GENERIC_C6, and ESP32_GENERIC_P4 among others. The author's TeachingMachineCYD project flashes the classic build to a CYD with esptool, writing it at 0x1000, the classic ESP32's bootloader offset (0x0 on an S3). The author's P4 kit, with rev 1.3 silicon, needs the build marked for chips before rev 3. Flashing and the ROM bootloader covers the step itself.

The REPL

After a reset the board offers the REPL (read-evaluate-print loop): a >>> prompt where each line you type runs at once. On the classic ESP32, MicroPython's ESP32 quick reference puts the REPL on UART0 (GPIO 1 transmits, GPIO 3 receives) at 115200 baud, which on a CYD reaches your computer through the CH340 bridge. Any serial terminal works; Thonny shows the REPL and the board's files in one window.

Control keys, from MicroPython's REPL documentation:

Key Byte Effect
Ctrl-C 0x03 Interrupt the running program (raises KeyboardInterrupt)
Ctrl-D 0x04 Soft reset from the prompt
Ctrl-E 0x05 Paste mode, which turns off auto-indent
Ctrl-A 0x01 Raw REPL: no echo, meant for programs, not people
Ctrl-B 0x02 Leave raw REPL

The raw REPL is how tools talk to the board. File-copy tools such as mpremote and ampy switch to it and send Python that writes your file into the filesystem. The author's projects copy files with ampy, one file at a time.

boot.py, then main.py

At every reset, MicroPython runs boot.py first and main.py second, then drops to the REPL if main.py ends. Two consequences follow.

  • A slow boot.py delays everything. The author's ESP_32_SAI dashboard joins WiFi in boot.py and waits up to 20 tries, one second apart, before giving up, so main.py can start up to about 20 seconds after power-on.
  • An exception in boot.py skips main.py. In the ESP32 port's startup code, main.py runs only if boot.py finished without an error. The traceback prints, and the board sits at the REPL instead of running your program.

Keep boot.py to early setup that must not fail, and catch exceptions inside it. And end main.py with a loop that never returns: the author's knowledge base notes that when main.py finishes, pins reset and a CYD's backlight goes dark.

The machine module

machine is MicroPython's hardware library: Pin for one GPIO, I2C and SoftI2C for sensors, SPI for displays, UART for serial ports. SoftI2C drives I2C from software instead of the I2C hardware block, and the author's notes find it more reliable than hardware I2C on many ESP32 boards.

Memory and the garbage collector

Your objects live in a heap, a pool of RAM shared by everything the program allocates. The garbage collector frees any object nothing refers to any more. That includes hardware objects. If you create a Pin without keeping it in a variable, MicroPython may free it, and the pin goes back to its reset state. This is the gotcha the author's notes repeat most: the CYD backlight, a chip-select line, or a bus object must each be held in a named variable for as long as you need it.

Worked example

The deep example: a sensor dashboard

This is excerpted from the author's ESP_32_SAI project, main.py, which runs on a CYD: it reads an AHT10 temperature and humidity sensor over I2C, draws the reading on the screen, and publishes it over MQTT. The imported names come from a configuration file whose values (WiFi, endpoint, certificates) stay off this page.

from machine import Pin, SoftI2C
import time
import json
...
def main():
    ...
    # Init I2C + sensor
    i2c = SoftI2C(sda=Pin(I2C_SDA), scl=Pin(I2C_SCL), freq=100000)
    devices = scan_i2c(i2c)
    if AHT10_ADDR not in devices:
        print("WARNING: AHT10 not found at", hex(AHT10_ADDR))
        return

    sensor = AHT10(i2c, AHT10_ADDR)
    ...
    # Main loop
    while True:
        try:
            temp, hum = sensor.read()
            temp = round(temp, 1)
            hum = round(hum, 1)

            # Update display
            dash.update(temp, hum)
            ...
        except Exception as e:
            print("Error:", e)

        time.sleep(READ_INTERVAL)


main()

Every habit from this page is in it. The bus is a SoftI2C held in the variable i2c. It checks the sensor answered before using it. The program ends in while True, and the try keeps one bad reading from ending the loop and leaving the board at the REPL. freq=100000 sets the I2C clock to 100 kHz.

The author's TeachingMachineCYD project shows the garbage-collector rule in its first lines of hardware setup:

backlight = Pin(21, Pin.OUT, value=1)
touch_cs = Pin(33, Pin.OUT, value=1)   # deselect touch
sd_cs = Pin(5, Pin.OUT, value=1)       # deselect SD

The CYD's backlight is GPIO 21, active high. Writing Pin(21, Pin.OUT, value=1) on a line by itself would light the screen and then, at some later garbage collection, let it go dark.

The uppercase echo, line by line

This is the MicroPython echo from Choosing a language, written for this page and not hardware-tested. It targets the CYD (lx6-uart0); saved as main.py, it runs after every reset.

import sys

while True:
    ch = sys.stdin.read(1)          # wait for one character from the REPL port
    if "a" <= ch <= "z":
        ch = chr(ord(ch) - 0x20)    # 'a' (0x61) becomes 'A' (0x41)
    sys.stdout.write(ch)
Line What it does
import sys sys.stdin and sys.stdout are the REPL's own input and output, which on the CYD is UART0 through the CH340.
while True: Loop forever, so main.py never ends.
sys.stdin.read(1) Wait until one character arrives, then return it as a one-character string. The author's Forage game reads keys a computer sends over the CYD's CH340 link with the same call.
"a" <= ch <= "z" Python compares characters by their codes, so this is true for 0x61 to 0x7A.
chr(ord(ch) - 0x20) ord gives the code, subtract 0x20, chr turns it back into a character.
sys.stdout.write(ch) Send the character back down the same wire.

Trace q: ord("q") is 0x71, minus 0x20 is 0x51, and chr(0x51) is "Q". Trace 7: its code 0x37 is below 0x61, so it comes back unchanged. One input is special: Ctrl-C (0x03) is caught by MicroPython before read sees it, stops the echo, and returns you to the REPL, which is also how you get control back to edit files.

In an ESP32 project

This is stage 2 of the pipeline on the hub, Write the program, on the CYD and other classic ESP32 boards. Stage 3 shrinks to one firmware flash. Stage 4 runs through machine: GPIO: buttons and LEDs, I2C and SPI peripherals (the AHT10 above), and Driving the CYD display. Stage 5 starts from boot.py in WiFi and MQTT.

Common mistakes

Most of these are from the author's MicroPython knowledge base and the ESP_32_Keyboard project's notes.

  • A hardware object in a throwaway variable. Symptom: the CYD screen goes dark at a random moment.
  • main.py that ends. Symptom: pins reset and the backlight goes off a moment after the program "finishes".
  • Firmware at the wrong offset. 0x1000 for the classic ESP32, 0x0 for the S3. Symptom: a boot loop after flashing.
  • ampy on Windows without --delay 2. Symptom: serial timeouts during file copy over the CH340.
  • Hardware I2C on a flaky bus. Symptom: a sensor that answers sometimes; switch to SoftI2C.
  • Seeding random from time.ticks_us() at boot. main.py starts at nearly the same moment after every reset, so the seed repeats. Symptom: the same "random" sequence every power-on. Leave random unseeded or seed it from os.urandom.

Cost

The firmware is the big fixed cost. The author's CYD image is 1,691,408 bytes, about 1651.8 KB with 1 KB = 1024 bytes, which is about 40 percent of a 4 MB flash before your first line; much of the rest becomes the filesystem. RAM is shared with the interpreter, and every object you create comes from the garbage-collected heap, so big lists and buffers run out sooner than in C. The program is interpreted, so a tight loop runs far slower than the same loop in C or assembly. In return, edit-to-run is the shortest of the four languages: type at the REPL, or copy a file and reset.

Going further

  • CircuitPython on the ESP32-S3, MicroPython's fork with a USB drive workflow
  • WiFi and MQTT, which picks up the SAI dashboard's boot.py
  • MicroPython's ESP32 quick reference and its REPL documentation
  • mpremote, MicroPython's own command-line tool for files and the REPL
  • The gc module (gc.mem_free()), to watch the heap while your program runs

Leads to

Back to ESP32 development: assembly, C, MicroPython, and CircuitPython