Fecha: 12 de septiembre de 2026
Plataforma: ESP32 (ESP32-D0WDQ6, dual core, 240 MHz) ·
ESP-IDF 5.4 · FreeRTOS Ruta:
~/esp/mflashv2 · app mflash · rama
master
Generador de señales DDS por DAC con interfaz local (OLED + encoder) y dashboard web por WiFi.
Firmware para ESP32 que sintetiza señales seno, cuadrada, sierra y PWM mediante DDS (Direct Digital Synthesis) sobre el DAC interno, con frecuencia ajustable de 0.1 Hz a 10 kHz. La señal se genera desde una ISR de timer a 20 kHz, aislada en el core 1. El usuario controla el generador con un encoder rotativo y ve el estado en una OLED SSD1306; en paralelo, un servidor HTTP publica el estado y acepta cambios en formato JSON.
| Elemento | Señal | GPIO | Configuración | Notas |
|---|---|---|---|---|
| Encoder HW-040 | CLK | GPIO 35 | Entrada, FLOATING, ANYEDGE | Input-only, pull-up externo obligatorio |
| Encoder HW-040 | DT | GPIO 32 | Entrada, PULLUP, ANYEDGE | Pull-up interno |
| Encoder HW-040 | SW | GPIO 33 | Entrada, PULLUP, NEGEDGE | Activo en LOW |
| DAC | Salida | GPIO 25 | DAC_CHAN_0 | Señal generada |
| Debug | Test | GPIO 2 | Salida | FS/2 = 10 kHz |
| OLED SSD1306 | SCL | GPIO 22 | I2C_NUM_0, 400 kHz | Maestro I2C |
| OLED SSD1306 | SDA | GPIO 19 | I2C_NUM_0, 400 kHz | Maestro I2C |
| OLED SSD1306 | Dirección | — | 0x3C | 7 bits |
Por qué GPIO35 no usa pull-up interno: es un pin
input-only del ESP32 y el silicio no le ofrece resistencia de
pull-up interna. En el código se configura explícitamente como
GPIO_FLOATING [1]. Al estar las interrupciones en
GPIO_INTR_ANYEDGE, un pin flotante no produce lecturas
erráticas: produce una tormenta de interrupciones. Es
el punto más frágil de todo el montaje y hay que verificar con
osciloscopio que CLK reposa en 3.3 V.
| Núcleo | Constante | Contenido |
|---|---|---|
| Core 1 | CORE_SIGNAL |
ISR del DAC (timer_isr) |
| Core 0 | CORE_APP |
OLED, UI, servidor web y pila WiFi |
La afinidad de la ISR del DAC no depende de los
flags de registro, sino del núcleo desde el que se registra la
interrupción. Por eso timer_init_hw() se invoca desde una
tarea anclada al core 1.
| Tarea | Función | Core | Prio | Stack | Misión |
|---|---|---|---|---|---|
| — | app_main |
0 | 1 | — | Init y creación de tareas; luego portMAX_DELAY |
sig |
signal_core_init |
1 | 5 | 4096 | Registra el timer en core 1, fija frecuencia, log único, se autodestruye |
ui |
task_ui |
0 | 3 | 4096 | Bloqueada en enc_q; aplica eventos de encoder |
OLED |
task_oled |
0 | 3 | 4096 | Refresco cada 500 ms |
web |
web_task |
0 | 3 | 6144 | Servidor HTTP en puerto 80 |
| ISR | Fuente | Disparo | Core | Notas |
|---|---|---|---|---|
timer_isr |
TIMG0 / TIMER_0 | Alarma a 20 kHz | 1 | Sin IRAM_ATTR, registrada con flags
0 |
enc_quad_isr |
GPIO35 + GPIO32 | GPIO_INTR_ANYEDGE |
0 | Una sola función para ambos pines |
enc_btn_isr |
GPIO33 | GPIO_INTR_NEGEDGE |
0 | Antirrebote 50 ms |
enc_quad_isr / enc_btn_isr --(xQueueSendFromISR)--> enc_q --(xQueueReceive)--> task_ui
|
menu_advance() / rotary_apply()
Las ISR solo encolan: no tocan el estado del
generador ni llaman a nada que no sea IRAM-safe
(gpio_get_level, esp_timer_get_time,
xQueueSendFromISR).
En el driver legacy de timer group, la interrupción queda ligada al
núcleo que la registra. La tarea de WiFi corre en el
core 0 con prioridad 23, así que registrar el timer desde
app_main (core 0) ponía la ISR de 20 kHz a competir con
WiFi y la pila de red. Registrándolo desde
signal_core_init, anclada a CORE_SIGNAL, la
ISR queda sola en el core 1.
El diseño anterior sondeaba el encoder desde una tarea con periodo de
tick (10 ms con CONFIG_FREERTOS_HZ = 100). A esa velocidad,
un giro rápido genera transiciones cada ~1 ms y se pierden
detentes. Sustituirlo por un filtro de tiempo era peor:
cualquier guarda cercana a los rebotes medidos recorta también
transiciones legítimas.
La solución es una máquina de estados de cuadratura
completa: se detectan los 4 estados (CLK, DT) y
solo se acepta la transición si es un paso válido de la secuencia Gray.
Los saltos en que cambian los dos bits son rebote y se descartan por
construcción. Además, un rebote que va y vuelve (00→01→00)
suma +1 y −1, y se cancela. Resultado: no hace falta antirrebote
por tiempo en CLK/DT.
La tabla enc_quad_table[16] está en
DRAM_ATTR (accesible con la caché de flash apagada), y
ENC_DEBOUNCE_US se eliminó del código.
ENC_COUNTS_PER_STEP = 2Un EC11 nominal recorre los 4 estados de cuadratura por detente, lo que sugeriría un divisor de 4. Medido en esta unidad HW-040 concreta: 10 clics producen +50 Hz con paso de 10 Hz, es decir 1 paso por clic. Eso significa que este encoder genera solo 2 transiciones válidas por detente (los detentes caen a mitad de ciclo).
El divisor es un parámetro de la pieza física, no del datasheet
genérico. Con 2 se obtiene exactamente 1 paso de menú por
clic.
Compromiso asumido: con divisor 2 hay la mitad de
holgura frente a transiciones espurias que con 4, y un giro parcial (sin
llegar al detente) puede registrar un paso. Si alguna vez aparecen
saltos fantasma, volver a 4.
El sentido real de giro se corrigió invirtiendo el signo de
los 16 valores de enc_quad_table respecto a la
versión de referencia. Es la forma canónica: la dirección es una
convención de signo, y los ceros (transiciones inválidas) permanecen
intactos, así que el rechazo de rebote no se ve afectado.
Consecuencia: los eventos EV_ROT_CW y
EV_ROT_CCW están cruzados respecto al
sentido físico de giro. Hoy es inocuo porque rotary_apply()
solo suma o resta 10 Hz y la OLED no muestra dirección. Si algún día se
añade un indicador de sentido en pantalla, hay que tenerlo en cuenta.
Ver pendiente D.
task_ui bloqueada en la cola con
portMAX_DELAYCon la tarea bloqueada en
xQueueReceive(enc_q, ..., portMAX_DELAY) el consumo en
reposo es cero: no hay sondeo, no hace falta
vTaskDelay, y el idle task del core 0 dispone de
CPU, así que el Task Watchdog no puede dispararse. Los eventos se emiten
solo cuando ocurren de verdad.
Esta decisión además elimina el parche temporal
vTaskDelay(1) que se había puesto para desbloquear el
watchdog. Contexto: vTaskDelay(pdMS_TO_TICKS(5)) con
CONFIG_FREERTOS_HZ = 100 vale 0, y
vTaskDelay(0) no bloquea, dejando la tarea girando y
matando de hambre a IDLE0. Fue la causa del WDT que se veía en la fase
de validación.
ESP_INTR_FLAG_IRAMtimer_isr llama a dac_output_voltage() y
gpio_set_level(), que no están en IRAM.
Mantener el flag obligaba a que la ISR pudiera ejecutarse con la caché
de flash deshabilitada, lo que es un riesgo real de crash intermitente
cuando WiFi escribe en flash.
Quitando el flag (y el IRAM_ATTR de la función), el
sistema enmascara la interrupción durante esas
operaciones, y la ISR nunca corre con la caché apagada. El problema
desaparece por construcción.
Coste aceptado: el DAC se queda congelado en el último valor (meseta) durante las escrituras en flash. En este firmware eso solo ocurre en el arranque, así que es una meseta de milisegundos casi teórica. Ver pendiente F y G.
La métrica de duración de la ISR usaba dos llamadas a
esp_timer_get_time() en cada interrupción:
40 000 llamadas por segundo, justo en el núcleo que se aisló para
proteger la señal. Además el dato se medía a sí mismo (incluía el coste
de las dos llamadas).
Con ISR_MEASURE_DIV = 1000 el coste cae 1000×, el dato
se sigue refrescando 20 veces por segundo (de sobra para una OLED a 500
ms) y task_oled y web_task no
cambian.
Además isr_duration_us pasó de uint64_t a
uint32_t para que la lectura cruzada entre
ISR y tareas sea atómica.
| Constante | Valor | Significado |
|---|---|---|
FS |
20000 Hz | Frecuencia de muestreo = frecuencia de la ISR del DAC (periodo 50 µs) |
TABLE_SIZE |
1024 | Entradas de cada tabla DDS |
TABLE_BITS |
10 | log2(TABLE_SIZE) |
PHASE_BITS |
32 | Bits del acumulador de fase |
TABLE_SHIFT |
22 | PHASE_BITS - TABLE_BITS; índice =
phase >> 22 |
amp_q15 |
32767 | Amplitud en formato Q15 (escala de 0 a 1) |
| divisor timer | 80 | 80 MHz / 80 = 1 MHz de contador; alarma = 1000000 / FS
= 50 |
ENC_QUEUE_LEN |
16 | Profundidad de enc_q (eventos
enc_evt_t) |
BTN_DEBOUNCE_US |
50000 | Antirrebote del pulsador (50 ms) |
ENC_COUNTS_PER_STEP |
2 | Transiciones válidas por paso de menú (medido en este HW-040) |
ISR_MEASURE_DIV |
1000 | Se mide 1 de cada 1000 ISR |
CORE_SIGNAL |
1 | Núcleo de la ISR del DAC |
CORE_APP |
0 | Núcleo de UI/OLED/web |
PRIO_APP |
3 | Prioridad de las tareas de trabajo (WiFi usa 23, idle 0) |
WIFI_SSID / WIFI_PASS |
hardcoded | Pendiente: moverlas a NVS o a Kconfig (ver pendiente C) |
Osciloscopio Hantek DS2100, DC, 1 ms/div, GND común con la ESP32, canal 1 en CLK (GPIO35) y canal 2 en DT (GPIO32):
| Señal | Medida | Repeticiones |
|---|---|---|
| Tren de rebotes CLK/DT | < 600 µs, ambos sentidos | Repetidas, siempre estable después |
| Rebote del pulsador (SW) | ~1200 µs | — |
Conclusión: entre dos transiciones válidas de un detente hay milisegundos, mientras que el rebote se agota en menos de 600 µs. Por eso no hace falta antirrebote por tiempo en CLK/DT: la tabla de cuadratura descarta los saltos inválidos por sí sola, y añadir una guarda temporal solo restaría margen a alta velocidad de giro. En el pulsador sí se mantiene el filtro de 50 ms, con holgura de sobra sobre los 1200 µs medidos.
Funciona y está verificado en hardware:
freq, waveform, pwm_percent,
cpu_percent, ram_free,
stack_free).MAIN: mflash arrancando - FS 20000 Hz, 1000.0 Hz en DAC.Pruebas superadas:
driver/dac.h y driver/timer.h [1]).Cache disabled but cached memory region accessed.master, árbol limpio, tags
fase4-ok y fase4-punto2-ok.Versión: mflash.bin ≈ 0xca870 bytes,
21% libre de la partición de 1 MB.
# Compilar
idf.py build
# Compilar guardando log y filtrando avisos
idf.py build 2>&1 | tee logs/build.log
grep -inE "warning|error:" logs/build.log
# Flashear y abrir monitor
idf.py -p /dev/ttyUSB0 flash monitor
# Solo monitor
idf.py monitor
# Git
git tag -l
git log --oneline --decorate -20
git describe --tags --dirty
# Comprobaciones rápidas del fuente
awk '{n+=gsub(/\{/,"{")-gsub(/\}/,"}")}END{print "balance:",n}' main/mflash.c
grep -c "void app_main" main/mflash.cPrueba de verificación del encoder (la que valida la cuadratura y el divisor):
CONFIGURACION con > en
Freq.Si saltara 4 pasos por clic, subir ENC_COUNTS_PER_STEP;
si saltara uno cada dos clics, bajarlo a 1. Si el sentido saliera
invertido, invertir de nuevo los signos de la tabla.
| Fase | Tag | Commit | Contenido |
|---|---|---|---|
| Baseline | — | — | Estado sucio: antes-limpieza-ssd1306-dirty. Un único
proveedor de ssd1306_init en
components/ssd1306 |
| Fases 1 + 2 | fase2-ok |
— | Un solo log de arranque; ISR del DAC en core 1; tareas de trabajo en core 0 a prioridad 3; corregido el WDT de IDLE0 |
| Fase 3 | fase3-ok |
9640758 |
Encoder por cuadratura: ISR en CLK y DT a cualquier flanco, tabla
enc_quad_table[16] en DRAM, 1 clic = 1 paso |
| Fase 4 (punto 1) | fase4-ok |
325d4e2 |
ISR del DAC sin ESP_INTR_FLAG_IRAM;
ENC_COUNTS_PER_STEP 4 → 2 |
| Fase 4 (punto 2) | fase4-punto2-ok |
e8085bc |
Medición de duración de ISR muestreada 1 de cada 1000 |
antes-limpieza-ssd1306 es la cadena que devolvía
git describe en el baseline, no un tag creado a
propósito.
Nota: los que en su día fueron los dos pendientes de prioridad alta y media —el
ESP_INTR_FLAG_IRAMconviviendo condac_output_voltage()/gpio_set_level(), y las dos llamadas aesp_timer_get_time()por interrupción— quedaron resueltos en la Fase 4 (puntos 1 y 2). Se documentan abajo como cerrados para que no se reabran por error.
| # | Pendiente | Prioridad | Estado |
|---|---|---|---|
| A | ESP_INTR_FLAG_IRAM +
dac_output_voltage()/gpio_set_level() en
timer_isr |
ALTA | Resuelto en Fase 4 punto 1 |
| B | Dos esp_timer_get_time() por interrupción a 20 kHz |
MEDIA | Resuelto en Fase 4 punto 2 |
| C | Credenciales WiFi hardcoded en el fuente | BAJA | Abierto |
| D | EV_ROT_CW/EV_ROT_CCW cruzados respecto al
sentido físico |
BAJA | Abierto (documentado) |
| E | Reconexión WiFi automática ante caída del AP | BAJA | Abierto |
| F | Los 2 warnings de deprecación (driver/dac.h,
driver/timer.h [1]) |
MEDIA | Abierto |
| G | Restricción latente: no hacer escrituras en flash en runtime | MEDIA | Abierto |
C — Credenciales WiFi. WIFI_SSID y
WIFI_PASS están en el fuente. Moverlas a NVS o a Kconfig.
Ojo: el repositorio queda publicado con las credenciales en el historial
de git.
D — Sentido de giro. Opciones: dejar la deuda
documentada, o renombrar los eventos intercambiándolos en
enc_quad_isr para que EV_ROT_CW coincida con
el sentido físico real. Renombrar en la ISR es más limpio que volver a
tocar la tabla.
E — Reconexión WiFi. Hoy hay
esp_wifi_connect() al arrancar, pero no hay manejador de
eventos que reconecte si el AP cae o se reinicia.
F — Warnings de deprecación. Son los únicos avisos
del build. La migración interesante es
driver/dac_continuous.h con DMA: generaría
la onda por hardware desde un buffer, eliminando la ISR entera, el
aislamiento de núcleo, el coste de CPU, la meseta durante escrituras en
flash y el warning del DAC, todo a la vez. Es el diseño natural para 1
kHz con 20 kHz de muestreo. Requiere rehacer esa parte con la API del
driver; no es un parche. Para el timer, driver/gptimer.h
sustituye al driver legacy y permite fijar la afinidad de núcleo de
forma explícita.
G — Escrituras en flash en runtime. Consecuencia
directa de la decisión 4.6: hoy la meseta del DAC durante una escritura
en flash es aceptable porque solo ocurre en el arranque. Si algún día se
añade OTA o persistencia en NVS en caliente, escribe en
flash durante segundos y congelaría la señal. En ese momento hay que
volver a ESP_INTR_FLAG_IRAM y escribir el DAC por registro
directo (RTC_IO_PAD_DAC1_REG y
GPIO.out_w1ts/out_w1tc), asumiendo la
dependencia de registros internos. Decidir esto antes de meter
OTA.
GPIO35 (CLK) necesita pull-up externo,
siempre. Verificar que el pin reposa en 3.3 V antes de conectar el
encoder [1].gpio_install_isr_service(0) se llama una sola
vez en todo el programa.IRAM_ATTR y
no llaman a nada que esté en flash.timer_isr no lleva
IRAM_ATTR a propósito. No volver a añadirlo sin añadir
también el flag y cambiar las llamadas al DAC.isr_duration_us se escribe
solo en timer_isr y se lee en
task_oled y web_task. Es uint32_t
para que la lectura sea atómica.logs/build_*.log entraron en varios
commits. Conviene añadir logs/ y backups/ a
.gitignore.