technique
RISC-V assembly on the ESP32-C6 and P4
Writing RISC-V assembly for the C6 and P4: registers and ABI names, load, store, branch, and li, the uppercase echo line by line, and running it inside ESP-IDF or as a bare image.
Before this
This page assumes you are comfortable with:
- prerequisiteMemory maps and registersHow a chip gives every RAM byte and every hardware control a numbered address, so reading or writing an address talks to the hardware.
- techniqueChoosing a languageMicroPython, CircuitPython, C with ESP-IDF, or assembly: what each is good at, what it costs, and the same uppercase echo in all four.
Why you need this
Assembly is the language with nothing hidden. Every line is one step the processor takes, so when a board misbehaves you can see exactly which register held what. The ESP32-C6 and ESP32-P4 use RISC-V, the simplest real instruction set in the ESP32 family, which makes them the best place to learn. This page covers stage 2 of the pipeline (write the program) for the rv32-usbjtag and rv32p4-usbjtag configs.
The idea
An instruction set is the list of operations a processor understands, each encoded as a number in memory. The C6's main core implements RV32IMAC: 32-bit RISC-V (RV32) with the base integer instructions (I), multiply and divide (M), atomic operations (A), and compressed 16-bit encodings (C) that save space. The P4's cores implement RV32IMAFC, which adds single-precision floating point (F). The echo uses only the IMAC subset, so the same source runs on both.
Registers
A register is a 32-bit storage slot inside the processor. RISC-V has 32 of them, numbered x0 to x31, and assembly usually calls them by ABI names (the names the calling convention gives them, where ABI means application binary interface).
| ABI name | Number | Used for |
|---|---|---|
| zero | x0 | Always reads 0; writes are thrown away |
| ra | x1 | Return address of a function call |
| sp | x2 | Stack pointer |
| t0 to t6 | x5 to x7, x28 to x31 | Temporaries: scratch values nobody expects you to keep |
| a0 to a7 | x10 to x17 | Function arguments and return values |
| s0 to s11 | x8, x9, x18 to x27 | Saved registers: a function must restore them before it returns |
The handful of instructions the echo needs
| Instruction | Meaning |
|---|---|
li t0, 0x6000F004 |
Load immediate: put a constant in a register |
lw t1, 0(t0) |
Load word: read 32 bits from the address in t0 plus 0 |
sw t2, 0(t0) |
Store word: write t2 to the address in t0 plus 0 |
andi t1, t1, 4 |
AND with a constant: keep only bit 2 |
beqz t1, label |
Branch to label if t1 equals zero |
bltu, bgeu |
Branch if less than, or greater or equal, comparing as unsigned numbers |
addi t2, t2, -0x20 |
Add a constant (here, subtract 0x20) |
j label |
Jump unconditionally |
A label is a name followed by a colon. It marks an address so a branch can jump there.
li is a pseudo-instruction: the assembler turns it into real instructions. A RISC-V instruction is 32 bits wide, so it cannot hold a 32-bit constant plus an opcode. The assembler splits the constant: lui (load upper immediate) sets the top 20 bits, then addi adds the low 12. For 0x6000F004 that is lui t0, 0x6000F (giving 0x6000F000) then addi t0, t0, 4. When the low 12 bits are zero, as in 0x6000F000, the lui alone is enough.
Peripheral registers are memory-mapped, so lw and sw are also how you talk to hardware. On the C6 the USB-Serial-JTAG controller (the chip's built-in USB serial port) has two registers, as the payload's header comment records from the ESP-IDF 5.4.1 headers:
| Address | Register | Bits |
|---|---|---|
| 0x6000F000 | EP1 | Read pops one received byte; write queues one byte to send |
| 0x6000F004 | EP1_CONF | Bit 0 WR_DONE (write 1 to flush); bit 1 SERIAL_IN_EP_DATA_FREE (room to send); bit 2 SERIAL_OUT_EP_DATA_AVAIL (a byte is waiting) |
The echo, line by line
This loop, for RISC-V (RV32IMAC on the ESP32-C6), is excerpted from the ESP32 Inspector's rv32-usbjtag echo payload, which has run on real hardware. Comments are the original, lightly trimmed.
app_main: # no prologue: nothing to save, never returns
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
Four blocks: wait for a byte, read it, uppercase it if it is a to z, wait for room, then send and flush. There is no prologue because app_main never returns. It saves nothing and only uses temporaries.
The P4 version (rv32p4-usbjtag) is the same program instruction for instruction. Only the base address moved: the P4 payload's comment derives it as 0x500C0000 + 0x12000 = 0x500D2000, so EP1 is 0x500D2000 and EP1_CONF is 0x500D2004, with the same bit layout.
Two ways to run it
Inside ESP-IDF (the graft path). The payload is an ordinary ESP-IDF project whose main component is this .S file. ESP-IDF's second-stage bootloader and runtime bring up flash, clocks, and watchdogs, then call app_main. The Inspector's assembly pages use this build as a template: they assemble your code and byte-patch it over app_main in a reserved 8 KB slot (the payload ends with .space 8192, 0 for exactly this).
As a bare RAM image (the /c6 path). The ESP32 Inspector's C6 assembly page links your code to RAM at 0x40870000, prepends a startup stub, and flashes one image that the chip's ROM loads directly, with no ESP-IDF bootloader. The stub (CRT0_S in the Inspector's startup.ts) does what the bootloader would otherwise do. This excerpt of its first lines, for RISC-V on the ESP32-C6, is hardware-proven:
_start:
la sp, _stack_top
li t2, 0x80000000 # BIT31 = USB_RESET_DISABLE
li t0, 0x600B1044 # LP_AON_USB_REG
lw t1, 0(t0)
or t1, t1, t2
sw t1, 0(t0)
li t1, 0x50D83AA1 # unlock key for the timer-group + RTC WDTs
li t0, 0x60008064 # TIMG0_WDTWPROTECT: unlock
sw t1, 0(t0)
li t0, 0x60008048 # TIMG0_WDTCONFIG0: clear (disables WDT)
sw zero, 0(t0)
...
call app_main
1: j 1b
Its duties, in order: point sp at the top of the RAM window, so the stack has room to grow down. Set LP_AON_USB_RESET_DISABLE (bit 31 of 0x600B1044), so a reset caused by the USB line, such as a browser opening the port, boots the app again instead of the ROM's download mode. Then unlock and switch off every watchdog the ROM left armed: both timer-group watchdogs, the RTC watchdog, and the super watchdog. On the C6 every one of those uses the key 0x50D83AA1, not the classic ESP32's key. Finally it calls app_main. Debugging resets and crashes tells how each of those lines was found. The P4 stub does the same watchdog work at P4 addresses and skips the USB step, because a USB-line reset on the P4 already boots the app.
Worked example
Trace the loop when the host sends q (0x71) and the transmit buffer has room. Reading EP1_CONF then returns bits 1 and 2 set, 0b110 = 6.
| Step | Instruction | Effect |
|---|---|---|
| 1 | li t0, 0x6000F004 |
t0 = 0x6000F004 |
| 2 | lw t1, 0(t0) |
t1 = 0x00000006 |
| 3 | andi t1, t1, 4 |
t1 = 6 AND 4 = 4 |
| 4 | beqz t1, echo_loop |
4 is not zero: fall through |
| 5 | li t0, 0x6000F000; lw t2, 0(t0) |
t2 = 0x71 |
| 6 | li t3, 'a'; bltu t2, t3, ... |
0x71 < 0x61? No: fall through |
| 7 | li t3, 'z'+1; bgeu t2, t3, ... |
0x71 >= 0x7B? No: fall through |
| 8 | addi t2, t2, -0x20 |
t2 = 0x71 - 0x20 = 0x51, which is Q |
| 9 | status read, andi t1, t1, 2 |
t1 = 6 AND 2 = 2: room to send |
| 10 | sw t2 to EP1, sw 1 to EP1_CONF |
Q queued and flushed to the host |
Had the byte been 5 (0x35), step 6 would branch straight to tx_wait and the byte would go back unchanged.
You can step the same loop yourself:
The built ESP-IDF payload shows what the assembler produced. The 21 lines above became 24 machine instructions in 84 bytes: each li of 0x6000F004 became two instructions, and 6 instructions used the 2-byte compressed form. The uppercase step addi t2, t2, -0x20, for example, is stored as the 2-byte 0x1381 instead of its 4-byte form 0xFE038393; how a CPU runs instructions takes such encodings apart bit by bit.
In an ESP32 project
Assembly lives in stage 2, but it touches stage 3. The graft path rides on ESP-IDF's boot chain, so the boot sequence is the normal one. The bare-image path replaces the bootloader with the stub, so the stub's duties become your problem. Writing to the board goes through flashing and the ROM bootloader either way: the C6 image goes at flash 0x0 and the P4 image at 0x2000.
Common mistakes
- Using C6 addresses on a P4. The program assembles fine and the board prints nothing, because 0x6000F000 is not the P4's USB port.
- Returning from
app_mainin a bare image. There is no runtime underneath. A cleanretlands in the stub's1: j 1band the board goes quiet. If your code overwroterawithout saving it,retjumps somewhere random, the chip faults, and it resets. - Testing the wrong bit.
andi t1, t1, 2where you meant 4 waits for room to send instead of a received byte. The loop then reads an empty register and echoes garbage. - Forgetting the flush. Without writing 1 to WR_DONE, bytes sit queued and the terminal shows nothing until the buffer fills.
- Leaving a watchdog armed in a bare image. The program runs for a few seconds, then the board resets, prints its boot banner, and repeats.
Cost
The echo's code is 84 bytes. The bare-image path loads everything into RAM, so a program must fit the linker window: 0xC000 bytes (48 KB, with 1 KB = 1024 bytes) on the C6 and 0x28000 (160 KB) on the P4. The graft path limits you to the 8 KB slot but keeps ESP-IDF's robust startup. Speed is the reason to bother: each wait loop is five machine instructions, so at the C6's 160 MHz, assuming one instruction per cycle, it could check the status bit 32 million times a second. The real cost is the maker's time. Nothing checks your register numbers, and a typo shows up as silence, not as an error message. Edit-to-run time in the Inspector is one assemble-and-flash from the browser.
Going further
- The Inspector's
clone-rv32milestone programs, starting withm1e-uart0-loopback-loop.S(UART0 bring-up) andm2-flash-read-loop.S(reading the chip's own flash), a step-by-step build toward one C6 cloning another. - A memory-debugger program written for the C6, among the C6 page's examples: subroutines with
jal raand a saved return address on the stack. - Xtensa assembly on the ESP32 and ESP32-S3, the same echo in the other instruction set.
- The RISC-V Unprivileged ISA specification, chapters on RV32I and the C extension.
- The ESP32-C6 Technical Reference Manual, chapter on the USB Serial/JTAG controller.
Leads to
Back to ESP32 development: assembly, C, MicroPython, and CircuitPython