From 5e372f14189c3f4b380be89311f1d93a53a31daf Mon Sep 17 00:00:00 2001 From: David Montero Date: Tue, 7 Apr 2026 04:36:06 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20add=20wiki=20=E2=80=94=20ESP32/C3=20WiF?= =?UTF-8?q?i=20emulation=20investigation=20and=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detailed document covering the full debugging process and three root causes fixed to make WiFi association work in QEMU emulation: 1. channel=0 in wifi_pkt_rx_ctrl_t 2. wrong AP lookup due to beacon timer race 3. DMA ring resetting to 0 after firmware clears next pointers --- docs/wiki/esp32-wifi-emulation-fixes.md | 938 ++++++++++++++++++++++++ 1 file changed, 938 insertions(+) create mode 100644 docs/wiki/esp32-wifi-emulation-fixes.md diff --git a/docs/wiki/esp32-wifi-emulation-fixes.md b/docs/wiki/esp32-wifi-emulation-fixes.md new file mode 100644 index 00000000..748e21bd --- /dev/null +++ b/docs/wiki/esp32-wifi-emulation-fixes.md @@ -0,0 +1,938 @@ +# ESP32 / ESP32-C3 WiFi Emulation — Investigation, Root Causes & Fixes + +> **Scope**: This document covers the full debugging journey and all fixes applied to make +> WiFi association work in the lcgamboa QEMU ESP32/C3 emulation layer used by Velxio. +> Target audience: future maintainers who need to understand *why* the code is the way it is. + +--- + +## Table of Contents + +1. [Background — what is this emulation layer?](#1-background) +2. [The symptom — WiFi never connects](#2-the-symptom) +3. [Architecture overview — frame delivery pipeline](#3-architecture-overview) +4. [Bug #1 — `wifi_pkt_rx_ctrl_t.channel = 0`](#4-bug-1--channel-field-is-zero) +5. [Bug #2 — Wrong AP lookup in handle_frame](#5-bug-2--wrong-ap-lookup-in-handle_frame) +6. [Bug #3 — DMA ring resets to 0 (the main blocker)](#6-bug-3--dma-ring-resets-to-0) +7. [Fix #1 — Channel resolution strategy](#7-fix-1--channel-resolution-strategy) +8. [Fix #2 — BSSID-first AP lookup](#8-fix-2--bssid-first-ap-lookup) +9. [Fix #3 — DMA ring reset on item.next == 0](#9-fix-3--dma-ring-reset-on-itemnext--0) +10. [Applying the same fixes to ESP32-C3](#10-applying-fixes-to-esp32-c3) +11. [How we debugged — the fprintf strategy](#11-how-we-debugged) +12. [Key data structures](#12-key-data-structures) +13. [Complete WiFi association flow (after fixes)](#13-complete-wifi-association-flow) +14. [File map — what lives where](#14-file-map) +15. [Build & deploy pipeline (Velxio)](#15-build--deploy-pipeline) +16. [Regression test](#16-regression-test) + +--- + +## 1. Background + +The Velxio platform runs ESP32 firmware inside QEMU (based on the lcgamboa fork of QEMU with +ESP32 board support). The ESP32 board model is implemented in `hw/misc/` and includes a custom +WiFi hardware model (`esp32_wifi.c`, `esp32_wifi_ap.c`, `esp32_wlan_packet.c`, etc.). + +The WiFi model simulates an 802.11 access point. When firmware calls `WiFi.begin(ssid, password)`, +the firmware's WiFi MAC sends probe requests via the outgoing DMA channel. The QEMU model +intercepts these, responds with probe responses, auth responses, and association responses +generated by the simulated AP, and delivers those responses back to the firmware via the incoming +DMA channel. + +The firmware used is ESP-IDF compiled for `esp32` (Xtensa LX7) or `esp32c3` (RISC-V). +The Velxio compilation pipeline normalises all user SSIDs to `"Espressif"` on channel 5 so the +QEMU AP can always respond correctly regardless of what the user wrote in their sketch. + +The shared library (`libqemu-xtensa.so` for ESP32, `libqemu-riscv32.so` for ESP32-C3) is built +by GitHub Actions and downloaded into the container at runtime. + +--- + +## 2. The Symptom + +When running an ESP32 HTTP Server sketch (or any sketch that calls `WiFi.begin()`), the serial +output showed the firmware stuck printing dots indefinitely: + +``` +Connecting to WiFi.............................(never stops) +``` + +For ESP32-C3, the association attempt went slightly further but also failed: + +``` +I (1115) wifi:state: init -> auth (b0) +I (1610) wifi:state: auth -> assoc (0) +...I (2610) wifi:state: assoc -> init (2700) +``` + +The state machine reached `assoc` but then timed out exactly 1 second later and fell back +to `init`, repeating indefinitely. + +--- + +## 3. Architecture Overview + +### Frame delivery pipeline (ESP32 → AP) + +``` +Firmware (ESP32 RAM) + │ writes descriptor address to A_WIFI_DMA_OUTLINK register + ▼ +esp32_wifi_write() in esp32_wifi.c + │ reads DMA descriptor → reads frame from firmware RAM + ▼ +Esp32_WLAN_handle_frame() in esp32_wifi_ap.c + │ dispatches by frame subtype: probe req, auth req, assoc req, data + ▼ +Esp32_WLAN_init_*_frame() in esp32_wlan_packet.c + │ builds the response frame (probe resp, auth resp, assoc resp) + ▼ +Esp32_sendFrame() in esp32_wifi.c + │ prepends wifi_pkt_rx_ctrl_t header (channel, rssi, etc.) + │ writes frame to firmware RAM via DMA descriptor + │ fires interrupt → firmware processes the frame + ▼ +Firmware receives frame, advances WiFi state machine +``` + +### Beacon timer (AP → ESP32) + +``` +QEMUTimer (50 ms interval) + │ + ▼ +Esp32_WLAN_beacon_timer() in esp32_wifi_ap.c + │ builds beacon frame for each AP in rotation + ▼ +Esp32_sendFrame() ← same path as responses +``` + +### Key registers (ESP32 variant) + +| Register | Offset | Purpose | +|---|---|---| +| `A_WIFI_DMA_INLINK` | `0x88` | Firmware writes head of RX DMA descriptor ring | +| `A_WIFI_DMA_OUTLINK` | `0x8C` | Firmware writes TX DMA descriptor pointer to transmit a frame | +| `A_WIFI_DMA_INT_CLR` | `0xCC` | Firmware clears interrupt bits | +| `A_WIFI_DMA_INT_STATUS` | `0xC8` | QEMU exposes pending interrupt bits | +| `A_WIFI_STATUS` | `0xC0` | Always returns 1 (hardware ready) | + +--- + +## 4. Bug #1 — Channel Field Is Zero + +### What went wrong + +`Esp32_sendFrame()` fills a `wifi_pkt_rx_ctrl_t` header before each frame. This header is +prepended to the raw 802.11 frame and contains metadata the firmware uses to decide whether +to process or discard the frame: + +```c +*pkt = (wifi_pkt_rx_ctrl_t){ + .rssi = signal_strength + ..., + .rate = 11, + .channel = esp32_wifi_channel, // <-- BUG: may be 0 + ... +}; +``` + +`esp32_wifi_channel` is a global that gets updated by `esp32_ana.c` when the firmware writes +certain PHY registers. The formula is: + +```c +// in esp32_ana.c, register address 0xC4: +if ((v % 10) == 4) + esp32_wifi_channel = (v / 10) - 1; +``` + +On a VPS or under heavy load, the PHY register write sequence can lag or be out of order, +leaving `esp32_wifi_channel == 0` for many frames. + +**ESP-IDF firmware behaviour**: if `wifi_pkt_rx_ctrl_t.channel == 0`, the firmware silently +discards the frame. This means *every single frame* (beacons, probe responses, auth responses, +association responses) was being discarded by the firmware, because they all had `channel = 0`. + +### Why it's masked for beacons + +Beacon frames carry a **DS Parameter Set Information Element** (tag `0x03`) that encodes the +channel number inside the frame body. The firmware can optionally parse this directly. So even +if `pkt.channel == 0`, the firmware may still process beacons — but it will not process probe +responses, auth responses, or association responses that have `channel == 0` in the header. + +--- + +## 5. Bug #2 — Wrong AP Lookup in handle_frame + +### What went wrong + +`Esp32_WLAN_handle_frame()` (in `esp32_wifi_ap.c`) handles frames sent *from* the firmware to +the simulated AP. For probe requests, auth requests, and association requests, it needs to +find the `access_point_info*` for the AP being targeted so it can build the correct response. + +The original code used `s->ap_macaddr` as the lookup key. `s->ap_macaddr` is updated every +50 ms by the beacon timer, which rotates through all 4 configured APs: + +``` +t=0 ap_macaddr = Velxio-GUEST (ch 6, MAC 42:13:37:55:aa:01) +t=50 ap_macaddr = PICSimLabWifi (ch 1, MAC 10:01:00:c4:0a:56) +t=100 ap_macaddr = Espressif (ch 5, MAC 10:01:00:c4:0a:51) +t=150 ap_macaddr = MasseyWifi (ch 10, MAC 10:01:00:c4:0a:52) +t=200 back to Velxio-GUEST... +``` + +The Velxio compilation pipeline normalises the sketch SSID to `"Espressif"` so the firmware +always targets `10:01:00:c4:0a:51`. However, if the firmware sends an auth request at `t=50` +(when `ap_macaddr` is pointing at PICSimLabWifi), the auth response would be built using +PICSimLabWifi's parameters. The firmware would reject this response because the source MAC +and BSSID in the response don't match what it expects (Espressif). + +### Result + +Auth and association responses were built with the wrong AP's credentials, causing the +firmware's state machine to reject them or ignore them. + +--- + +## 6. Bug #3 — DMA Ring Resets to 0 (The Main Blocker) + +This was the hardest bug to find and the root cause of the persistent connection failure. + +### Background — ESP32 WiFi RX DMA + +When the firmware initialises the WiFi stack, it calls internal WiFi driver code that sets up +a pool of **static RX management buffers** (`wifi:Init static rx buffer num: 4` in the log). +These 4 buffers are organised as a linked list of DMA descriptors in firmware RAM. + +Each descriptor has this layout (from `include/hw/misc/esp32_wifi.h`): + +```c +typedef struct dma_list_item { + unsigned size : 12; // buffer capacity + unsigned length : 12; // data length written by DMA + unsigned : 6; // reserved + unsigned eof : 1; // end-of-frame flag + unsigned owner : 1; // 1 = DMA owns it, 0 = CPU owns it + uint32_t address; // pointer to the actual buffer in RAM + uint32_t next; // pointer to the next descriptor (0 = end of list) +} QEMU_PACKED dma_list_item; +``` + +Total size: 12 bytes. + +The firmware sets up 4 descriptors as a **circular ring**: + +``` +0x3ffb62d0 → next: 0x3ffb62dc +0x3ffb62dc → next: 0x3ffb62e8 +0x3ffb62e8 → next: 0x3ffb62f4 +0x3ffb62f4 → next: 0x3ffb62d0 ← points back to head +``` + +It then writes the **head address** (`0x3ffb62d0`) to `A_WIFI_DMA_INLINK`. + +### QEMU's frame delivery logic (BEFORE fix) + +```c +void Esp32_sendFrame(Esp32WifiState *s, mac80211_frame *frame, int length, int signal_strength) +{ + if (s->dma_inlink_address == 0) return; // drop if no DMA set up + + // ... build header, fill buffer ... + + dma_list_item item; + address_space_read(&address_space_memory, s->dma_inlink_address, + MEMTXATTRS_UNSPECIFIED, &item, 12); // read descriptor + address_space_write(&address_space_memory, item.address, + MEMTXATTRS_UNSPECIFIED, header, length); // write frame data + item.length = length; + item.eof = 1; + address_space_write(&address_space_memory, s->dma_inlink_address, + MEMTXATTRS_UNSPECIFIED, &item, 4); // write back word 0 only + s->dma_inlink_address = item.next; // advance to next slot + set_interrupt(s, 0x1000024); +} +``` + +This looks correct: read the current descriptor, write data to its buffer, mark it done, +advance to `item.next`. On a clean circular ring it would go: +`d0 → dc → e8 → f4 → d0 → dc → ...` forever. + +### The failure sequence + +We added debug logging to trace what happened: + +``` +[wifi-dma-inlink] written=0x3ffb62d0 current_inlink=0x0 +[wifi-tx-sent] sub=8 ch=1 → next_inlink=0x3ffb62dc (beacon #1) +[wifi-tx-sent] sub=8 ch=5 → next_inlink=0x3ffb62e8 (beacon #2) +[wifi-tx-sent] sub=8 ch=10 → next_inlink=0x3ffb62f4 (beacon #3) +[wifi-tx-sent] sub=8 ch=6 → next_inlink=0x3ffb62d0 (beacon #4, wrapped) +[wifi-tx-sent] sub=8 ch=1 → next_inlink=0x3ffb62dc (beacon #5) +[wifi-tx-sent] sub=8 ch=5 → next_inlink=0x3ffb62e8 (beacon #6) +[wifi-tx-sent] sub=8 ch=10 → next_inlink=0x3ffb62f4 (beacon #7) +[wifi-tx-sent] sub=8 ch=6 → next_inlink=0x3ffb62d0 (beacon #8, wrapped again) +[wifi-tx-sent] sub=8 ch=1 → next_inlink=0x0 ← ZERO! (beacon #9) +[wifi-fw→ap] subtype=4 ch=5 bssid=ff:ff:ff:ff:ff:ff (probe req, no response delivered) +[wifi-fw→ap] subtype=4 ch=5 bssid=ff:ff:ff:ff:ff:ff (probe req, no response delivered) +... +``` + +After **9 beacons** (two full passes through the 4-slot ring plus one more), the descriptor +at `0x3ffb62d0` had `item.next = 0`. From that point, `s->dma_inlink_address = 0` and every +subsequent frame (probe responses, auth responses, assoc responses) was silently dropped. + +### Why does `item.next` become 0? + +The firmware processes each received frame from the RX ring. As part of buffer recycling +(calling `esp_wifi_internal_free_rx_buffer()`), the ESP-IDF WiFi driver clears the `next` +pointer of the descriptor it just processed. This effectively **breaks the circular ring** +by removing one link. + +Specifically: +- Descriptor `d0` is written to on use #1 and use #5 (both work, `d0.next = dc`) +- The firmware processes the frame from use #1. During recycling it clears `d0.next = 0` +- By use #9, QEMU reads `d0` and finds `next = 0` +- QEMU sets `s->dma_inlink_address = 0` +- All subsequent frames are dropped + +### Why the firmware still scans + +Even with the ring broken at slot d0, the firmware already received enough beacons in the +first 8 deliveries (including 2 Espressif beacons on ch=5) to start scanning. It sends probe +requests via the *outgoing* DMA path (write to `A_WIFI_DMA_OUTLINK`), which is separate from +the RX ring and not affected by this bug. + +However, the **probe responses** that should come back are delivered via `Esp32_sendFrame()` +which needs `dma_inlink_address != 0`. Since that address is 0, all probe responses, auth +responses, and association responses are silently dropped. The firmware sees no responses and +keeps scanning indefinitely. + +### For ESP32-C3 + +The same bug exists in `esp32c3_wifi.c`. The C3 boots much faster (WiFi init at ~1090ms vs +~10000ms for ESP32), so the DMA ring is set up at ~1090ms. The beacon timer fires every 50ms, +so within 400ms (8 × 50ms) the ring is broken. Auth starts at ~1115ms, succeeds (auth response +arrives within the 8-delivery window), but association starts at ~1610ms — after the ring has +been broken — so the association response is dropped. The firmware times out after 1 second +(`assoc → init (2700)`). + +--- + +## 7. Fix #1 — Channel Resolution Strategy + +**File**: `hw/misc/esp32_wifi.c` (and `esp32c3_wifi.c`) + +Replace the direct assignment `channel = esp32_wifi_channel` with a priority-ordered lookup: + +```c +int pkt_channel = esp32_wifi_channel; // fallback: PHY register value + +if (frame->frame_control.type == IEEE80211_TYPE_MGT) { + if (frame->frame_control.sub_type == IEEE80211_TYPE_MGT_SUBTYPE_BEACON) { + // Beacons carry the real channel in DS Parameter Set IE (tag 0x03). + // Fixed beacon header = 12 bytes (timestamp 8 + interval 2 + capability 2). + int data_len = length - IEEE80211_HEADER_SIZE; + int pos = 12; + while (pos + 2 <= data_len) { + uint8_t tag = frame->data_and_fcs[pos]; + uint8_t ie_len = frame->data_and_fcs[pos + 1]; + if (tag == 0x03 && ie_len >= 1) { + pkt_channel = frame->data_and_fcs[pos + 2]; + break; + } + pos += 2 + ie_len; + } + } + // For all MGT frames (including beacons): if channel still 0, + // look up the AP by its source MAC address. + if (pkt_channel == 0) { + for (int i = 0; i < nb_aps; i++) { + if (memcmp(access_points[i].mac_address, frame->source_address, 6) == 0) { + pkt_channel = access_points[i].channel; + break; + } + } + } +} else if (frame->frame_control.type == IEEE80211_TYPE_DATA) { + // Data frames: look up by BSSID (the AP's MAC). + if (pkt_channel == 0) { + for (int i = 0; i < nb_aps; i++) { + if (memcmp(access_points[i].mac_address, frame->bssid_address, 6) == 0) { + pkt_channel = access_points[i].channel; + break; + } + } + } +} +``` + +The `access_points[]` array and `nb_aps` counter are defined in `esp32_wifi_ap.c` and +declared `extern` at the top of `esp32_wifi.c`: + +```c +#include "esp32_wlan.h" +extern access_point_info access_points[]; +extern int nb_aps; +``` + +**Priority order**: +1. Beacon: parse DS Parameter Set IE directly from the frame body → exact channel +2. Any management frame with `channel == 0`: look up by `source_address` MAC → AP's configured channel +3. Data frame with `channel == 0`: look up by `bssid_address` MAC → AP's configured channel +4. Fallback: use `esp32_wifi_channel` (the PHY register value) + +--- + +## 8. Fix #2 — BSSID-First AP Lookup + +**File**: `hw/misc/esp32_wifi_ap.c`, function `Esp32_WLAN_handle_frame()` + +Replace the `s->ap_macaddr` lookup (which races with the beacon timer) with a deterministic +lookup that checks the BSSID in the incoming frame first: + +```c +access_point_info *ap_info = NULL; + +// 1. Look up by BSSID (directed probe/auth/assoc — most reliable) +if (memcmp(frame->bssid_address, BROADCAST, 6) != 0) { + for (int i = 0; i < nb_aps; i++) { + if (memcmp(access_points[i].mac_address, frame->bssid_address, 6) == 0) { + ap_info = &access_points[i]; + break; + } + } +} + +// 2. Try destination_address (some frames use this instead) +if (!ap_info && memcmp(frame->destination_address, BROADCAST, 6) != 0) { + for (int i = 0; i < nb_aps; i++) { + if (memcmp(access_points[i].mac_address, frame->destination_address, 6) == 0) { + ap_info = &access_points[i]; + break; + } + } +} + +// 3. Fallback by current channel (broadcast probe on an AP's channel) +if (!ap_info) { + for (int i = 0; i < nb_aps; i++) { + if (access_points[i].channel == esp32_wifi_channel) { + ap_info = &access_points[i]; + break; + } + } +} + +// 4. Last resort: the last AP that sent a beacon +if (!ap_info) { + for (int i = 0; i < nb_aps; i++) { + if (memcmp(access_points[i].mac_address, s->ap_macaddr, 6) == 0) { + ap_info = &access_points[i]; + break; + } + } +} +``` + +**Why this order matters**: After the firmware scans and finds `Espressif` on channel 5, it +sends auth and association frames with BSSID = `10:01:00:c4:0a:51`. The BSSID lookup (step 1) +always resolves to Espressif regardless of when the beacon timer last fired. The old code using +`s->ap_macaddr` would sometimes resolve to a different AP depending on the 50ms timer. + +--- + +## 9. Fix #3 — DMA Ring Reset on item.next == 0 + +**File**: `hw/misc/esp32_wifi.c`, function `Esp32_sendFrame()` + +This is the critical fix. Replace the unconditional advance with a reset-to-base fallback: + +```c +// BEFORE (broken): +s->dma_inlink_address = item.next; + +// AFTER (fixed): +if (item.next != 0) { + s->dma_inlink_address = item.next; +} else { + /* The firmware cleared the next pointer during buffer recycling, + * breaking the circular ring. Reset to the ring head (the last + * value the firmware wrote to A_WIFI_DMA_INLINK) so probe/auth/assoc + * responses are not permanently dropped. */ + s->dma_inlink_address = s->mem[A_WIFI_DMA_INLINK / 4]; +} +``` + +### Why `s->mem[A_WIFI_DMA_INLINK/4]`? + +The write handler stores every register write into `s->mem[]`: + +```c +static void esp32_wifi_write(void *opaque, hwaddr addr, uint64_t value, unsigned int size) +{ + Esp32WifiState *s = ESP32_WIFI(opaque); + switch (addr) { + case A_WIFI_DMA_INLINK: + s->dma_inlink_address = value; + break; + ... + } + s->mem[addr / 4] = value; // always stored +} +``` + +So `s->mem[A_WIFI_DMA_INLINK/4]` always holds the last address the firmware wrote as the +ring head. When the ring is broken (next = 0), we reset to this address, which restores the +circular behaviour that the firmware originally intended. + +### Why this is safe + +- The firmware created the ring to be circular. The `next = 0` is a side effect of buffer + recycling, not an intentional "stop here" signal. +- Resetting to the ring head means QEMU will write to slot `d0` again. The firmware has + already processed or is in the process of processing the data from `d0`. In the worst case, + QEMU overwrites `d0` with a new beacon before the firmware reads it — losing one beacon. + Beacons are sent every 50ms and are redundant, so this is acceptable. +- The critical frames (probe response, auth response, assoc response) are each sent exactly + once in response to a request. They need to be delivered to *some* valid descriptor slot. + After the ring reset, they land in `d0` and the firmware receives them. + +### What happens after the fix (observed in logs) + +``` +[wifi-dma-inlink] written=0x3ffb5e20 +[wifi-ring-reset] item.next=0, reset inlink→0x3ffb5e20 (many times, beacons) +[wifi-fw→ap] subtype=4 ch=5 bssid=ff:ff:ff:ff:ff:ff (probe request from firmware) +[wifi-tx→fw] subtype=5 ch=5 src=10:01:00:c4:0a:51 (probe response DELIVERED) +[wifi-fw→ap] subtype=11 ch=5 bssid=10:01:00:c4:0a:51 (auth request) +[wifi-tx→fw] subtype=11 ch=5 src=10:01:00:c4:0a:51 (auth response DELIVERED) +[wifi-fw→ap] subtype=0 ch=5 bssid=10:01:00:c4:0a:51 (assoc request, state=1) +[wifi-tx→fw] subtype=1 ch=5 src=10:01:00:c4:0a:51 (assoc response DELIVERED) +``` + +Then in serial output: +``` +I (58701) wifi:connected with Espressif, aid = 1, channel 5, BW20, bssid = 10:01:00:c4:0a:51 +I (70300) esp_netif_handlers: sta ip: 192.168.4.x +Connected! +IP Address: 192.168.4.x +``` + +--- + +## 10. Applying Fixes to ESP32-C3 + +**File**: `hw/misc/esp32c3_wifi.c` + +The ESP32-C3 WiFi emulation is a separate file that mirrors `esp32_wifi.c` but uses different +register names (`A_C3_WIFI_DMA_INLINK` instead of `A_WIFI_DMA_INLINK`) and a different RX +control header type (`wifi_pkt_rx_ctrl_c3_t` instead of `wifi_pkt_rx_ctrl_t`). + +Both Bug #1 (channel = 0) and Bug #3 (DMA ring reset) were present in the C3 file. +Bug #2 (wrong AP lookup) lives in `esp32_wifi_ap.c` which is shared between ESP32 and C3, +so it only needed to be fixed once. + +### Headers added + +```c +#include "esp32_wlan.h" + +extern access_point_info access_points[]; +extern int nb_aps; +``` + +### Channel fix (same logic, applied to C3) + +```c +int pkt_channel = esp32_wifi_channel; + +if (frame->frame_control.type == IEEE80211_TYPE_MGT) { + if (frame->frame_control.sub_type == IEEE80211_TYPE_MGT_SUBTYPE_BEACON) { + // Parse DS Parameter Set IE... + } + if (pkt_channel == 0) { /* lookup by source MAC */ } +} else if (frame->frame_control.type == IEEE80211_TYPE_DATA) { + if (pkt_channel == 0) { /* lookup by BSSID */ } +} + +*pkt = (wifi_pkt_rx_ctrl_c3_t){ + ... + .channel = pkt_channel, // was: .channel = esp32_wifi_channel + ... +}; +``` + +### DMA ring reset fix (same logic, C3 register name) + +```c +if (item.next != 0) { + s->dma_inlink_address = item.next; +} else { + s->dma_inlink_address = s->mem[A_C3_WIFI_DMA_INLINK / 4]; +} +``` + +### C3-specific timing observation + +Because the ESP32-C3 boots much faster than the ESP32 (RTOS starts at ~79ms vs ~10000ms), +the WiFi stack initialises much earlier. The DMA ring is set up at ~1090ms. With 50ms beacons, +the ring is broken after ~400ms (8 beacons). Authentication starts at ~1115ms and +*succeeds* (the auth response arrives within the 8-delivery window). Association starts at +~1610ms but the ring has already broken at ~1490ms, so the association response is dropped. +This causes the exact 1-second assoc timeout (`assoc → init (2700)`) visible in the original +log. + +After the fix, the C3 connects in approximately 3 seconds (compared to ~58 seconds for the +ESP32, which spends more time scanning). + +--- + +## 11. How We Debugged + +### Phase 1 — Establish what frames the firmware was sending + +Added `fprintf(stderr, ...)` logging to `Esp32_WLAN_handle_frame()` in `esp32_wifi_ap.c`: + +```c +if (frame->frame_control.type == IEEE80211_TYPE_MGT && + frame->frame_control.sub_type != IEEE80211_TYPE_MGT_SUBTYPE_BEACON) { + fprintf(stderr, "[wifi-fw→ap] type=%d subtype=%d ch=%d state=%d bssid=%02x:...\n", + frame->frame_control.type, frame->frame_control.sub_type, + esp32_wifi_channel, s->ap_state, + frame->bssid_address[0], ...); +} +``` + +**Finding**: The firmware was sending ~950 probe requests (broadcast BSSID, channels 1–11 +rotating). This confirmed `esp32_wifi_channel` IS updated correctly on this VPS, and the +firmware's scan machinery is working. But zero probe responses were arriving back. + +### Phase 2 — Confirm probe responses are generated but not delivered + +Added logging to `Esp32_sendFrame()` for non-beacon management frames: + +```c +if (frame->frame_control.sub_type != IEEE80211_TYPE_MGT_SUBTYPE_BEACON) { + fprintf(stderr, "[wifi-tx→fw] subtype=%d ch=%d src=...\n", ...); +} +``` + +**Finding**: The log was empty. `Esp32_sendFrame()` was never being called for probe +responses. The probe responses were generated but lost before reaching `Esp32_sendFrame`. + +Then we checked the early return: + +```c +if (s->dma_inlink_address == 0) return; +``` + +Added logging here: + +```c +if (s->dma_inlink_address == 0) { + fprintf(stderr, "[wifi-tx-drop] dma_inlink=0, frame type=%d sub=%d\n", ...); + return; +} +``` + +**Finding**: Every single frame was being dropped here. `dma_inlink_address` was always 0. + +### Phase 3 — Find when `A_WIFI_DMA_INLINK` is written + +Added logging in the write handler: + +```c +if (addr == A_WIFI_DMA_INLINK) { + fprintf(stderr, "[wifi-dma-inlink] written=0x%x current_inlink=0x%x\n", + (unsigned)value, (unsigned)s->dma_inlink_address); +} +``` + +**Finding**: The firmware wrote `A_WIFI_DMA_INLINK = 0x3ffb62d0` **exactly once**, after +about 20 beacon drop log lines. Before that write, all beacons were dropped. After the write, +frames should start flowing. + +### Phase 4 — Understand what happens after the DMA is set up + +Added `item.next` logging after delivery: + +```c +fprintf(stderr, "[wifi-tx-sent] sub=%d ch=%d → next_inlink=0x%x\n", + frame->frame_control.sub_type, pkt_channel, (unsigned)item.next); +``` + +**Finding** (the smoking gun): + +``` +d0 → next=0x3ffb62dc (beacon 1) +dc → next=0x3ffb62e8 (beacon 2) +e8 → next=0x3ffb62f4 (beacon 3) +f4 → next=0x3ffb62d0 (beacon 4, circular ✓) +d0 → next=0x3ffb62dc (beacon 5) +dc → next=0x3ffb62e8 (beacon 6) +e8 → next=0x3ffb62f4 (beacon 7) +f4 → next=0x3ffb62d0 (beacon 8, circular ✓) +d0 → next=0x0 ← ZERO on beacon 9! +``` + +The descriptor at `d0` had its `next` field cleared by the firmware's buffer recycling code +between beacon #5 (where `d0.next = 0x3ffb62dc`) and beacon #9 (where `d0.next = 0`). + +### Phase 5 — Implement and verify the fix + +Replaced `s->dma_inlink_address = item.next` with the ring-reset fallback. + +**Verified** via logs that after the fix: +1. `[wifi-ring-reset]` fires repeatedly (beacons keep going to `d0` as expected) +2. Probe request arrives → probe response delivered +3. Auth request arrives → auth response delivered +4. Assoc request arrives → assoc response delivered +5. Serial output shows `connected with Espressif, aid = 1, channel 5` + +All debug `fprintf` calls were removed before the final commit. + +--- + +## 12. Key Data Structures + +### `dma_list_item` (12 bytes) + +```c +typedef struct dma_list_item { + unsigned size : 12; // buffer capacity (set by firmware when queueing) + unsigned length : 12; // data length (set by QEMU when delivering) + unsigned : 6; // padding + unsigned eof : 1; // end-of-frame: set to 1 by QEMU per delivery + unsigned owner : 1; // 1 = DMA hardware owns it, 0 = CPU owns it + uint32_t address; // physical address of data buffer + uint32_t next; // physical address of next descriptor (0 = end) +} QEMU_PACKED dma_list_item; +``` + +QEMU only writes back the first 4 bytes (word 0) after a delivery: +```c +address_space_write(&address_space_memory, s->dma_inlink_address, + MEMTXATTRS_UNSPECIFIED, &item, 4); // 4 bytes only! +``` +This updates `length` and `eof` without touching `address` or `next`. + +### `wifi_pkt_rx_ctrl_t` (ESP32) / `wifi_pkt_rx_ctrl_c3_t` (ESP32-C3) + +The firmware-facing RX frame header prepended by QEMU before writing to the DMA buffer. +Key fields: + +```c +typedef struct { + signed rssi : 8; // received signal strength (dBm, negative) + unsigned rate : 4; // data rate + unsigned sig_len : 12; // length of the 802.11 frame + unsigned channel : 4; // channel number ← must be non-zero! + unsigned timestamp : 32; // QEMU virtual clock (microseconds) + unsigned noise_floor : 8; + // ... match flags (damatch0/1, bssidmatch0/1) ... +} wifi_pkt_rx_ctrl_t; +``` + +The `channel` field is 4 bits (values 1–13). **If it is 0, ESP-IDF firmware discards the +frame silently** without any log output. This is the most common cause of frames "disappearing" +in the emulation layer. + +### `access_point_info` (from `esp32_wlan.h`) + +```c +typedef struct { + char ssid[32]; + uint8_t mac_address[6]; + uint8_t channel; + uint8_t auth_mode; + // ... +} access_point_info; +``` + +Configured APs (as of writing): + +| SSID | Channel | MAC | Notes | +|---|---|---|---| +| `Espressif` | 5 | `10:01:00:c4:0a:51` | Default target (Velxio normalises to this) | +| `PICSimLabWifi` | 1 | `10:01:00:c4:0a:56` | | +| `MasseyWifi` | 10 | `10:01:00:c4:0a:52` | | +| `Velxio-GUEST` | 6 | `42:13:37:55:aa:01` | | + +--- + +## 13. Complete WiFi Association Flow (After Fixes) + +``` +t = 0ms + QEMU starts beacon timer (50ms interval) + dma_inlink_address = 0 + +t ≈ 10-11s (ESP32) / 90ms (ESP32-C3) + Firmware WiFi stack initialises + Firmware writes A_WIFI_DMA_INLINK = 0x3ffb62d0 + → dma_inlink_address = 0x3ffb62d0 + +t = 50ms intervals + Beacon timer fires, Esp32_sendFrame() called for each of 4 APs + Beacons delivered to ring slots d0 → dc → e8 → f4 → d0 (circular) + After ~8 deliveries, ring reset triggers: d0.next was cleared by firmware + → dma_inlink_address resets to 0x3ffb62d0 (ring head) + Firmware receives beacons including Espressif on ch=5 + +t = (after sufficient beacons received) + Firmware decides to connect to "Espressif" (the normalised SSID) + Firmware sends PROBE REQUEST (type=0, subtype=4) with broadcast BSSID + + QEMU: Esp32_WLAN_handle_frame() receives probe req + QEMU: builds PROBE RESPONSE for Espressif (SSID IE + channel IE + rates) + QEMU: Esp32_sendFrame() → channel = 5 (from DS IE or MAC lookup) + QEMU: delivers probe response to dma_inlink_address + QEMU: fires interrupt + + Firmware: receives probe response with channel=5 for "Espressif" + Firmware: state: init → auth + + Firmware sends AUTH REQUEST (subtype=11) with BSSID=10:01:00:c4:0a:51 + QEMU: lookup AP by BSSID → Espressif + QEMU: builds AUTH RESPONSE (algorithm=Open, seq=2, status=0) + QEMU: delivers auth response + Firmware: state: auth → assoc + + Firmware sends ASSOC REQUEST (subtype=0) with BSSID=10:01:00:c4:0a:51 + QEMU: lookup AP by BSSID → Espressif + QEMU: builds ASSOC RESPONSE (status=0, AID=1, capability, rates) + QEMU: delivers assoc response + Firmware: state: assoc → run + + Firmware: "connected with Espressif, aid = 1, channel 5" + Firmware: starts DHCP (sends DHCPDISCOVER to 192.168.4.1 via SLIRP) + SLIRP: responds with DHCPOFFER / DHCPACK + Firmware: gets IP address (e.g. 192.168.4.15) + + Serial: "Connected!\nIP Address: 192.168.4.15" +``` + +Total time: ~58 seconds (ESP32, includes long PHY init), ~3 seconds (ESP32-C3). + +--- + +## 14. File Map + +``` +hw/misc/ +├── esp32_wifi.c Main WiFi emulation for ESP32 (Xtensa) +│ ├── esp32_wifi_read() Register read handler +│ ├── esp32_wifi_write() Register write handler (DMA TX, INLINK setup) +│ └── Esp32_sendFrame() Delivers a frame to firmware via RX DMA +│ ← Bug #1 fix (channel), Bug #3 fix (ring reset) +│ +├── esp32c3_wifi.c WiFi emulation for ESP32-C3 (RISC-V) — mirrors esp32_wifi.c +│ └── Esp32_sendFrame() ← Bug #1 fix + Bug #3 fix (same, different reg names) +│ +├── esp32_wifi_ap.c Access point simulation, shared by ESP32 and C3 +│ ├── access_points[] Array of 4 configured APs +│ ├── Esp32_WLAN_handle_frame() Dispatches frames from firmware +│ │ ← Bug #2 fix (BSSID-first AP lookup) +│ └── beacon timer callback Sends beacons for each AP in rotation +│ +├── esp32_wlan_packet.c Frame construction helpers +│ ├── Esp32_WLAN_init_probe_response_frame() +│ ├── Esp32_WLAN_init_auth_response_frame() +│ └── Esp32_WLAN_init_assoc_response_frame() +│ +└── esp32_ana.c PHY/analog peripherals including channel update + └── Updates esp32_wifi_channel when PHY register 0xC4 is written + +include/hw/misc/ +├── esp32_wifi.h Esp32WifiState struct, dma_list_item, register offsets +└── esp32c3_wifi.h Same for C3 variant + +hw/misc/esp32_wlan.h access_point_info struct declaration +``` + +--- + +## 15. Build & Deploy Pipeline (Velxio) + +### Source repo + +``` +https://github.com/davidmonterocrespo24/qemu-lcgamboa +branch: picsimlab-esp32 +``` + +### Build trigger + +Every push to `picsimlab-esp32` triggers `.github/workflows/build-libqemu.yml`. +The workflow compiles both `libqemu-xtensa.so` (ESP32) and `libqemu-riscv32.so` (ESP32-C3) +and uploads them as assets to the `qemu-prebuilt` release in the velxio repo. + +### Deploy to container + +```bash +# Deploy ESP32 library +docker exec velxio-app bash -c " + curl -L -H 'Authorization: token TOKEN' \ + 'https://github.com/davidmonterocrespo24/velxio/releases/download/qemu-prebuilt/libqemu-xtensa.so' \ + -o /app/lib/libqemu-xtensa.so.new && \ + mv /app/lib/libqemu-xtensa.so.new /app/lib/libqemu-xtensa.so +" + +# Deploy ESP32-C3 library +docker exec velxio-app bash -c " + curl -L -H 'Authorization: token TOKEN' \ + 'https://github.com/davidmonterocrespo24/velxio/releases/download/qemu-prebuilt/libqemu-riscv32.so' \ + -o /app/lib/libqemu-riscv32.so.new && \ + mv /app/lib/libqemu-riscv32.so.new /app/lib/libqemu-riscv32.so +" + +# Restart uvicorn (not supervised — must be done manually) +docker exec -d velxio-app bash -c " + pkill -f uvicorn; sleep 2 + cd /app && . /opt/esp-idf/export.sh > /dev/null 2>&1 + uvicorn app.main:app --host 127.0.0.1 --port 8001 > /tmp/uvicorn.log 2>&1 +" +``` + +### Important notes + +- `uvicorn` is **not supervised**. If killed (e.g. by `pkill`), it must be manually restarted. + nginx (PID 1) stays up but all API requests return 502. +- Debug output from the `.so` (`fprintf(stderr, ...)`) appears in `/tmp/uvicorn.log` inside + the container, prefixed with the worker ID. +- Both `.so` files live in `/app/lib/` inside the container. +- The ESP32-C3 board name in the Velxio API is `esp32-c3` (with hyphen), **not** `esp32c3`. + The C3 board uses `libqemu-riscv32.so`; all other ESP32 variants use `libqemu-xtensa.so`. + +--- + +## 16. Regression Test + +A Python test script is available at `/tmp/test_wifi_debug.py` (ESP32) and +`/tmp/test_c3_wifi.py` (ESP32-C3) on the host machine. They compile a minimal HTTP Server +sketch via the Velxio API, start a simulation via WebSocket, and wait for the `Connected!` +string or an IP address in the serial output. + +Expected results after fixes: +- **ESP32**: connects in ~55–60 seconds, prints `connected with Espressif, aid = 1, channel 5` +- **ESP32-C3**: connects in ~3 seconds, same message + +To run: +```bash +python3 /tmp/test_wifi_debug.py # ESP32 +python3 /tmp/test_c3_wifi.py # ESP32-C3 +``` + +Both scripts cache the compiled binary in `/tmp/` to avoid recompiling on subsequent runs. +Delete `/tmp/esp32_wifi_binary.b64` or `/tmp/esp32c3_wifi_binary.b64` to force recompilation. + +--- + +## Commit History (relevant) + +| SHA | Message | +|---|---| +| `86ad10b` | fix: channel resolution and BSSID-first AP lookup | +| `3b22e64` | fix: reset DMA inlink to ring base when item.next=0 | +| `824b967` | chore: remove debug logging from WiFi emulation | +| `3237387` | fix: apply DMA ring reset and channel lookup fixes to ESP32-C3 WiFi |