prerequisite
Memory maps and registers
How a chip gives every RAM byte and every hardware control a numbered address, so reading or writing an address talks to the hardware.
Before this
This page assumes you are comfortable with:
Why you need this
Every ESP32 program, in any language, ends up reading and writing numbered addresses. Some hold your variables. Others are wired to hardware: reading one says whether a byte has arrived over USB, and writing another sends it. The assembly echo is nothing but loads and stores to two such addresses, and the Python and C versions do the same thing a few layers down.
The idea
An address is a number that names one byte. The ESP32 cores use 32-bit addresses, so there are possible addresses, numbered 0x00000000 to 0xFFFFFFFF. That is far more than the chip has memory, so most addresses lead nowhere and the useful ones come in blocks.
A memory map is the table of those blocks: which address range is ROM, which is RAM, which shows the contents of flash, and which talks to hardware. Here is the ESP32-C6's, taken from the ESP-IDF 5.4.1 headers for the C6 (they follow Espressif's ESP32-C6 Technical Reference Manual). Sizes use 1 KB = 1024 bytes.
| Start | End | Size | What lives there |
|---|---|---|---|
| 0x40000000 | 0x4004FFFF | 320 KB | ROM: code baked into the chip, including the ROM bootloader. Read-only. |
| 0x40800000 | 0x4087FFFF | 512 KB | HP SRAM: the main RAM, for code and data |
| 0x42000000 | set by configuration | Flash, seen through a cache, so code can run straight from it | |
| 0x50000000 | 0x50003FFF | 16 KB | LP SRAM: a small RAM in the low-power domain |
| 0x60000000 | 0x600FFFFF | 1 MB | Peripheral registers |
The last row is the interesting one. A peripheral is a hardware block on the chip: a UART, the USB-Serial-JTAG controller, the GPIO pins, a timer. A register is a 32-bit location inside a peripheral that you reach by address. Wiring hardware into the address space like this is called memory-mapped I/O: the processor uses the same load and store instructions for a register as for RAM, and the address alone decides which one it reaches.
Registers differ from RAM in two ways that matter.
- Reading can have an effect. Reading the C6's USB receive register removes the byte it returns. Read it twice and the second read gets the next byte, or nothing.
- Writing can be an action, not a store. Writing 1 to a "write-to-act" bit tells the hardware to do something now. Nothing is remembered there; read it back and it may say 0.
Base plus offset
Each peripheral has a base address, and its registers sit at fixed offsets from that base. The C6's ESP-IDF headers give these bases:
| Peripheral | Base |
|---|---|
| UART0 | 0x60000000 |
| USB-Serial-JTAG | 0x6000F000 |
| GPIO | 0x60091000 |
| LP_AON (always-on registers) | 0x600B1000 |
To find a register you add. The GPIO input register sits at offset 0x3C, so its address is 0x60091000 + 0x3C = 0x6009103C. The author's notes use exactly this register to check the C6's physical BOOT button: bit 9 of 0x6009103C is the level on GPIO 9.
Bit fields
One 32-bit register usually holds several small fields, each a run of bits with its own meaning. Bit 0 is the least significant. A field can be one bit (a flag) or several bits (a count).
The classic ESP32 shows both shapes. The Inspector's lx6-uart0 echo, citing chapter 13 of Espressif's ESP32 Technical Reference Manual, uses UART0's FIFO register at 0x3FF40000 (read takes a received byte, write sends one) and its status register at 0x3FF4001C, offset 0x1C from the same base. In that status register, bits 7 to 0 are RXFIFO_CNT, the number of bytes waiting, and bits 23 to 16 are TXFIFO_CNT, the number queued to send. If the status reads 0x00020003, then RXFIFO_CNT is 0x00020003 AND 0xFF = 3 bytes waiting, and TXFIFO_CNT is (0x00020003 shifted right 16) AND 0xFF = 2 bytes queued.
Reading a register description
A technical reference manual describes each register the same way: its name, its address, and a table of fields with bit positions, a reset value, and an access type (short codes such as RO for read-only and R/W for read and write, defined in the manual). Read the field table before writing: writing a whole word to set one bit also writes every other field.
Worked example
The echo on the ESP32-C6 uses two registers of the USB-Serial-JTAG controller. These facts are from the comments in the ESP32 Inspector's C6 echo payload, which cite the ESP-IDF 5.4.1 headers for the C6.
| Address | Name | Fields used |
|---|---|---|
| 0x6000F000 (base + 0x0) | EP1 | bits 7 to 0, RDWR_BYTE: a read pops one received byte, a write queues one byte to send (up to 64 per flush) |
| 0x6000F004 (base + 0x4) | EP1_CONF | bit 0 WR_DONE: write 1 to flush queued bytes to the host. Bit 1 SERIAL_IN_EP_DATA_FREE: 1 when there is room to send. Bit 2 SERIAL_OUT_EP_DATA_AVAIL: 1 when a byte is waiting. |
Now follow one letter, q, through it. The status values are examples of what the register could read, not a recording.
| Step | Access | Value | Meaning |
|---|---|---|---|
| 1 | read 0x6000F004 | 0x00000002 = 0b010 | bit 2 is 0: nothing waiting yet. Bit 1 is 1: room to send. Read again. |
| 2 | read 0x6000F004 | 0x00000006 = 0b110 | 0x6 AND 0x4 = 0x4, not zero: a byte is waiting |
| 3 | read 0x6000F000 | 0x00000071 | the byte is 0x71, q. The read removed it from the buffer. |
| 4 | compute | 0x71 - 0x20 = 0x51 | Q |
| 5 | read 0x6000F004 | 0x00000002 | 0x2 AND 0x2 = 0x2, not zero: room to send |
| 6 | write 0x6000F000 | 0x00000051 | Q is queued, but the host has not seen it |
| 7 | write 0x6000F004 | 0x00000001 | WR_DONE: the queued byte goes to the host now |
Steps 1 and 2 are the test from Binary and hexadecimal: AND with a mask, compare with zero. Step 7 is a write-to-act bit; it stores nothing.
Here are steps 1 to 3 in the Inspector's C6 echo payload, which is RISC-V assembly for the ESP32-C6 and has run on real hardware. li loads a number into a register, lw loads a word from an address, and andi ANDs with a mask.
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
...
The same controller exists on the S3 and the P4 with the same offsets and bits, but a different base: 0x60038000 on the S3 and 0x500D2000 on the P4, per the Inspector's catalog. So the P4's EP1_CONF is 0x500D2000 + 0x4 = 0x500D2004. Porting the echo between them changes the two addresses and nothing else.
In an ESP32 project
Registers sit under every stage of the pipeline on the hub. In stage 2 you write them by name in assembly. In stage 4, Talk to hardware, the drivers for GPIO, UART, I2C, and SPI are register reads and writes wrapped in functions. In stage 6, Debug, the author's C6 reset investigation stored progress markers in an always-on register in LP_AON (0x600B1000) that survives a warm reset, then read it back; Debugging resets and crashes tells that story.
Common mistakes
- One chip's address on another chip. The C6's 0x6000F000 means nothing useful on the P4. Symptom: the program runs but no byte ever arrives, or the core faults on the access and resets.
- Reading a pop register twice. Logging the value of EP1, then reading it again to use it, throws a byte away. Symptom: every other character goes missing.
- Forgetting the write-to-act bit. Queuing a byte without writing WR_DONE leaves it in the buffer. Symptom: nothing appears on the host, then a burst once the buffer fills.
- A dropped hex digit. 0x6000F00 is not 0x6000F000. Symptom: an access fault and a reset loop.
- Writing to ROM. Stores into the ROM range are silently ignored, per the author's notes. Symptom: a memory viewer shows your edit "did not stick".
- Testing a count field as a flag. RXFIFO_CNT is eight bits; masking only bit 0 misses a count of 2. Symptom: the program sometimes waits even though bytes are there.
Cost
A register access is one load or store instruction, the same as RAM, so memory-mapped I/O costs no extra instructions. The real costs are elsewhere. Polling a status register in a tight loop keeps the core busy and drawing power while it waits; Polling and interrupts covers the alternative. And the maker's time goes into the reference manual: a guessed address costs far more than looking it up.
Going further
- How a CPU runs instructions, for what
lwandswdo inside the core - Flash, RAM, and partitions, for what lives in the flash window at 0x42000000
- RISC-V assembly, where the whole echo is traced
- The "System and Memory" chapter of Espressif's ESP32-C6 Technical Reference Manual
- The
soccomponent's register headers in ESP-IDF, which name every register and field
Leads to
- techniqueDebugging resets and crashesWhy a board reboots, loops, or goes silent, and a method for finding out: reset reasons, watchdogs, boot modes, and leaving yourself notes in memory that survives a reset.
- prerequisiteFlash, RAM, and partitionsWhere code and data live on an ESP32: flash that survives power-off, RAM that does not, PSRAM, and the partition table that divides flash into regions.
- techniqueRISC-V assembly on the ESP32-C6 and P4Writing 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.
Back to ESP32 development: assembly, C, MicroPython, and CircuitPython