technique
Xtensa assembly on the ESP32 and ESP32-S3
The other instruction set in the family: Xtensa LX6 and LX7 on the classic ESP32 and the S3, with register windows, literal pools, and the same echo for comparison.
Before this
This page assumes you are comfortable with:
Why you need this
The classic ESP32 (inside every CYD) and the ESP32-S3 do not run RISC-V. They run Xtensa, a different instruction set from a different designer. If your board is in the lx6-uart0, lx7-uart0, or lx7-usbjtag config, this is the assembly it understands. Reading it next to the RISC-V version also shows which parts of a program belong to the chip and which belong to the idea.
The idea
An instruction set is the list of operations a processor understands and the numbers that encode them. The classic ESP32 has Xtensa LX6 cores and the S3 has Xtensa LX7 cores. The payload comments record that every instruction the echo uses exists on both, so one listing style covers all three configs.
Registers and register windows
Xtensa code sees sixteen 32-bit registers named a0 to a15. Two have fixed jobs in the calling convention: a0 holds the return address and a1 is the stack pointer, which assembly may also write as sp. The disassembler prints the payloads' entry sp, 32 as entry a1, 32.
The unusual part is the register window. The core has more physical registers than the sixteen you can name. When one function calls another with call4, call8, or call12, the called function's entry instruction slides the window along by 4, 8, or 12 registers, so the callee gets fresh a-registers while the caller's values stay parked in the physical file. entry sp, 32 also reserves a 32-byte stack frame. The matching return is retw, which slides the window back. RISC-V has no such thing: a RISC-V function that needs a register the caller still wants must store it to the stack itself.
Every Inspector Xtensa payload starts with entry sp, 32 and has no retw, because app_main loops forever and never returns. ESP-IDF calls it as a normal windowed function, so the entry is still required.
Constants and literal pools
movi loads a constant, but it only has room for a small one: the Xtensa ISA reference gives it a 12-bit signed immediate, -2048 to 2047. A 32-bit address like 0x3FF4001C does not fit. Where RISC-V's assembler splits a big constant into lui plus addi, Xtensa's assembler stores the constant in a literal pool, a small table of 32-bit words in a .literal section, and turns movi into l32r, a load from that table relative to the program counter. The build map for the classic ESP32 echo shows exactly this: 16 bytes of literals before the linker merged duplicates, 8 bytes after, which is the two addresses 0x3FF4001C and 0x3FF40000.
Bit fields
extui a3, a3, 16, 8 means extract unsigned: shift a3 right by 16 and keep 8 bits. It does in one instruction what RISC-V does with andi for a single bit, and it can pull out a multi-bit count. If the status register reads 0x00050003, extui ..., 0, 8 gives 3 and extui ..., 16, 8 gives 5.
The echo on the classic ESP32
This listing, for Xtensa LX6 on the classic ESP32, is from the ESP32 Inspector's lx6-uart0 echo payload, hardware-proven on the CYD. UART0 reaches the computer through the board's CH340 bridge chip, and the ROM has already set it to 115200 baud, so there is no setup. The comments cite the ESP32 Technical Reference Manual: 0x3FF40000 is the UART FIFO (read takes a received byte, write sends one) and 0x3FF4001C is the status register, with the received-byte count in bits 7:0 and the queued-to-send count in bits 23:16.
app_main:
entry sp, 32 # Xtensa windowed-register prologue
echo_loop:
movi a2, 0x3FF4001C # UART_STATUS_REG
l32i a3, a2, 0 # read status
extui a3, a3, 0, 8 # extract bits 7:0 = RXFIFO_CNT
beqz a3, echo_loop # spin until at least one byte arrives
movi a2, 0x3FF40000 # UART_FIFO_REG
l32i a4, a2, 0 # read byte from RX FIFO into a4
movi a5, 'a' # 0x61
bltu a4, a5, tx_wait # byte < 'a': leave unchanged
movi a5, 'z'+1 # 0x7B
bgeu a4, a5, tx_wait # byte > 'z': leave unchanged
addi a4, a4, -0x20 # subtract 0x20 to uppercase
tx_wait:
movi a2, 0x3FF4001C # UART_STATUS_REG
l32i a3, a2, 0 # read status
extui a3, a3, 16, 8 # extract bits 23:16 = TXFIFO_CNT
bgeui a3, 128, tx_wait # spin if TX FIFO is full (128 entries)
movi a2, 0x3FF40000 # UART_FIFO_REG
s32i a4, a2, 0 # write byte to TX FIFO
j echo_loop # loop forever
The echo on the S3's native USB
The lx7-usbjtag payload, for Xtensa LX7 on the ESP32-S3 (hardware-proven on the Adafruit Feather ESP32-S3 and Super Mini ESP32-S3), talks to the S3's USB-Serial-JTAG controller instead. Its comment gives the registers from the ESP-IDF 5.4.1 headers: 0x60038000 is the byte window and 0x60038004 holds the flags, with bit 0 WR_DONE, bit 1 room to send, and bit 2 a byte waiting. This excerpt shows only the lines that differ from the UART version:
movi a2, 0x60038004 # USB_SERIAL_JTAG_EP1_CONF_REG
l32i a3, a2, 0 # read status flags
extui a3, a3, 2, 1 # extract bit 2 = SERIAL_OUT_EP_DATA_AVAIL
beqz a3, echo_loop # spin until a byte is waiting
...
movi a2, 0x60038000 # USB_SERIAL_JTAG_EP1_REG
s32i a4, a2, 0 # queue the byte
movi a2, 0x60038004 # USB_SERIAL_JTAG_EP1_CONF_REG
movi a3, 1 # WR_DONE
s32i a3, a2, 0 # flush: host sees the byte now
The lx7-uart0 payload is the classic one with UART0 moved to 0x60000000 and the FIFO counts widened to 10 bits, so its extui reads 10 bits instead of 8.
Side by side with RISC-V
| Job | Xtensa | RISC-V |
|---|---|---|
| Load a 32-bit address | movi (becomes l32r from a literal pool) |
li (becomes lui plus addi) |
| Read a register | l32i a3, a2, 0 |
lw t1, 0(t0) |
| Write a register | s32i a4, a2, 0 |
sw t2, 0(t0) |
| Test a flag | extui a3, a3, 2, 1 |
andi t1, t1, 4 |
| Compare with a constant | bgeui a3, 128, label |
load the constant, then bgeu |
| Function start | entry sp, 32 |
nothing needed |
Worked example
Take one step of the echo, "wait until a byte is waiting", and count what the assembler actually produced. These counts come from disassembling the built Inspector payloads.
| Config | Instructions in the wait step | Whole loop | Code bytes | Literal bytes |
|---|---|---|---|---|
lx6-uart0 |
4: l32r, l32i, extui, beqz |
18 plus entry |
55 | 8 |
lx7-usbjtag |
4: same shape | 21 plus entry |
62 | 8 |
rv32-usbjtag |
5: lui, addi, lw, andi, beqz |
24 | 84 | 0 |
Xtensa wins on size here for two reasons. Most of its instructions are 3 bytes, and the assembler shrank two of them to 2-byte forms (l32i.n, s32i.n), giving 17 x 3 + 2 x 2 = 55 bytes for the classic loop. And the big addresses live once in the literal pool instead of being rebuilt by two instructions every time. The USB versions are longer than the UART one because they must write WR_DONE to flush each byte.
Neither program can run on the other chip. The RISC-V instruction lw t1, 0(t0) is the 4-byte number 0x0002A303. An Xtensa core decodes the same bytes by its own rules, as some unrelated instruction or as no valid instruction at all, and the program falls apart on the first step. The source idea carries over. The machine code does not.
In an ESP32 project
Stage 2 (write the program) for every board in the three Xtensa configs. All the Inspector's Xtensa payloads are built as ESP-IDF projects with the .S file as the main component, so ESP-IDF's boot chain runs first and calls app_main. The payloads disable the task watchdog in sdkconfig.defaults, because the polling loop never yields to the operating system. Flashing differs by chip: the classic ESP32's bootloader goes at 0x1000, the S3's at 0x0 (see flashing and the ROM bootloader).
A second example from the author's projects, an assembly blink and echo written for the CYD, shows Xtensa driving a pin. The echo is the same program as the lx6-uart0 payload. The blink, for Xtensa LX6 on the CYD, turns on the red LED on GPIO 4, which its comment says is active low, so writing the pin low turns the LED on:
movi a2, 0x3FF44024 # GPIO_ENABLE_W1TS_REG
movi a3, 0x10 # bit 4 = GPIO4
s32i a3, a2, 0 # enable GPIO4 output direction
blink_loop:
movi a2, 0x3FF4400C # GPIO_OUT_W1TC_REG (clear = drive LOW)
s32i a3, a2, 0
movi a4, 40000000
delay_on:
addi a4, a4, -1
bnez a4, delay_on
...
Its comment budgets about 3 cycles per delay iteration at 240 MHz, so 40,000,000 iterations take 120,000,000 cycles, which is 0.5 s.
Common mistakes
- Mixing the windowed and plain conventions. A function that begins with
entrymust end withretw, notret. The mismatch returns into the wrong register window and the program crashes; ESP-IDF prints a panic and reboots. The payloads avoid the question by never returning. - Using classic ESP32 addresses on an S3. UART0 moved from 0x3FF40000 to 0x60000000. The program assembles and flashes, and the terminal stays silent.
- Waiting on the wrong flag.
extui a3, a3, 1, 1where you meant bit 2 waits for room to send instead of for a byte, so the loop reads an empty register and echoes garbage. - Forgetting the flush on
lx7-usbjtag. The UART version has no WR_DONE step. Copying it to the USB board leaves bytes queued and the terminal blank. - Pasting RISC-V register names.
t0does not exist on Xtensa, so the assembler stops with an error before anything is flashed.
Cost
The classic echo is 55 bytes of code plus 8 bytes of literals, against about 148 KB (1 KB = 1024 bytes) for the whole ESP-IDF app it sits inside, so the assembly itself costs almost nothing in flash. The busy loop costs a whole CPU core and its power, which is why the payloads turn off the task watchdog. The bigger cost is the maker's time: register windows and literal pools are extra ideas RISC-V does not need, and a wrong register or bit number shows up as silence on the board, not as an error message.
Going further
- RISC-V assembly on the ESP32-C6 and P4, for the side-by-side.
- The Xtensa Instruction Set Architecture Reference Manual, sections on the windowed register option,
L32R, andEXTUI. - The ESP32 Technical Reference Manual chapters on UART and GPIO, the sources of the addresses in the payload comments.
- Polling and interrupts, for what the busy loop costs and the alternative.
Back to ESP32 development: assembly, C, MicroPython, and CircuitPython