Trabajar con muestreo de audio ESP32
Publicado: 2022-03-11El ESP32 es un microcontrolador habilitado para Wi-Fi y Bluetooth de próxima generación. Es el sucesor de Espressif, con sede en Shanghái, del muy popular y revolucionario microcontrolador ESP8266 para el público aficionado.
Un gigante entre los microcontroladores, las especificaciones del ESP32 incluyen todo menos el fregadero de la cocina. Es un producto de sistema en un chip (SoC) y prácticamente requiere un sistema operativo para hacer uso de todas sus funciones.
Este tutorial ESP32 explicará y resolverá un problema particular de muestreo del convertidor analógico a digital (ADC) de una interrupción del temporizador. Usaremos el IDE de Arduino. Incluso si es uno de los peores IDE que existen en términos de conjuntos de funciones, Arduino IDE es al menos fácil de configurar y usar para el desarrollo de ESP32, y tiene la colección más grande de bibliotecas para una variedad de módulos de hardware comunes. Sin embargo, también usaremos muchas API ESP-IDF nativas en lugar de las de Arduino, por motivos de rendimiento.
ESP32 Audio: Temporizadores e Interrupciones
El ESP32 contiene cuatro temporizadores de hardware, divididos en dos grupos. Todos los temporizadores son iguales, tienen preescaladores de 16 bits y contadores de 64 bits. El valor de preescala se usa para limitar la señal del reloj del hardware, que proviene de un reloj interno de 80 MHz que ingresa al temporizador, a cada enésimo tic. El valor mínimo de preescala es 2, lo que significa que las interrupciones pueden dispararse oficialmente a 40 MHz como máximo. Esto no está mal, ya que significa que con la resolución de temporizador más alta, el código del controlador debe ejecutarse en un máximo de 6 ciclos de reloj (núcleo de 240 MHz/40 MHz). Los temporizadores tienen varias propiedades asociadas:
-
divider
: el valor de preescala de frecuencia -
counter_en
: si el contador de 64 bits asociado del temporizador está habilitado (generalmente verdadero) -
counter_dir
: si el contador se incrementa o se reduce -
alarm_en
—si la “alarma”, es decir, la acción del contador, está habilitada -
auto_reload
: si el contador se restablece cuando se activa la alarma
Algunos de los modos de temporizador distintos importantes son:
- El temporizador está desactivado. El hardware no funciona en absoluto.
- El temporizador está habilitado, pero la alarma está deshabilitada. El hardware del temporizador está en marcha, incrementa o decrementa opcionalmente el contador interno, pero no sucede nada más.
- El temporizador está habilitado y su alarma también está habilitada. Como antes, pero esta vez se realiza alguna acción cuando el contador del temporizador alcanza un valor configurado particular: el contador se reinicia y/o se genera una interrupción.
Los contadores de los temporizadores se pueden leer mediante código arbitrario, pero en la mayoría de los casos, estamos interesados en hacer algo periódicamente, y esto significa que configuraremos el hardware del temporizador para generar una interrupción y escribiremos código para manejarla.
Una función de manejo de interrupciones debe finalizar antes de que se genere la siguiente interrupción, lo que nos da un límite superior estricto sobre cuán compleja puede llegar a ser la función. Generalmente, un manejador de interrupciones debe hacer la menor cantidad de trabajo posible.
Para lograr algo remotamente complejo, en su lugar, debe establecer una bandera que se verifique mediante un código de no interrupción. Cualquier tipo de E/S más complejo que leer o configurar un solo pin en un solo valor a menudo se descarga mejor a un controlador separado.
En el entorno ESP-IDF, la función FreeRTOS vTaskNotifyGiveFromISR()
se puede usar para notificar a una tarea que el controlador de interrupciones (también llamado Interrupt Service Routine, o ISR) tiene algo que hacer. El código se ve así:
portMUX_TYPE DRAM_ATTR timerMux = portMUX_INITIALIZER_UNLOCKED; TaskHandle_t complexHandlerTask; hw_timer_t * adcTimer = NULL; // our timer void complexHandler(void *param) { while (true) { // Sleep until the ISR gives us something to do, or for 1 second uint32_t tcount = ulTaskNotifyTake(pdFALSE, pdMS_TO_TICKS(1000)); if (check_for_work) { // Do something complex and CPU-intensive } } } void IRAM_ATTR onTimer() { // A mutex protects the handler from reentry (which shouldn't happen, but just in case) portENTER_CRITICAL_ISR(&timerMux); // Do something, eg read a pin. if (some_condition) { // Notify complexHandlerTask that the buffer is full. BaseType_t xHigherPriorityTaskWoken = pdFALSE; vTaskNotifyGiveFromISR(complexHandlerTask, &xHigherPriorityTaskWoken); if (xHigherPriorityTaskWoken) { portYIELD_FROM_ISR(); } } portEXIT_CRITICAL_ISR(&timerMux); } void setup() { xTaskCreate(complexHandler, "Handler Task", 8192, NULL, 1, &complexHandlerTask); adcTimer = timerBegin(3, 80, true); // 80 MHz / 80 = 1 MHz hardware clock for easy figuring timerAttachInterrupt(adcTimer, &onTimer, true); // Attaches the handler function to the timer timerAlarmWrite(adcTimer, 45, true); // Interrupts when counter == 45, ie 22.222 times a second timerAlarmEnable(adcTimer); }
Nota: Las funciones utilizadas en el código a lo largo de este artículo están documentadas con la API de ESP-IDF y en el proyecto principal GitHub de ESP32 Arduino.
Cachés de CPU y la arquitectura de Harvard
Una cosa muy importante a tener en cuenta es la cláusula IRAM_ATTR
en la definición del controlador de interrupciones onTimer()
. La razón de esto es que los núcleos de la CPU solo pueden ejecutar instrucciones (y acceder a datos) desde la RAM integrada, no desde el almacenamiento flash donde normalmente se almacenan el código del programa y los datos. Para evitar esto, una parte del total de 520 KiB de RAM se dedica como IRAM, un caché de 128 KiB que se usa para cargar código de forma transparente desde el almacenamiento flash. El ESP32 usa buses separados para código y datos ("arquitectura Harvard"), por lo que se manejan por separado, y eso se extiende a las propiedades de la memoria: IRAM es especial y solo se puede acceder a ella en límites de direcciones de 32 bits.
De hecho, la memoria ESP32 es muy poco uniforme. Las diferentes regiones están dedicadas para diferentes propósitos: la región continua máxima tiene un tamaño de alrededor de 160 KiB, y toda la memoria "normal" a la que pueden acceder los programas del usuario solo suma alrededor de 316 KiB.
La carga de datos desde el almacenamiento flash es lenta y puede requerir acceso al bus SPI, por lo que cualquier código que dependa de la velocidad debe tener cuidado de caber en la memoria caché IRAM y, a menudo, mucho más pequeño (menos de 100 KiB) ya que una parte es utilizada por el Sistema operativo. En particular, el sistema generará una excepción si el código del controlador de interrupciones no se carga en el caché cuando ocurre una interrupción. Sería muy lento y una pesadilla logística cargar algo desde el almacenamiento flash justo cuando ocurre una interrupción. El especificador IRAM_ATTR
en el controlador onTimer()
le dice al compilador y al enlazador que marquen este código como especial: se colocará estáticamente en IRAM y nunca se intercambiará.

Sin embargo, IRAM_ATTR
solo se aplica a la función en la que se especifica; las funciones llamadas desde esa función no se ven afectadas.
Muestreo de datos de audio ESP32 de una interrupción de temporizador
La forma habitual en que se muestrean las señales de audio de una interrupción implica mantener un búfer de memoria de muestras, llenarlo con datos muestreados y luego notificar a una tarea de controlador que los datos están disponibles.
El ESP-IDF documenta la función adc1_get_raw()
que mide datos en un canal ADC particular en el primer periférico ADC (el segundo lo usa WiFi). Sin embargo, usarlo en el código del controlador del temporizador da como resultado un programa inestable, porque es una función compleja que llama a un número no trivial de otras funciones IDF, en particular las que se ocupan de bloqueos, y ni adc1_get_raw()
ni las funciones las llamadas están marcadas con IRAM_ATTR
. El controlador de interrupciones se bloqueará tan pronto como se ejecute un código lo suficientemente grande como para que las funciones ADC se intercambien fuera de IRAM, y esto puede ser la pila WiFi-TCP/IP-HTTP o la biblioteca del sistema de archivos SPIFFS, O algo más.
Nota: Algunas funciones IDF están especialmente diseñadas (y marcadas con IRAM_ATTR
) para que puedan llamarse desde controladores de interrupciones. La función vTaskNotifyGiveFromISR()
del ejemplo anterior es una de esas funciones.
La forma más amigable de IDF para evitar esto es que el controlador de interrupciones notifique a una tarea cuando se necesita tomar una muestra de ADC, y hacer que esta tarea realice el muestreo y la administración del búfer, y posiblemente se use otra tarea para el análisis de datos (o compresión o transmisión o cualquiera que sea el caso). Desafortunadamente, esto es extremadamente ineficiente. Tanto el lado del controlador (que notifica una tarea que hay trabajo por hacer) como el lado de la tarea (que recoge una tarea por hacer) involucran interacciones con el sistema operativo y miles de instrucciones que se ejecutan. Este enfoque, aunque teóricamente correcto, puede atascar tanto la CPU que deja poca potencia de CPU disponible para otras tareas.
Excavando a través del código fuente de IDF
El muestreo de datos de un ADC suele ser una tarea sencilla, por lo que la siguiente estrategia es ver cómo lo hace el IDF y replicarlo directamente en nuestro código, sin llamar a la API proporcionada. La función adc1_get_raw()
se implementa en el archivo rtc_module.c
del IDF, y de las ocho o más cosas que hace, solo una realmente está muestreando el ADC, que se realiza mediante una llamada a adc_convert()
. Afortunadamente, adc_convert()
es una función simple que muestrea el ADC mediante la manipulación de registros de hardware periférico a través de una estructura global llamada SENS
.
Adaptar este código para que funcione en nuestro programa (e imitar el comportamiento de adc1_get_raw()
) es fácil. Se parece a esto:
int IRAM_ATTR local_adc1_read(int channel) { uint16_t adc_value; SENS.sar_meas_start1.sar1_en_pad = (1 << channel); // only one channel is selected while (SENS.sar_slave_addr1.meas_status != 0); SENS.sar_meas_start1.meas1_start_sar = 0; SENS.sar_meas_start1.meas1_start_sar = 1; while (SENS.sar_meas_start1.meas1_done_sar == 0); adc_value = SENS.sar_meas_start1.meas1_data_sar; return adc_value; }
El siguiente paso es incluir los encabezados relevantes para que la variable SENS
esté disponible:
#include <soc/sens_reg.h> #include <soc/sens_struct.h>
Finalmente, dado que adc1_get_raw()
realiza algunos pasos de configuración antes de muestrear el ADC, debe llamarse directamente, justo después de configurar el ADC. De esa manera, la configuración relevante se puede realizar antes de que se inicie el temporizador.
La desventaja de este enfoque es que no funciona bien con otras funciones de IDF. Tan pronto como se llame a algún otro periférico, controlador o código aleatorio que restablezca la configuración del ADC, nuestra función personalizada ya no funcionará correctamente. Al menos WiFi, PWM, I2C y SPI no influyen en la configuración de ADC. En caso de que algo lo influya, una llamada a adc1_get_raw()
configurará ADC apropiadamente nuevamente.
Muestreo de audio ESP32: el código final
Con la función local_adc_read()
en su lugar, nuestro código de controlador de temporizador se ve así:
#define ADC_SAMPLES_COUNT 1000 int16_t abuf[ADC_SAMPLES_COUNT]; int16_t abufPos = 0; void IRAM_ATTR onTimer() { portENTER_CRITICAL_ISR(&timerMux); abuf[abufPos++] = local_adc1_read(ADC1_CHANNEL_0); if (abufPos >= ADC_SAMPLES_COUNT) { abufPos = 0; // Notify adcTask that the buffer is full. BaseType_t xHigherPriorityTaskWoken = pdFALSE; vTaskNotifyGiveFromISR(adcTaskHandle, &xHigherPriorityTaskWoken); if (xHigherPriorityTaskWoken) { portYIELD_FROM_ISR(); } } portEXIT_CRITICAL_ISR(&timerMux); }
Aquí, adcTaskHandle
es la tarea de FreeRTOS que se implementaría para procesar el búfer, siguiendo la estructura de la función complexHandler
en el primer fragmento de código. Haría una copia local del búfer de audio y luego podría procesarlo en su tiempo libre. Por ejemplo, podría ejecutar un algoritmo FFT en el búfer, o podría comprimirlo y transmitirlo a través de WiFi.
Paradójicamente, usar la API de Arduino en lugar de la API de ESP-IDF (es decir analogRead()
en lugar de adc1_get_raw()
) funcionaría porque las funciones de Arduino están marcadas con IRAM_ATTR
. Sin embargo, son mucho más lentos que los ESP-IDF ya que proporcionan un mayor nivel de abstracción. Hablando de rendimiento, nuestra función de lectura de ADC personalizada es aproximadamente el doble de rápida que la de ESP-IDF.
Proyectos ESP32: A OS o No a OS
Lo que hicimos aquí (reimplementar una API del sistema operativo para solucionar algunos problemas que ni siquiera existirían si no usáramos un sistema operativo) es una buena ilustración de los pros y los contras de usar un sistema operativo en el primer lugar.
Los microcontroladores más pequeños se programan directamente, a veces en código ensamblador, y los desarrolladores tienen control total sobre todos los aspectos de la ejecución del programa, de cada instrucción de la CPU y todos los estados de todos los periféricos del chip. Naturalmente, esto puede volverse tedioso a medida que el programa crece y usa más y más hardware. Un microcontrolador complejo como el ESP32, con un gran conjunto de periféricos, dos núcleos de CPU y un diseño de memoria complejo y no uniforme, sería difícil y laborioso de programar desde cero.
Si bien todos los sistemas operativos imponen algunos límites y requisitos al código que utiliza sus servicios, los beneficios suelen valer la pena: un desarrollo más rápido y sencillo. Sin embargo, a veces podemos, y en el espacio incrustado a menudo deberíamos, sortearlo.