This is the engineering reference for the model: what it implements, register by register, for people writing firmware against it. For using the board in the editor, start with The ESP32 DevKit V1 and the board page.

ESP32 DevKit board contract (Boards Wave C)

The ESP32 DevKit V1 is the thirty-pin DOIT board around an ESP32-WROOM-32 module: an Xtensa LX6 at 240 MHz, the first non-RISC-V, non-AVR, non-Arm part here. One kernel component (esp32 in crates/parts) wraps an ESP32 SoC model (crates/esp32) on an Xtensa interpreter (crates/xtensa-core). This document is the contract, and it wins over any comment in the code.

The catalog type is esp32. The catalog program string is elf32 xtensa lx6 esp32.

Read section 6 first if you are taking a build from here to real silicon. It says, address by address, which numbers came out of the ESP32 Technical Reference Manual and which are this model's belief, and it lists what is not here at all: the second core, the FPU, the cache, flash, PSRAM and both radios among them. Nothing is guessed at quietly.

A sketch for this board compiles in the browser with clang-v3, the wasm build of Espressif's LLVM fork (-mcpu=esp32), published 2026-09-28 and checked on this simulator (docs/compile.md). An untouched example still runs its prebuilt ELF from web/public/firmware/esp32/, which Espressif's GCC builds. Section 7 has the details.

1. Board pins (catalog order)

Top view, the micro-USB socket at the bottom, the WROOM-32 module and its PCB antenna at the top. The board is 51.5 x 28.3 mm and its two 15-pin headers are 22.86 mm (0.9 inch) apart, which is what lets a DOIT DevKit V1 straddle a breadboard with one row of holes left free on each side. The visual draws it at that size.

The 38-pin Espressif DevKitC is the other board people mean by "an ESP32 DevKit", and its headers are a full inch apart: on a breadboard it leaves no row free at all. This is the thirty-pin DOIT board.

Left header, top to bottom (15 holes): EN, VP, VN, D34, D35, D32, D33, D25, D26, D27, D14, D12, GND, D13, VIN.

Right header, top to bottom (15 holes): D23, D22, TX0, RX0, D21, D19, D18, D5, TX2, RX2, D4, D0, D2, D15, 3V3.

30 pins. Dn is GPIO n. VP is GPIO 36 and VN is GPIO 39: the converter's SENSOR_VP and SENSOR_VN pins, which the silkscreen names for their function. TX0/RX0 are GPIO 1 and 3, the console UART; TX2/RX2 are GPIO 17 and 16. EN is the chip enable, which is this board's reset. VIN is the USB 5 V rail. Duplicated names would carry a letter, because catalog pin names are unique; this board has none.

GPIO 6 to 11 are not on either header. They are the SPI flash (SD_CLK, SD_DATA0..3 and SD_CMD), and the program is running out of them. A board that let a sketch drive them would be a board that bricks itself, so they are not pins here. crates/parts/src/parts/esp32.rs has a test that refuses to let them appear on a header.

GPIO 34 to 39 have no output driver. On this part they are inputs and nothing else: no push-pull, no open drain, and no internal pull-up or pull-down either. pinMode(34, OUTPUT) writes GPIO_ENABLE1_REG, the bit does not stick, and the pin stays high-Z. Four of the six are on the header (D34, D35, VP, VN). The model counts every attempt and the board part reports the count through probe (section 5), because this is the mistake that costs an ESP32 owner an afternoon and a simulator that let it through would be teaching the wrong thing.

Two pins carry something on the board:

  • GPIO 2 has a blue LED on it, and it is an ordinary LED on an ordinary pad: the pad's level is the lamp, nothing decodes anything. It is also a strapping pin, so a real DevKit's LED state before pinMode runs varies between boards.
  • GPIO 0 is the BOOT button, active low. It is on the right header as D0, so it can be wired to as well; poke grounds it on the board.

Electrical: every GPIO with an output driver drives push-pull at 3.3 V through R_STRONG, sits high-Z, or pulls up or down through 45 k. An input reads high at 1.65 V (half the I/O supply), and a floating or contended net reads 0. 3V3 drives 3.3 V at R_SUPPLY, VIN drives 5 V, GND drives 0 V. EN carries a 10 k pull-up to 3.3 V and pulling it below 1.65 V holds the core in reset. The 100 nF beside that resistor on a real DevKit is the board's power-on reset and is not modeled, so EN here is the resistor alone.

The exact silkscreen ordering of the two headers is this model's reading of the DOIT DevKit V1 pinout. The GPIO numbers are the chip's and are what firmware depends on; their position on the header is what a diagram depends on. If a particular DevKit's rows are ordered differently (and they do vary between the 30-pin and 36-pin boards), a circuit drawn here would need rewiring on a desk, and nothing else would change.

2. Memory map

IRAM (instruction) 128 KB at 0x4008_0000 to 0x4009_FFFF
DRAM (data) 192 KB at 0x3FFB_0000 to 0x3FFD_FFFF
stack top 0x3FFE_0000
DPORT 0x3FF0_0000, section 3.7
UART0 0x3FF4_0000, section 3.3
GPIO matrix 0x3FF4_4000, section 3.1
SENS 0x3FF4_8800, section 3.5
IO_MUX 0x3FF4_9000, section 3.2
LEDC 0x3FF5_9000, section 3.6
Timer group 0 0x3FF5_F000, section 3.4

Each peripheral block is 4 KB here.

Two windows, not one, and that is the shape of this chip. The ESP32 has three internal SRAMs, two of which are visible through both the instruction bus and the data bus at different addresses, and SRAM1's 32 KB blocks appear in the opposite order through the two. That alias is not modeled. What is modeled is the pair ESP-IDF itself uses and keeps apart: IRAM for code, DRAM for data. Code goes in the 128 KB at 0x4008_0000; .data, .bss and the stack go in the 192 KB at 0x3FFB_0000. A program that tries to read its own .text through the data bus is refused rather than served, because on the real part that read would have come back through the other window with the blocks in a different order.

Every PT_LOAD segment must land inside one of those two ranges; anything else is refused with the offending address in the message, and the chip is left with no program.

The core resets with pc at the ELF entry point and every address register zero. There is no boot ROM here: the tens of milliseconds a real ESP32 spends in its first-stage loader, the flash reads, the clock tree and the cache set-up are all absent, and firmware/esp32/crt0.S makes the state it wants explicit rather than assuming what a ROM left behind.

3. Peripheral registers

Only the subset below is implemented. Anything else inside a mapped 4 KB block reads 0 and counts as a stray access; anything outside every block is an access fault.

3.1 GPIO matrix (0x3FF4_4000)

Forty pins, so every register with one bit per pin comes in two halves: the plain one covers GPIO 0 to 31 and the 1 one covers GPIO 32 to 39 in its low eight bits.

offset register
0x00 GPIO_BT_SELECT_REG
0x04 GPIO_OUT_REG
0x08 GPIO_OUT_W1TS_REG
0x0C GPIO_OUT_W1TC_REG
0x10 GPIO_OUT1_REG
0x14 GPIO_OUT1_W1TS_REG
0x18 GPIO_OUT1_W1TC_REG
0x20 GPIO_ENABLE_REG
0x24 GPIO_ENABLE_W1TS_REG
0x28 GPIO_ENABLE_W1TC_REG
0x2C GPIO_ENABLE1_REG
0x30 GPIO_ENABLE1_W1TS_REG
0x34 GPIO_ENABLE1_W1TC_REG
0x38 GPIO_STRAP_REG
0x3C GPIO_IN_REG
0x40 GPIO_IN1_REG
0x44 GPIO_STATUS_REG
0x48 GPIO_STATUS_W1TS_REG
0x4C GPIO_STATUS_W1TC_REG
0x50 GPIO_STATUS1_REG
0x54 GPIO_STATUS1_W1TS_REG
0x58 GPIO_STATUS1_W1TC_REG
0x88 + 4n GPIO_PINn_REG
0x530 + 4n GPIO_FUNCn_OUT_SEL_CFG_REG

Verified against the technical reference manual: the block's base address and every offset in this table.

GPIO_PINn_REG bit 2 PAD_DRIVER makes the pad open drain; bits 7..9 INT_TYPE choose what raises the pin's interrupt (0 off, 1 rising, 2 falling, 3 any edge, 4 low level, 5 high level); bit 13 PIN_INT_ENA bit for the PRO core lets the pin reach the matrix at all.

GPIO_FUNCn_OUT_SEL_CFG_REG at 256 makes the pad follow GPIO_OUT. 71 to 78 hand it to LEDC high-speed channels 0 to 7. Any other value selects a peripheral that is not modeled, and the model treats it as 256 rather than invent a level. A pad an LEDC channel is driving still needs its output driver on in GPIO_ENABLE.

Unverified: the GPIO matrix signal indices 71 to 78 for ledc_hs_sig_out0 to ledc_hs_sig_out7. The matrix's signal table is not one this model can quote back, and the number lives in exactly two places (OUT_SEL_LEDC_HS_SIG in crates/esp32/src/map.rs and GPIO_FUNC_OUT_SEL_LEDC_HS in firmware/esp32/include/esp32.h), so the model and the runtime agree because both read the same constant, not because either checked. GPIO_FUNC_OUT_SEL_GPIO = 256 is the manual's.

GPIO_STRAP_REG reads back 0b1101: GPIO 0, 2 and 5 high, everything else low, which is how a WROOM-32 leaves the factory. It is a constant here, not a latch of what the pins were doing at reset.

The six pins with no output driver (GPIO 34 to 39) accept writes to their ENABLE1 bit and keep it clear, and the model counts the attempt (section 1, section 5).

3.2 IO_MUX (0x3FF4_9000)

IO_MUX_PIN_CTRL_REG at 0x00, then one register per pad. Bit 7 FUN_WPD, bit 8 FUN_WPU, bit 9 FUN_IE, bits 12..14 MCU_SEL of which 2 is plain GPIO on this part. A pad only reaches GPIO_IN_REG with its input buffer enabled, which is what the hardware does.

The ESP32's IO_MUX is not indexed by GPIO number. The registers are in the package's pad order and the mapping from GPIO to pad is a table in the manual. This model's table is:

GPIO offset GPIO offset GPIO offset GPIO offset
0 0x44 10 0x58 20 0x78 32 0x1C
1 0x88 11 0x5C 21 0x7C 33 0x20
2 0x40 12 0x38 22 0x80 34 0x14
3 0x84 13 0x3C 23 0x8C 35 0x18
4 0x48 14 0x30 24 0x90 36 0x04
5 0x6C 15 0x34 25 0x24 37 0x08
6 0x60 16 0x4C 26 0x28 38 0x0C
7 0x64 17 0x50 27 0x2C 39 0x10
8 0x68 18 0x70 28..31 none
9 0x54 19 0x74

Believed rather than verified. The table is this model's reading of the pad order and not a page quoted back. It is in two places, IOMUX_INDEX in crates/esp32/src/map.rs and mokxi_iomux_index[] in firmware/esp32/src/gpio.c, so model and runtime agree with each other whether or not they agree with silicon. GPIO 28 to 31 do not exist on this part and have no register.

Every pad powers up at FUN_IE | (2 << 12) here: plain GPIO with the input buffer on. The manual's per-pad reset values differ (the strapping and flash pins come up with pulls), and this model gives every pin the same neutral state so that a program which only writes GPIO_ENABLE and GPIO_OUT behaves the same on every pin. Section 6 repeats this as an approximation rather than an address.

3.3 UART0 (0x3FF4_0000)

offset register
0x00 UART_FIFO_REG
0x04 UART_INT_RAW_REG
0x08 UART_INT_ST_REG
0x0C UART_INT_ENA_REG
0x10 UART_INT_CLR_REG
0x14 UART_CLKDIV_REG
0x1C UART_STATUS_REG
0x20 UART_CONF0_REG
0x24 UART_CONF1_REG

Verified: the base and every offset, the two FIFO-reset bits and the width of the two FIFO counts below. It is the same UART IP as the ESP32-C3's and the ESP8266's, register for register, which is why crates/esp32/src/uart.rs and crates/esp8266/src/uart.rs are the same file twice.

Two 128-byte FIFOs. UART_CONF0_REG bit 17 RXFIFO_RST and bit 18 TXFIFO_RST empty them: the ESP32 Technical Reference Manual (v5.8), register 19.9 UART_CONF0_REG, puts TXFIFO_RST at bit 18 and RXFIFO_RST at bit 17. An earlier version of this model and of firmware/esp32/include/esp32.h had the two swapped. The runtime's Serial.begin sets and then clears both bits together, so the value it writes is the same either way and its images were unaffected.

UART_CONF1_REG bits 0..6 are RXFIFO_FULL_THRHD and bits 8..14 TXFIFO_EMPTY_THRHD, both 96 at reset (register 19.10); an earlier version of this model had nine-bit fields at 0 and 9, and the runtime never writes the register. UART_CLKDIV_REG bits 0..19 are the divisor's integral part (register 19.6; the fractional part at bits 20..23 is not modeled). The divisor divides the 80 MHz APB clock and resets to 694, which is 115 274 baud: near enough 115200 that a monitor does not care, and exactly what the number gives. The transmit pacing once read only twelve bits of it, so Serial.begin(9600), whose divisor is 8333, sent bytes about 59 times too fast; it now reads all twenty.

UART_STATUS_REG bits 0..7 are RXFIFO_CNT and bits 16..23 TXFIFO_CNT (register 19.8). Bits 8..11 and 24..27 above them are the receiver's and transmitter's state machines (ST_URX_OUT, ST_UTX_OUT). The model returns the two counts at those positions and 0 in the state-machine bits, since it does not run either state machine. The runtime reads eight bits, as the manual gives the fields. An earlier runtime read ten, which here gave the same numbers because the bits above read 0, but on silicon would have picked up two bits of ST_UTX_OUT: the transmit count would read 256 or more while a byte was on the wire, and Serial.write, which waits for room in the FIFO, would wait forever. The images were rebuilt when that was corrected, and crates/esp32/tests/contract.rs holds the header's masks to the model's.

Bytes leave the transmit FIFO at the baud rate, not instantly. Without that a program printing in a loop empties its FIFO as fast as it fills it, TXFIFO_CNT never rises, and firmware that checks it before writing never has to wait. With it a 115 200 baud port carries 11 520 bytes a second here exactly as it does on a desk, and crates/esp32/tests/runtime.rs measures the gap between two bytes of the serial example against the 86.8 us a byte takes.

UART1 and UART2 are not modeled. TX2 and RX2 on the header are ordinary GPIOs here.

3.4 Timer group 0, timer 0 (0x3FF5_F000)

The 64-bit counter behind micros() and millis().

offset register
0x00 TIMG_T0CONFIG_REG
0x04 TIMG_T0LO_REG
0x08 TIMG_T0HI_REG
0x0C TIMG_T0UPDATE_REG
0x10 TIMG_T0ALARMLO_REG
0x14 TIMG_T0ALARMHI_REG
0x18 TIMG_T0LOADLO_REG
0x1C TIMG_T0LOADHI_REG
0x20 TIMG_T0LOAD_REG
0x98 TIMG_INT_ENA_TIMERS_REG
0x9C TIMG_INT_RAW_TIMERS_REG
0xA0 TIMG_INT_ST_TIMERS_REG
0xA4 TIMG_INT_CLR_TIMERS_REG

Verified: the block's base address. Believed rather than verified: every offset in this table. The base is the manual's; the layout inside it is this model's, and it is the same in crates/esp32/src/map.rs and firmware/esp32/include/esp32.h.

T0CONFIG bit 31 EN, bit 30 INCREASE, bit 29 AUTORELOAD, bits 13..28 DIVIDER, bit 10 ALARM_EN. The runtime sets DIVIDER to 80, which is one tick a microsecond off the 80 MHz APB clock.

Reading is latch-then-read, as the hardware requires: write T0UPDATE, then read T0HI and T0LO. The counter is not counted. It is simulated time divided by the divider, the way the C3's SYSTIMER is, so there is nothing for it to drift against and firmware that busy-waits on micros() measures exactly the time the rest of the circuit saw pass.

The alarm is implemented and TIMG_INT_RAW rises when the counter passes it, but no interrupt line leaves this block. Nothing routes it into the interrupt matrix. The model's millisecond tick comes from the core's own CCOMPARE0 instead (section 4), which is a core register wired to a fixed CPU interrupt and so stands behind no address this model had to guess. TIMG_INT_ST can still be polled and cleared.

Timer group 0's timer 1, the whole of timer group 1, and both watchdogs are absent.

3.5 SENS: ADC1, one shot (0x3FF4_8800)

ADC1 in one-shot mode: the path adc1_get_raw and the Arduino core's analogRead take on this part, and nothing else.

offset register bits
0x00 SENS_SAR_READ_CTRL_REG stored, read back; conversion timing here is fixed
0x34 SENS_SAR_ATTEN1_REG two bits of attenuation per channel, channel n at bits 2n..2n+1
0x54 SENS_SAR_MEAS_START1_REG bits 0..15 MEAS1_DATA_SAR (the result, read only), bit 16 MEAS1_DONE_SAR, bit 17 MEAS1_START_SAR, bit 18 MEAS1_START_FORCE, bits 19..26 SAR1_EN_PAD (one bit per channel), bit 31 SAR1_EN_PAD_FORCE

Verified: the base address. Believed rather than verified: the three offsets and every bit position in them. They are this model's reading of the SENS block, in the same sense docs/esp32c3.md section 3.5 marks the C3's own SAR offsets. Firmware built here agrees with the model whether or not it agrees with silicon.

One conversion: put the attenuation in ATTEN1, set START_FORCE and EN_PAD_FORCE, put the channel's bit in SAR1_EN_PAD, raise MEAS1_START_SAR, wait for MEAS1_DONE_SAR, read the data. A conversion takes a fixed 5 us of simulated time, which firmware really does wait for.

Eight channels, and on the ESP32 they are not GPIO n:

channel 0 1 2 3 4 5 6 7
GPIO 36 37 38 39 32 33 34 35

Channel 0 is SENSOR_VP and channel 3 is SENSOR_VN; 37 and 38 are not bonded out on a WROOM-32, so the four the DevKit brings out as ordinary numbers are channels 4 to 7. That table is the manual's.

The result is twelve bits, round(4095 x Vin / Vfull), clamped, of the net voltage at the moment the conversion started. ATTEN picks the full scale at the datasheet's nominal figures: 0 dB is 1.1 V, 2.5 dB 1.5 V, 6 dB 2.2 V and 11 dB 3.9 V, which is the ESP32's figure and not the C3's 3.3 V, so a pot across a 3.3 V rail reads about 0 to 3466 here rather than 0 to 4095. A channel with nothing holding its pin reads 0 rather than noise.

The real converter's transfer function bends at both ends and is corrected out of the eFuse calibration curve. None of that is modeled: this one is a straight line. Section 6 lists it as an approximation.

ADC2 (which a real ESP32 shares with WiFi), the DMA and pattern-table path, the digital controller, the calibration eFuses, the internal reference, the touch sensor and the hall sensor are all absent.

3.6 LEDC, the high-speed group (0x3FF5_9000)

The LED PWM controller. The high-speed group is the one ledcSetup, ledcAttachPin and ledcWrite reach with channels 0 to 7, and it is the one analogWrite uses here: four timers and eight channels.

offset register bits
0x00 + 0x14n LEDC_HSCHn_CONF0_REG bits 0..1 TIMER_SEL, bit 2 SIG_OUT_EN, bit 3 IDLE_LV, bit 4 PARA_UP (stored; a duty written here takes effect at once)
0x04 + 0x14n LEDC_HSCHn_HPOINT_REG bits 0..13 HPOINT: the count the pulse starts at
0x08 + 0x14n LEDC_HSCHn_DUTY_REG bits 0..18 DUTY: the pulse width in sixteenths of a count
0x0C + 0x14n LEDC_HSCHn_CONF1_REG the duty-fade hardware: stored, read back, and fades nothing
0x10 + 0x14n LEDC_HSCHn_DUTY_R_REG the duty as it stands, read only
0x140 + 8n LEDC_HSTIMERn_CONF_REG bits 0..4 DUTY_RES (0 is a timer nobody configured and it counts nothing), bits 5..22 CLK_DIV, bit 23 PAUSE, bit 24 RST
0x144 + 8n LEDC_HSTIMERn_VALUE_REG the counter, read only
0x180 LEDC_INT_RAW_REG bit n = timer n overflowed
0x184 LEDC_INT_ST_REG raw and enabled
0x188 LEDC_INT_ENA_REG stored; no line reaches the core
0x18C LEDC_INT_CLR_REG write 1 to clear
0x190 LEDC_CONF_REG bits 0..1 APB_CLK_SEL, 1 = the 80 MHz APB clock, the only source modeled
0x1FC LEDC_DATE_REG reads 0x1605_3100

Verified: the block's base address. Believed rather than verified: the whole layout inside it: the channel stride, the timer block at 0x140, and the interrupt and config registers at 0x180 upwards.

A timer counts 2^DUTY_RES counts at 80 MHz divided by CLK_DIV, a Q10.8 number of APB clocks per count:

f = 80e6 / ((CLK_DIV / 256) * 2^DUTY_RES)

so 5 kHz at eight bits is DUTY_RES 8 with CLK_DIV 16000, exactly. A channel picks a timer, a start count HPOINT and a duty, and its output is high from HPOINT for DUTY >> 4 counts, wrapping round the top of the period. A duty of 0 is a solid low and a duty of 2^DUTY_RES << 4 a solid high with no edge at all, which is why the Arduino core turns ledcWrite(pin, 255) at eight bits into a duty of 256 rather than 255, which would leave a one-count notch every period.

A channel reaches a pad through the GPIO matrix: set the pad's GPIO_ENABLE bit and point its GPIO_FUNCn_OUT_SEL_CFG_REG at 71 + channel (section 3.1, and that index is the unverified one).

The counter is not stepped: it is a closed-form function of simulated time, and so is the time of its next edge. A core parked in waiti therefore costs nothing while a PWM runs, and the board is woken for the edges and for nothing else. crates/esp32/tests/runtime.rs measures the fade example's period against the 1 ms of 1 kHz within 5 us.

Not modeled: the low-speed group (the ESP32 has one, with its own four timers, eight channels and a slow clock; nothing in the runtime uses it), the duty-fade hardware, a channel's own overflow counter, clock sources other than APB, and LEDC's line into the interrupt matrix: LEDC_INT_RAW_REG can be polled and cleared but never reaches the core.

3.7 DPORT and the interrupt matrix (0x3FF0_0000)

On the ESP32 a peripheral does not have a fixed CPU interrupt. It has a source number, and the matrix's map register for that source says which of the core's 32 interrupts it raises. That is why attachInterrupt here is three writes rather than one: configure the pin, route the source, unmask the CPU interrupt.

offset register
0x104 + 4n DPORT_PRO_..._INTR_MAP_REG for source n

96 sources. A map register holds a CPU interrupt number in its low five bits; a source mapped to 0 raises interrupt 0, which nothing enables, so nothing happens, which is what the hardware does too and is how a source is switched off.

Verified: DPORT_BASE = 0x3FF0_0000. Believed rather than verified: the map array's offset 0x104, and the two source numbers this model routes:

source number
GPIO 22
UART0 34

Only those two sources reach anything. Every other map register is stored and read back and routes nothing, because nothing else in this model raises an interrupt. The rest of DPORT (the cache and MMU control, the APP-CPU control registers, the peripheral clock and reset gates, the DMA arbitration) is absent.

3.8 Mokxi network mailbox (0x3FEF_E000)

This block is not the ESP32's. Every other address in this document is the chip's, quoted from the technical reference manual, and an image built against it is an image for a real WROOM-32. This one is Mokxi's own: the simulated network of crates/net, in the gap the data bus leaves between the external memory windows (which end at 0x3FBF_FFFF) and the peripheral window (which starts at DPORT, 0x3FF0_0000), a range the chip reserves and answers nothing in, so that an image using it faults on silicon rather than reading some other peripheral's register. That fault is the honest answer, because the thing behind this block does not exist on the chip.

The block raises no interrupt at all. Every other source here reaches the core through the interrupt matrix of section 3.7, and a matrix source number is the chip's; there is no honest number to give a peripheral the chip has not got, so the mailbox is given none. NET_INT_RAW_REG still reads, the firmware polls, and a core parked in waiti is still woken when the network has something to say, the same way it is woken for a PWM edge.

There is no radio, no 802.11 and no real internet here. Nothing in the model transmits, scans, associates, encrypts or opens a socket: a password is compared as a string, a name that is not under .mokxi does not resolve, and the three origins the network serves are functions in crates/net/src/http.rs. The mailbox is the shape of the real seam rather than a fiction about a MAC: on a real ESP32 the WiFi hardware is a closed binary blob with a command interface, and Arduino's WiFi.h ends up on the other side of one of these. docs/wifi.md is the contract for what is behind it; this section is only the registers.

A command register, a status register, a 4096-byte ring each way and an interrupt bit. Writing NET_CMD_REG runs the command; a command that takes simulated time raises BUSY and clears it, with DONE in NET_INT_RAW_REG, when it is finished.

offset name bits
0x00 NET_ID_REG reads 0x494B4F4D, and nothing else does. A sketch that wants to know whether it is on Mokxi reads this; on silicon the access faults
0x04 NET_CMD_REG write a command code (table below); the write runs it
0x08 NET_ARG_REG one argument word, read back: the port for LISTEN, the status for REPLY
0x0C NET_STATUS_REG bit 0 BUSY, bit 1 ERR, bit 2 RX_READY, bit 3 LINK, bit 4 SERVE, bits 8..11 STATE
0x10 NET_RESULT_REG what the last command answered
0x14 NET_INT_RAW_REG bit 0 DONE, bit 1 EVENT
0x18 NET_INT_ENA_REG the same two bits
0x1C NET_INT_CLR_REG write 1 to clear, the same two bits
0x20 NET_TX_LEN_REG bytes staged in the outbound ring
0x24 NET_TX_DATA_REG write: push one byte (bits 0..7) into the outbound ring
0x28 NET_RX_LEN_REG bytes waiting in the inbound ring
0x2C NET_RX_DATA_REG read: pop one byte from the inbound ring, 0 when it is empty
0x30 NET_RING_LEN_REG capacity of each ring in bytes, so firmware need not hard-code 4096

STATE is where the station is: 0 idle, 1 starting, 2 connecting, 3 connected, 4 failed, 5 no such network. NET_CMD_STATUS turns the same thing into the Arduino wl_status_t code a sketch reads from WiFi.status().

NET_CMD_REG name what it does
0 NOP nothing
1 RESET disconnect, drop both rings, drop any request
2 CONNECT the outbound ring holds ssid\0password; raises BUSY for one to three seconds
3 DISCONNECT drop the association and the address
4 STATUS NET_RESULT_REG = the wl_status_t code
5 IP NET_RESULT_REG = the leased address, first octet in the low byte
6 RSSI NET_RESULT_REG = the signal in dBm, sign extended
7 SSID the inbound ring gets the access point's name
8 MAC the inbound ring gets the board's six MAC bytes
9 REQUEST the outbound ring holds method\0url\0headers\0body; raises BUSY
10 POLL NET_RESULT_REG = 0 in flight, then the HTTP status, or a negative error with ERR up; the body lands in the inbound ring
11 LISTEN listen on the port in NET_ARG_REG
12 ACCEPT NET_RESULT_REG = 1 and the inbound ring holds method\0path\0query\0body, or 0 when nothing is waiting
13 REPLY answer the accepted request with the status in NET_ARG_REG; the outbound ring holds content-type\0body
14 STOP stop listening
15 WL_GPIO_SET drive the wireless part's own GPIOs from NET_ARG_REG. Not this board: this part's radio has no such pin, so it sets ERR and drives nothing. It is the path a Pico W's on-board LED takes (docs/picow.md section 3.8)
16 WL_GPIO_GET read them back, and the same ERR here

The negative POLL codes: -1 the name does not resolve, -2 the station is not associated, -3 https:// and TLS is not simulated, -4 a body longer than the ring, -5 a URL this network cannot parse, -6 a request is already in flight. firmware/include/HTTPClient.h turns them into the Arduino-shaped codes a sketch compares against, and docs/wifi.md lists the mapping.

Timing, all of it this model's own and all of it stated in docs/wifi.md: an association takes between one and three seconds, decided from the SSID so a lesson takes the same time twice; a wrong password becomes WL_CONNECT_FAILED after five seconds; a request to a simulated origin comes back 60 ms later; a request into the sketch's own server that the sketch never answers is closed by the network with a 504 after ten seconds.

4. The core: which ABI, and how an interrupt arrives

crates/xtensa-core is an Xtensa LX interpreter written from the Xtensa Instruction Set Architecture reference and from the encodings the Espressif toolchains produce. Nothing in it is derived from QEMU, from ESP-IDF, from the Arduino cores or from any other emulator. xtensa-esp32-elf-as was used the way a bench instrument is used (assemble an instruction, read the encoding back, write the decoder against what came out), and crates/xtensa-core/tests/encoding.rs keeps those measurements, so the decoder can be checked against the evidence rather than against a table somebody typed.

The windowed ABI, and there was no choice. xtensa-esp32-elf-gcc emits entry, call8 and retw.n, and has no -mabi=call0 option at all. So the LX6 configuration here has sixty-four physical address registers and the register window, with ENTRY, RETW, ROTW, MOVSP, L32E/S32E and the window overflow and underflow exceptions through their own vectors. (The ESP8266's LX106 has sixteen registers and no window, so its compiler emits call0; docs/esp8266.md section 4 is the other half of this.)

What the LX6 configuration has that the LX106 does not: hardware divide (QUOS, QUOU, REMS, REMU), the 32-bit high multiply, MIN/MAX, SEXT, CLAMPS, the zero-overhead loop (LOOP, LOOPNEZ, LOOPGTZ) and the two atomic loads. Each of those differences was put to the assembler of the toolchain that builds for that chip; the encodings that came back are in tests/encoding.rs.

Modeled: loads and stores at every width with the alignment exception, the whole branch and conditional-branch set, CALL0/CALL4/CALL8/CALL12 and their indirect forms, L32R, MOVI, the density (.n) instructions, the integer ALU, MUL16/MULL/MULUH/MULSH, the special registers, and WAITI.

Vectors. VECBASE is a special register and the table is the ISA's:

offset from VECBASE vector
0x000 WindowOverflow4
0x040 WindowUnderflow4
0x080 WindowOverflow8
0x0C0 WindowUnderflow8
0x100 WindowOverflow12
0x140 WindowUnderflow12
0x300 KernelException (PS.UM clear)
0x340 UserException (PS.UM set), and every level-1 interrupt, with EXCCAUSE 4
0x3C0 DoubleException (PS.EXCM already set)

Which of the three overflow handlers is taken is decided by how wide the frame being spilled is, not by how far the window rotated to reach it: WindowOverflow4 spills through a5, 8 through a9 and 12 through a13, and those are the stack pointers one, two and three windows further on. A CALL8 writes a8 before the callee's ENTRY ever runs, so the overflow rule is on the register access and not only on ENTRY. Both of those were bugs this board's own firmware found, and both are fixed in the core.

Interrupts. INTENABLE masks; PS.INTLEVEL gates. Only level 1 is modeled: interrupt levels above one, the NMI and the debug interrupt are not. The level-1 interrupts are 0 to 10, 12, 13, 17 and 18, mask 0x0006_37FF. Verified against the ESP32 Technical Reference Manual (v5.8), table 8.3-2 "CPU Interrupts": 19, 20 and 21 are level-triggered at level 2, so they are not in the mask. An earlier version of this model had them in it (0x003E_37FF), which would have dispatched a level-2 line at level 1; crates/xtensa-core/tests/interrupts.rs now holds the mask to the table. The runtime only ever enables 6 and 13 (section 7), both level 1 on the same table, so no firmware depended on the old mask.

CCOUNT and CCOMPARE. The LX6 has three comparators. Which CPU interrupt each raises is taken to be:

comparator CPU interrupt
CCOMPARE0 6
CCOMPARE1 15
CCOMPARE2 16

Verified against the same table 8.3-2, whose internal interrupts 6, 15 and 16 are "Timer.0", "Timer.1" and "Timer.2". The table also puts 15 at level 3 and 16 at level 5, so only CCOMPARE0 is ever taken here. The runtime uses CCOMPARE0 and CPU interrupt 6 for its tick, which is the one every Xtensa port uses.

A WSR to a CCOMPARE is what arms the comparator, and writing it also clears the pending interrupt. That is the hardware's rule and it is the rule here. Two details of the model, both deliberate and both because simulated time is not a stepped clock:

  • Real hardware raises the interrupt on the one cycle where CCOUNT equals the comparator and holds it until the comparator is written again. Here the comparison is "has reached or passed", because a core parked in WAITI stops retiring instructions while time keeps moving and would otherwise step straight over the single cycle that matters. For a handler that sets the next deadline (which is every use of this register), the two behave the same.
  • A deadline that has already passed is not armed. A handler takes several hundred cycles, so a comparator set to "now" is in the past before the handler returns, fires again at once, and the interrupted program gets one instruction at a time. Three seconds of blink cost 200 million instructions before that rule and under half a million after it.

MOVSP does not raise the Alloca exception here. The exception exists on hardware for a window state MOVSP cannot handle; this model performs the move instead. No code the toolchain emits depends on the exception.

Every floating-point instruction faults. The ESP32 has an FPU and this model does not: every coprocessor-0 encoding raises EXCCAUSE 32, coprocessor disabled, so a hard-float binary stops on its first float rather than returning a wrong number. The runtime is soft float, which is what firmware/esp32/build.sh builds and what a sketch here gets. Section 6 says so again.

5. Time model, probe, poke and the host channel

The CPU runs at 240 MHz, one instruction per cycle, and the peripheral bus at 80 MHz. 240 MHz is not a whole number of picoseconds a cycle, so the time of instruction k is recomputed as ps_for(k) = k * 12500 / 3 rather than accumulated (the way the Blue Pill's and the XIAO's are), and a 2400-instruction slice is exactly 10 us.

Inside a slice the instruction count is the clock: a timer read, a UART byte and a pin change all land at the instruction that caused them, not at the end of the slice. The board part turns those into drive_after calls at the same offsets, so two writes four instructions apart really are 16.7 ns apart on the net. RSR.CCOUNT reads the same clock.

A core parked in WAITI costs nothing: the board asks to be woken at the earliest of its next CCOMPARE deadline and its next LEDC edge, and a pin change or a host byte wakes it too.

probe is a bitmask, not a level, because this board has four things worth saying:

bit meaning
1 running
2 parked in waiti
4 the blue LED on GPIO 2 is lit
8 the firmware has tried to make one of GPIO 34 to 39 an output and been refused

0 means no program, held in reset, or crashed. Bit 8 is not gated on running: a program that has already been refused an output stays refused, and the board goes on saying so.

poke: 1 holds BOOT (GPIO 0 low), 0 releases it; 2 holds EN, 3 releases it.

The host channel. host_write puts bytes in UART0's receive FIFO; host_read takes the bytes that have left its transmit FIFO. That is Serial in a sketch and the serial monitor in the UI. Bytes are UTF-8 as far as the UI is concerned and opaque to the model. A DevKit's micro-USB socket goes to a CP2102 or CH340 bridge on this port; the bridge itself is not modeled, and neither is auto-reset over DTR/RTS.

Crash. 1000 consecutive traps with no forward progress and the core is declared crashed and halted, with where it died recorded.

6. What this model is sure of, and what it is not

Three groups, and then a fourth for the approximations that are not about addresses at all. This section exists because a board model that quietly guesses an address is worse than one that says it did not know.

Taken from the technical reference manual

DPORT_BASE 0x3FF0_0000, UART0_BASE 0x3FF4_0000, GPIO_BASE 0x3FF4_4000, SENS_BASE 0x3FF4_8800, IOMUX_BASE 0x3FF4_9000, LEDC_BASE 0x3FF5_9000 and TIMG0_BASE 0x3FF5_F000. Every register offset inside the GPIO matrix and inside UART0, and UART_CONF0_REG's RXFIFO_RST (bit 17) and TXFIFO_RST (bit 18) from register 19.9, UART_STATUS_REG's eight-bit RXFIFO_CNT and TXFIFO_CNT (register 19.8), UART_CONF1_REG's thresholds (register 19.10) and UART_CLKDIV_REG's twenty-bit divisor (register 19.6). The level-1 CPU interrupt mask 0x0006_37FF (0 to 10, 12, 13, 17 and 18) and the CCOMPARE interrupts 6, 15 and 16, both from table 8.3-2 (section 4). The IRAM window at 0x4008_0000 and the DRAM window at 0x3FFB_0000. GPIO_FUNC_OUT_SEL = 256 for "follow GPIO_OUT". The MCU_SEL value 2 for plain GPIO. ADC1's channel map: channels 0 to 7 are GPIO 36, 37, 38, 39, 32, 33, 34, 35. The forty GPIOs, that 6 to 11 are the flash and that 34 to 39 have no output driver. The blue LED on GPIO 2 and the BOOT button on GPIO 0. The Xtensa vector offsets and EXCCAUSE numbers, which are the ISA's rather than the chip's.

Believed, not verified

  • The IO_MUX pad-order table (section 3.2). The registers are in pad order and this is this model's reading of which pad each GPIO is.
  • Timer group 0's register offsets (section 3.4). The base is the manual's; the layout inside it is not.
  • The LEDC block's layout (section 3.6): the channel stride, the timer block at 0x140, and the interrupt and config registers. The base is the manual's.
  • The SENS offsets and bit positions (section 3.5).
  • DPORT_INTR_MAP = 0x104, and the two source numbers, GPIO 22 and UART0 34 (section 3.7).
  • The GPIO matrix signal indices 71 to 78 for LEDC high-speed channels 0 to 7 (section 3.1). This one is the weakest claim in the document.

Every one of them is named in one place the firmware reads (firmware/esp32/include/esp32.h), so an image being taken to real silicon has a short list of #defines to check. crates/esp32/tests/contract.rs holds that header and crates/esp32/src/map.rs to the same numbers, so the two halves cannot drift apart; it does not make either of them right.

Modeled, but not to the manual's numbers

These are approximations rather than addresses, and a sketch can see every one of them:

  • UART_STATUS_REG's state-machine fields read 0 (section 3.3). On silicon ST_URX_OUT and ST_UTX_OUT move while a byte is on the wire; the runtime reads only the eight-bit counts, so it does not see the difference.
  • The ADC is a straight line. round(4095 x Vin / Vfull) with no eFuse calibration curve and no bending at the ends. A real ESP32 at 11 dB is visibly non-linear below about 150 mV and above about 3.1 V.
  • Every pad powers up the same (section 3.2): plain GPIO, input buffer on, no pull. The manual gives the strapping and flash pins different reset values.
  • GPIO_STRAP_REG is a constant, 0b1101, not a latch of what the pins were doing at reset.
  • A conversion takes exactly 5 us and an LEDC edge lands on the exact picosecond. The real part has jitter and neither figure is a datasheet number.
  • EN is a 10 k pull-up alone. The 100 nF power-on reset capacitor beside it is not modeled, so an EN that is merely released comes back instantly here.
  • There is no boot ROM, and no clock tree. A real ESP32 spends tens of milliseconds in a first-stage loader before a sketch's first instruction; here POWER_ON is 1 us, the core is at 240 MHz and the APB clock at 80 MHz from the first instruction, and there is nothing to configure either with.
  • CCOMPARE compares "reached or passed", and a deadline in the past is not armed (section 4).
  • MOVSP never raises Alloca (section 4).
  • The two RAM windows are not aliased. On the real part the same SRAM is visible through both buses at different addresses, with SRAM1's blocks in the opposite order (section 2).

Deliberately absent

Each of these was left out rather than invented:

  • The second core. The ESP32 has two Xtensa LX6 cores, PRO and APP, and only the PRO core runs here. There is no DPORT_APPCPU_CTRL, no cross-core interrupt and no FreeRTOS to schedule across them. A sketch written for the Arduino core's single loop() never notices; anything that starts a task on core 1 will not run.
  • The FPU. Every floating-point encoding faults (section 4). The runtime is soft float.
  • The cache, the MMU and flash. Code runs from IRAM; there is no XIP, no flash device and no cache to miss. PSRAM likewise.
  • WiFi and Bluetooth, in every form: no radio, no stack, no WiFi.h, not even a stub that fails politely.
  • The RTC, its slow memory, the ULP coprocessor, deep sleep and the power-management unit.
  • ADC2, the SAR's DMA and digital controller, the calibration eFuses, the internal reference, the touch sensor and the hall sensor.
  • LEDC's low-speed group and its duty-fade hardware.
  • Timer group 1, timer group 0's timer 1, and both watchdogs.
  • UART1 and UART2, I2C, SPI, I2S, RMT, the pulse counter, the motor PWM, SDIO, Ethernet, the CAN controller and the AES, SHA, RSA and RNG accelerators.
  • The interrupt matrix beyond two sources (section 3.7), and every CPU interrupt level above one.

That is why vendor ESP-IDF and Arduino-core binaries do not run here, and firmware built for this document does.

The two Xtensa boards' images are not interchangeable either. The catalog program string is elf32 xtensa lx6 esp32 here and elf32 xtensa lx106 esp8266 on the NodeMCU, and the difference is real rather than bookkeeping: this core runs the windowed ABI and that one does not have a window to run it with, so an image built for one faults on the other rather than misbehaving quietly.

7. Firmware runtime (firmware/esp32/)

An Arduino-shaped runtime: setup() and loop(), pinMode, digitalWrite, digitalRead, shiftOut, analogRead (with analogReadResolution, analogSetAttenuation and analogSetPinAttenuation), analogWrite and the ledcAttach/ledcWrite/ledcRead family, millis, micros, micros64, delay, delayMicroseconds, attachInterrupt, Serial, map, random. No Arduino core, no ESP-IDF, no libc.

Static constructors come out of .ctors here, not .init_array. Espressif's GCC 8.4 is configured without --enable-initfini-array and has no -fuse-init-array, so a global object's constructor lands in .ctors where every other toolchain in this repository emits .init_array. link.ld collects both into the one range crt0.S walks. Without that a sketch with a global object (WebServer server(80) in wifilamp) would link, run, and find its members zero. The two are walked in one pass and .ctors is not reversed, because the order constructors run in across translation units is unspecified in C++ and a sketch must not depend on it.

LED_BUILTIN is 2. BOOT_BUTTON is 0. A0 is 36, and A3 to A7 are 39, 32, 33, 34 and 35, which is what the DevKit's own analog pins are called.

analogRead is ADC1 one-shot (section 3.5), twelve bits at 11 dB by default. analogWrite(pin, 0..255) is eight bits at 1 kHz on any GPIO with an output driver, and attaches a free LEDC high-speed channel the first time it is called; ledcAttach(pin, frequency, bits) is what a sketch wants when the frequency matters, and channels asking for the same frequency and resolution share one of the four timers. mokxi_pin_is_input_only(pin) is there so a sketch can ask before it is refused.

millis() and micros() come from timer group 0's timer 0 at a tick a microsecond (section 3.4). delay() parks the core on CCOMPARE0 rather than spinning, so a second of blink costs the core under half a million instructions out of the 240 million cycles the clock counted. attachInterrupt is three writes rather than one, because a peripheral reaches the core through the interrupt matrix (section 3.7).

The shipped examples are built by Espressif's GCC; a user's sketch is built by clang. Upstream LLVM's ld.lld cannot link Xtensa at all, so the browser uses clang-v3, the wasm build of Espressif's LLVM fork, which builds every example here with the browser's command list (docs/compile.md). The prebuilt ELFs stay GCC's: they are smaller (docs/compile.md, "Code size") and nothing needs them to change. deploy/fetch-xtensa.sh puts xtensa-esp32-elf-gcc 8.4.0 in tools/xtensa/, which is gitignored: GCC is GPL, and nothing it builds here links against libgcc or any other part of the toolchain, so no GPL code reaches a shipped ELF. docs/compile.md has the full account and the remaining steps.

crt0.S carries the six window overflow and underflow handlers at VECBASE, written out from the ISA reference rather than taken from any vendor source, and it gives the root frame a base save area of its own: a window overflow of the root frame runs l32e a0, a1, -12 to find its caller's stack pointer and then writes through it, and the root frame has no caller, so one is built sixty-four bytes below the top of the stack. Without it the first deep call chain spills through a null pointer.

The exception entry does not touch a1, and that is the correctness of it rather than tidiness: the handler runs in the interrupted function's own window, and a window overflow of an older frame finds where to spill by reading the stack pointer out of that window. Move a1 and every older frame spills to the wrong place and comes back wrong. The interrupted registers go into a fixed block in .bss instead, which is safe without a lock because the handler runs at INTLEVEL 1.

Linked against firmware/esp32/link.ld: .text in the 128 KB of IRAM, everything else in the 192 KB of DRAM, stack at the top. A WROOM-32 has 4 MB of flash and an example should be a small part of it, so the build refuses an image with more than 64 KB loadable; the real ceiling the linker script enforces is the instruction RAM.

Five examples, each with a sketch the UI shows: blink (the board's LED on GPIO 2 and a wired one on GPIO 4), button (a switch to ground on GPIO 15 with the pad's own pull-up), serial (a tick a second, and an echo of what you type), fade (an LED on GPIO 4 on LEDC, eight bits at 1 kHz) and knob (a pot on GPIO 32, which is ADC1 channel 4, dimming an LED on GPIO 4 with the raw reading printed).

crates/esp32/tests/runtime.rs boots those five ELFs and measures rather than watches: blink's half period within 5 ms of 500, LEDC's period within 5 us of the 1 ms of 1 kHz, millis() within 60 ms of the kernel's own clock over five seconds, and no two UART0 bytes closer than the 86.8 us of 115200 baud. Each test skips itself when the images are not built, so the crate's tests never depend on having the cross compiler.