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-C3 and the board page.

ESP32-C3 board contract (Phase 2)

The first microcontroller in Mokxi. One kernel component (esp32c3 in crates/parts) wraps a SoC model (crates/esp32c3) built on the RV32IMC core (crates/rv32-core). Firmware is a plain RV32 ELF that talks to the peripherals below at their real ESP32-C3 addresses, so a program built for Mokxi also runs on a real board. Running vendor ESP-IDF or Arduino-core binaries is out of scope for Phase 2: they need the ROM, the clock tree, the cache and the interrupt matrix, none of which are modeled.

The catalog type is esp32c3 (the ESP32-C3-DevKitM-1 board). Three agents build against this file: the SoC and part (Rust), the firmware runtime and examples (C, clang), the board visual and serial monitor (TypeScript). Change it only by editing this file first.

1. Board pins (catalog order)

In the physical order of the two 15-pin headers on the DevKitM-1 (top view, USB connector at the bottom):

Left header (top to bottom): GND, 3V3, 3V3b, 2, 3, RST, GNDb, 0, 1, 10, 4, 5, 6, 7, 8 Right header (top to bottom): 5V, 5Vb, GNDc, GNDd, 9, 18, 19, 20, 21, NC1, NC2, NC3, NC4, NC5, NC6

Duplicate names carry a letter suffix because catalog pin names are unique. NC* pins are present so the header is drawn complete; they are high-Z and never read.

Electrical:

  • 3V3* drive 3.3 V at R_SUPPLY; 5V* drive 5 V at R_SUPPLY; GND* drive 0 V at R_SUPPLY. The board is always powered (USB). It is a supply for the rest of the circuit.
  • RST is an input with a 10 k internal pull-up to 3.3 V; driven below 1.65 V it holds the CPU in reset; releasing it restarts the program from the entry point.
  • GPIO 0..10, 18, 19, 20, 21 are inout. Output enabled: push-pull at 3.3 V or 0 V through R_STRONG. Output disabled: high-Z, plus a 45 k pull-up to 3.3 V when FUN_WPU is set in IO_MUX, a 45 k pull-down when FUN_WPD is set. Input reads the net voltage against 1.65 V (half of 3.3 V); Unknown and Floating both read as 0 with no error (a real pin reads whatever noise it has; document that in the part).
  • GPIO 0 to 4 are also ADC1 channels 0 to 4: the net voltage at those five pins is read as a voltage as well as against the threshold, so a potentiometer, a thumb stick or a divider on one of them is a real analog input (section 3.5). A pin nothing is holding has no voltage and converts to 0.
  • Pins 18/19 double as USB D-/D+ and 20/21 as UART0 RX/TX on the real board; here they are plain GPIO. UART0 bytes go to the host channel (section 4), not to the pins.

2. Memory map (real ESP32-C3 addresses)

region address size notes
SRAM, instruction bus 0x4037_C000 400 KB one RAM, two views
SRAM, data bus 0x3FC7_C000 400 KB same bytes: DRAM = IRAM - 0x0070_0000
GPIO 0x6000_4000 4 KB section 3.1
IO_MUX 0x6000_9000 4 KB section 3.2
UART0 0x6000_0000 4 KB section 3.3
SYSTIMER 0x6002_3000 4 KB section 3.4
APB_SARADC 0x6004_0000 4 KB section 3.5
LEDC 0x6001_9000 4 KB section 3.7
Mokxi network mailbox 0x6FFF_E000 4 KB section 3.8. Not the chip's: Mokxi's own block, where a real C3 has nothing

The whole 400 KB is RAM (real chips reserve the top for ROM data and the cache; not modeled). Anything else is an access fault (the core traps; the part counts it, see section 5). Unmapped peripheral registers inside a mapped block read 0 and ignore writes, with a counter for diagnostics.

Firmware ELF: loadProgram takes an ELF32 little-endian RISC-V executable. Every PT_LOAD segment must fall inside SRAM through either view. Entry is the ELF entry point; the CPU resets with pc = entry, sp = top of SRAM data view (0x3FCE_0000), all other registers zero, mtvec = 0. A load at run time resets the board.

3. Peripheral registers (subset, real offsets, real bit positions)

Registers are 32 bits and word aligned. Halfword and byte access to a register is unsupported (treated as a word access of the aligned word, which is what the chip does for reads; writes of narrower width write the whole word from the narrow value, not a real behavior, so firmware must not do it).

3.1 GPIO (0x6000_4000)

offset name bits
0x04 GPIO_OUT_REG bit n = level of GPIO n when output is enabled
0x08 GPIO_OUT_W1TS_REG write 1 to set bits of OUT
0x0C GPIO_OUT_W1TC_REG write 1 to clear bits of OUT
0x20 GPIO_ENABLE_REG bit n = output driver of GPIO n on
0x24 GPIO_ENABLE_W1TS_REG set
0x28 GPIO_ENABLE_W1TC_REG clear
0x3C GPIO_IN_REG bit n = input level of GPIO n (needs FUN_IE)
0x44 GPIO_STATUS_REG interrupt status, bit n
0x4C GPIO_STATUS_W1TC_REG clear
0x74 + 4n GPIO_PINn_REG bit 2 PAD_DRIVER (1 = open drain), bits 7..9 INT_TYPE (0 off, 1 rising, 2 falling, 3 any, 4 low level, 5 high level)
0x554 + 4n GPIO_FUNCn_OUT_SEL_CFG_REG bits 0..7 OUT_SEL: 128 = the pad follows GPIO_OUT_REG bit n (the power-on value used here); 45 to 50 = LEDC low-speed channels 0 to 5 drive it (section 3.7); anything else is unsupported and treated as 128

The signal indices 45 to 50 are unverified: they are what this model implements for ledc_ls_sig_out0 to ledc_ls_sig_out5, chosen so the GPIO matrix has somewhere to put them, and not a row quoted from the manual's peripheral-signal table. A pad an LEDC channel is driving still needs its output driver on in GPIO_ENABLE_REG, which is what gpio_set_direction sets before the matrix is switched over; the channel decides the level and GPIO_ENABLE decides whether the pad drives at all.

Only bits 0..21 exist. Open drain: output high means high-Z. GPIO interrupts set GPIO_STATUS_REG and raise the external interrupt line (section 3.6).

3.2 IO_MUX (0x6000_9000)

offset name bits
0x04 + 4n IO_MUX_GPIOn_REG bit 7 FUN_WPD pull-down, bit 8 FUN_WPU pull-up, bit 9 FUN_IE input enable, bits 12..14 MCU_SEL (1 = GPIO, the only value supported; power-on value 1 here for every pin so raw firmware just works)

3.3 UART0 (0x6000_0000)

offset name bits
0x00 UART_FIFO_REG write: push a byte to TX; read: pop a byte from RX (0 when empty)
0x04 UART_INT_RAW_REG bit 0 RXFIFO_FULL, bit 1 TXFIFO_EMPTY, bit 8 RXFIFO_TOUT
0x08 UART_INT_ST_REG raw and enabled
0x0C UART_INT_ENA_REG
0x10 UART_INT_CLR_REG write 1 to clear
0x14 UART_CLKDIV_REG bits 0..11 integer divisor of 40 MHz (baud only affects the TX pacing in section 5; stored, read back)
0x1C UART_STATUS_REG bits 0..9 RXFIFO_CNT, bits 16..25 TXFIFO_CNT
0x20 UART_CONF0_REG stored, read back; bit 17 RXFIFO_RST, bit 18 TXFIFO_RST clear the FIFOs (TRM v1.4 register 26.9)
0x24 UART_CONF1_REG bits 0..8 RXFIFO_FULL_THRHD, bits 9..17 TXFIFO_EMPTY_THRHD

FIFOs are 128 bytes. TX bytes leave the FIFO at the configured baud (default 115200: one byte per 86.8 us) into the host channel; a full TX FIFO drops bytes (real behavior). RX bytes arrive from the host channel instantly. RXFIFO_TOUT raises when RX has data and no byte arrived for 10 byte times.

3.4 SYSTIMER (0x6002_3000)

16 MHz free-running 52-bit counter, one comparator wired to the CPU timer interrupt.

offset name bits
0x04 SYSTIMER_UNIT0_OP_REG bit 30 UPDATE: write 1 to latch the count into VALUE_HI/LO; bit 29 VALUE_VALID reads 1 once latched
0x40 SYSTIMER_UNIT0_VALUE_HI_REG bits 0..19, latched count bits 32..51
0x1C SYSTIMER_TARGET0_HI_REG bits 0..19
0x20 SYSTIMER_TARGET0_LO_REG
0x34 SYSTIMER_TARGET0_CONF_REG bit 30 PERIOD_MODE, bits 0..25 PERIOD
0x44 SYSTIMER_UNIT0_VALUE_LO_REG latched count bits 0..31
0x50 SYSTIMER_COMP0_LOAD_REG write 1 to load the target
0x64 SYSTIMER_INT_ENA_REG bit 0
0x68 SYSTIMER_INT_RAW_REG bit 0
0x6C SYSTIMER_INT_CLR_REG bit 0
0x70 SYSTIMER_INT_ST_REG bit 0

The timer is always running. The count is derived from kernel time: count = now_ps / 62_500. It never drifts from the circuit.

Verified register by register against the ESP32-C3 Technical Reference Manual v1.4, section 10.6 (register summary) and registers 10.2, 10.14 to 10.17. An earlier version of this model put comparator 0 at 0x50 to 0x5C, which on the C3 are the load strobes COMP0_LOAD to COMP2_LOAD and UNIT0_LOAD; that layout was never the chip's, and the runtime and its images were rebuilt when it was corrected.

3.5 APB_SARADC (0x6004_0000)

ADC1 in one-shot mode: the path ESP-IDF's adc_oneshot driver and the Arduino core's analogRead take, and nothing else. Channels 0 to 4 are GPIO 0 to GPIO 4; ADC2 is not modeled (on a real C3 it is shared with the radio, and there is no radio here).

The block's base address is the chip's. The offsets in this table marked "unverified" were not taken from a page of the technical reference manual: they are what this model implements, chosen to match the shape of the one-shot path, and firmware built here agrees with the model whether or not it agrees with silicon. 0x00 and 0x04 are the manual's; the rest of the one-shot registers are the unverified ones, and a program that needs to run on a DevKitM-1 unchanged should check them against the manual first.

offset name bits
0x00 APB_SARADC_CTRL_REG bits 7..14 SAR_CLK_DIV: SAR clock divider. Stored and read back; conversion timing here is fixed
0x04 APB_SARADC_CTRL2_REG stored, read back. The timer-triggered and DMA paths are not modeled
0x0C APB_SARADC_FSM_WAIT_REG stored, read back (unverified offset)
0x38 APB_SARADC_ONETIME_SAMPLE_REG bits 23..24 ONETIME_ATTEN, bits 25..28 ONETIME_CHANNEL, bit 29 ONETIME_START, bit 30 SAR2_ONETIME_SAMPLE (stored, converts nothing), bit 31 SAR1_ONETIME_SAMPLE (unverified offset and field positions)
0x44 APB_SARADC_1_DATA_STATUS_REG bits 0..16 ADC1_DATA: the last ADC1 conversion, twelve bits wide (unverified offset)
0x64 APB_SARADC_INT_ENA_REG stored; no line reaches the core (unverified offset)
0x68 APB_SARADC_INT_RAW_REG bit 31 ADC1_DONE (unverified offset)
0x6C APB_SARADC_INT_ST_REG raw and enabled (unverified offset)
0x70 APB_SARADC_INT_CLR_REG write 1 to clear, same bit (unverified offset)
0x78 APB_SARADC_APB_ADC_CLKM_CONF_REG stored, read back (unverified offset)

One conversion: write ONETIME_SAMPLE with SAR1_ONETIME_SAMPLE, the channel and the attenuation, write it again with ONETIME_START up, wait for ADC1_DONE in INT_RAW, read APB_SARADC_1_DATA_STATUS_REG, clear the flag and drop ONETIME_START. A conversion starts on the write that raises ONETIME_START with ADC1 taking part, and takes a fixed 5 us. The real figure depends on the SAR clock and the FSM's counts and is a few microseconds; firmware waits for it either way, since the result does not appear early.

The result is twelve bits, round(4095 x Vin / Vfull), clamped, of the net voltage at the moment the conversion started. ONETIME_ATTEN picks the full scale, at the nominal figures the data sheet quotes:

ONETIME_ATTEN attenuation full scale
0 0 dB 1.1 V
1 2.5 dB 1.5 V
2 6 dB 2.2 V
3 11 dB 3.3 V

So a divider at half the 3.3 V rail reads about 2048 at 11 dB. A real part's transfer function bends near the top of its range and is straightened out with the calibration curve in the eFuses; neither the bend nor the eFuses are modeled. A channel with nothing holding its pin reads 0 rather than noise, as an unconnected analog pin does everywhere else in Mokxi.

ADC1_DONE can be polled and cleared, which is what the one-shot path does, but it does not reach mip: the ADC is not on the external interrupt line (section 3.6). Neither are the digital controller, the DMA pattern table, the filters, the threshold monitors or the internal temperature and VDD channels.

3.6 Interrupts

RISC-V machine mode, mtvec direct or vectored as the core supports. Lines:

  • Machine timer interrupt (mip.MTIP, cause 7): SYSTIMER target 0 when INT_ENA bit 0 and INT_RAW bit 0.
  • Machine external interrupt (mip.MEIP, cause 11): GPIO (GPIO_STATUS_REG non-zero) or UART0 (UART_INT_ST_REG non-zero). Firmware reads the two status registers to find out which. The real interrupt matrix is not modeled.

wfi parks the core until any line is pending.

3.7 LEDC (0x6001_9000)

The LED PWM controller, low-speed group, which is the only group this chip has: four timers and six channels. This is the block behind analogWrite, ledcAttach and ledcWrite.

A timer is a free-running counter of 2^DUTY_RES counts clocked at 80 MHz divided by CLK_DIV, which is a Q10.8 fixed-point number of APB clocks per count, so

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

and 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. So a duty of 0 is a solid low and a duty of 2^DUTY_RES 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 leaving a one-count notch every period.

Verified: the base address, every offset in the table below and every bit it names, against the ESP32-C3 Technical Reference Manual v1.4, section 32.5 (register summary) and registers 32.1 to 32.13.

offset name bits
0x00 + 0x14n LEDC_CHn_CONF0_REG bits 0..1 TIMER_SEL, bit 2 SIG_OUT_EN, bit 3 IDLE_LV (the level held with the output generator off), bit 4 PARA_UP (stored; a duty written here takes effect at once)
0x04 + 0x14n LEDC_CHn_HPOINT_REG bits 0..13 HPOINT: the count the channel's pulse starts at
0x08 + 0x14n LEDC_CHn_DUTY_REG bits 0..18 DUTY: the pulse width in sixteenths of a count
0x0C + 0x14n LEDC_CHn_CONF1_REG the duty-fade hardware: stored, read back, and fades nothing
0x10 + 0x14n LEDC_CHn_DUTY_R_REG the duty as it stands, read only
0xA0 + 8n LEDC_TIMERn_CONF_REG bits 0..3 DUTY_RES (1 to 14; 0 is a timer nobody has configured and it counts nothing), bits 4..21 CLK_DIV (Q10.8 APB clocks per count), bit 22 PAUSE, bit 23 RST, bit 25 PARA_UP (stored); bit 24 is reserved. TRM v1.4 register 32.7. The C3 has no per-timer TICK_SEL: the clock is chosen for all four timers in LEDC_CONF_REG. An earlier version of this model used the classic ESP32's layout here (five resolution bits and the divider from bit 5). The manual's reset value has RST set; here the register powers up as 0
0xA4 + 8n LEDC_TIMERn_VALUE_REG the counter, read only
0xC0 LEDC_INT_RAW_REG bit n = timer n overflowed
0xC4 LEDC_INT_ST_REG raw and enabled
0xC8 LEDC_INT_ENA_REG stored; no line reaches the core
0xCC LEDC_INT_CLR_REG write 1 to clear
0xD0 LEDC_CONF_REG bits 0..1 APB_CLK_SEL (1 = the 80 MHz APB clock, the only source modeled), bit 31 CLK_EN (stored)
0xFC LEDC_DATE_REG a version stamp, read only

Setting up a channel is what ledc_timer_config and ledc_channel_config do: write LEDC_CONF_REG, write the timer's DUTY_RES and CLK_DIV, write the channel's CONF0 with its timer and SIG_OUT_EN, write HPOINT and DUTY, set the pad's GPIO_ENABLE bit and point its GPIO_FUNCn_OUT_SEL_CFG_REG at 45 + channel.

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 wfi therefore costs nothing while a PWM runs, and the board part is woken for the edges and for nothing else.

Not modeled: the high-speed group (this chip has none), the duty-fade hardware, a channel's own overflow counter, clock sources other than APB, and the LEDC interrupt line into the interrupt matrix. LEDC_INT_RAW_REG can be polled and cleared but never reaches mip, exactly as the ADC's ADC1_DONE does not (section 3.6).

3.8 Mokxi network mailbox (0x6FFF_E000)

This block is not the ESP32-C3's. Every other address in this document is the chip's, quoted from the technical reference manual, and an image built against it runs on a real DevKitM-1. This one is Mokxi's own: the simulated network of crates/net, mapped at an address the real part leaves unmapped, 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.

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. Host channel

host = "utf-8 bytes on UART0". readHost returns UART0 TX bytes as they leave the FIFO; writeHost pushes bytes into the UART0 RX FIFO (overflow drops, as on the chip). The serial monitor shows bytes as text, decoding UTF-8 and treating \n as a line end (\r ignored).

5. Time model

The CPU runs at 160 MHz with one instruction per cycle. The part executes in slices: on each wake it runs SLICE_NS * 160 instructions (SLICE_NS = 10 us, so 1600 instructions) then schedules the next wake, with these rules:

  • Peripheral writes that change a pin (GPIO OUT or ENABLE, IO_MUX pulls) drive the pin at the time the slice started plus the instruction count so far divided by 160 MHz. Use drive_after with that delay so two toggles within one slice keep their spacing. Reads of GPIO_IN see the pin level as of the slice start.
  • The timer compare and UART pacing are evaluated against the same in-slice time, so micros() is monotonic and a busy-wait delayMicroseconds is accurate to one instruction.
  • wfi: the part stops slicing and schedules a wake at the earliest of the timer target and a UART event; a GPIO pin change wakes it immediately.
  • Access faults: the core traps to mtvec; with mtvec = 0 that re-traps forever. The part counts traps and after 1000 consecutive traps with no forward progress it halts and reports "firmware crashed at pc=…, mcause=…" through probe (see below) and the host channel (one line).

Probe: 1 when running, 0 when halted (no program, in reset, or crashed), 0.5 while parked in wfi. Poke: 1 = press the BOOT button (GPIO 9 low while held), 0 = release; 2 = press RST, 3 = release RST. The UI gives the board interaction: 'hold' on its RST button region only if the visual supports region-specific interaction; for Phase 2 the whole board is not interactive, and BOOT/RST are wired to pushbuttons by users if they want them.

6. Firmware runtime (firmware/)

A freestanding C and C++ runtime built with clang (--target=riscv32-unknown-elf -march=rv32imc -mabi=ilp32) and lld, no libc, no GPL: crt0.S (stack, .data copy, .bss clear, trap vector, main), esp32c3.h (the registers above as macros), arduino.h with pinMode, digitalWrite, digitalRead, analogRead (one-shot ADC1 on GPIO 0..4, twelve bits, with analogReadResolution, analogSetAttenuation and analogSetPinAttenuation), analogWrite and the ledcAttach/ledcWrite pair (LEDC, section 3.7: eight bits at 1 kHz on any GPIO by default, with analogWriteResolution and analogWriteFrequency, and a channel allocated to a pin the first time it is written to), delay, delayMicroseconds, millis, micros, Serial.begin/print/println/write/available/read (a small C++ Print class with integer, float, string and char overloads), attachInterrupt for GPIO. The sketch is setup() and loop(). build.sh compiles every example under firmware/examples/ to web/public/firmware/<name>.elf, which the UI ships. Examples: blink (GPIO 8, the DevKitM-1 LED pin, 500 ms), button (GPIO 9 with pull-up lights GPIO 8), serial (prints millis() once a second and echoes what it receives), fade (bit-banged PWM on GPIO 8 for a breathing LED), counter (74HC595 driven on GPIO 4 = DS, 5 = SHCP, 6 = STCP, counting on eight LEDs), interrupt (attachInterrupt on GPIO 9 counts presses and prints the count), pwmfade (the same breathing LED as fade, on analogWrite rather than by hand, so the core sleeps through it), servo (an SG90 swept end to end by a 50 Hz LEDC channel on GPIO 4).

The board's code in the editor is not compiled in Phase 2 (see docs/compile-options.md); the UI offers the built-in examples and an ELF upload.

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

Three groups, the same sorting docs/esp32c6.md section 7 uses. It 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, and run through by real firmware. UART0_BASE = 0x6000_0000, GPIO_BASE = 0x6000_4000, IOMUX_BASE = 0x6000_9000, SYSTIMER_BASE = 0x6002_3000, APB_SARADC_BASE = 0x6004_0000 and LEDC_BASE = 0x6001_9000, with every register offset inside the GPIO, IO_MUX, UART0, SYSTIMER and LEDC blocks. Checked against the ESP32-C3 Technical Reference Manual v1.4 while the ESP32-S3 was being added: the SYSTIMER comparator 0 offsets (section 10.6: TARGET0_HI 0x1C, TARGET0_LO 0x20, TARGET0_CONF 0x34, COMP0_LOAD 0x50), LEDC_TIMERn_CONF_REG's fields (register 32.7), LEDC's interrupt and global registers (registers 32.3 and 32.9 to 32.13) and UART_CONF0_REG's FIFO resets (register 26.9). The first two and the last were wrong here before that check and were corrected, with the runtime's images rebuilt. The GPIO range 0 to 21, ADC1's channel map (channel n is GPIO n for n in 0 to 4), the 400 KB of SRAM at two views 0x0070_0000 apart, and the 16 MHz SYSTIMER tick. Each of those blocks is driven by a test that loads a real ELF and runs it: crates/esp32c3/tests/firmware.rs for GPIO, UART0 and SYSTIMER, and tests/examples.rs's pwmfade and servo for LEDC.

Believed, not verified. Named as such register by register in sections 3.1 and 3.5, and repeated here so the list is in one place:

  • Every one-shot register offset inside APB_SARADC except 0x00 and 0x04. That is FSM_WAIT at 0x0C, ONETIME_SAMPLE at 0x38 and its field positions, 1_DATA_STATUS at 0x44, the four interrupt registers at 0x64 to 0x70, and APB_ADC_CLKM_CONF at 0x78. They are what this model implements, chosen to match the shape of the one-shot path, not rows quoted from a page of the manual.
  • The GPIO matrix signal indices 45 to 50 for ledc_ls_sig_out0 to ledc_ls_sig_out5, chosen so the matrix has somewhere to put them.

Firmware built for this document agrees with the model whether or not it agrees with silicon. An image being taken to a real DevKitM-1 should check those #defines in firmware/include/esp32c3.h against the manual first.

Modeled, but not held to the manual's numbers. Behaviors rather than addresses:

  • The ADC's transfer function is the nominal straight line. round(4095 × Vin / Vfull) against the attenuator's nominal full scale (1.1 V, 1.5 V, 2.2 V, 3.3 V), with no noise, no nonlinearity and none of the eFuse calibration curve a real C3 needs to be accurate at all. A real reading at 11 dB bends several percent near the top of its range; this one does not.
  • One instruction is one cycle at 160 MHz. There are no pipeline stalls, no cache (there is no cache), no flash wait states and no bus contention, so the instruction count is the clock. A real C3 running from flash is slower and less even than this.
  • The pads are three states and a threshold. Push-pull at 3.3 V, high-Z, or a 45 kΩ internal pull, read back against half the supply, with no hysteresis, no drive-strength setting, no rise time and no current limit. An undriven pin reads 0 rather than floating to something.
  • The board is an ideal supply: 3V3 and 5V at rail impedance for ever, so nothing browns out. Startup is a microsecond, where a real C3 spends tens of milliseconds in its boot ROM.
  • A slice is 10 µs, and the SoC and the circuit meet at slice boundaries or at whatever the chip is waiting for.

Deliberately absent. Left out rather than invented, because the register layout is not something this model can reproduce honestly: Bluetooth LE (no radio, and no pretense of one), SPI, I2C, I2S, RMT, USB Serial/JTAG, the timer groups and their watchdogs, the full interrupt matrix (section 3.6 says what stands in for it), ADC2 and the SAR ADC's DMA pattern table, digital controller, filters, threshold monitors and calibration eFuses, LEDC's high-speed group and duty-fade hardware and its interrupt line into the core, the PMU and the clock tree, the cache, the ROM and flash. That is why vendor ESP-IDF and Arduino core binaries do not run here, and firmware built for this document does.

WiFi: simulated, see docs/wifi.md. It was in the list above until September 2026, as "absent: WiFi in every form". It is not absent any more and it is not modeled either: there is no radio and no 802.11, and what there is instead is section 3.8's mailbox (a block this chip does not have, at an address it leaves unmapped) with a network written in software behind it. docs/wifi.md is the contract for what that network does and, at greater length, for what it does not.

Questions

Does it do WiFi?

Simulated WiFi, yes. Your sketch uses the real Arduino calls (WiFi.begin, HTTPClient, WebServer) and the board joins, fetches and serves, but there is no radio, no 802.11 and no real internet under it. Section 3.8 and the Simulated WiFi page have the contract.

Will my firmware run on a real DevKitM-1?

Yes. The model sits on the real memory map and register offsets for everything it implements, so a build from here is not Mokxi-specific.