ESP32-S3: undocumented radio registers

What this project knows about the ESP32-S3 registers that lib/esp_sdr relies on, or that a later feature could use, and that Espressif does not document, or documents without the behaviour that matters: where each one is, its fields, what it does, the values seen, and how sure we are. The ESP32-C6 has its own page, undocumented-registers-esp32c6.md. How the library copes with the radio’s quirks is in hardware-quirks.md.

How to read this page

  • The register part follows Espressif’s technical reference manual: a register summary, then each register with a bit-field table and its fields’ descriptions. The table’s Init row, where present, is the value after the PHY’s initialization (written by the vendor’s init code or read on the bench after it), not the reset value. “(unknown)” marks bits nobody has tied to a function yet, “(documented)” bits that Espressif’s SVD documents in a register this page only adds to.

  • Names are spelled as in Espressif’s headers (REGISTER_REG, FIELD): Espressif’s own for the registers its SVD has (RTC_CNTL_USB_CONF_REG, SENSITIVE_INTERNAL_SRAM_USAGE_3_REG), this project’s elsewhere, also for the registers under “Baseband” that this page adds to Espressif’s BB peripheral.

  • Access: R/W read and write, RO read only (set by the hardware), WO written only (reads unknown). It is inferred from the vendor code and this project’s use.

  • Analog I2C registers are 8 bits wide and addressed block:reg with a decimal register number; 0x62:12[3:2] is bits 3:2 of register 12 in analog block 0x62 (upstream esp-sdr uses the same form). Their blocks are not memory-mapped (see “Analog I2C access”).

  • The register part is generated from registers/esp32s3.svd, a CMSIS-SVD file of this project’s additions to Espressif’s SVD (espressif/svd at be20aa1), by doc/registers/svd2md.py: edit the SVD, then run the script.

  • Evidence paths are relative to this repository (lib/, doc/, …) or, for files outside it, to the west workspace: modules/lib/esp-sdr/ is upstream esp-sdr (commit 1fe5535), modules/hal/espressif/ the HAL. git show <commit>:<path> cites a file at an older upstream commit: run it in modules/lib/esp-sdr/. “Bench” with a date is a measurement with scratch tools that are not in the repository: 2026-10-05 and 06 on two XIAO ESP32S3, 2026-10-07 to 09 and 2026-10-11 on one XIAO ESP32S3 next to one XIAO ESP32C6 (2026-10-11: the C6 transmitting into the S3 over coax, the S3’s receive gain fixed at index 0).

  • Entries marked decompile cite the blob object they were read from, as in “S3 phy_i2c.o”, and the function where the text names one. The phy_*.o objects except phy_test.o are members of modules/hal/espressif/zephyr/blobs/lib/esp32s3/libphy.a; bb_common.o, crypto_common.o, esp_phy_api.o, mac_common.o, phy_test.o, rf_test.o and wifi.o of zephyr/blobs/lib/esp32s3/librftest.a (esp-phy-lib e37ecdc9). Decompiled C drops the arguments of indirect calls, so argument values come from the disassembly. ROM addresses refer to esp32s3_rev0_rom.elf of esp-rom-elfs 20230320.

Confidence

Meaning

measured

Seen on the bench boards or on air between them.

working

The library depends on it and works; the meaning is not proven beyond that.

upstream

Stated by upstream esp-sdr (code, comments, docs); not re-checked here.

decompile

Read from vendor code (decompile or disassembly); not confirmed on hardware.

speculative

A guess. Treat the field as unknown.

Analog I2C access

The library reaches the analog blocks through two ROM functions, rom_chip_i2c_readReg(block, host, reg) and rom_chip_i2c_writeReg(block, host, reg, value), on 8-bit registers. The Zephyr link maps them to the ROM at 0x40035548 and 0x40035818, through the stubs at 0x40005cd0 and 0x40005cdc.

Fact

Evidence

The host argument is ignored. The I2C master is picked from the block id: 0x62, 0x63, 0x64, 0x67 and 0x6b go to master 1, every other block to master 0 (bit mask 0x227 over block - 0x62).

decompile: S3 ROM rom_chip_i2c_writeReg overwrites the host register with the block, rom_chip_i2c_readReg never reads it; S3 phy_i2c.o

The ROM’s own get_i2c_hostid() (0x400354bc) sends only 0x62 to 0x64 to master 1. libphy replaces it at PHY init (g_phyFuns + 0x15c, ram_get_i2c_hostid()), which adds 0x67 and 0x6b.

decompile: S3 phy_init.o

Host values in code are therefore cosmetic and inconsistent: the library passes 0 for BBTOP and 1 for the RF PLL and the LO selector; upstream’s docs say host 1 for BBTOP on every chip while its S3 code passes 0; the HAL headers give hosts that do not match the routing (I2C_BBPLL_HOSTID 1 for a master 0 block).

lib/esp_sdr/src/esp_sdr_priv.h:61; modules/lib/esp-sdr/docs/rx-controls.md:43; modules/lib/esp-sdr/main/targets/esp32s3/receiver.c:76; modules/hal/espressif/components/soc/esp32s3/include/soc/regi2c_bbpll.h:18-19

The ROM functions take no lock (rom_enter_critical_phy and rom_exit_critical_phy, 0x40055bb8 and 0x40055bc0, are empty). libphy’s ram_chip_i2c_* call phy_i2c_enter_critical(), which the HAL maps to regi2c_enter_critical(). The Wi-Fi PHY may run a sequence on the other core at any time, so the library takes regi2c_enter_critical() around every rom_chip_i2c_* sequence.

decompile: S3 phy_i2c.o; modules/hal/espressif/components/esp_phy/src/phy_override.c:50-58; working: lib/esp_sdr/src/esp_sdr_priv.h:175-180, hardware-quirks.md

An access costs tens of microseconds, so the library writes filter codes at front-end setup, not around every capture.

measured: lib/esp_sdr/src/esp_sdr_rx.c:110-114

The I2C masters themselves are memory-mapped (I2C_ANA_MST below).

Block map

Register counts are the loop bounds of the vendor’s debug dump phy_i2c_check(), names its strings. The HAL defines fields only for 0x61, 0x66, 0x69 and 0x6d.

Block

Vendor name and registers

Used by esp_sdr

0x61

i2c_ulp, 9

no

0x62

i2c_rfpll, 13 (0 to 12)

VCO status

0x63

i2c_rfpll_sdm, 6

no (read on the bench)

0x64

i2c_rfrx, 11

no

0x65

i2c_ckgen, 9

LO divider

0x66

i2c_bbpll, 11

no

0x67

i2c_bbtop, 57

RX and TX filter codes

0x69

i2c_sar, 8

no

0x6a

i2c_bias, 8

no

0x6b

i2c_txrf, 12

no

0x6d

i2c_dig_reg, 15

no

Evidence: decompile S3 phy_debug.o (phy_i2c_check).

Register summary

Name

Description

Address

Access

Dump and DAC engine

DUMP_CTRL_REG

Receive side: run, trigger and count of a capture

0x60033d5c

varies

DUMP_WRITE_INDEX_REG

Words written by a capture; the live write index in circular mode

0x60033d60

RO

DUMP_DAC_TRIG_REG

Transmit side: run, trigger and count of a playback

0x60033d64

varies

DUMP_CONFIG_REG

Lane selectors: the sources packed into a dump word

0x60033d90

R/W

SRAM bank ownership

SENSITIVE_INTERNAL_SRAM_USAGE_3_REG

Hands SRAM banks to the dump engine

0x600c101c

R/W

AGC force

AGC_FORCE_REG

Forced receive gain index, force bit and largest calibrated index

0x6001c02c

R/W

AGC_R6C_REG

AGC RSSI; lead, not tried

0x6001c06c

RO

AGC_R80_REG

Unknown; bits 7:6 cleared by bt_rx_force(0)

0x6001c080

R/W

Front end

FE_TX_CTRL_REG

Force TX on and the TX gain memory index

0x60006000

R/W

FE_R100_REG

Unknown; bits 18:16 listed by upstream’s filter probe

0x60006100

RO

FE_R110_REG

Unknown; written by force_txon_mode() and bt_rx_force(0)

0x60006110

R/W

FE_IQ_EST_CONF0_REG

IQ estimator configuration; lead, not tried

0x60006140

R/W

FE_IQ_EST_CONF1_REG

IQ estimator configuration; lead, not tried

0x60006144

R/W

FE_R174_REG

IQ estimator done flag; lead, not tried

0x60006174

RO

Baseband

BB_SPUR_NOTCH0_REG

Spur notch; lead, not tried

0x6001d014

R/W

BB_SPUR_NOTCH1_REG

Spur notch; lead, not tried

0x6001d018

R/W

BB_CCA_BUSY_CNT0_REG, BB_CCA_BUSY_CNT1_REG, BB_CCA_BUSY_CNT2_REG

CCA busy counters; lead, not tried

0x6001d058, 0x6001d05c, 0x6001d060

RO

USB PHY select

RTC_CNTL_USB_CONF_REG

USB PHY select; documented, descriptions empty

0x60008120

R/W

Analog I2C master

I2C_ANA_MST_I2C0_CTRL_REG, I2C_ANA_MST_I2C1_CTRL_REG

Command word, master 0 (I2C0) and master 1 (I2C1)

0x6000e000, 0x6000e004

varies

I2C_ANA_MST_ANA_CONFIG_REG

Readback enable, one bit per block

0x6000e044

R/W

I2C_ANA_MST_ANA_CONFIG2_REG

Master select, one bit per block

0x6000e048

R/W

I2C_ANA_MST_HOP_CTRL_REG

Frequency hop engine control (speculative)

0x6000e0c4

varies

I2C_ANA_MST_R130_REG

Frequency hop engine module reset (speculative)

0x6000e130

R/W

I2C_ANA_MST_HOP_WDATA_REG

Frequency hop engine table write data (speculative)

0x6000e148

R/W

I2C_ANA_MST_R150_REG

Frequency hop engine channel index (speculative)

0x6000e150

R/W

I2C_ANA_MST_R170_REG

Frequency hop engine current channel (speculative)

0x6000e170

RO

Analog I2C block 0x61 (ULP)

ULP_R8

Bit 2 set during the RC time-constant measurement

0x61:8

R/W

Analog I2C block 0x62 (RF PLL)

RFPLL_R0

VCO calibration start

0x62:0

varies

RFPLL_R1

Manual capacitor code, bits 7:0

0x62:1

R/W

RFPLL_R2

4-bit code override and manual capacitor code bit 8

0x62:2

R/W

RFPLL_R5

Capacitor code readback, bits 7:0

0x62:5

RO

RFPLL_R6

Calibrated 4-bit code

0x62:6

RO

RFPLL_R7

Capacitor code bit 8 and calibration done

0x62:7

RO

RFPLL_R11

Capacitor code source

0x62:11

R/W

RFPLL_R12

VCO window comparator and two unknown status bits

0x62:12

RO

Analog I2C block 0x63 (RF PLL fractional-N divider)

RFPLL_SDM_R0

Divider word load (presumed)

0x63:0

R/W

RFPLL_SDM_R3

Integer part above 32

0x63:3

R/W

RFPLL_SDM_R4

Fraction, high byte

0x63:4

R/W

RFPLL_SDM_R5

Fraction, low byte

0x63:5

R/W

Analog I2C block 0x65 (clock generator, LO 5/6 divider)

CKGEN_R0

LO 5/6 divider select

0x65:0

R/W

Analog I2C block 0x66 (BBPLL)

Analog I2C block 0x67 (BBTOP, analog baseband)

BBTOP_R2

Unknown 2-bit field, 1 at init

0x67:2

R/W

BBTOP_R3

Unknown bit, changed by the receive calibration

0x67:3

R/W

BBTOP_R4, BBTOP_R5

Receive low-pass capacitor code, I (BBTOP_R4) and Q (BBTOP_R5)

0x67:4, 0x67:5

R/W

BBTOP_R6, BBTOP_R7

Second capacitor pair, unknown

0x67:6, 0x67:7

R/W

BBTOP_R12, BBTOP_R13

Transmit low-pass pair A (0x0c, 0x0d)

0x67:12, 0x67:13

R/W

BBTOP_R14, BBTOP_R15

Transmit low-pass pair B (0x0e, 0x0f)

0x67:14, 0x67:15

R/W

BBTOP_R20, BBTOP_R21, BBTOP_R22, BBTOP_R23

Unknown pairs (0x14 to 0x17), written from PHY parameters at init

0x67:20, 0x67:21, 0x67:22, 0x67:23

R/W

BBTOP_R28, BBTOP_R29, BBTOP_R30, BBTOP_R31

Unknown pairs (0x1c to 0x1f), written from PHY parameters at init

0x67:28, 0x67:29, 0x67:30, 0x67:31

R/W

Analog I2C block 0x6a (bias): RC calibration

BIAS_R2

RC measurement setting in bits 6:5

0x6a:2

R/W

BIAS_R4

RC measurement mode and control bits

0x6a:4

varies

BIAS_R5

RC measurement result

0x6a:5

RO

BIAS_R6

RC measurement setting in bits 4:0

0x6a:6

R/W

Registers

Dump and DAC engine

The Wi-Fi MAC’s debug dump engine moves raw baseband words between the radio and one 64 KiB SRAM bank (see “SRAM bank ownership”). Its page, 0x60033000, has no name in the HAL. Layout from upstream esp-sdr; the DAC side from librftest’s dactrig().

Wi-Fi MAC interrupt. CPU interrupt line 0 (ETS_WMAC_INUM, modules/hal/espressif/components/soc/esp32s3/include/soc/soc.h:212); esp_sdr_ring_run() disables it on its CPU (hardware-quirks.md, “Capture and DAC engine”; lib/esp_sdr/src/esp_sdr_ring.c:462-491).

Register DUMP_CTRL_REG (0x60033d5c)

Receive side: run, trigger and count of a capture

Bits

31

30:28

27:20

19

18

17

16

15

14:0

Field

DUMP_RUN

DUMP_TRIG_SEL

DUMP_SRC

DUMP_TRIGGER

DUMP_DONE

DUMP_CIRCULAR

DUMP_RATE_16M

DUMP_RATE_40M

DUMP_COUNT

DUMP_RUN Enables the engine. (R/W)

DUMP_TRIG_SEL The vendor adctrig()’s second argument: 0 = software trigger (DUMP_TRIGGER); 5 and 6 make it send frames while it waits. Seen 0. The trigger select could take work off the CPU; lead, not tried. (R/W)

DUMP_SRC The vendor’s third argument; upstream calls it the source. Seen 0. (R/W)

DUMP_TRIGGER Write 1, then 0, to start a one-shot capture. (WO)

DUMP_DONE Reads 1 once DUMP_COUNT words are written; the library gives up after 20 ms. (RO)

DUMP_CIRCULAR With DUMP_RUN and no trigger, the engine writes continuously round a 16384-pair ring (0x00024000 = this bit plus count 16384). In adctrig() the same bit skips the final count check. (R/W)

DUMP_RATE_16M Separate 16 MS/s path: 4096 samples in 256 us. adctrig() sets it unless bit 0 of its fourth argument is set. (R/W)

DUMP_RATE_40M 80 MS/s halved, through the radio’s decimation filter (each symbol spread over about +-8 samples, same MER as 80 MS/s): 4096 samples in 102 us. (R/W)

DUMP_COUNT Words to capture, 256 to 16380. The library uses bits 13:0 (at most 16380, ESP_SDR_SAMPLES_MAX); adctrig() writes count + 1 and checks a 15-bit written count, and the ring writes 16384 (bit 14), so the field is probably 15 bits wide. (R/W)

With neither DUMP_RATE_16M nor DUMP_RATE_40M the engine captures at 80 MS/s (4096 samples in 51 to 60 us). One-shot sequence: DUMP_CTRL_REG = 0, DUMP_CONFIG_REG, bank select, DUMP_RUN | rate | count, the same with DUMP_TRIGGER, the same without, poll DUMP_DONE, DUMP_CTRL_REG = 0, restore the bank select. The engine can stop early without any error flag (hardware-quirks.md, “Capture and DAC engine”).

Evidence:

  • lib/esp_sdr/src/esp_sdr_rx.c:246-326 (sequence), lib/esp_sdr/src/esp_sdr_rx.c:287-293, 321-323 (early stop)

  • DUMP_RUN: working lib/esp_sdr/src/esp_sdr_rx.c:300-310

  • DUMP_TRIG_SEL: decompile S3 mac_common.o

  • DUMP_TRIG_SEL: decompile S3 libphy.a (lead)

  • DUMP_SRC: decompile S3 mac_common.o; upstream modules/lib/esp-sdr/main/targets/esp32s3/receiver.c:164

  • DUMP_TRIGGER: working lib/esp_sdr/src/esp_sdr_rx.c:308-310

  • DUMP_DONE: working lib/esp_sdr/src/esp_sdr_rx.c:311-314

  • DUMP_CIRCULAR: working lib/esp_sdr/src/esp_sdr_ring.c:41-42, 876-881; decompile S3 mac_common.o

  • DUMP_RATE_16M: measured apps/capture/README.rst:56; upstream (USRP B210) modules/lib/esp-sdr/main/targets/esp32s3/receiver.c:161-163; decompile S3 mac_common.o

  • DUMP_RATE_40M: measured apps/capture/README.rst:53, hardware-quirks.md, “Receiving at 40 MS/s”

  • DUMP_COUNT: working lib/esp_sdr/src/esp_sdr_priv.h:33; decompile S3 mac_common.o

Register DUMP_WRITE_INDEX_REG (0x60033d60)

Words written by a capture; the live write index in circular mode

Bits

31:15

14:0

Field

(unknown)

DUMP_WRITE_INDEX

DUMP_WRITE_INDEX Words written, a 15-bit value: after DUMP_DONE, adctrig() compares it with count + 1. In circular mode bits 13:0 are the live write index, mod 16384, advancing whichever bank is selected; the ring times its bank switches on it. (RO)

No read pointer is known for the DAC side; loop playback runs open loop on CCOUNT.

Evidence:

  • measured lib/esp_sdr/src/esp_sdr_priv.h:236-244 (no DAC read pointer)

  • DUMP_WRITE_INDEX: decompile S3 mac_common.o (words written)

  • DUMP_WRITE_INDEX: working lib/esp_sdr/src/esp_sdr_ring.c:40, 116-119; upstream modules/lib/esp-sdr/main/common/ring_capture.c:112 (circular mode)

Register DUMP_DAC_TRIG_REG (0x60033d64)

Transmit side: run, trigger and count of a playback

Bits

31

30:28

27:20

19

18

17

16

15

14

13:0

Field

DUMP_DAC_RUN

(unknown)

DUMP_B27_20

DUMP_DAC_TRIGGER

DUMP_DAC_DONE

(unknown)

DUMP_B16

DUMP_DAC_RATE_80M

(unknown)

DUMP_DAC_COUNT

DUMP_DAC_RUN Run bit; dactrig() sets it. (R/W)

DUMP_B27_20 dactrig() writes count & 0xff here; no effect on the rate. (R/W)

DUMP_DAC_TRIGGER Pulsed: one pass. Held: the engine replays the first DUMP_DAC_COUNT words back to back, gapless, until the register is cleared. A retrigger leaves a hole of about 1.35 us. (R/W)

DUMP_DAC_DONE Done flag; dactrig() polls it. (RO)

DUMP_B16 No effect on the rate: there is no 16 MS/s DAC rate. (R/W)

DUMP_DAC_RATE_80M The DAC has its own clock: 1 = 80 MS/s, 0 = 40 MS/s. 40 MS/s gives about 4 dB less MER. (R/W)

DUMP_DAC_COUNT Words to play, 256 to 16380. (R/W)

8 bytes above DUMP_CTRL_REG.

  • Writing 0 stops the engine at once (esp_sdr_dac_halt()).

  • In loop mode the DAC clock is exact (CPU stores to other banks do not slow it) and the engine reads whichever bank is selected at the moment, so a bank switch takes effect mid-pass.

  • The vendor’s dactrig(count, a2, a3, ...) fills bank 2 (0x3fcd0000) with a ramp, writes count to DUMP_DAC_COUNT and bits 27:20, sets DUMP_DAC_RATE_80M unless a2 is 1, sets DUMP_DAC_TRIGGER (and leaves it set) only if a3 is not 0, sets DUMP_DAC_RUN, polls its own DUMP_DAC_DONE, waits 10 ms and clears DUMP_CTRL_REG (0x60033d5c), not itself. It never writes the bank select. The library skips the ramp so the caller’s samples survive, and clears DUMP_CTRL_REG after every transmit session as well.

  • The front end must be forced to transmit, or only a spur near -6 MHz leaks (FE_FORCE_TXON, see “Front end”).

Evidence:

  • measured lib/esp_sdr/src/esp_sdr_priv.h:236-244 (loop mode)

  • decompile S3 mac_common.o (dactrig())

  • lib/esp_sdr/src/esp_sdr_tx.c:222-223, 311-312 (the library skips the ramp, clears DUMP_CTRL_REG)

  • DUMP_DAC_RUN: working lib/esp_sdr/src/esp_sdr_tx.c:146-156

  • DUMP_B27_20: measured lib/esp_sdr/src/esp_sdr_priv.h:46-48; decompile S3 mac_common.o

  • DUMP_DAC_TRIGGER: measured lib/esp_sdr/src/esp_sdr_tx.c:197-199, 292-302, lib/esp_sdr/src/esp_sdr_priv.h:236-244

  • DUMP_DAC_DONE: working lib/esp_sdr/src/esp_sdr_tx.c:158-167

  • DUMP_B16: measured lib/esp_sdr/src/esp_sdr_priv.h:46-48

  • DUMP_DAC_RATE_80M: measured lib/esp_sdr/src/esp_sdr_priv.h:46-49, hardware-quirks.md

  • DUMP_DAC_COUNT: working lib/esp_sdr/src/esp_sdr_tx.c:148-149

Register DUMP_CONFIG_REG (0x60033d90)

Lane selectors: the sources packed into a dump word

Bits

31:25

24

23:18

17:12

11:6

5:0

Field

(unknown)

DUMP_RX_AGC

DUMP_LANE3

DUMP_LANE2

DUMP_LANE1

DUMP_LANE0

DUMP_RX_AGC Upstream probe flag (rx_agc). Seen 0. (R/W)

DUMP_LANE3 Source of lane 3; 3 for receive I/Q. (R/W)

DUMP_LANE2 Source of lane 2; 2 for receive I/Q. (R/W)

DUMP_LANE1 Source of lane 1; 1 for receive I/Q. (R/W)

DUMP_LANE0 Source of lane 0; 0 for receive I/Q. (R/W)

Four 6-bit lane selectors (lane n in bits 6n+5:6n) choose the internal sources packed into a dump word; 0x000c2040 selects sources 0, 1, 2, 3: receive I/Q. librftest’s mac_init() writes 0x000c2040 here once; the library writes it before every capture and ring run. The reading as lane selectors is upstream’s.

Only sources 0 to 3 are ever used. The others could take work off the CPU; lead, not tried.

Evidence:

  • working lib/esp_sdr/src/esp_sdr_priv.h:34-36; upstream modules/lib/esp-sdr/main/targets/esp32s3/receiver.c:159 (lane selectors)

  • decompile S3 mac_common.o (mac_init())

  • decompile S3 libphy.a (lead)

  • DUMP_RX_AGC: upstream modules/lib/esp-sdr/main/targets/esp32s3/receiver.c:159

SRAM bank ownership

Register SENSITIVE_INTERNAL_SRAM_USAGE_3_REG (0x600c101c)

Hands SRAM banks to the dump engine

Bits

31:4

3:0

Field

(unknown)

SENSITIVE_INTERNAL_SRAM_MAC_DUMP_USAGE

SENSITIVE_INTERNAL_SRAM_MAC_DUMP_USAGE One-hot: bit n hands bank n to the engine, for capture and playback alike; 0 gives every bank back to the CPUs. The HAL defines the field without a description. (R/W)

The banks by field bit:

Bit

Bank

Use in esp_sdr

0

0x3fcb0000, 64 KiB

ring (CONFIG_ESP_SDR_RING)

1

0x3fcc0000

second DAC and capture bank (CONFIG_ESP_SDR_BANK1), ring

2

0x3fcd0000

capture bank; the one adctrig() and dactrig() use

3

0x3fce0000

never: it holds ROM data

The library saves the register and restores it after each use. Undocumented behaviour:

  • While the engine owns a bank the CPUs cannot reach any of it, so nothing else may live there; the linker snippets reserve the banks and lower _heap_sentry.

  • CPU stores reach the DAC only if made while the CPU owns the bank; otherwise the DAC replays the old content.

  • The engine follows a change of the field while it runs, in both directions: the ring splits one continuous capture into gapless units, the DAC switches banks mid-pass.

  • Bank 3 holds ROM data (0x3fceffxx); handing it to the engine wedges the board, USB and shell die.

Evidence:

  • lib/esp_sdr/src/esp_sdr_rx.c:246-278, lib/esp_sdr/src/esp_sdr.c:123-133 (save and restore)

  • lib/esp_sdr/esp_sdr_bank_ring.ld:4-14 (reserved banks)

  • measured lib/esp_sdr/src/esp_sdr_tx.c:392-396 (CPU stores and the DAC)

  • measured hardware-quirks.md, “Capture and DAC engine”; lib/esp_sdr/src/esp_sdr_priv.h:236-244 (a change while running)

  • measured bench 2026-10-05, lib/esp_sdr/esp_sdr_bank.ld; upstream modules/lib/esp-sdr/main/common/ring_capture.h:26 (bank 3)

  • SENSITIVE_INTERNAL_SRAM_MAC_DUMP_USAGE: modules/hal/espressif/components/soc/esp32s3/register/soc/sensitive_reg.h:101-107

AGC force

Register AGC_FORCE_REG (0x6001c02c)

Forced receive gain index, force bit and largest calibrated index

Bits

31:24

23

22:15

14:8

7:0

Field

AGC_FORCE_INDEX

AGC_FORCE_EN

(unknown)

AGC_GAIN_MAX

(unknown)

AGC_FORCE_INDEX Forced receive gain index. (R/W)

AGC_FORCE_EN Force: 1 holds the index, 0 hands the gain to the AGC. (R/W)

AGC_GAIN_MAX Largest calibrated gain index, 0 to 82, written by the PHY when it builds the gain table (capped at 82). Larger reads point at uncalibrated slots; the library treats them as 0. (R/W)

Upstream calls the register AGCPWR_CTRL7. The PHY’s rom_agc_reg_init() pulses AGC_FORCE_EN (bit 23) with index 0x32. The ESP32-C6 has the same layout. Levels with a 50 ohm load: AGC -25.7, index 0 -46.0, 40 -39.0, 60 -27.0, 82 -19.9 dBFS.

  • force_rx_gain(force, index, 0) also calls bt_rx_force(0), which clears AGC_R80_REG bits 7:6 and steps FE_R110_REG bits 9:8 through 2, 3 and 0.

  • A forced gain is occasionally lost; this register does not show it (hardware-quirks.md, “The forced receive gain gets lost”).

  • A new forced index alone can leave stale receive state, so a gain change reruns the front-end setup.

Evidence:

  • decompile S3 phy_reg.o (rom_agc_reg_init())

  • measured bench 2026-10-05 (levels)

  • decompile S3 phy_test.o (bt_rx_force(0))

  • upstream modules/lib/esp-sdr/main/targets/esp32s3/receiver.c:107-113; lib/esp_sdr/src/esp_sdr_rx.c:164-166 (gain change)

  • AGC_FORCE_INDEX: decompile S3 phy_test.o; working

  • AGC_FORCE_EN: decompile S3 phy_test.o; working

  • AGC_GAIN_MAX: working lib/esp_sdr/src/esp_sdr_rx.c:29-34; decompile S3 phy_rx_gain.o; upstream modules/lib/esp-sdr/main/common/burst_gain.h:1-4, 29-37

Register AGC_R6C_REG (0x6001c06c)

AGC RSSI; lead, not tried

Bits

31:16

15:8

7:0

Field

(unknown)

AGC_RSSI

(unknown)

AGC_RSSI AGC RSSI. With the CCA busy counters (BB_CCA_BUSY_CNT0_REG to BB_CCA_BUSY_CNT2_REG) a candidate for 802.15.4 energy detection and CCA, work it could take off the CPU; lead, not tried. (RO)

Evidence:

  • AGC_RSSI: decompile S3 libphy.a

Register AGC_R80_REG (0x6001c080)

Unknown; bits 7:6 cleared by bt_rx_force(0)

Bits

31:8

7:6

5:0

Field

(unknown)

AGC_B7_6

(unknown)

AGC_B7_6 Cleared by bt_rx_force(0), which force_rx_gain(force, index, 0) calls (see AGC_FORCE_REG). (R/W)

Evidence:

  • AGC_B7_6: decompile S3 phy_test.o

Front end

The FE block, DR_REG_FE_BASE 0x60006000: the HAL has the base, no registers.

Register FE_TX_CTRL_REG (0x60006000)

Force TX on and the TX gain memory index

Bits

31:26

25:18

17:10

9:2

1

0

Field

(unknown)

FE_BASE_INDEX

FE_TX_GAIN_INDEX

(unknown)

FE_FORCE_TXON

(unknown)

FE_BASE_INDEX Base index, read by force_txon_mode() and by the tone generator. (R/W)

FE_TX_GAIN_INDEX TX gain memory index (inferred). force_txon_mode(en, mode, ofs) writes (ofs + (mode ? 16 : 0) + bits[25:18]) & 0xff; the tone generator writes bits[25:18] + 9. (R/W)

FE_FORCE_TXON Force TX on. Without a frame in flight the MAC never enables the transmitter; force_txon_mode(1, ...) sets the bit, (0, ...) clears it, the vendor tone generator does the same. Without it only a spur near -6 MHz leaks; with it a tone stands 35 to 37 dB over the TX-off noise. (R/W)

The library calls force_txon_mode(1, 0, 0) and force_tx_gain(), which writes gain memory slot 0. That this is the slot the front end then uses holds only while FE_BASE_INDEX reads 0; the code does not check it.

Evidence:

  • lib/esp_sdr/src/esp_sdr_priv.h:164-169 (slot 0)

  • FE_BASE_INDEX: decompile

  • FE_TX_GAIN_INDEX: decompile S3 phy_test.o, phy_feature.o

  • FE_FORCE_TXON: measured bench 2026-10-05; working lib/esp_sdr/src/esp_sdr_tx.c:84-87, lib/esp_sdr/src/esp_sdr_rx.c:84-86; decompile S3 phy_test.o, phy_feature.o

Register FE_R100_REG (0x60006100)

Unknown; bits 18:16 listed by upstream’s filter probe

Bits

31:19

18:16

15:0

Field

(unknown)

FE_B18_16

(unknown)

FE_B18_16 Unknown; listed by upstream’s filter probe. Could not be written from the shell (devmem). (RO)

Evidence:

  • FE_B18_16: measured bench 2026-10-06; speculative modules/lib/esp-sdr/main/diagnostics/filter_probe.h:19-20

Register FE_R110_REG (0x60006110)

Unknown; written by force_txon_mode() and bt_rx_force(0)

Bits

31:14

13:12

11:10

9:8

7:0

Field

(unknown)

FE_B13_12

(unknown)

FE_B9_8

(unknown)

FE_B13_12 3 when force_txon_mode()’s mode is not 0, else 0. (R/W)

FE_B9_8 Stepped 0x200, 0x300, 0 (2, 3 and 0 in the field) by bt_rx_force(0), which force_rx_gain(force, index, 0) calls (see AGC_FORCE_REG). (R/W)

Evidence:

  • FE_B13_12: decompile S3 phy_test.o

  • FE_B9_8: decompile S3 phy_test.o

Register FE_IQ_EST_CONF0_REG (0x60006140)

IQ estimator configuration; lead, not tried

Bits

31:0

Field

(unknown)

The vendor’s ram_iq_est_enable() configures the IQ estimator in FE_IQ_EST_CONF0_REG and FE_IQ_EST_CONF1_REG (count up to 0x7fff); its done flag is FE_IQ_EST_DONE. It gives power, DC and a quadrature correlation against the transmit tone generator, work it could take off the CPU.

Evidence:

  • decompile S3 libphy.a

Register FE_IQ_EST_CONF1_REG (0x60006144)

IQ estimator configuration; lead, not tried

Bits

31:0

Field

(unknown)

Configured together with FE_IQ_EST_CONF0_REG; see there.

Evidence:

  • decompile S3 libphy.a

Register FE_R174_REG (0x60006174)

IQ estimator done flag; lead, not tried

Bits

31:17

16

15:0

Field

(unknown)

FE_IQ_EST_DONE

(unknown)

FE_IQ_EST_DONE Done flag of the IQ estimator (see FE_IQ_EST_CONF0_REG). (RO)

Evidence:

  • FE_IQ_EST_DONE: decompile S3 libphy.a

Baseband

Leads, none tried: a spur notch and CCA busy counters that could take work off the CPU.

Register BB_SPUR_NOTCH0_REG (0x6001d014)

Spur notch; lead, not tried

Bits

31:0

Field

(unknown)

The spur notch is at BB_SPUR_NOTCH0_REG and BB_SPUR_NOTCH1_REG.

Evidence:

  • decompile S3 libphy.a

Register BB_SPUR_NOTCH1_REG (0x6001d018)

Spur notch; lead, not tried

Bits

31:0

Field

(unknown)

See BB_SPUR_NOTCH0_REG.

Evidence:

  • decompile S3 libphy.a

Register BB_CCA_BUSY_CNT0_REG, BB_CCA_BUSY_CNT1_REG, BB_CCA_BUSY_CNT2_REG (0x6001d058, 0x6001d05c, 0x6001d060)

CCA busy counters; lead, not tried

Bits

31:0

Field

(unknown)

With AGC_RSSI, candidates for 802.15.4 energy detection and CCA.

Evidence:

  • decompile S3 libphy.a

USB PHY select

Register RTC_CNTL_USB_CONF_REG (0x60008120)

USB PHY select; documented, descriptions empty

Bits

31:21

20

19

18:0

Field

(unknown)

RTC_CNTL_SW_HW_USB_PHY_SEL

RTC_CNTL_SW_USB_PHY_SEL

(documented)

RTC_CNTL_SW_HW_USB_PHY_SEL 1: software drives the USB PHY mux. (R/W)

RTC_CNTL_SW_USB_PHY_SEL 1: internal PHY to USB OTG. 0: to USB-Serial-JTAG. (R/W)

The OTG driver sets both fields and they survive a software reset; the consequence is in hardware-quirks.md, “Building and flashing”.

Evidence:

  • modules/hal/espressif/components/soc/esp32s3/register/soc/rtc_cntl_reg.h:3071-3083; mapping in modules/hal/espressif/components/esp_hal_usb/esp32s3/include/hal/usb_wrap_ll.h:55-66

  • measured, bench 2026-10-09 (survive a software reset)

Analog I2C master

Base 0x6000e000. The HAL defines only 0x6000e040 (I2C_MST_ANA_CONF0_REG), bits 17 and 18 of 0x6000e044 (ANA_CONFIG_REG) and bit 16 of 0x6000e048 (ANA_CONFIG2_REG) (modules/hal/espressif/components/soc/esp32s3/include/soc/regi2c_defs.h:12-26). The names I2C0_CTRL and I2C1_CTRL are the ESP32-C6 HAL’s for its command registers.

Frequency hop engine (speculative field meanings, decompile flow). The S3 PHY can tune Wi-Fi channels with a hardware I2C sequencer in this block instead of a software calibration. set_channel_rfpll_freq() takes this path when phy_param + 0x120 bit 5 is set, which get_rf_freq_init() does at PHY init after filling an 85-entry table (2400 to 2484 MHz, capacitor codes interpolated between calibrations at 2400 and 2464 MHz); otherwise it calls set_rf_freq_offset(). So set_chanfreq() on a channel frequency probably loads a table entry without a fresh VCO calibration (S3 phy_rfpll.o, phy_hw_freq.o). Its registers are I2C_ANA_MST_HOP_CTRL_REG, I2C_ANA_MST_R130_REG, I2C_ANA_MST_HOP_WDATA_REG, I2C_ANA_MST_R150_REG and I2C_ANA_MST_R170_REG.

Register I2C_ANA_MST_I2C0_CTRL_REG, I2C_ANA_MST_I2C1_CTRL_REG (0x6000e000, 0x6000e004)

Command word, master 0 (I2C0) and master 1 (I2C1)

Bits

31:27

26

25

24

23:16

15:8

7:0

Field

(unknown)

I2C_ANA_MST_B26

I2C_ANA_MST_BUSY

I2C_ANA_MST_WRITE

I2C_ANA_MST_DATA

I2C_ANA_MST_REG_ADDR

I2C_ANA_MST_BLOCK

I2C_ANA_MST_B26 Unknown; always set. (R/W)

I2C_ANA_MST_BUSY Busy, polled after each command. (RO)

I2C_ANA_MST_WRITE Set for a write. (R/W)

I2C_ANA_MST_DATA Data to write, or the read result. (R/W)

I2C_ANA_MST_REG_ADDR Register. (R/W)

I2C_ANA_MST_BLOCK Block. (R/W)

One command word per access, then poll I2C_ANA_MST_BUSY: block, register and bit 26 for a read (0x04000000 base), plus data and I2C_ANA_MST_WRITE for a write (0x05000000 base). The result is in bits 23:16.

Evidence:

  • decompile S3 ROM rom_chip_i2c_readReg_org (0x400354fc), rom_chip_i2c_writeReg; S3 phy_i2c.o

Register I2C_ANA_MST_ANA_CONFIG_REG (0x6000e044)

Readback enable, one bit per block

Bits

31:23

22

21:19

18

17

16

15

14

13

12

11

10

9

8

7:0

Field

(unknown)

I2C_ANA_MST_ULP

(unknown)

I2C_ANA_MST_SAR

I2C_ANA_MST_BBPLL

I2C_ANA_MST_CKGEN

I2C_ANA_MST_BLK68

I2C_ANA_MST_DIG

I2C_ANA_MST_BIAS

I2C_ANA_MST_TXRF

I2C_ANA_MST_RFPLL

I2C_ANA_MST_BBTOP

I2C_ANA_MST_RFPLL_SDM

I2C_ANA_MST_RFRX

(unknown)

I2C_ANA_MST_ULP Block 0x61 (i2c_ulp); clear = readback enabled. (R/W)

I2C_ANA_MST_SAR Block 0x69 (i2c_sar); clear = readback enabled. (R/W)

I2C_ANA_MST_BBPLL Block 0x66 (i2c_bbpll); clear = readback enabled. (R/W)

I2C_ANA_MST_CKGEN Block 0x65 (i2c_ckgen); clear = readback enabled. (R/W)

I2C_ANA_MST_BLK68 Block 0x68; clear = readback enabled. (R/W)

I2C_ANA_MST_DIG Block 0x6d (i2c_dig_reg); clear = readback enabled. (R/W)

I2C_ANA_MST_BIAS Block 0x6a (i2c_bias); clear = readback enabled. (R/W)

I2C_ANA_MST_TXRF Block 0x6b (i2c_txrf); clear = readback enabled. (R/W)

I2C_ANA_MST_RFPLL Block 0x62 (i2c_rfpll); clear = readback enabled. (R/W)

I2C_ANA_MST_BBTOP Block 0x67 (i2c_bbtop); clear = readback enabled. (R/W)

I2C_ANA_MST_RFPLL_SDM Block 0x63 (i2c_rfpll_sdm); clear = readback enabled. (R/W)

I2C_ANA_MST_RFRX Block 0x64 (i2c_rfrx); clear = readback enabled. (R/W)

The HAL’s ANA_CONFIG_REG. Before a read the ROM writes the inverse of the block’s bit (clear = readback enabled).

Evidence:

  • decompile ROM table at 0x3ff193dc (rom_get_i2c_read_mask), rom_chip_i2c_readReg_org

Register I2C_ANA_MST_ANA_CONFIG2_REG (0x6000e048)

Master select, one bit per block

Bits

31:17

16

15

14

13

12

11

10

9

8

7

6

5

4

3:0

Field

(unknown)

I2C_ANA_MST_SAR

I2C_ANA_MST_ULP

I2C_ANA_MST_BBPLL

I2C_ANA_MST_CKGEN

(unknown)

I2C_ANA_MST_BLK68

I2C_ANA_MST_DIG

I2C_ANA_MST_BIAS

I2C_ANA_MST_TXRF

I2C_ANA_MST_RFPLL

I2C_ANA_MST_BBTOP

I2C_ANA_MST_RFPLL_SDM

I2C_ANA_MST_RFRX

(unknown)

I2C_ANA_MST_SAR Block 0x69 (i2c_sar); 1 = master 0, 0 = master 1. (R/W)

I2C_ANA_MST_ULP Block 0x61 (i2c_ulp); 1 = master 0, 0 = master 1. (R/W)

I2C_ANA_MST_BBPLL Block 0x66 (i2c_bbpll); 1 = master 0, 0 = master 1. (R/W)

I2C_ANA_MST_CKGEN Block 0x65 (i2c_ckgen); 1 = master 0, 0 = master 1. (R/W)

I2C_ANA_MST_BLK68 Block 0x68; 1 = master 0, 0 = master 1. (R/W)

I2C_ANA_MST_DIG Block 0x6d (i2c_dig_reg); 1 = master 0, 0 = master 1. (R/W)

I2C_ANA_MST_BIAS Block 0x6a (i2c_bias); 1 = master 0, 0 = master 1. (R/W)

I2C_ANA_MST_TXRF Block 0x6b (i2c_txrf); 1 = master 0, 0 = master 1. (R/W)

I2C_ANA_MST_RFPLL Block 0x62 (i2c_rfpll); 1 = master 0, 0 = master 1. (R/W)

I2C_ANA_MST_BBTOP Block 0x67 (i2c_bbtop); 1 = master 0, 0 = master 1. (R/W)

I2C_ANA_MST_RFPLL_SDM Block 0x63 (i2c_rfpll_sdm); 1 = master 0, 0 = master 1. (R/W)

I2C_ANA_MST_RFRX Block 0x64 (i2c_rfrx); 1 = master 0, 0 = master 1. (R/W)

The HAL’s ANA_CONFIG2_REG. Bits 16:4 hold one master bit per block: set = master 0, clear = master 1. The ROM’s get_i2c_hostid() writes bits 16:4 from 0x1ff40 (0x62 to 0x64 on master 1), libphy’s from 0x1fe00 (0x62, 0x63, 0x64, 0x67, 0x6b), on every access.

Evidence:

  • decompile ROM table at 0x3ff193a8 (rom_get_i2c_mst0_mask), rom_get_i2c_hostid (0x400354bc); S3 phy_i2c.o

Register I2C_ANA_MST_HOP_CTRL_REG (0x6000e0c4)

Frequency hop engine control (speculative)

Bits

31:26

25

24

23:20

19:16

15:10

9

8

7:0

Field

(unknown)

I2C_ANA_MST_HOP_OFF

I2C_ANA_MST_HOP_EN

I2C_ANA_MST_HOP_PARAM1

I2C_ANA_MST_HOP_PARAM0

(unknown)

I2C_ANA_MST_HOP_MEM_WR

I2C_ANA_MST_HOP_START

I2C_ANA_MST_HOP_MEM_ADDR

I2C_ANA_MST_HOP_OFF 1 = hardware frequency setting off (phy_dis_hw_set_freq()). (R/W)

I2C_ANA_MST_HOP_EN Enable, set at init. (R/W)

I2C_ANA_MST_HOP_PARAM1 Init parameter, 4 at init. (R/W)

I2C_ANA_MST_HOP_PARAM0 Init parameter, 2 at init. (R/W)

I2C_ANA_MST_HOP_MEM_WR Table memory write strobe. (WO)

I2C_ANA_MST_HOP_START Start pulse. (WO)

I2C_ANA_MST_HOP_MEM_ADDR Table memory address (entry x 3 + word for writes). (R/W)

Evidence:

  • speculative field meanings, decompile flow

Register I2C_ANA_MST_R130_REG (0x6000e130)

Frequency hop engine module reset (speculative)

Bits

31:18

17

16:0

Field

(unknown)

I2C_ANA_MST_HOP_RST_N

(unknown)

I2C_ANA_MST_HOP_RST_N Module reset, active low. (R/W)

Evidence:

  • speculative field meanings, decompile flow

Register I2C_ANA_MST_HOP_WDATA_REG (0x6000e148)

Frequency hop engine table write data (speculative)

Bits

31:0

Field

I2C_ANA_MST_HOP_WDATA

I2C_ANA_MST_HOP_WDATA Table write data: word 0 = 0x62:1 + (0x62:2 image << 8), word 1 = the block 0x63 bytes. (R/W)

Evidence:

  • speculative field meanings, decompile flow

Register I2C_ANA_MST_R150_REG (0x6000e150)

Frequency hop engine channel index (speculative)

Bits

31:28

27:20

19:0

Field

(unknown)

I2C_ANA_MST_HOP_CHAN

(unknown)

I2C_ANA_MST_HOP_CHAN Channel index (MHz - 2400). (R/W)

Evidence:

  • speculative field meanings, decompile flow

Register I2C_ANA_MST_R170_REG (0x6000e170)

Frequency hop engine current channel (speculative)

Bits

31:24

23:17

16:0

Field

(unknown)

I2C_ANA_MST_HOP_CUR_CHAN

(unknown)

I2C_ANA_MST_HOP_CUR_CHAN Current channel, read back. (RO)

Evidence:

  • speculative field meanings, decompile flow

Analog I2C block 0x61 (ULP)

One bit of this block takes part in the RC calibration of block 0x6a.

Register ULP_R8 (0x61:8)

Bit 2 set during the RC time-constant measurement

Bits

7:3

2

1:0

Field

(unknown)

ULP_R8_B2

(unknown)

ULP_R8_B2 Set for the RC time-constant measurement in get_rc_dout(), cleared after (see block 0x6a). (R/W)

Evidence:

  • ULP_R8_B2: decompile S3 phy_analog_cal.o

Analog I2C block 0x62 (RF PLL)

The RF PLL’s VCO and its calibration. The library reads RFPLL_VCO_WINDOW after every tune. The capacitor code is 0x62:5 | 0x62:7[2] << 8 (9 bits; the vendor clamps to 0x1ff); a higher code means a lower VCO frequency.

  • After set_rf_freq_offset() the code is a manual one: rfpll_cap_init_cal() tries about 10 codes on either side of the calibrated one and keeps the average of those that read in-window (decompile S3 phy_rfpll.o).

  • The Wi-Fi PHY’s temperature tracking (rfpll_cap_track(), after a change of 10 temperature units by default) reruns rfpll_cap_correct(), so the code can move after a tune (decompile S3 phy_track.o).

  • Upstream esp-sdr does a manual 512-code search on the ESP32-S2 through the same fields (0x62:1, 0x62:2[4], 0x62:11[6], 0x62:0[7], and 0x62:12[3:2] as the window indication); it does not apply it to the S3 or the C6 (upstream modules/lib/esp-sdr/main/targets/esp32s2/pll.h:8-36).

Register RFPLL_R0 (0x62:0)

VCO calibration start

Bits

7

6

5

4:0

Field

(unknown)

RFPLL_R0_B6

RFPLL_CAL_START

(unknown)

Init

1

0

1

0x8

RFPLL_R0_B6 Unknown; set around the pulse on RFPLL_CAL_START. (R/W)

RFPLL_CAL_START Calibration start: a rising edge starts the VCO calibration. (WO)

Init: 0xa8, written by the vendor’s PHY init

restart_cal() starts the VCO calibration with a rising edge on bit 5 while bit 6 is toggled: it sets bit 6, pulses bit 5, clears bit 6. Bit 6 and the other bits: unknown.

Evidence:

  • decompile S3 phy_rfpll.o, S3 phy_i2c.o

Register RFPLL_R1 (0x62:1)

Manual capacitor code, bits 7:0

Bits

7:0

Field

RFPLL_MANUAL_CODE

RFPLL_MANUAL_CODE write_pll_cap() writes the code here (rf_test prints it as ir_cap_ext). After a tune it holds the low byte of the code in use: 127 with code 383, 208 at 2412 MHz. (R/W)

Evidence:

  • RFPLL_MANUAL_CODE: decompile S3 phy_rfpll.o, rf_test.o; measured bench 2026-10-09

Register RFPLL_R2 (0x62:2)

4-bit code override and manual capacitor code bit 8

Bits

7

6:5

4

3:0

Field

RFPLL_CODE4_OVR

(unknown)

RFPLL_MANUAL_CODE_B8

RFPLL_CODE4_VAL

Init

1

0x0

0

0x8

RFPLL_CODE4_OVR 4-bit code override: cleared before and set after the copy into RFPLL_CODE4_VAL. (R/W)

RFPLL_MANUAL_CODE_B8 Manual capacitor code, bit 8. Written with RFPLL_MANUAL_CODE. (R/W)

RFPLL_CODE4_VAL The 4-bit code, copied from RFPLL_CODE4_CAL. (R/W)

Init: 0x88, written by the vendor’s PHY init

get_rf_freq_init() clears RFPLL_CODE4_OVR, calibrates at 2437 MHz, copies RFPLL_CODE4_CAL into RFPLL_CODE4_VAL and sets RFPLL_CODE4_OVR. What the 4-bit code controls is unknown.

Evidence:

  • decompile S3 phy_hw_freq.o, phy_i2c.o

  • RFPLL_MANUAL_CODE_B8: decompile S3 phy_rfpll.o

Register RFPLL_R5 (0x62:5)

Capacitor code readback, bits 7:0

Bits

7:0

Field

RFPLL_CAP_CODE

RFPLL_CAP_CODE Capacitor code readback, bits 7:0. Values: see “Measured PLL behaviour”. (RO)

Evidence:

  • RFPLL_CAP_CODE: decompile S3 phy_rfpll.o; measured

Register RFPLL_R6 (0x62:6)

Calibrated 4-bit code

Bits

7:4

3:0

Field

(unknown)

RFPLL_CODE4_CAL

RFPLL_CODE4_CAL Copied into RFPLL_CODE4_VAL by the vendor. (RO)

Evidence:

  • RFPLL_CODE4_CAL: decompile S3 phy_hw_freq.o, phy_i2c.o (as RFPLL_R2)

Register RFPLL_R7 (0x62:7)

Capacitor code bit 8 and calibration done

Bits

7:3

2

1

0

Field

(unknown)

RFPLL_CAP_CODE_B8

RFPLL_CAL_DONE

RFPLL_R7_B0

RFPLL_CAP_CODE_B8 Capacitor code readback, bit 8. (RO)

RFPLL_CAL_DONE Calibration done; 1 after every tune. wait_rfpll_cal_end() polls it every 20 us, 100 times, then prints error: pll_cal exceeds 2ms!!!. (RO)

RFPLL_R7_B0 Unknown; 1 in every read (RFPLL_R7 = 0x03 or 0x07). (RO)

Evidence:

  • RFPLL_CAP_CODE_B8: decompile S3 phy_rfpll.o; measured

  • RFPLL_CAL_DONE: decompile S3 phy_rfpll.o; measured

  • RFPLL_R7_B0: measured bench 2026-10-09

Register RFPLL_R11 (0x62:11)

Capacitor code source

Bits

7

6

5:0

Field

(unknown)

RFPLL_CODE_SRC

(unknown)

Init

0

1

0x8

RFPLL_CODE_SRC Cleared before each software calibration (set_rfpll_freq()), set by rfpll_cap_init_cal() before it tries manual codes. Inferred: 0 = code from the calibration engine, 1 = code from RFPLL_MANUAL_CODE and RFPLL_MANUAL_CODE_B8. (R/W)

Init: 0x48, written by the vendor’s PHY init

Evidence:

  • RFPLL_CODE_SRC: decompile S3 phy_rfpll.o

Register RFPLL_R12 (0x62:12)

VCO window comparator and two unknown status bits

Bits

7:4

3:2

1

0

Field

(unknown)

RFPLL_VCO_WINDOW

RFPLL_R12_B1

RFPLL_R12_B0

Init

0x0

0x0

0

0

RFPLL_VCO_WINDOW After calibration: 0 = the VCO inside its tuning window; 1 = target below the window (code pinned at its maximum); 2 = target above it (code 0). Live for the current code: the vendor’s rfpll_cap_correct() moves the code in steps of 2, up while it reads 1 and down while it reads 2. esp_sdr_set_freq() and esp_sdr_set_freq_offset() return -ERANGE when it is not 0. Window (PLL): 2193 to 2781 MHz. (RO)

RFPLL_R12_B1 Unknown; set from about 2700 MHz (PLL) up. (RO)

RFPLL_R12_B0 Unknown; always 0. (RO)

Init: 0x00, read at 2412 MHz (see “Measured PLL behaviour”)

Evidence:

  • RFPLL_VCO_WINDOW: measured bench 2026-10-09; working lib/esp_sdr/src/esp_sdr.c:78-97, 194-196, 213-215; decompile S3 phy_rfpll.o

  • RFPLL_R12_B1: measured bench 2026-10-09

  • RFPLL_R12_B0: measured bench 2026-10-09

Analog I2C block 0x63 (RF PLL fractional-N divider)

The S3 sets the PLL frequency through registers 0 and 3 to 5:

f_PLL = 0.75 * f_xtal * (32 + r3 + r4 / 2^8 + r5 / 2^16)

With the 40 MHz crystal that is 30 MHz per integer step and about 458 Hz per LSB. write_rfpll_sdm() writes reg 0 = 0x07, regs 3 to 5, then reg 0 = 0x17 (bit 4 presumably loads the word).

PLL MHz

reg 3

reg 4

reg 5

Decoded

2412

0x30

0x66

0x66

30 MHz x 80.4

2500

0x33

0x55

0x55

30 MHz x 83.33

2700

0x3a

0x00

0x00

30 MHz x 90

Evidence: decompile S3 phy_rfpll.o (rfpll_set_freq()), S3 phy_rfpll.o (write_rfpll_sdm()); measured bench 2026-10-09: the raw values match the formula exactly.

The crystal selector of the vendor calls picks the divisor, as on the C6: 1 = 26 MHz, 2 = 32 MHz, anything else = 40 MHz (decompile S3 phy_rfpll.o).

Register RFPLL_SDM_R0 (0x63:0)

Divider word load (presumed)

Bits

7:5

4

3:0

Field

(unknown)

RFPLL_SDM_LOAD

(unknown)

RFPLL_SDM_LOAD Presumably loads the word: write_rfpll_sdm() writes the register as 0x07 before registers 3 to 5 and as 0x17 after them. (R/W)

Evidence:

  • RFPLL_SDM_LOAD: decompile S3 phy_rfpll.o (write_rfpll_sdm())

Register RFPLL_SDM_R3 (0x63:3)

Integer part above 32

Bits

7:0

Field

RFPLL_SDM_INTEGER

RFPLL_SDM_INTEGER r3 in the formula above: 30 MHz per step with the 40 MHz crystal. (R/W)

Register RFPLL_SDM_R4 (0x63:4)

Fraction, high byte

Bits

7:0

Field

RFPLL_SDM_FRAC_H

RFPLL_SDM_FRAC_H r4 in the formula above, weight 1 / 2^8. (R/W)

Register RFPLL_SDM_R5 (0x63:5)

Fraction, low byte

Bits

7:0

Field

RFPLL_SDM_FRAC_L

RFPLL_SDM_FRAC_L r5 in the formula above, weight 1 / 2^16: about 458 Hz per LSB with the 40 MHz crystal. (R/W)

Analog I2C block 0x65 (clock generator, LO 5/6 divider)

Register 0 bit 4 (mask 0x10) selects the LO 5/6 divider. The ESP32-C6 has no block 0x65; its selector is in block 0x62.

  • The S3 libphy and librftest objects only read block 0x65 in their debug dump (decompile S3 phy_debug.o).

  • Upstream qualifies the selector on the ESP32, S2, S3, C2, C3 and C6 and warns that the C5, C61, H2 and S31 lay out their analog blocks differently (modules/lib/esp-sdr/docs/rx-controls.md:102-107).

Register CKGEN_R0 (0x65:0)

LO 5/6 divider select

Bits

7:5

4

3:0

Field

(unknown)

CKGEN_LO_DIV

(unknown)

CKGEN_LO_DIV 0: the LO is the PLL. 1: LO = PLL / 1.2 (5/6 conversion). One selector feeds both the receive and the transmit mixer. (R/W)

  • Change only the bit (read-modify-write). Clear it before the vendor tuning call, which calibrates in normal mode; set it after the receive or transmit front-end setup.

  • Without the select on the transmit side, transmissions in the divider band went out at 6/5 of the target.

  • The library uses the divider for every frequency below 2210 MHz (PLL at 1.2 times the LO), upstream only from 1842 MHz.

Evidence:

  • working lib/esp_sdr/src/esp_sdr.c:106-108, lib/esp_sdr/src/esp_sdr_rx.c:92-95, lib/esp_sdr/src/esp_sdr_tx.c:25-26, 77-83 (read-modify-write and order)

  • measured bench 2026-10-09 (6/5 of the target on transmit)

  • lib/esp_sdr/src/esp_sdr.c:65-76, modules/lib/esp-sdr/main/common/rx_lo.h:15-19 (divider band)

  • CKGEN_LO_DIV: upstream modules/lib/esp-sdr/main/common/rx_lo.h:47-62, external-tone tests modules/lib/esp-sdr/docs/rx-controls.md:76-100; measured on air for receive and transmit (bench 2026-10-09)

Analog I2C block 0x66 (BBPLL)

Documented by the HAL. Upstream’s never-released SAMPLE_RATE_PROBE builds write 0x66:4[3:2] (I2C_BBPLL_DIV_ADC) around a capture to look for other ADC rates and record no result (upstream modules/lib/esp-sdr/main/targets/esp32s3/receiver.c:140-155). esp_sdr does not touch the block.

Analog I2C block 0x67 (BBTOP, analog baseband)

esp_sdr writes the receive and transmit filter codes here.

  • BBTOP_R4 and BBTOP_R6 both reading 0x10 is the vendor’s sign that BBTOP lost its state; it then reruns its init (decompile S3 phy_i2c.o).

  • Writing the other pairs (0x06/0x07 up to 0x1e/0x1f) disturbed reception until a reboot (measured bench 2026-10-06). esp_sdr_bbtop_read() and esp_sdr_bbtop_write() give unchecked access (lib/esp_sdr/src/esp_sdr_rx.c:648-667).

  • The PHY’s channel bandwidth mode changes these filters too (hardware-quirks.md, “40 MHz mode widens the analog filters”).

Registers 0x24 to 0x38 (36 to 56) take init constants, bits 7:0 (decompile S3 phy_i2c.o):

Registers

Value

0x24, 0x28

0x48

0x25, 0x29

0x08

0x2c, 0x2d, 0x30, 0x31

0x88

0x34, 0x35

0x11

0x38

0xff

Register BBTOP_R2 (0x67:2)

Unknown 2-bit field, 1 at init

Bits

7:4

3:2

1:0

Field

(unknown)

BBTOP_R2_B3_2

(unknown)

BBTOP_R2_B3_2 Unknown; 1 at init. (R/W)

phy_close_rf() writes the register as 6.

Evidence:

  • decompile S3 phy_i2c.o, phy_init.o

Register BBTOP_R3 (0x67:3)

Unknown bit, changed by the receive calibration

Bits

7:3

2

1:0

Field

(unknown)

BBTOP_R3_B2

(unknown)

BBTOP_R3_B2 Unknown; saved, changed and restored by the receive calibration. (R/W)

The S3 frequency hop engine (see “Analog I2C master”) writes the register as 0xf0 and 0xf4.

Evidence:

  • speculative S3 phy_rx_cal.o, phy_hw_freq.o

Register BBTOP_R4, BBTOP_R5 (0x67:4, 0x67:5)

Receive low-pass capacitor code, I (BBTOP_R4) and Q (BBTOP_R5)

Bits

7:6

5:0

Field

(unknown)

BBTOP_RX_LPF

BBTOP_RX_LPF Receive low-pass capacitor code, 0 to 63. Larger is narrower; 0 is the widest setting, not a bypass: about 69 MHz (0) to 13 MHz (60) two-sided, PHY default about 22 MHz. Bits 7:6 are kept. (R/W)

Vendor scaling: the corner goes as 1 / (code + 8). phy_rx_band_set(1, bw) writes clamp((base + 8) * 19 / min(bw, 19) - 8, 2, 60) to both; base comes from the RC calibration (block 0x6a).

Evidence:

  • decompile S3 phy_api.o (vendor scaling)

  • BBTOP_RX_LPF: working lib/esp_sdr/src/esp_sdr_rx.c:126-148; upstream curves modules/lib/esp-sdr/main/common/rx_bandwidth.h:79-84; measured (S3 default) bench 2026-10-05

Register BBTOP_R6, BBTOP_R7 (0x67:6, 0x67:7)

Second capacitor pair, unknown

Bits

7:6

5:0

Field

(unknown)

BBTOP_CAP2

BBTOP_CAP2 Capacitor code, unknown. (R/W)

Probably a second receive stage. Written at init and, with BBTOP_R14 and BBTOP_R15 (0x0e, 0x0f), for 10 and 5 MHz channels. The library leaves it alone.

Evidence:

  • speculative; decompile S3 phy_i2c.o, phy_feature.o

Register BBTOP_R12, BBTOP_R13 (0x67:12, 0x67:13)

Transmit low-pass pair A (0x0c, 0x0d)

Bits

7:6

5:0

Field

(unknown)

BBTOP_TX_LPF_A

BBTOP_TX_LPF_A Transmit low-pass code, pair A. 63 narrows TX to about +-6 MHz; codes below the calibrated one (about 35) do not widen it. (R/W)

Evidence:

  • BBTOP_TX_LPF_A: measured bench 2026-10-06; decompile S3 rf_test.o

Register BBTOP_R14, BBTOP_R15 (0x67:14, 0x67:15)

Transmit low-pass pair B (0x0e, 0x0f)

Bits

7:6

5:0

Field

(unknown)

BBTOP_TX_LPF_B

BBTOP_TX_LPF_B Transmit low-pass code, pair B. (R/W)

Evidence:

  • BBTOP_TX_LPF_B: decompile S3 rf_test.o; working lib/esp_sdr/src/esp_sdr_rx.c:578-637

Register BBTOP_R20, BBTOP_R21, BBTOP_R22, BBTOP_R23 (0x67:20, 0x67:21, 0x67:22, 0x67:23)

Unknown pairs (0x14 to 0x17), written from PHY parameters at init

Bits

7:6

5:0

Field

(unknown)

BBTOP_B5_0

BBTOP_B5_0 Unknown. (R/W)

Evidence:

  • speculative S3 phy_i2c.o

Register BBTOP_R28, BBTOP_R29, BBTOP_R30, BBTOP_R31 (0x67:28, 0x67:29, 0x67:30, 0x67:31)

Unknown pairs (0x1c to 0x1f), written from PHY parameters at init

Bits

7:6

5:0

Field

(unknown)

BBTOP_B5_0

BBTOP_B5_0 Unknown. (R/W)

Stepped with BBTOP_R12 and BBTOP_R13 (0x0c, 0x0d) in a TX DC calibration test.

Evidence:

  • speculative S3 phy_i2c.o, phy_test.o

Analog I2C block 0x6a (bias): RC calibration

Not used by esp_sdr. get_rc_dout(mode) measures an on-chip RC time constant: it writes 0x6a:2[6:5] = 2, 0x6a:6[4:0] = 2, 0x6a:4[7:4] = 7, 11 or 6 by mode, 0x61:8[2] = 1 and 0x6a:4[0] = 1, pulses 0x6a:4[3], waits 100 us, reads 0x6a:5[5:0] and clears 0x61:8[2] and 0x6a:4[0]. rc_cal() turns the result into the receive filter base code, clamp((dout + 56) * 82 / 190 - 8, 2, 63), so the filter codes follow process spread. Evidence: decompile S3 phy_analog_cal.o.

Register BIAS_R2 (0x6a:2)

RC measurement setting in bits 6:5

Bits

7

6:5

4:0

Field

(unknown)

BIAS_R2_B6_5

(unknown)

BIAS_R2_B6_5 Unknown; get_rc_dout() writes 2. (R/W)

Evidence:

  • decompile S3 phy_analog_cal.o

Register BIAS_R4 (0x6a:4)

RC measurement mode and control bits

Bits

7:4

3

2:1

0

Field

BIAS_R4_B7_4

BIAS_R4_B3

(unknown)

BIAS_R4_B0

BIAS_R4_B7_4 get_rc_dout(mode) writes 7, 11 or 6 by mode. (R/W)

BIAS_R4_B3 Pulsed by get_rc_dout(), which then waits 100 us and reads BIAS_RC_DOUT. (WO)

BIAS_R4_B0 Set by get_rc_dout() before the pulse on BIAS_R4_B3, cleared after the read. (R/W)

Evidence:

  • decompile S3 phy_analog_cal.o

Register BIAS_R5 (0x6a:5)

RC measurement result

Bits

7:6

5:0

Field

(unknown)

BIAS_RC_DOUT

BIAS_RC_DOUT The RC time-constant measurement, read by get_rc_dout() 100 us after the pulse on BIAS_R4_B3; rc_cal() turns it into the receive filter base code. (RO)

Evidence:

  • decompile S3 phy_analog_cal.o

Register BIAS_R6 (0x6a:6)

RC measurement setting in bits 4:0

Bits

7:5

4:0

Field

(unknown)

BIAS_R6_B4_0

BIAS_R6_B4_0 Unknown; get_rc_dout() writes 2. (R/W)

Evidence:

  • decompile S3 phy_analog_cal.o

Sample word formats

Word

Bits 9:0

Bits 19:10

Bits 31:20

Evidence

receive, in the dump bank

Q

I

unused here; upstream reads 27:20 as gain metadata (unverified)

measured include/esp_sdr/esp_sdr_rx.h:209-225, bench 2026-10-05; upstream modules/lib/esp-sdr/main/common/ring_capture.c:290, 857

transmit, played by the DAC

I

Q

0

measured include/esp_sdr/esp_sdr_tx.h:84-88

  • Each field is 10-bit two’s complement, -512 to 511.

  • The receive orientation was found by detuning only the receive LO against a known carrier: with I taken from the low field, a tone below the LO showed up above it. Upstream’s code comments call the low field I (modules/lib/esp-sdr/main/targets/esp32s3/s3_unpack.S:7, carried into lib/esp_sdr/src/esp_sdr_ring_fir.S:12), and its docs say the raw ring comes out spectrally inverted so hosts conjugate (modules/lib/esp-sdr/docs/iq-stream.md:32-35): the same finding from the other side. The ring keeps upstream’s kernels, filters j conj(z) and swaps the outputs (lib/esp_sdr/src/esp_sdr_ring.c:222-224).

  • Packed forms: IQ8 is bits 19:12 then 9:2 (I then Q); IQ10 packs bits 19:0 of two words into 5 bytes (lib/esp_sdr/src/esp_sdr_rx.c:548-576).

Vendor library functions as used

Function

Library

Called as

What it does

Evidence

set_chanfreq(mhz, cbw)

libphy

(channel ? mhz : 2412, cbw), cbw 0 or 2

MHz to channel (rom_mhz2ieee), then chip_v7_set_chan(chan, cbw): tunes the PLL (hop table or full calibration), passes cbw to rom_bb_bss_cbw40, which selects the analog filters. No 80 MHz mode; mode 2 widens the filters (hardware-quirks.md).

working lib/esp_sdr/src/esp_sdr.c:109-113, 232-244; decompile S3 phy_rfpll.o

set_rf_freq_offset(xtal, mhz, khz)

libphy

(0, PLL MHz, kHz + offset)

The first argument is the crystal selector (1: 26, 2: 32, other: 40 MHz); 0 selects 40 MHz, right for the XIAO. Clears 0x62:11[6], writes block 0x63, restarts and waits for the calibration, then runs rfpll_cap_init_cal().

working lib/esp_sdr/src/esp_sdr.c:112, lib/esp_sdr/src/esp_sdr_priv.h:138; decompile S3 phy_rfpll.o; measured: the bench SDM values fit 40 MHz

stop_tx_tone, rom_pbus_workmode, rom_pbus_xpd_tx_off, rom_pbus_xpd_rx_on, rom_set_rxclk_en, rom_pbus_xpd_tx_on

libphy, ROM

RX: stop_tx_tone(1), workmode(), xpd_tx_off(), xpd_rx_on(1), set_rxclk_en(1). TX: workmode(), xpd_rx_on(0), xpd_tx_on(1).

Forces the analog chain and the receive clock on with the MAC idle, so the engine sees live ADC data (sequence from upstream prepare_rx()). Settle 3 ms after a retune; 50 us without one is enough for packet links.

working lib/esp_sdr/src/esp_sdr_rx.c:78-98, lib/esp_sdr/src/esp_sdr_tx.c:68-89; upstream modules/lib/esp-sdr/main/targets/esp32s3/receiver.c:92-106; measured hardware-quirks.md

force_rx_gain(force, index, bt)

librftest

(manual, index, 0); index 40 while the AGC is in charge

See AGC_FORCE_REG.

lib/esp_sdr/src/esp_sdr_rx.c:41-54

force_tx_gain(gain, bb, dig)

librftest

ladder step, dig 0

Turns TX power tracking off, writes TX gain memory slot 0 (rom_set_tx_gain_mem(0, 1, ...)) and the digital gain. bb is 0, 0x20, 0x80, 0xa0 or 0x100. The 18-step ladder is the vendor’s table in phy_tx_gain.o .rodata (gain at 0x04, bb at 0x16, power at 0x3a in quarter dB): 46.3 dB range, monotonic, 4.24 units per dB.

working lib/esp_sdr/src/esp_sdr_tx.c:30-66; decompile S3 wifi.o, phy_tx_gain.o; working include/esp_sdr/esp_sdr_tx.h:61; measured bench 2026-10-05

force_txon_mode(en, mode, ofs)

librftest

(1, 0, 0) for TX, (0, 0, 0) for RX

See FE_TX_CTRL_REG and FE_R110_REG; ends with rom_force_txon.

working lib/esp_sdr/src/esp_sdr_tx.c:84-87; decompile S3 phy_test.o

adctrig(), dactrig()

librftest

not called

Reference for the dump engine (DUMP registers).

decompile S3 mac_common.o

esp_wifi_set_ps(), esp_wifi_set_promiscuous(), esp_wifi_set_channel()

Wi-Fi driver

WIFI_PS_NONE; true; (1, WIFI_SECOND_CHAN_NONE)

The engine and the PHY calls need the Wi-Fi driver up. NULL mode accepts a channel only in promiscuous mode.

working lib/esp_sdr/src/esp_sdr.c:153-166

For reading the decompile: the PHY calls ROM helpers through the function table g_phyFuns (ROM image at .data_phyrom, 0x3fcef3d8). Slots used on this page: 0x28 get_data_sat(value, max, min), 0x5c mhz2ieee, 0x6c bb_bss_cbw40, 0xbc force_txon, 0x154 get_i2c_read_mask, 0x158 get_i2c_mst0_mask, 0x15c get_i2c_hostid (libphy’s), 0x160 and 0x164 enter/exit_critical_phy (empty), 0x168 chip_i2c_readReg_org, 0x16c and 0x18c chip_i2c_readReg/writeReg (libphy’s), 0x188 i2c_readReg(block, host, reg), 0x190 i2c_writeReg(block, host, reg, data), 0x194 i2c_readReg_Mask(block, host, reg, msb, lsb), 0x198 i2c_writeReg_Mask(block, host, reg, msb, lsb, data), 0x1d4 chan_to_freq, 0x200 and 0x204 phy_en/dis_hw_set_freq (libphy’s), 0x20c write_pll_cap (libphy’s), 0x210 set_tx_gain_mem. Evidence: decompile S3 ROM image, S3 phy_init.o.

Measured PLL behaviour

Bench 2026-10-09, frequencies are the PLL’s (the LO is PLL / 1.2 behind the divider). Code is the capacitor code (0x62:5 plus 256 times 0x62:7[2]), reg 12 the raw 0x62:12.

PLL MHz

Code

reg 12

2170 to 2192

383

0x04 (below the window)

2193

383

0x00

2210

371

0x00

2300

293

0x00

2412

208

0x00

2500

148

0x00

2600

88

0x00

2700

34

0x02

2750

9

0x02

2780, 2781

0

0x02

2782 and up

0

0x0a (above the window)

VCO window, 0x62:12[3:2] = 0 (PLL)

2193 to 2781 MHz (2780 in another run)

PLL locks on air

2180 to 2793 MHz

LO without -ERANGE

1828 to 2781 MHz

LO on air

1817 to 2793 MHz

  • Below the window the calibration pins the code at 383, above it at 0. Repeated calibrations at one frequency land a few codes apart.

  • The window sits about 12 MHz inside the lock limits, so -ERANGE errs on the safe side (include/esp_sdr/esp_sdr.h:31-38, 110-125, README.rst, “The library”).

  • The limits are the same with and without the divider, so they are VCO limits, per chip and untested over temperature. Nothing locks directly below 2180 or above 2793 MHz (searched 200 to 6000 MHz). Below 2210 MHz the library always uses the divider, which reaches the VCO’s lower limit / 1.2 (lib/esp_sdr/src/esp_sdr.c:65-76; tests/regression/lo_tuning guards it, doc/testing.md, “lo_tuning”).

  • 0x62:12[1] is set from about 2700 MHz up; its meaning is unknown.