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
.pyfiles 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-crosstool, which precompiles.pyfiles 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