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 232=4,294,967,2962^{32} = 4{,}294{,}967{,}296 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 lw and sw do 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 soc component's register headers in ESP-IDF, which name every register and field

Leads to

Back to ESP32 development: assembly, C, MicroPython, and CircuitPython