technique

Choosing a language

MicroPython, CircuitPython, C with ESP-IDF, or assembly: what each is good at, what it costs, and the same uppercase echo in all four.

Before this

This page assumes you are comfortable with:

Why you need this

The language you pick decides how long you wait between editing a line and seeing it run, how much of the chip's flash and RAM is left for your program, and how close you can get to the hardware. Switching later means rewriting, so it is worth deciding on purpose. This page compares the four options and shows the same small program in each.

The idea

There are two families. An interpreter runs on the chip and executes your source code (or a compact form of it called bytecode) while the program runs. A compiler translates your source into machine code on your computer before anything reaches the chip. Interpreters and compilers explains the trade in general; here it is for the ESP32.

  • MicroPython is a reimplementation of Python 3 small enough to fit on a microcontroller. You flash the MicroPython firmware once, then copy .py files to a small filesystem in the board's flash, or type at its prompt, the REPL (read-evaluate-print loop).
  • CircuitPython is Adafruit's fork of MicroPython, with a different hardware library and a workflow built around a USB drive: save code.py and the board reruns it.
  • C with ESP-IDF is C compiled with Espressif's own framework, ESP-IDF. Every change is rebuilt on your computer into a full firmware image and flashed again.
  • Assembly is the processor's own instructions written out by name, one line per instruction. On this site it is built either inside an ESP-IDF project or by the ESP32 Inspector in the browser.
MicroPython CircuitPython C with ESP-IDF Assembly
How code gets on the board Firmware flashed once; files copied over serial (Thonny, mpremote, ampy) Firmware flashed once; save code.py to the CIRCUITPY drive, or upload over the web workflow Build an image with idf.py, flash it every change Same as C, or the Inspector's assemble-and-flash pages
Edit-to-run Copy a file and reset, or type at the REPL Save the file; it reruns Rebuild and reflash Rebuild and reflash
Speed Interpreted: every bytecode is decoded as it runs Interpreted, like MicroPython Compiled machine code, full speed Full speed; you choose every instruction
Memory The interpreter itself takes a large share of flash; your objects share a garbage-collected heap Same shape as MicroPython Only what you link, plus the framework Only what you write
Hardware access The machine module: Pin, I2C, SPI, UART The board, digitalio, busio modules Every driver, FreeRTOS tasks, WiFi, BLE, USB host Registers, by address
Chips (official builds) ESP32, S3, C6, P4 and more ESP32, S3, C6, P4 families and more All All, with a separate source per core
Proven on the author's boards CYD (classic ESP32), P4 kit YD-ESP32-S3 (CircuitPython 10.x) Every Inspector echo payload; the keyboard host on the YD-ESP32-S3 An echo for every Inspector config

The "Chips" row comes from the official download pages. MicroPython publishes generic builds named ESP32_GENERIC, ESP32_GENERIC_S3, ESP32_GENERIC_C6, and ESP32_GENERIC_P4. CircuitPython's downloads page lists boards in the ESP32, ESP32-S2, ESP32-S3, ESP32-C3, ESP32-C6, and ESP32-P4 families, including the YD-ESP32-S3. A build existing is not the same as a build proven on your board: the author's catalog marks C6 MicroPython as not yet verified on the workbench.

Worked example

The running example is the uppercase echo: type a letter on your computer, and the board sends it back uppercase. Lowercase ASCII letters run from a (0x61) to z (0x7A), and each uppercase letter is exactly 0x20 lower: a (0x61) becomes A (0x41). Everything that is not a to z passes through unchanged.

Assembly. This loop is excerpted from the ESP32 Inspector's rv32-usbjtag echo payload, which is RISC-V assembly for the ESP32-C6 and has run on real hardware (it turned hello-c6 into HELLO-C6 on the author's C6). It polls the chip's USB-Serial-JTAG registers directly. The section-heading comments are removed here, and one comment dash became a comma.

echo_loop:
    li      t0, 0x6000F004           # USB_SERIAL_JTAG_EP1_CONF_REG
    lw      t1, 0(t0)                # read status flags
    andi    t1, t1, 4                # keep bit 2 = SERIAL_OUT_EP_DATA_AVAIL
    beqz    t1, echo_loop            # spin until a byte is waiting
    li      t0, 0x6000F000           # USB_SERIAL_JTAG_EP1_REG
    lw      t2, 0(t0)                # pop one RX byte into t2
    li      t3, 'a'                  # 0x61
    bltu    t2, t3, tx_wait          # byte < 'a' → leave unchanged
    li      t3, 'z'+1                # 0x7B
    bgeu    t2, t3, tx_wait          # byte > 'z' → leave unchanged
    addi    t2, t2, -0x20            # subtract 0x20 to uppercase
tx_wait:
    li      t0, 0x6000F004           # USB_SERIAL_JTAG_EP1_CONF_REG
    lw      t1, 0(t0)                # read status flags
    andi    t1, t1, 2                # keep bit 1 = SERIAL_IN_EP_DATA_FREE
    beqz    t1, tx_wait              # spin until the TX buffer has room
    li      t0, 0x6000F000           # USB_SERIAL_JTAG_EP1_REG
    sw      t2, 0(t0)                # queue the byte
    li      t0, 0x6000F004           # USB_SERIAL_JTAG_EP1_CONF_REG
    li      t1, 1                    # WR_DONE
    sw      t1, 0(t0)                # flush, host sees the byte now
    j       echo_loop                # loop forever

MicroPython. Written for this page, not hardware-tested. It targets the CYD (lx6-uart0), where the REPL lives on UART0 through the CH340 bridge, so the program reads and writes through the same standard input and output the REPL uses.

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)

CircuitPython. Written for this page, not hardware-tested. It targets a native-USB ESP32-S3 such as the YD-ESP32-S3's left port or a lx7-usbjtag board, where the console is a USB serial port that CircuitPython's usb_cdc module exposes as an object.

import usb_cdc

port = usb_cdc.console              # the USB serial port the REPL uses
while True:
    b = port.read(1)                # a bytes object; b"" if nothing arrived
    if not b:
        continue
    c = b[0]                        # the byte as a number, 0 to 255
    if 0x61 <= c <= 0x7A:           # 'a' to 'z'
        c -= 0x20                   # 'a' (0x61) becomes 'A' (0x41)
    port.write(bytes([c]))

C with ESP-IDF. Written for this page, not hardware-tested. It uses only standard C input and output, which ESP-IDF routes to whichever console the project is configured for (UART0 or USB-Serial-JTAG), so it builds for any config. In ESP-IDF, reading the console returns at once when nothing is waiting, so EOF here means "no byte yet", not "end of input".

#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

void app_main(void)
{
    while (1) {
        int c = getchar();                  // EOF: no byte waiting yet
        if (c == EOF) {
            clearerr(stdin);
            vTaskDelay(pdMS_TO_TICKS(10));  // sleep 10 ms so other tasks run
            continue;
        }
        if (c >= 'a' && c <= 'z') c -= 0x20;   // 'a' (0x61) becomes 'A' (0x41)
        putchar(c);
        fflush(stdout);                     // stdout is line-buffered: send now
    }
}

The same three steps, side by side:

Step Assembly MicroPython CircuitPython C with ESP-IDF
Wait for a byte Spin on bit 2 of 0x6000F004 sys.stdin.read(1) waits port.read(1), retry on b"" getchar(), sleep on EOF
Uppercase Two unsigned compares, then subtract 0x20 Compare characters, subtract with ord and chr Compare numbers, subtract 0x20 Compare characters, subtract 0x20
Send it Wait for bit 1, store the byte, write 1 to flush sys.stdout.write port.write putchar, then fflush
Non-blank lines you wrote 21 instructions 6 10 17

Trace the letter q (0x71) through any of them: 0x71 is between 0x61 and 0x7A, so 0x71 minus 0x20 gives 0x51, which is Q. A digit such as 7 (0x37) is below 0x61 and comes back unchanged.

The assembly is the only one that knows a register address. The other three ask a layer underneath (the interpreter, or ESP-IDF's console driver) to find the bytes, which is exactly why they port between chips and the assembly does not.

Rules of thumb

  • First board, want something on screen this afternoon: MicroPython on a CYD.
  • A native-USB S3 and you like editing files on a drive: CircuitPython.
  • WiFi, USB host, and a web page at the same time, or tight timing: C with ESP-IDF. The author's keyboard project shows the pattern: its first USB keyboard reader was CircuitPython, and the version that also forwards keys over ESP-NOW and serves a configuration page is C with ESP-IDF.
  • Bringing up a new chip, or learning what the processor really does: assembly. It is also how every Inspector config is first proven.

In an ESP32 project

This is the second half of stage 1, Choose, on the hub. The board's config from Chips, boards, and configs limits the menu, and the language then picks which stage-2 page you live on: MicroPython on the ESP32, CircuitPython on the ESP32-S3, C with ESP-IDF, or RISC-V assembly and Xtensa assembly.

Common mistakes

  • Expecting one firmware to cover every chip. The author's bundled MicroPython image is classic-ESP32 only; an S3, C6, or P4 needs its own build. Symptom: the flash completes, then the board boot-loops or prints nothing.
  • Mixing the two Pythons' libraries. MicroPython's machine.Pin does not exist in CircuitPython, and digitalio does not exist in MicroPython. Symptom: ImportError on the first line.
  • Sending Ctrl-C to a MicroPython echo. Byte 0x03 is the REPL's interrupt key, so it stops the program instead of being echoed. Symptom: a KeyboardInterrupt traceback and a >>> prompt.
  • Expecting C's getchar to wait. On ESP-IDF's default console it returns EOF at once when nothing has arrived. Symptom: a loop that never sees input or races at full speed.
  • Assembly for the wrong core. RISC-V code on an Xtensa chip is garbage to it. Symptom: an illegal-instruction crash and a reset loop.

Cost

The figures here come from the author's files, with 1 KB = 1024 bytes. The MicroPython firmware the author flashes onto the CYD is 1,691,408 bytes, about 1651.8 KB, before your program exists. A complete ESP-IDF build of the C6 echo payload is a 21,872-byte bootloader plus a 123,216-byte app, about 120.3 KB of app, almost all of it ESP-IDF's startup and drivers around a 21-instruction loop. Edit-to-run is the other cost. The Python options win at the bench: save or paste and see it run. C and assembly pay a rebuild and reflash every time, and the first ESP-IDF build compiles the whole framework. Speed and power go the other way: a compiled or hand-written loop does its work in a couple of dozen instructions per byte, while an interpreter decodes bytecode for every step, so it needs more time, and more energy, per byte.

Option What sits in flash before your code
MicroPython (author's CYD image) 1,691,408 bytes
C echo app (author's C6 payload) 123,216 bytes, plus a 21,872-byte bootloader
Assembly echo loop alone 21 instructions

Going further

Leads to

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