ESP32-C3 Radio (WiFi/BT) — Register-Map Reverse Engineering
The ESP32-C3 WiFi/BT MAC and RF front-end registers are not published by
Espressif: they are absent from the TRM and from the SVD
(tests/fixtures/real_world/esp32c3.svd). LabWired therefore could not onboard
the radio the way every other block was onboarded (wire an SVD descriptor,
validate reset values). This document records how the radio register surface was
reverse-engineered from live silicon, and the resulting memory map — the
foundation for a functional radio model.
Method — dynamic trace off the live chip
Rather than disassemble the opaque libphy/libpp blobs, the register surface
was recovered by watching what the real driver does to the silicon:
- A minimal probe app (
scripts/hw-oracle/wifi-re/wifi_probe.c) brings the WiFi driver up —esp_wifi_init()→esp_wifi_set_mode(STA)→esp_wifi_start()— with no scan/connect, so the trace is just PHY + MAC bring-up. Four no-op anchor functions (probe_before_init,probe_after_init,probe_after_start,probe_idle) bracket the phases. scripts/hw-oracle/wifi-re/trace_radio.shflashes the build, sets hardware breakpoints on the anchors over the built-in USB-JTAG (openocd-esp32), and dumps every candidate radio window at each phase.- Diffing the phases yields exactly which registers the driver configures, and in which bring-up phase — recovering the map without any vendor docs.
Captured against the live C3 (rev v0.4, MAC 38:44:be:42:f5:58); raw dumps in
scripts/hw-oracle/captures/esp32c3/wifi-radio-<ts>/.
Discovered radio memory map
All windows reset to 0x0 (radio is powered down until phy_enable), except
the analog-master block which carries hardware defaults.
| Base | Block | Regs configured | Role |
|---|---|---|---|
0x60006000 |
FE — RF front-end | 9 | TX gain / attenuation tables (e.g. 0xc4c0c0c0, 0xf4e8dcd0) |
0x6001cc00 |
NRX — receiver | 33 | RX chain / AGC config |
0x6001d000 |
BB — baseband | 33 | baseband / modem config |
0x60033000 |
WiFi MAC (WDEV) | 29 | addr/BSSID filters (0x42be4438…), RX masks (0xffffffff) |
0x60034000 |
WiFi MAC (cont.) | 11 | descriptor / buffer base pointers (0x00400000) |
0x60035000 |
analog master / MAC | 6 | RF synth control; 12 non-zero HW reset defaults |
0x60042000 |
clock | 1 | radio clock enable |
Surface size: ~122 registers across 7 windows (peaks at 164 non-zero during
phy_enable, settles to 130). Bounded and modelable — not thousands.
The 0x60033000 base is the headline find: the C3 WiFi MAC, which appears
in no SVD or TRM. bb (0x6001d000) was already wired from a stub descriptor
in #236; FE/NRX/MAC are newly located here.
Model plan
Two layers, by register character:
- PHY/FE/BB/NRX config (~76 regs) — register-backed. These are static
calibration/gain tables
libphyblasts in. Adeclarative(GenericPeripheral) block per window — accepts writes, reads back, reset value0x0— is faithful and lets the firmware's config writes succeed instead of faulting on an unmapped window. - WiFi MAC (
0x60033000–0x60035000, ~46 regs) — behavioral. Descriptor rings, TX/RX, and IRQ need real semantics, bridged to SimNet for the data path. The model must flip the bits the driver busy-waits on duringesp_wifi_startso bring-up completes in sim.trace_radio.shcaptured the write surface; the poll surface is captured bytrace_poll.sh(below).
Until layer 2 lands, the WiFi/BT data path stays on the existing thunks
(wifi_thunks.rs + SimNet); layer 1 removes the unmapped-window faults so radio
firmware runs through configuration.
Poll surface — captured & reproduced (live C3 v0.4)
trace_poll.sh arms a HW read-watchpoint over the MAC window across a tight
esp_wifi_start() bracket and logs every load the driver issues while bringing
the MAC + DMA up. Two runs (94 / 65 watchpoint hits, both reaching
probe_after_start) classify the 0x60033000 window by register character —
diffing the two runs separates deterministic config from live state:
| Offset band | Character (run-to-run) | Reading |
|---|---|---|
0x60033084 |
stable 0x80000000 |
b31 = MAC command busy/done — toggles 80000000→0→80000000; prime handshake/ready bit the driver spins on |
0x60033088–0x6003309c |
differs | free-running TSF/timer counter (0x000a49xx) — model as monotonic, not fixed |
0x600330a8–0x600330d4 |
differs (random) | RNG / RX-FIFO data port (0xd4b5bab8, 0x6a2c46bf…) — live data, never a poll bit |
0x600330d8–0x600330e4 |
stable | state words settling 0x7960→0x7940→0x7945→0x7045 |
0x60033100/0x60033104 |
stable 0x05000000 |
MAC control/status |
0x60033110 |
stable 0xa0100000 |
control |
0x6003311c–0x60033150 |
stable | config burst written right before esp_wifi_start returns |
0x60033148/0x6003314c |
stable 4400 4300 / 4300 4400 |
MAC-address / filter bytes |
0x60033158–0x6003316c |
stable ffff…/ff |
BSSID / multicast filter masks (cold = all-ones) |
The block read 0x60033000–0x6003302c (6× from PC 0x42049456) is a register-
bank readback loop (MAC ID / cal), not a busy-wait.
Write surface: the handshake is driver-managed scratch, not HW
A second pass with WP_TYPE=w (write-watchpoint over the same bracket, 99 hits)
recovers the command order, and it overturns the original "behavioral handshake"
assumption. 0x60033084 b31 is written by firmware, not the MAC:
pc=0x4202f4ca 0x084 <- 80000000 # bulk-init of the 0x080-9c block (memcpy)
pc=0x420490dc 0x084 <- 00000000 # driver clears b31 (begin sequence)
... driver does setup; seeds TSF counter 0x088/08c <- 000a49cc ...
pc=0x42049396 0x084 <- 80000000 # driver sets b31 back (done)
The driver writes 0x60033084 (and the whole 0x080–0x09c block) then reads
its own values back — it's using MMIO as scratch state, not polling a bit the
hardware flips. A plain register-backed model (what Layer 1 already is) therefore
reproduces this faithfully with zero behavioral logic. The only genuinely
HW-sourced state is:
0x600330a8–0x600330d4— RNG / RX-FIFO data port (must vary on read);0x60033088–0x6003309c— TSF/timer counter (seeded by driver, then HW-advanced).
So #9 is far narrower than first scoped: not a status-handshake state machine,
but (a) a varying RNG/data port and a monotonic counter, and (b) the DMA descriptor
rings (lldesc, reused from gdma.rs) bridged to network::WirelessPacket (#10).
The decisive next step is empirical — boot wifi_probe.elf in the C3 sim (the
register-backed Layer 1 model + real ROM) and find the exact PC where it stalls;
that pinpoints which of the above the firmware actually requires to advance,
instead of modeling speculatively.
Raw capture logs aren't committed (see the repo-root fixtures/ ignore);
regenerate with trace_poll.sh (reads) and WP_TYPE=w trace_poll.sh (writes) on
a live C3 — both runs above reproduced the deterministic bands byte-for-byte.
The real blocker is the boot path, not the MAC (diagnosed 2026-06-13)
Booting the real IDF wifi_probe.elf in the C3 sim shows the WiFi MAC can't even
be reached yet — the firmware dies in boot:
- fast-boot, no ROM image: runs 196,613 instructions of IDF C startup, then PC
slides off the end of the zero-filled ROM region at
0x40060000(it called a C3 ROM function and there was nothing there). - fast-boot, real ROM dumped from silicon (
openocd dump_image 0x40000000 0x60000→LABWIRED_ESP32C3_ROM,0x3FF00000 0x20000→_ROM_DATA): crashes at step ~6596 insiderom_i2c_writeReg_Mask:
40039234: lw a5, 1464(a5) # a5 = *(0x3fcdf5b8) = rom_phyFuns (DRAM global)
40039240: lw a5, 428(a5) # a5 = phyFuns->fn[107]
40039256: jalr a5 # → 0xfe38d096 (garbage) → fault
rom_phyFuns (0x3fcdf5b8) is a ROM function-pointer table in DRAM that the
BROM reset handler initializes on silicon. Fast-boot jumps straight to the app
ELF entry (0x403802dc) and skips the BROM reset sequence, so the ROM's DRAM
globals are never set up and the indirect call goes to garbage. (Symbols mapped
against ~/.espressif/tools/esp-rom-elfs/.../esp32c3_rev3_rom.elf.)
Fix, the faithful way (run the binary, don't thunk it): RISC-V rom-boot — reset
the CPU to the BROM vector 0x40000400, back the XIP/flash windows with the real
flash image (LABWIRED_ESP32C3_FLASH = bootloader+ptable+app), and let the real
ROM run: it initializes its own DRAM globals (rom_phyFuns et al.), loads the
2nd-stage bootloader + app through the flash path, and jumps to app_main exactly
like silicon. Today --rom-boot is implemented only for Xtensa
(configure_xtensa_esp32s3, LABWIRED_ESP32S3_FLASH); C3 (RISC-V) only fast-boots.
That's the one remaining task (#18) gating real WiFi-in-sim — once IDF apps reach
esp_wifi_start, the MAC is mostly register-backed scratch (above) plus the DMA
ring → SimNet bridge (#10).
Reproduce
# build + flash the probe (needs ESP-IDF v5.3.1, esp32c3 target)
. ~/esp/esp-idf/export.sh
cd scripts/hw-oracle/wifi-re && idf.py set-target esp32c3 build
idf.py -p /dev/cu.usbmodemXXXX flash
# trace the radio register surface (write surface, by phase)
./trace_radio.sh build/wifi_probe.elf /tmp/radio-trace
# trace the MAC poll surface (status bits the driver busy-waits on)
./trace_poll.sh build/wifi_probe.elf /tmp/c3-poll
python3 poll_surface_to_table.py /tmp/c3-poll/poll_trace.log
Flashing over JTAG (port-unambiguous when several boards are attached, since the
board cfg locks onto the C3's USB-JTAG PID 0x1001):