velxio/docs/ESP32_EMULATION.md

18 KiB

ESP32 Emulation — Documentación Técnica

Estado: Funcional · Backend completo · Frontend parcial Motor: lcgamboa/qemu-8.1.3 · Plataforma: arduino-esp32 2.0.17 (IDF 4.4.x)


1. Arquitectura general

Usuario (browser)
  └── WebSocket (/ws/{client_id})
        └── simulation.py  (FastAPI router)
              ├── EspLibManager          ← backend con DLL (GPIO, WiFi, I2C, SPI, RMT…)
              └── EspQemuManager         ← fallback solo-UART via subprocess
                    │
              [QEMU_ESP32_LIB=libqemu-xtensa.dll]
                    │
              Esp32LibBridge (ctypes)
                    │
              libqemu-xtensa.dll  ←  lcgamboa fork de QEMU 8.1.3
                    │
              Machine: esp32-picsimlab
                    │
         ┌──────────┴──────────┐
     CPU Xtensa LX6      periféricos emulados
     (dual-core)    GPIO · ADC · UART · I2C · SPI
                    RMT · LEDC · Timer · WiFi · Flash

El sistema selecciona backend automáticamente:

  • DLL disponibleEspLibManager (GPIO completo + todos los periféricos)
  • DLL ausenteEspQemuManager (solo UART serial via TCP, subprocess QEMU)

Activación de DLL: colocar libqemu-xtensa.dll en backend/app/services/ o setear:

QEMU_ESP32_LIB=C:/ruta/a/libqemu-xtensa.dll uvicorn app.main:app

2. Componentes del sistema

2.1 libqemu-xtensa.dll

Compilada desde el fork lcgamboa/qemu rama qemu-8.1.3.

Dependencias en runtime (Windows):

C:\msys64\mingw64\bin\
  libglib-2.0-0.dll
  libgcrypt-20.dll
  libgpg-error-0.dll
  libslirp-0.dll
  libintl-8.dll
  libpcre2-8-0.dll
  (y ~15 DLLs más de MinGW64)

El bridge las registra automáticamente con os.add_dll_directory().

ROM binaries requeridas (deben estar en la misma carpeta que la DLL):

backend/app/services/
  libqemu-xtensa.dll        ← motor principal
  esp32-v3-rom.bin          ← ROM de boot del ESP32 (copiar de esp-qemu)
  esp32-v3-rom-app.bin      ← ROM de aplicación

Cómo obtener los ROM binaries:

# Desde instalación de Espressif QEMU:
copy "C:\esp-qemu\qemu\share\qemu\esp32-v3-rom.bin" backend\app\services\
copy "C:\esp-qemu\qemu\share\qemu\esp32-v3-rom-app.bin" backend\app\services\

Exports de la DLL:

void    qemu_init(int argc, char** argv, char** envp)
void    qemu_main_loop(void)
void    qemu_cleanup(void)
void    qemu_picsimlab_register_callbacks(callbacks_t* cbs)
void    qemu_picsimlab_set_pin(int slot, int value)        // GPIO input
void    qemu_picsimlab_set_apin(int channel, int value)    // ADC input (0-4095)
void    qemu_picsimlab_uart_receive(int id, uint8_t* buf, int size)
void*   qemu_picsimlab_get_internals(int type)             // LEDC duty array
int     qemu_picsimlab_get_TIOCM(void)                     // UART modem lines

Struct de callbacks C:

typedef struct {
    void    (*picsimlab_write_pin)(int pin, int value);       // GPIO output changed
    void    (*picsimlab_dir_pin)(int pin, int value);         // GPIO direction changed
    int     (*picsimlab_i2c_event)(uint8_t id, uint8_t addr, uint16_t event);
    uint8_t (*picsimlab_spi_event)(uint8_t id, uint16_t event);
    void    (*picsimlab_uart_tx_event)(uint8_t id, uint8_t value);
    const short int *pinmap;   // slot → GPIO number mapping
    void    (*picsimlab_rmt_event)(uint8_t ch, uint32_t config0, uint32_t value);
} callbacks_t;

2.2 GPIO Pinmap

# Identity mapping: QEMU IRQ slot i → GPIO number i-1
_PINMAP = (ctypes.c_int16 * 41)(
    40,               # pinmap[0] = count
    *range(40)        # pinmap[1..40] = GPIO 0..39
)

Cuando GPIO N cambia, QEMU llama picsimlab_write_pin(slot=N+1, value). El bridge traduce automáticamente slot → GPIO real antes de notificar listeners.

GPIOs input-only en ESP32-WROOM-32: {34, 35, 36, 39} — no pueden ser output.


2.3 Esp32LibBridge (Python ctypes)

Archivo: backend/app/services/esp32_lib_bridge.py

bridge = Esp32LibBridge(lib_path, asyncio_loop)

# Registrar listeners (async, llamados desde asyncio)
bridge.register_gpio_listener(fn)    # fn(gpio_num: int, value: int)
bridge.register_dir_listener(fn)     # fn(gpio_num: int, direction: int)
bridge.register_uart_listener(fn)    # fn(uart_id: int, byte_val: int)
bridge.register_rmt_listener(fn)     # fn(channel: int, config0: int, value: int)

# Registrar handlers I2C/SPI (sync, llamados desde thread QEMU)
bridge.register_i2c_handler(fn)      # fn(bus, addr, event) -> int
bridge.register_spi_handler(fn)      # fn(bus, event) -> int

# Control
bridge.start(firmware_b64, machine='esp32-picsimlab')
bridge.stop()
bridge.is_alive  # bool

# GPIO / ADC / UART
bridge.set_pin(gpio_num, value)      # Drive GPIO input (usa GPIO real 0-39)
bridge.set_adc(channel, millivolts)  # ADC en mV (0-3300)
bridge.set_adc_raw(channel, raw)     # ADC en raw 12-bit (0-4095)
bridge.uart_send(uart_id, data)      # Enviar bytes al UART RX del ESP32

# LEDC/PWM
bridge.get_ledc_duty(channel)        # canal 0-15 → raw duty | None
bridge.get_tiocm()                   # UART modem lines bitmask

# Helper
bridge.decode_rmt_item(value)        # → (level0, dur0, level1, dur1)

Threading crítico: qemu_init() y qemu_main_loop() deben correr en el mismo thread (BQL — Big QEMU Lock es thread-local). El bridge los ejecuta en un único daemon thread y usa threading.Event para sincronizar el inicio:

# Correcto:
def _qemu_thread():
    lib.qemu_init(argc, argv, None)   # init + init_done.set()
    lib.qemu_main_loop()              # bloquea indefinidamente

# Incorrecto:
lib.qemu_init(...)         # en thread A
lib.qemu_main_loop()       # en thread B  ← crash: "qemu_mutex_unlock_iothread assertion failed"

2.4 EspLibManager (Python)

Archivo: backend/app/services/esp32_lib_manager.py

Convierte callbacks de hardware en eventos WebSocket para el frontend:

Evento emitido Datos Cuándo
system {event: 'booting'|'booted'|'crash'|'reboot', ...} Ciclo de vida
serial_output {data: str, uart: 0|1|2} UART TX del ESP32
gpio_change {pin: int, state: 0|1} GPIO output cambia
gpio_dir {pin: int, dir: 0|1} GPIO cambia dirección
i2c_event {bus, addr, event, response} Transacción I2C
spi_event {bus, event, response} Transacción SPI
rmt_event {channel, config0, value, level0, dur0, level1, dur1} Pulso RMT
ws2812_update {channel, pixels: [{r,g,b}]} Frame NeoPixel completo
ledc_update {channel, duty, duty_pct} PWM duty cycle
error {message: str} Error de boot

Detección de crash y reboot:

# El firmware imprime en UART cuando crashea:
"Cache disabled but cached memory region accessed"   event: crash
"Rebooting..."                                       event: reboot

API pública del manager:

manager = esp_lib_manager  # singleton

manager.start_instance(client_id, board_type, callback, firmware_b64)
manager.stop_instance(client_id)
manager.load_firmware(client_id, firmware_b64)        # hot-reload

manager.set_pin_state(client_id, gpio_num, value)     # GPIO input
manager.set_adc(client_id, channel, millivolts)
manager.set_adc_raw(client_id, channel, raw)
await manager.send_serial_bytes(client_id, data, uart_id=0)

manager.set_i2c_response(client_id, addr, byte)       # Simular dispositivo I2C
manager.set_spi_response(client_id, byte)             # Simular dispositivo SPI
await manager.poll_ledc(client_id)                    # Leer PWM (llamar periódicamente)
manager.get_status(client_id)                         # → dict con runtime state

2.5 simulation.py — Mensajes WebSocket

Frontend → Backend (mensajes entrantes):

Tipo Datos Acción
start_esp32 {board, firmware_b64?} Iniciar emulación
stop_esp32 {} Detener
load_firmware {firmware_b64} Hot-reload firmware
esp32_gpio_in {pin, state} Drive GPIO input (GPIO real 0-39)
esp32_serial_input {bytes: [int], uart: 0} Enviar serial al ESP32
esp32_uart1_input {bytes: [int]} UART1 RX
esp32_uart2_input {bytes: [int]} UART2 RX
esp32_adc_set {channel, millivolts?} o {channel, raw?} Setear ADC
esp32_i2c_response {addr, response} Configurar respuesta I2C
esp32_spi_response {response} Configurar MISO SPI
esp32_status {} Query estado runtime

3. Firmware — Requisitos para lcgamboa

3.1 Versión de plataforma requerida

Usar: arduino-esp32 2.x (IDF 4.4.x) No usar: arduino-esp32 3.x (IDF 5.x)

# Instalar/cambiar a 2.x:
arduino-cli core install esp32:esp32@2.0.17

Por qué: El WiFi emulado de lcgamboa (core 1) desactiva la caché SPI flash periódicamente. En IDF 5.x esto provoca un crash cuando las interrupciones del core 0 intentan ejecutar código desde IROM (flash cache). En IDF 4.4.x el comportamiento de la caché es diferente y compatible.

Mensaje de crash (IDF 5.x):

Guru Meditation Error: Core  / panic'ed (Cache error).
Cache disabled but cached memory region accessed
EXCCAUSE: 0x00000007

3.2 Imagen de flash

La imagen debe ser un archivo binario completo de 4 MB (formato merged flash):

# Compilar con DIO flash mode:
arduino-cli compile --fqbn esp32:esp32:esp32:FlashMode=dio \
  --output-dir build/ sketch/

# Crear imagen 4MB completa (¡obligatorio! QEMU requiere 2/4/8/16 MB exactos):
esptool --chip esp32 merge_bin \
  --fill-flash-size 4MB \          # ← sin esto QEMU falla con "only 2,4,8,16 MB supported"
  -o firmware.merged.bin \
  --flash_mode dio --flash_size 4MB \
  0x1000  bootloader.bin \
  0x8000  partitions.bin \
  0x10000 app.bin

El backend (arduino_cli.py) fuerza FlashMode=dio automáticamente para todos los targets esp32:*.

3.3 Sketch compatible con lcgamboa (ejemplo mínimo)

Para sketches que necesiten máxima compatibilidad (sin Arduino framework):

// GPIO directo vía registros (evita código en flash en ISRs)
#define GPIO_OUT_W1TS    (*((volatile uint32_t*)0x3FF44008))
#define GPIO_OUT_W1TC    (*((volatile uint32_t*)0x3FF4400C))
#define GPIO_ENABLE_W1TS (*((volatile uint32_t*)0x3FF44020))
#define LED_BIT          (1u << 2)   // GPIO2

// Funciones ROM (siempre en IRAM, nunca crashean)
extern "C" {
    void ets_delay_us(uint32_t us);
    int  esp_rom_printf(const char* fmt, ...);
}

// Strings en DRAM (no en flash)
static const char DRAM_ATTR s_on[]  = "LED_ON\n";
static const char DRAM_ATTR s_off[] = "LED_OFF\n";

void IRAM_ATTR setup() {
    GPIO_ENABLE_W1TS = LED_BIT;
    for (int i = 0; i < 5; i++) {
        GPIO_OUT_W1TS = LED_BIT;
        esp_rom_printf(s_on);
        ets_delay_us(300000);          // 300 ms
        GPIO_OUT_W1TC = LED_BIT;
        esp_rom_printf(s_off);
        ets_delay_us(300000);
    }
}

void IRAM_ATTR loop() { ets_delay_us(1000000); }

Sketches Arduino normales (con Serial.print, delay, digitalWrite) también funcionan correctamente con IDF 4.4.x.


4. WiFi emulada

lcgamboa implementa una WiFi simulada con SSIDs hardcoded:

// Solo estas redes están disponibles en la emulación:
WiFi.begin("PICSimLabWifi", "");    // sin contraseña
WiFi.begin("Espressif", "");

El ESP32 emulado puede:

  • Escanear redes (WiFi.scanNetworks()) → devuelve las dos SSIDs
  • Conectar y obtener IP (192.168.4.x)
  • Abrir sockets TCP/UDP (via SLIRP — NAT hacia el host)
  • Usar HTTPClient, WebServer, etc.

Limitaciones:

  • No hay forma de configurar las SSIDs o contraseñas desde Python
  • La IP del "router" virtual es 10.0.2.2 (host Windows)
  • El ESP32 emulado es accesible en localhost:PORT via port forwarding SLIRP

5. I2C emulado

El callback I2C es síncrono — QEMU espera la respuesta antes de continuar:

# Protocolo de eventos I2C (campo `event`):
0x0100  # START + dirección (READ si bit0 de addr=1)
0x0200  # WRITE byte (byte en bits 7:0 del event)
0x0300  # READ request (el callback debe retornar el byte a poner en SDA)
0x0000  # STOP / idle

Simular un sensor I2C (ej. temperatura):

# Configurar qué byte devuelve el ESP32 cuando lee la dirección 0x48:
esp_lib_manager.set_i2c_response(client_id, addr=0x48, response_byte=75)
# → analogRead equivalente: el firmware leerá 75 de ese registro

Desde WebSocket:

{"type": "esp32_i2c_response", "data": {"addr": 72, "response": 75}}

6. RMT / NeoPixel (WS2812)

El evento RMT lleva un item de 32 bits codificado así:

bit31: level0  | bits[30:16]: duration0 | bit15: level1 | bits[14:0]: duration1

El _RmtDecoder acumula bits y decodifica frames WS2812 (24 bits por LED en orden GRB):

# Threshold de bit: pulso alto > 48 ticks (a 80 MHz APB = ~600 ns) → bit 1
_WS2812_HIGH_THRESHOLD = 48

# Bit 1: high ~64 ticks (800 ns), low ~36 ticks (450 ns)
# Bit 0: high ~32 ticks (400 ns), low ~68 ticks (850 ns)

El evento emitido al frontend:

{
  "type": "ws2812_update",
  "data": {
    "channel": 0,
    "pixels": [
      {"r": 255, "g": 0, "b": 0},
      {"r": 0, "g": 255, "b": 0}
    ]
  }
}

7. LEDC / PWM

qemu_picsimlab_get_internals(0) retorna un puntero a un array de 16 uint32_t con el duty cycle de cada canal LEDC. Llamar periódicamente (cada ~50 ms):

await esp_lib_manager.poll_ledc(client_id)
# Emite: {"type": "ledc_update", "data": {"channel": 0, "duty": 4096, "duty_pct": 50.0}}

El duty máximo típico es 8192 (timer de 13 bits). Para brillo de LED: duty_pct / 100.


8. Compilación de la DLL

8.1 Requisitos

  • MSYS2 instalado en C:\msys64
  • Paquetes MINGW64: gcc glib2 libgcrypt libslirp pixman ninja meson python git
pacman -S mingw-w64-x86_64-{gcc,glib2,libgcrypt,libslirp,pixman,ninja,meson,python,git}

8.2 Proceso de build

# 1. Configurar (en MSYS2 MINGW64):
cd wokwi-libs/qemu-lcgamboa
./configure \
  --target-list=xtensa-softmmu \
  --disable-werror --enable-tcg \
  --enable-gcrypt --enable-slirp \
  --enable-iconv --without-default-features

# 2. Compilar el binario principal:
ninja -j$(nproc) qemu-system-xtensa.exe

# 3. Relinkar como DLL (script automatizado):
bash build_qemu_step4.sh
# → genera libqemu-xtensa.dll en build/
# → la copia a backend/app/services/

El proceso extrae el comando de link de build.ninja, elimina softmmu_main.c.obj (que contiene main()), y agrega flags de DLL:

cc -m64 -mcx16 -shared \
   -Wl,--export-all-symbols \
   -Wl,--allow-multiple-definition \
   -o libqemu-xtensa.dll \
   @dll_link.rsp      # todos los .obj excepto softmmu_main

8.4 Verificar exports

objdump -p libqemu-xtensa.dll | grep -i "qemu_picsimlab\|qemu_init\|qemu_main"
# Debe mostrar:
#   qemu_init
#   qemu_main_loop
#   qemu_cleanup
#   qemu_picsimlab_register_callbacks
#   qemu_picsimlab_set_pin
#   qemu_picsimlab_set_apin
#   qemu_picsimlab_uart_receive
#   qemu_picsimlab_get_internals
#   qemu_picsimlab_get_TIOCM

Windows no permite crear symlinks sin privilegios de administrador. El script de QEMU falla con WinError 1314. Parche aplicado:

# En scripts/symlink-install-tree.py, dentro del loop de symlinks:
if os.name == 'nt':
    if not os.path.exists(source):
        continue
    import shutil
    try:
        shutil.copy2(source, bundle_dest)
    except Exception as copy_err:
        print(f'error copying {source}: {copy_err}', file=sys.stderr)
    continue

9. Tests

Archivo: test/esp32/test_esp32_lib_bridge.py

# Ejecutar todos los tests:
backend/venv/Scripts/python.exe -m pytest test/esp32/test_esp32_lib_bridge.py -v

# Resultado esperado: 28 passed en ~13 segundos

Grupos de tests:

Grupo Tests Qué verifica
TestDllExists 5 Rutas de DLL, ROM binaries, MinGW64
TestDllLoads 3 Carga de DLL, symbols exportados
TestPinmap 3 Estructura del pinmap, GPIO2 en slot 3
TestManagerAvailability 2 is_available(), API surface
TestEsp32LibIntegration 15 QEMU real con firmware blink: boot, UART, GPIO, ADC, SPI, I2C

Firmware de test: test/esp32-emulator/binaries_lcgamboa/blink_lcgamboa.ino.merged.bin Compilado con arduino-esp32 2.0.17, DIO flash mode, imagen 4MB completa.


10. Limitaciones conocidas (no solucionables sin modificar QEMU)

Limitación Causa Workaround
Una sola instancia ESP32 por proceso QEMU usa estado global en variables estáticas Lanzar múltiples procesos Python
WiFi solo con SSIDs hardcoded lcgamboa codifica "PICSimLabWifi" y "Espressif" en C Modificar y recompilar la DLL
Sin BLE / Bluetooth Classic No implementado en lcgamboa No disponible
Sin touch capacitivo touchRead() no tiene callback en picsimlab No disponible
Sin DAC GPIO25/GPIO26 analógico no expuesto por picsimlab No disponible
Flash fija en 4MB Hardcoded en la machine esp32-picsimlab Recompilar DLL
arduino-esp32 3.x causa crash IDF 5.x maneja caché diferente al WiFi emulado Usar 2.x (IDF 4.4.x)

11. Pendiente en el frontend

Los eventos son emitidos por el backend pero el frontend aún no los consume:

Evento Componente frontend a crear
ws2812_update NeoPixel.tsx — strip de LEDs RGB
ledc_update Modificar LED.tsx para brillo variable
gpio_change Conectar al PinManager del ESP32 (análogo al AVR)
gpio_dir Mostrar dirección de pin en el inspector
i2c_event Sensores I2C simulados (SSD1306, BME280, etc.)
spi_event Displays SPI (ILI9341 ya implementado para AVR)
system: crash Notificación en la UI + botón de restart
system: reboot Indicador de reinicio en el canvas

12. Variables de entorno

Variable Valor Efecto
QEMU_ESP32_LIB ruta a libqemu-xtensa.dll Fuerza ruta de DLL (override auto-detect)
QEMU_ESP32_BINARY ruta a qemu-system-xtensa.exe Fallback subprocess (sin DLL)

Si QEMU_ESP32_LIB no está seteado, el sistema busca libqemu-xtensa.dll en la misma carpeta que esp32_lib_bridge.py.