10 KiB
ESP32 family — QEMU Waveform Injection for the SAR ADC
How Velxio achieves per-read ADC fidelity on ESP32 / ESP32-S3 / ESP32-C3, the QEMU-side data contract, the WebSocket protocol that delivers waveforms to the subprocess, and how to rebuild
qemu-lcgamboawhen you change the C code.
Why a separate doc?
The AVR and RP2040 paths live entirely in JS — we monkey-patch
AVRADC.onADCRead / RPADC.onADCRead. ESP32, ESP32-S3, and ESP32-C3 all
run inside a forked QEMU (third-party/qemu-lcgamboa), so JS can't reach
the ADC peripheral. Instead, the full periodic waveform is pushed down
to the QEMU SAR ADC, which interpolates it against QEMU_CLOCK_VIRTUAL
on every MMIO read.
The circuit-emulation-adc-aliasing.md page covers why per-read sampling
is needed. This page covers how it's implemented for the ESP32 family.
1. End-to-end pipeline
┌─────────────────────────────┐
│ frontend SPICE solve (.tran)│
└──────────────┬──────────────┘
│ useElectricalStore.timeWaveforms
▼
┌─────────────────────────────┐
│ subscribeToStore.ts │
│ pushEsp32Waveforms() │
│ - iterate boards │
│ - map ADC pin → SPICE net │
│ - quantise to 12-bit u16 │
└──────────────┬──────────────┘
│ bridge.setAdcWaveform(gpio, u12, periodNs)
▼
┌─────────────────────────────┐
│ Esp32Bridge.ts │
│ base64(u12 LE) + period │
└──────────────┬──────────────┘
│ WebSocket "esp32_adc_waveform"
▼
┌─────────────────────────────┐
│ FastAPI /ws/simulation │
│ esp_lib_manager. │
│ set_adc_waveform(...) │
└──────────────┬──────────────┘
│ JSON line on subprocess stdin
▼
┌─────────────────────────────┐
│ esp32_worker.py │
│ cmd == 'set_adc_waveform' │
│ ctypes → libqemu symbol │
└──────────────┬──────────────┘
│ qemu_picsimlab_set_apin_waveform(ch, u16*, n, period_ns)
▼
┌─────────────────────────────┐
│ esp32_sens.c / esp32c3_saradc.c │
│ - g_malloc LUT │
│ - latch epoch = virt_ns │
│ - interpolate on read │
└─────────────────────────────┘
DC fallback. When no .tran result is available the subsystem falls
back to the existing qemu_picsimlab_set_apin(channel, mv) code path so
DC examples behave exactly as before. Passing samples=NULL or n=0 to
qemu_picsimlab_set_apin_waveform clears the LUT and re-arms the DC path.
2. QEMU API contract
New symbol
void qemu_picsimlab_set_apin_waveform(int chn,
const uint16_t *samples,
int n,
uint64_t period_ns);
chn— SAR ADC channel (0-based; 8 channels on ESP32, 10 on ESP32-C3, varies by board).samples— array of 12-bit raw ADC counts (0..4095). When the frontend quantises a SPICE voltage it usesround(v / 3.3 · 4095), clamped.n— number of samples. Pass0(orsamples=NULL) to clear.period_ns— full period of the waveform in nanoseconds. Usually the last entry oftimeWaveforms.time× 1e9.
Device-state additions
Per SAR-ADC peripheral (Esp32SensState, Esp32c3SarAdcState):
uint16_t *waveform_samples[NUM_CHANNELS];
int waveform_len[NUM_CHANNELS];
uint64_t waveform_period_ns[NUM_CHANNELS];
uint64_t waveform_epoch_ns[NUM_CHANNELS];
Read-path interpolation
Pseudo-code (the real helpers live in
esp32c3_saradc_sample_waveform and the ESP32 equivalent):
uint64_t now = qemu_clock_get_ns(QEMU_CLOCK_VIRTUAL);
uint64_t dt = (now - epoch) % period_ns;
double t_frac = (double) dt / period_ns * (n - 1);
int lo = (int) t_frac;
int hi = lo + 1;
double a = t_frac - lo;
uint16_t raw = round(samples[lo] * (1 - a) + samples[hi] * a);
Clamp raw to [0, 0xFFFF]. The SAR ADC handler writes it into the 17-bit
data register as usual — so as far as the guest knows, the ADC just
happened to return a slightly different value on every conversion.
Reset / cleanup
The peripheral's reset handler frees every waveform_samples[i] and zeros
the metadata arrays. That keeps esp_restart() clean and avoids leaking
LUTs across reboots.
3. WebSocket protocol
Frontend → backend
{
"type": "esp32_adc_waveform",
"data": {
"channel": 0,
"samples_u12_le": "<base64>",
"period_ns": 20000000
}
}
channel— GPIO number to map (the backend resolves GPIO → SAR channel).samples_u12_le— base64 of aUint16Arrayin little-endian order. Each entry is a 12-bit sample occupying the low 12 bits; upper 4 bits are always zero.period_ns— same as the QEMU API.
Pass an empty samples_u12_le (base64 "") or period_ns: 0 to clear.
Backend → QEMU
esp32_worker.py decodes the base64 into a ctypes (c_uint16 * n) buffer
and calls lib.qemu_picsimlab_set_apin_waveform(ch, arr, n, period_ns).
If the symbol is missing (you haven't rebuilt QEMU yet), the worker logs
a warning and silently falls through to the existing DC path.
4. Rebuilding qemu-lcgamboa
Windows
Requires MSYS2 + mingw64 (the user's env already has C:\msys64\mingw64\bin):
cd third-party/qemu-lcgamboa
./configure --target-list=xtensa-softmmu,riscv32-softmmu --enable-picsimlab
make -j$(nproc)
Copy the resulting qemu-system-xtensa.exe and qemu-system-riscv32.exe
plus the generated libqemu*.dll into backend/qemu-bin/ (or wherever
esp32_worker.py picks them up — see ESP32_QEMU_DIR env var).
First-time build is ~15-20 minutes. Incremental make after touching one
.c file is seconds.
Linux / macOS
cd third-party/qemu-lcgamboa
./configure --target-list=xtensa-softmmu,riscv32-softmmu --enable-picsimlab
make -j$(nproc)
sudo make install # or copy binaries manually into backend/qemu-bin
Verifying the new symbol
nm -D $(which qemu-system-xtensa) | grep qemu_picsimlab_set_apin_waveform
Should emit one match per target binary. If it doesn't, the build picked
up stale object files — run make clean and rebuild.
5. Mapping ADC pins to GPIO
ESP32 has 18 SAR-capable GPIOs split across ADC1 (8 channels) and ADC2
(10 channels); ESP32-C3 has 5 ADC channels on ADC1 only; ESP32-S3 has 10
on ADC1 + 10 on ADC2. The frontend stores the mapping in ADC_PIN_TO_GPIO
(see subscribeToStore.ts). To add a new board or pin alias:
- Add it to
ADC_PIN_MAP[boardKind](list of{ pinName, channel }). - Add a resolver function to
ADC_PIN_TO_GPIO[boardKind]that converts thepinName+ channel into the GPIO number expected by QEMU. pushEsp32Waveforms()will pick it up automatically.
6. Data-rate sanity check
At peak we push 400 samples × 2 bytes × 8 channels = 6.4 KB per solve per
board. CircuitScheduler rate-limits to ~5 solves / sec under heavy
activity, so worst-case traffic is ~32 KB/s — comfortable on the local
WebSocket and well below the ws_max_message_size default.
If you add a board with 20+ ADC channels, increase MAX_PUSH_BYTES in
Esp32Bridge.ts before running large .tran windows.
7. Testing
Unit tests for the waveform-stats helpers live at
frontend/src/__tests__/waveform-stats.test.ts. The per-read hook itself
is exercised in frontend/src/__tests__/spice-rectifier-live-repro.test.ts
(AVR path) — the ESP32 E2E counterpart is
frontend/src/__tests__/esp32-rectifier-integration.test.ts and is
gated behind VELXIO_ESP32_E2E=1 because it requires a running QEMU
toolchain plus a live backend:
VELXIO_ESP32_E2E=1 npm test -- esp32-rectifier-integration
8. Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
analogRead returns stale DC on ESP32 despite .tran |
QEMU binary predates this patch | make clean && make in qemu-lcgamboa, redeploy to backend/qemu-bin/ |
esp32_worker logs symbol 'qemu_picsimlab_set_apin_waveform' not found |
Same — rebuild not yet deployed | As above; the worker falls back to DC so the app still runs |
| Guest sees 0 V after a few frames | Waveform cleared on reset but not re-pushed | Confirm pushEsp32Waveforms() is called on every solve (see installAdcReadHooks) |
| WebSocket frame ~2 MB pause | You pushed a huge .tran (tens of thousands of samples) |
Tighten pickDynamicAnalysis step / stop so the solve returns fewer samples |
9. Key files
| File | Role |
|---|---|
third-party/qemu-lcgamboa/hw/misc/esp32_sens.c |
ESP32 SAR ADC1/2 — waveform LUT + sample_waveform helper |
third-party/qemu-lcgamboa/hw/misc/esp32c3_saradc.c |
ESP32-C3 SAR ADC — same pattern |
third-party/qemu-lcgamboa/include/hw/misc/esp32_sens.h |
State struct + esp32_sens_set_waveform prototype |
third-party/qemu-lcgamboa/include/hw/misc/esp32c3_saradc.h |
Same for C3 |
third-party/qemu-lcgamboa/hw/xtensa/esp32_picsimlab.c |
Defines qemu_picsimlab_set_apin_waveform for ESP32 |
third-party/qemu-lcgamboa/hw/riscv/esp32c3_picsimlab.c |
Defines it for ESP32-C3 |
backend/app/services/esp32_worker.py |
ctypes bridge from stdin JSON → libqemu |
backend/app/services/esp32_lib_manager.py::set_adc_waveform |
Queues the command on the subprocess |
backend/app/api/routes/simulation.py |
Handles esp32_adc_waveform WebSocket message |
frontend/src/simulation/Esp32Bridge.ts::setAdcWaveform |
Base64-encodes + sends the WebSocket frame |
frontend/src/simulation/spice/subscribeToStore.ts::pushEsp32Waveforms |
Iterates boards, quantises samples, calls the bridge |