Trabalhando com Amostragem de Áudio ESP32

Publicados: 2022-03-11

O 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.

Relacionado: Como eu fiz uma estação meteorológica Arduino totalmente funcional