Trabalhando com Amostragem de Áudio ESP32
Publicados: 2022-03-11O ESP32 é um microcontrolador de última geração, habilitado para WiFi e Bluetooth. É o sucessor do Espressif, com sede em Xangai, do muito popular – e, para o público amador, revolucionário – microcontrolador ESP8266.
Um gigante entre os microcontroladores, as especificações do ESP32 incluem tudo, menos a pia da cozinha. É um produto system-on-a-chip (SoC) e praticamente requer um sistema operacional para fazer uso de todos os seus recursos.
Este tutorial do ESP32 explicará e resolverá um problema específico de amostragem do conversor analógico para digital (ADC) de uma interrupção do temporizador. Usaremos a IDE do Arduino. Mesmo que seja um dos piores IDEs do mercado em termos de conjuntos de recursos, o Arduino IDE é pelo menos fácil de configurar e usar para desenvolvimento ESP32 e possui a maior coleção de bibliotecas para uma variedade de módulos de hardware comuns. No entanto, também usaremos muitas APIs ESP-IDF nativas em vez de Arduino, por motivos de desempenho.
Áudio ESP32: Temporizadores e interrupções
O ESP32 contém quatro temporizadores de hardware, divididos em dois grupos. Todos os temporizadores são os mesmos, tendo prescalers de 16 bits e contadores de 64 bits. O valor de pré-escala é usado para limitar o sinal de clock de hardware – que vem de um clock interno de 80 MHz que entra no temporizador – a cada enésimo tique. O valor mínimo de pré-escala é 2, o que significa que as interrupções podem disparar oficialmente a 40 MHz no máximo. Isso não é ruim, pois significa que na resolução mais alta do temporizador, o código do manipulador deve ser executado em no máximo 6 ciclos de clock (240 MHz core/40 MHz). Os temporizadores têm várias propriedades associadas:
-
divider
— o valor de pré-escala de frequência -
counter_en
— se o contador de 64 bits associado ao temporizador está habilitado (geralmente verdadeiro) -
counter_dir
— se o contador é incrementado ou decrementado -
alarm_en
o “alarme”, ou seja, a ação do contador, está habilitado -
auto_reload
— se o contador é reiniciado quando o alarme é acionado
Alguns dos importantes modos de temporizador distintos são:
- O temporizador está desabilitado. O hardware não está funcionando.
- O temporizador está ativado, mas o alarme está desativado. O hardware do temporizador está funcionando, está opcionalmente incrementando ou decrementando o contador interno, mas nada mais está acontecendo.
- O temporizador está habilitado e seu alarme também está habilitado. Como antes, mas desta vez alguma ação é executada quando o contador do temporizador atinge um determinado valor configurado: O contador é reinicializado e/ou uma interrupção é gerada.
Os contadores dos temporizadores podem ser lidos por código arbitrário, mas na maioria dos casos, estamos interessados em fazer algo periodicamente, e isso significa que configuraremos o hardware do temporizador para gerar uma interrupção e escreveremos código para lidar com isso.
Uma função de tratamento de interrupção deve terminar antes que a próxima interrupção seja gerada, o que nos dá um limite superior rígido de quão complexa a função pode ficar. Geralmente, um manipulador de interrupção deve fazer o mínimo de trabalho possível.
Para conseguir algo remotamente complexo, ele deve definir um sinalizador que é verificado por código ininterrupto. Qualquer tipo de E/S mais complexo do que ler ou definir um único pino para um único valor geralmente é melhor transferido para um manipulador separado.
No ambiente ESP-IDF, a função vTaskNotifyGiveFromISR()
do FreeRTOS pode ser usada para notificar uma tarefa de que o manipulador de interrupção (também chamado de Rotina de Serviço de Interrupção ou ISR) tem algo para fazer. O código fica assim:
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); }
Observação: as funções usadas no código ao longo deste artigo estão documentadas com a API ESP-IDF e no projeto GitHub do núcleo ESP32 Arduino.
Caches de CPU e a Arquitetura Harvard
Uma coisa muito importante a ser observada é a cláusula IRAM_ATTR
na definição do manipulador de interrupção onTimer()
. A razão para isso é que os núcleos da CPU só podem executar instruções (e acessar dados) da RAM incorporada, não do armazenamento flash onde o código do programa e os dados são normalmente armazenados. Para contornar isso, uma parte do total de 520 KiB de RAM é dedicada como IRAM, um cache de 128 KiB usado para carregar de forma transparente o código do armazenamento flash. O ESP32 usa barramentos separados para código e dados (“arquitetura Harvard”), portanto, eles são tratados separadamente e isso se estende às propriedades da memória: o IRAM é especial e só pode ser acessado em limites de endereço de 32 bits.
Na verdade, a memória ESP32 é muito não uniforme. Diferentes regiões dele são dedicadas para diferentes propósitos: A região contínua máxima é de cerca de 160 KiB de tamanho, e toda a memória “normal” acessível por programas do usuário totaliza apenas cerca de 316 KiB.
O carregamento de dados do armazenamento flash é lento e pode exigir acesso ao barramento SPI, portanto, qualquer código que dependa da velocidade deve ter o cuidado de caber no cache IRAM, e geralmente muito menor (menos de 100 KiB), pois uma parte dele é usada pelo sistema operacional. Notavelmente, o sistema gerará uma exceção se o código do manipulador de interrupção não for carregado no cache quando ocorrer uma interrupção. Seria muito lento e um pesadelo logístico carregar algo do armazenamento flash assim que ocorresse uma interrupção. O especificador IRAM_ATTR
no manipulador onTimer()
instrui o compilador e o vinculador a marcar esse código como especial - ele será colocado estaticamente no IRAM e nunca trocado.
No entanto, o IRAM_ATTR
só se aplica à função em que está especificado—qualquer função chamada dessa função não é afetada.

Amostragem de dados de áudio ESP32 de uma interrupção do temporizador
A maneira usual como os sinais de áudio são amostrados de uma interrupção envolve manter um buffer de memória de amostras, preenchê-lo com dados amostrados e, em seguida, notificar uma tarefa do manipulador de que os dados estão disponíveis.
O ESP-IDF documenta a função adc1_get_raw()
que mede dados em um determinado canal ADC no primeiro periférico ADC (o segundo é usado por WiFi). No entanto, usá-lo no código do manipulador de timer resulta em um programa instável, porque é uma função complexa que chama um número não trivial de outras funções IDF - em particular aquelas que lidam com bloqueios - e nem adc1_get_raw()
nem as funções as chamadas são marcadas com IRAM_ATTR
. O manipulador de interrupção travará assim que um pedaço de código grande o suficiente for executado, o que faria com que as funções do ADC fossem trocadas do IRAM - e isso pode ser a pilha WiFi-TCP/IP-HTTP ou a biblioteca do sistema de arquivos SPIFFS, ou qualquer outra coisa.
Nota: Algumas funções IDF são especialmente criadas (e marcadas com IRAM_ATTR
) para que possam ser chamadas de manipuladores de interrupção. A função vTaskNotifyGiveFromISR()
do exemplo acima é uma dessas funções.
A maneira mais amigável do IDF de contornar isso é o manipulador de interrupção notificar uma tarefa quando uma amostra ADC precisa ser coletada e fazer com que essa tarefa faça a amostragem e o gerenciamento de buffer, com possivelmente outra tarefa sendo usada para análise de dados (ou compressão ou transmissão ou qualquer que seja o caso). Infelizmente, isso é extremamente ineficiente. Tanto o lado do manipulador (que notifica uma tarefa que há trabalho a ser feito) quanto o lado da tarefa (que seleciona uma tarefa a fazer) envolvem interações com o sistema operacional e milhares de instruções sendo executadas. Essa abordagem, embora teoricamente correta, pode sobrecarregar tanto a CPU que deixa pouca energia de CPU sobrando para outras tarefas.
Explorando o código-fonte do IDF
A amostragem de dados de um ADC geralmente é uma tarefa simples, portanto, a próxima estratégia é ver como o IDF faz isso e replicá-lo diretamente em nosso código, sem chamar a API fornecida. A função adc1_get_raw()
é implementada no arquivo rtc_module.c
do IDF, e das oito ou mais coisas que ela faz, apenas uma está realmente amostrando o ADC, o que é feito por uma chamada para adc_convert()
. Felizmente, adc_convert()
é uma função simples que mostra o ADC manipulando registradores de hardware periférico por meio de uma estrutura global chamada SENS
.
Adaptar este código para que funcione em nosso programa (e para imitar o comportamento de adc1_get_raw()
) é fácil. Se parece com isso:
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; }
O próximo passo é incluir os cabeçalhos relevantes para que a variável SENS
fique disponível:
#include <soc/sens_reg.h> #include <soc/sens_struct.h>
Finalmente, como adc1_get_raw()
executa algumas etapas de configuração antes de amostrar o ADC, ele deve ser chamado diretamente, logo após a configuração do ADC. Dessa forma, a configuração relevante pode ser realizada antes que o temporizador seja iniciado.
A desvantagem dessa abordagem é que ela não funciona bem com outras funções IDF. Assim que algum outro periférico, driver ou um pedaço aleatório de código for chamado que redefine a configuração do ADC, nossa função personalizada não funcionará mais corretamente. Pelo menos WiFi, PWM, I2C e SPI não influenciam a configuração do ADC. Caso algo o influencie, uma chamada para adc1_get_raw()
irá configurar o ADC apropriadamente novamente.
Amostragem de áudio ESP32: o código final
Com a função local_adc_read()
em vigor, nosso código do manipulador de timer se parece com isso:
#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); }
Aqui, adcTaskHandle
é a tarefa do FreeRTOS que seria implementada para processar o buffer, seguindo a estrutura da função complexHandler
no primeiro trecho de código. Ele faria uma cópia local do buffer de áudio e poderia processá-lo à vontade. Por exemplo, ele pode executar um algoritmo FFT no buffer ou comprimi-lo e transmiti-lo por WiFi.
Paradoxalmente, usar a API do Arduino em vez da API do ESP-IDF (ou seja, analogRead()
em vez de adc1_get_raw()
) funcionaria porque as funções do Arduino são marcadas com IRAM_ATTR
. No entanto, eles são muito mais lentos que os ESP-IDF, pois fornecem um nível mais alto de abstração. Falando em desempenho, nossa função de leitura ADC personalizada é cerca de duas vezes mais rápida que a do ESP-IDF.
Projetos ESP32: para SO ou não para SO
O que fizemos aqui - reimplementar uma API do sistema operacional para contornar alguns problemas que nem existiriam se não usássemos um sistema operacional - é uma boa ilustração dos prós e contras de usar um sistema operacional no primeiro lugar.
Microcontroladores menores são programados diretamente, às vezes em código assembler, e os desenvolvedores têm controle total sobre todos os aspectos da execução do programa, de cada instrução da CPU e todos os estados de todos os periféricos no chip. Isso pode naturalmente se tornar tedioso à medida que o programa aumenta e usa cada vez mais hardware. Um microcontrolador complexo como o ESP32, com um grande conjunto de periféricos, dois núcleos de CPU e um layout de memória complexo e não uniforme, seria desafiador e trabalhoso para programar do zero.
Embora todo sistema operacional coloque alguns limites e requisitos no código que usa seus serviços, os benefícios geralmente valem a pena: desenvolvimento mais rápido e simples. No entanto, às vezes podemos, e no espaço incorporado muitas vezes devemos, contornar isso.