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 atR_SUPPLY;5V*drive 5 V atR_SUPPLY;GND*drive 0 V atR_SUPPLY. The board is always powered (USB). It is a supply for the rest of the circuit.RSTis 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, 21are inout. Output enabled: push-pull at 3.3 V or 0 V throughR_STRONG. Output disabled: high-Z, plus a 45 k pull-up to 3.3 V whenFUN_WPUis set in IO_MUX, a 45 k pull-down whenFUN_WPDis 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
0to4are 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/19double as USB D-/D+ and20/21as 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 whenINT_ENAbit 0 andINT_RAWbit 0. - Machine external interrupt (
mip.MEIP, cause 11): GPIO (GPIO_STATUS_REGnon-zero) or UART0 (UART_INT_ST_REGnon-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_afterwith 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-waitdelayMicrosecondsis 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; withmtvec = 0that 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=…" throughprobe(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_SARADCexcept0x00and0x04. That isFSM_WAITat 0x0C,ONETIME_SAMPLEat 0x38 and its field positions,1_DATA_STATUSat 0x44, the four interrupt registers at 0x64 to 0x70, andAPB_ADC_CLKM_CONFat 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_out0toledc_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.