prerequisite

Interpreters and compilers

The difference between running source code through an interpreter on the chip and translating it to machine code before it ever reaches the chip.

Before this

This page assumes you are comfortable with:

Why you need this

The four languages in this cluster split into two camps. C with ESP-IDF and assembly are translated into machine code on your computer, and the chip runs the result. MicroPython and CircuitPython put a Python interpreter on the chip, and you hand it source code. That one difference explains why Python on a board lets you change a line and see it run in seconds, why C runs much faster, and why the two use flash and RAM so differently.

The idea

Source code and machine code

Source code is what you write: text in a language people can read. Machine code is what the CPU runs: instructions stored as numbers, as described on How a CPU runs instructions. Every program has to get from the first to the second somehow. The question is when, and where.

Ahead of time: compilers, assemblers, linkers

A compiler reads source code in a language like C and writes out machine code (usually by way of assembly language) before the program ever runs. An assembler translates assembly language, which is machine code spelled as words like addi and lw, into the actual numbers. A linker joins the pieces (your code, library code, the startup code) into one image and decides the address each piece will live at.

The ESP32 Inspector's C6 clone project records its build steps for a RISC-V assembly program. The assembler is run as riscv32-esp-elf-as -march=rv32imac -mabi=ilp32, which names the instruction set (RV32IMAC). The linker is told -Ttext=0x4200788c -e app_main: put the code at address 0x4200788C, and start at the label app_main. Only then is the image flashed.

For C with ESP-IDF, idf.py build runs the compiler, assembler, and linker for your code and for the whole ESP-IDF framework. The output is a set of binary files: a bootloader, a partition table, and the application.

At run time: interpreters

An interpreter is a program that reads source code and carries it out directly, without producing a separate machine-code program first. The interpreter itself is machine code, built ahead of time; the program you write is data it reads.

Pure line-by-line interpretation is slow, so most interpreters first turn the source into bytecode: a compact list of simple instructions for an imaginary machine, called a virtual machine. The interpreter's main loop fetches each bytecode instruction, decodes it, and runs a chunk of real machine code to carry it out. It is fetch-decode-execute again, one level up.

MicroPython compiles to bytecode on the board. When you save main.py and reset, the board's own compiler turns your Python into MicroPython bytecode in RAM, and the virtual machine runs it. CircuitPython, a fork of MicroPython, works the same way. Both can also load precompiled .mpy files, which hold bytecode prepared ahead of time so the board skips that step.

The REPL

Because the compiler lives on the chip, MicroPython can offer a REPL (read, evaluate, print, loop): a prompt over the serial connection where you type one line of Python, the board compiles and runs it, and prints the result. No build, no flash. A compiled C program has no equivalent; changing it means rebuilding and reflashing.

The trade

Compiled (C, assembly) Interpreted (MicroPython, CircuitPython)
When source becomes machine code on your computer, before flashing bytecode compiled on the chip, then interpreted
Edit-to-run rebuild and reflash save the file or type at the REPL
Run speed full speed of the CPU each bytecode costs many machine instructions
What sits in flash your program plus only the libraries it uses the whole interpreter, plus your source files
Errors found many at build time when that line runs
Hardware access every register what the interpreter's modules expose, plus raw memory access

Worked example

The uppercase step from the echo: if the byte b is a lowercase letter (0x61 to 0x7A), subtract 0x20.

Python, written for this page (not hardware-tested), as one line:

b = b - 0x20 if 0x61 <= b <= 0x7A else b

What it turns into: bytecode. MicroPython's bytecode format is its own, and it is not shown here. To give a feel for it, this is the same line compiled by desktop CPython 3.14's dis module, trimmed with ...:

LOAD_SMALL_INT          97
LOAD_NAME                0 (b)
SWAP                     2
COPY                     2
COMPARE_OP              58 (bool(<=))
POP_JUMP_IF_FALSE        8 (to L1)
...
LOAD_NAME                0 (b)
LOAD_SMALL_INT          32
BINARY_OP               10 (-)
STORE_NAME               0 (b)
...

The full listing is two dozen bytecode instructions for one line, and the interpreter runs a stretch of machine code for each one: looking up the name b, checking its type, doing the comparison, making the result.

C, written for this page (not hardware-tested):

if (b >= 'a' && b <= 'z') {
    b -= 0x20;
}

What it turns into: the compiler emits RISC-V or Xtensa instructions directly, much like the assembly below, with b held in a register. Nothing about names or types is left to decide at run time.

Assembly, excerpted from the ESP32 Inspector's C6 echo payload, which has run on real hardware. It is RISC-V, and the received byte is in 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

Three instructions do the work (two branches and the subtraction), and two load the comparison constants. What it turns into: the assembler writes each line as one number; addi t2, t2, -0x20 becomes 0xFE038393, or the 16-bit 0x1381 with RISC-V's compressed instructions. No further translation happens on the chip.

Version Translated by When Runs as
Python MicroPython's compiler, on the board each time the file loads bytecode, through the interpreter
C the C compiler, on your computer at build time machine code
Assembly the assembler, on your computer at build time machine code, one-for-one with the source

In an ESP32 project

Size tells the story. The ESP_32_DAD project's MicroPython blink program, main.py, is 240 bytes; the setup notes beside it say the MicroPython firmware it runs on is a download of about 1.7 MB. The same project's Xtensa assembly echo, built with ESP-IDF for the classic ESP32, produced an application image of 175,344 bytes (about 171 KB, with 1 KB = 1024 bytes) and a bootloader of 25,984 bytes (about 25 KB). Most of that application image is ESP-IDF's startup and runtime, not the nineteen instruction lines of the echo itself.

One build detail from the author's ESP-IDF notes shows the toolchain at work: ESP-IDF runs .S assembly files through the C preprocessor before the assembler sees them, so a comment line that starts # if or # else is read as a preprocessor command and breaks the build.

Choosing between the camps is the job of Choosing a language.

Common mistakes

  • Expecting C speed from Python. Symptom: a tight loop that bit-bangs a signal or polls a pin runs far slower than expected or misses events.
  • Expecting Python's errors at build time. A misspelled name in a branch that rarely runs. Symptom: the program works for an hour, then stops with a NameError.
  • Running out of RAM compiling on the board. Large .py files need RAM to compile. Symptom: a MemoryError at import, fixed by splitting the file or using precompiled .mpy.
  • Flashing the wrong address after a build. Symptom: the board boot-loops; see Flashing and the ROM bootloader.
  • Mixing instruction sets. Machine code for Xtensa does not run on RISC-V. Symptom: the build fails, or the chip crashes at once.

Cost

Interpreted code costs flash for the whole interpreter (about 1.7 MB in the author's MicroPython case) and RAM for bytecode and objects, and each line costs many machine instructions to run. In exchange, edit-to-run takes seconds and you get a REPL. Compiled code costs a build and a reflash for every change, and setting up a toolchain, but it runs at full speed and uses only the memory it needs. Interpretation starts to hurt when timing is tight (fast signals, high data rates) or RAM is short; compilation hurts when you are still exploring and changing things every minute.

Going further

  • Just-in-time compilers, which compile hot bytecode to machine code while the program runs.
  • MicroPython's native and viper code emitters, which compile chosen Python functions to machine code.
  • The mpy-cross tool, which precompiles .py files to .mpy.
  • Linker scripts and memory maps, which decide where each piece of a compiled program lives.
  • Choosing a language, which puts these trade-offs to work.

Leads to

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