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 entry must end with retw, not ret. 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, 1 where 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. t0 does 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, and EXTUI.
  • 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