Reference

ESP32 DevKit board contract (Boards Wave C)

The classic ESP32 on an Xtensa LX6: the GPIO matrix, LEDC, ADC1, and one core where the chip has two.

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.

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); a contended (Unknown) net reads 0, and a floating one reads noise, as on the Uno (docs/uno.md section 1). GPIO0 reads high when nothing holds it, from the DevKit's own pull-up beside the BOOT button. 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.

3.3a UART1 (0x3FF5_0000) and UART2 (0x3FF6_E000)

UART0's register block twice more, at ESP-IDF's REG_UART_BASE(1) and REG_UART_BASE(2), with the same offsets. Verified against ESP-IDF's soc/esp32/register/soc/uart_reg.h: the bases, the offsets, and the fields below. Unlike UART0 they are wired to no host channel: they reach the circuit only through the GPIO matrix (section 3.1), one bit at a time.

  • Matrix routing. GPIO_FUNCn_OUT_SEL_CFG_REG signal 17 is U1TXD, 198 is U2TXD; GPIO_FUNCm_IN_SEL_CFG_REG (at 0x130 + 4m, 256 of them) for signal 17 is U1RXD and for 198 U2RXD, with SIG_IN_SEL (bit 7) taking the GPIO in IN_SEL (bits 0..5) and IN_INV_SEL (bit 6) inverting it. The numbers are ESP-IDF's gpio_sig_map.h. OUT_SEL's INV_SEL (bit 9) inverts the output. A signal with SIG_IN_SEL clear comes from its IO_MUX pad on the chip; that path is not modeled (the line reads idle), and the runtime routes every pin through the matrix, which works on every pin on silicon too.
  • The frame. UART_CONF0_REG PARITY/PARITY_EN (bits 0 and 1), BIT_NUM (2..3, five to eight bits) and STOP_BIT_NUM (4..5: one, one and a half, two). On the chip two stop bits need ESP-IDF's RS485_CONF.DL1_EN workaround; here STOP_BIT_NUM 3 is simply two. RXD_INV (19) and TXD_INV (22) invert the lines, ERR_WR_MASK (26) keeps bytes with a framing or parity error out of the FIFO.
  • The clock. TICK_REF_ALWAYS_ON (bit 27, set at reset) divides the 80 MHz APB clock; clear, the 1 MHz REF_TICK, which ESP-IDF picks up to 250 000 baud. UART_CLKDIV_REG is the integral part in bits 0..19 and sixteenths in CLKDIV_FRAG, bits 20..23, both modeled: 9600 baud off REF_TICK is 104 and 2/16, a bit of 104.125 us, 9603.8 baud, as on the chip.
  • FIFOs and thresholds. 128 bytes each way. A byte starts the moment the shift register is free and the next follows its last stop bit with no gap (tx_idle_num 0, as ESP-IDF sets it). TXFIFO_CNT counts the bytes still waiting, not the one going out, and ST_UTX_OUT (24..27) and ST_URX_OUT (8..11) report the slot each state machine is in, 0 when idle. STATUS bit 15 is the receive line and bit 31 the transmit line.
  • Receive. A fall on an idle line starts a frame; the start bit is checked at its middle (a glitch is ignored), every other bit is sampled in the middle of its slot and the byte lands at the middle of the stop bit. The core runs up to a slice ahead of the circuit, so the receiver only samples time the circuit has already reported.
  • Interrupts. RXFIFO_FULL (bit 0) and TXFIFO_EMPTY (1) against CONF1's thresholds, PARITY_ERR (2), FRM_ERR (3), RXFIFO_OVF (4), RXFIFO_TOUT (8) and TX_DONE (14), on interrupt matrix sources 35 and 36. RX_TOUT_EN and RX_TOUT_THRHD (CONF1 bits 31 and 24..30) set the timeout in the units ESP-IDF's uart_ll_set_rx_tout writes: eight bit times a unit off APB, an eighth of one off REF_TICK.
  • Not modeled: hardware and software flow control, RS-485, IrDA, autobaud, break, the loopback bit, the AT-command detector and the DPORT clock-enable and reset bits (both UARTs are always clocked). Their registers are stray.

A UART the program never writes costs the simulator nothing: neither is looked at on any path until a register of it is written, and crates/esp32/src/bus.rs has a test that says so.

The runtime's Serial1 and Serial2 (firmware/esp32/src/uartn.c) drive these the way the arduino-esp32 core 3.x and ESP-IDF's driver do; docs/help/boards/esp32-devkit.md says what a sketch sees.

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 and the whole of timer group 1 are absent; timer group 0's watchdog is section 3.9's.

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 of the net voltage at the moment the conversion started. At 0, 2.5 and 6 dB it is the datasheet's nominal straight line, round(4095 x Vin / Vfull) clamped, with full scale 1.1 V, 1.5 V and 2.2 V. At 11 dB, the Arduino core's default, it is the real part's raw curve (CURVE_11DB in crates/esp32/src/adc.rs): 0 up to about 80 mV, close to linear through the middle, bending upward near the top and saturating at 4095 from about 3.15 V. The nominal 3.9 V full scale is not what a raw reading does; the datasheet only promises accuracy from 150 to 2450 mV at 11 dB, and a typical part's raw count runs out well before 3.9 V. So a pot across the 3.3 V rail reads 0 to 4095, reaching 4095 a little before the end of its travel, half the rail (1.65 V) reads about 1890, and map(analogRead(pin), 0, 4095, 0, 180) gets a servo all the way around. The points are the widely used fourth-order fit to measured ESP32s, inverted; a unit's eFuse calibration moves them a few percent, and that is not modeled. analogReadMilliVolts inverts the same curve, the way the core's calibration does, so it reads about 1650 at 1.65 V. A channel with nothing on its pin wanders, as a floating input does (docs/uno.md section 1).

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..19 HPOINT: the count the pulse starts at
0x08 + 0x14n LEDC_HSCHn_DUTY_REG bits 0..24 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, 1 to 20 bits (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 around 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
UART1 35
UART2 36

UART1's and UART2's are verified against the order of ESP-IDF's soc/esp32/include/soc/interrupts.h, which also puts GPIO at 22 and UART0 at 34. Only these sources, and timer group 0's watchdog (section 3.9), 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
0x40 NET_BT_CMD_REG Bluetooth: write a Bluetooth command code (docs/bluetooth.md); the write runs it
0x44 NET_BT_ARG_REG Bluetooth: one argument word, read back; an event leaves its number here
0x48 NET_BT_RESULT_REG Bluetooth: what the last Bluetooth command answered
0x4C NET_BT_STATUS_REG bit 0 CLASSIC_ON, bit 1 CLASSIC_LINK, bit 2 EVENT, bit 3 BLE_ADV, bit 4 BLE_LINK, bit 5 BT_ERR, bit 8 HAS_CLASSIC, bit 9 HAS_BLE
0x50 NET_BT_TX_LEN_REG Bluetooth: bytes staged in its own outbound ring
0x54 NET_BT_TX_DATA_REG Bluetooth: write: push one byte into that ring
0x58 NET_BT_RX_LEN_REG Bluetooth: bytes waiting in its own inbound ring
0x5C NET_BT_RX_DATA_REG Bluetooth: read: pop one byte, 0 when it is empty

The eight NET_BT_ registers are the Bluetooth half of the block, with rings of their own so a Bluetooth callback can never touch an HTTP body: Bluetooth Classic and LE, both of which the classic ESP32 has. There is no radio behind them either; docs/bluetooth.md is their contract.

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
17 BLYNK one operation on the simulated Blynk server, the operation in NET_ARG_REG and its fields in the outbound ring; answered at once in NET_RESULT_REG, with ERR up off the network (docs/wifi.md, "Blynk")
19 ETH wired Ethernet (firmware/include/ETH.h), the operation in NET_ARG_REG: 1 brings the link up over a simulated cable, with no SSID and no password, joined 1.5 s later with the same lease; 2 puts 1 in NET_RESULT_REG when the link is (or last was) the wired one. There is one link: STATUS, IP and every request read it whichever way it came up

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.

3.9 Timer group 0's watchdog and RTC_CNTL resets (0x3FF5_F048, 0x3FF4_8000)

Timer group 0's main watchdog, as the C3's (docs/esp32c3.md section 3.9) with the ESP32's layout: a write takes effect at once, the interrupt bit is bit 2 of TIMG_INT_* (0x98..0xA4), and it reaches the core through the interrupt matrix as source 16 (the runtime maps it to CPU interrupt 17). RTC_CNTL (0x3FF4_8000, the two kilobytes below SENS): RTC_CNTL_RESET_STATE_REG at 0x34, the store registers at 0x4C..0x58 and 0xB0..0xBC, and the two software resets. Timer group 1 is not modeled.

The runtime runs ESP-IDF's task watchdog on timer group 0's watchdog, as ESP-IDF does (firmware/src/main.cpp): 500 µs ticks, stage 0 an interrupt at the timeout and stage 1 a system reset at twice it, started before setup() at 5 s with a panic, watching core 0's idle task. The sketch runs on core 1, whose idle task is not watched unless it asks (enableCore1WDT()), so a loop that never yields runs on, as on the board. The interrupt prints ESP-IDF's lines ("E (…) task_wdt: Task watchdog got triggered. …", the tasks, "Aborting.", the abort line and "Rebooting..."), leaves ESP-IDF's ESP_RST_TASK_WDT hint in the RTC store register and resets the CPU through the software reset, so esp_reset_reason() says 6 afterwards. esp_task_wdt_*, enableLoopWDT() and enable/disableCore0WDT() act on it. Timer group 1's watchdog is modeled the same way and nothing in the runtime arms it: the interrupt watchdog is not simulated.

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; it does what the exception and its handler do together. On hardware MOVSP traps when the caller's window has already been spilled (WINDOWSTART clear for WINDOWBASE - 1 to - 3), and the handler copies the caller's a0..a3, the base save area in the 16 bytes below the old stack pointer, to below the new one before the move. This model makes that copy in place and then moves (crates/xtensa-core/src/cpu.rs, movsp). Code does depend on it: clang-v3 brackets every call that passes arguments on the stack with movsp a1, a8, and before the copy was made a sketch calling WiFi.scanNetworks() (eight arguments with this) crashed in the window underflow on the way back. So did any long setup() that called sprintf with more than six arguments after enough prints to spill its caller: everything printed, then exccause=9 at WindowUnderflow8 as setup() returned (on the ESP32 and the S3; the C3 and the Pico have no window). crates/xtensa-core/tests/alloca.rs runs CALL8 and CALL12 frames that each move their stack pointer down and back up with MOVSP around a call, at every depth from 0 to 40, and web/src/compile/xtensa-movsp.test.ts runs the sketches on both boards.

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. Every sketch a user builds is soft float: the browser's clang build turns the FPU feature off (-Xclang -target-feature -Xclang -fp, because -mcpu=esp32 turns it on and the Xtensa driver has no -mfloat-abi), and the runtime objects it links (web/scripts/runtime-objects.ts) are built the same way, so a float add, compare or isnan() is a call into mokxi_float.c as on every other soft-float board here and gets the right answer. firmware/esp32/build.sh is not soft float. Espressif's GCC for this chip always emits FPU instructions for float and has no option to stop (-msoft-float and -mfloat-abi=soft are refused); only double goes through mokxi_float.c. The shipped examples (web/public/firmware/esp32/, esp32devkitc/, lolin32lite/) do no float arithmetic, and none of the FPU code in mokxi_libm.c (sinf, powf and the rest) survives --gc-sections into any of them, so none executes an FPU instruction; an example added there that does float arithmetic stops on its first one. Write it with double, or build it through the browser's toolchain as web/scripts/combo-firmware.ts does, and as web/scripts/browser-examples.ts does for an example whose example.json says "compiler": "browser" (pzemmonitor, whose PZEM004Tv30 library returns float); build.sh leaves those alone. 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.

A read of the inputs after a write to the outputs waits for the circuit. The core runs a slice ahead of the circuit, and GPIO_IN_REG holds the levels sampled when the slice began. So once a GPIO or IO_MUX write has moved a pad in a slice, a load of GPIO_IN_REG or GPIO_IN1_REG is not answered: the bus returns BusError::Retry, xtensa-core stops the core before that instruction with nothing about it done, the slice ends there, the board puts the move on the net, and the next slice samples the inputs and runs the read. A keypad scan (drive a column, read the rows) and a wire from one pin to another both read what the circuit made of the write. The AVR boards stop before such a read in the same way.

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 (what does not fit in its 128 bytes is held and comes in one byte time apart as the sketch reads, the way a real board's bytes trickle in at the baud rate into the Arduino core's ring buffer, rather than being dropped); 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 at 11 dB is a typical part's curve, not this unit's. The raw reading follows a fit to measured ESP32s (section 3.5): the dead zone at the bottom, the bend at the top and saturation at about 3.15 V are there, the unit-to-unit spread the eFuse calibration corrects is not. The other attenuations are the nominal straight lines.
  • 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; it makes the handler's copy of the base save area itself (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 sketches built in the browser are soft float, so floats work; a hard-float binary does not, and that includes a float in an example built by firmware/esp32/build.sh (section 4).
  • 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, the RTC watchdog and the interrupt watchdog.
  • 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 the sources of 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, Serial1 and Serial2, 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). delayMicroseconds() under 50 us counts CCOUNT, 240 cycles a microsecond, so it is never shorter than asked (counting the microsecond timer could make delayMicroseconds(10) 9.01 us, which an HC-SR04 ignores as a trigger); 50 us and over parks like delay(). 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).

Serial1 and Serial2 are src/uartn.c on UART1 and UART2 (section 3.3a), routed through the GPIO matrix to the pins begin(baud, config, rx, tx) or setPins() names, with the arduino-esp32 3.x defaults when none are given (GPIO 26 and 27 for Serial1, 4 and 25 for Serial2). It behaves as ESP-IDF's driver does under the core: REF_TICK up to 250 000 baud and APB above, an interrupt on RXFIFO_FULL (120 bytes) or the one-symbol receive timeout that moves the FIFO into a ring of setRxBufferSize() bytes (256), all of a chunk or none of it, with the chunk held aside and the receive interrupts off while the ring is full and the overflow interrupt emptying the FIFO meanwhile; available() counts the ring only. write() waits on the 128-byte FIFO and flush() until the transmitter is idle. Both UARTs share CPU interrupt 18. onReceive() runs from mokxi_uartn_events(), which delay(), yield() and the loop in main.cpp call (between loop()s), because there are no FreeRTOS tasks. Not there: flow control, RS-485, IrDA, autobaud and a TX ring buffer.

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). pzemmonitor, a PZEM-004T energy meter on Serial2 (GPIO 16 and 17) through the PZEM004Tv30 library, is built by the browser's compiler rather than GCC (section 4) and is the ESP32 PZEM-004T energy monitor project.

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.