Lavorare con il campionamento audio ESP32

Pubblicato: 2022-03-11

L'ESP32 è un microcontrollore di nuova generazione abilitato per WiFi e Bluetooth. È il successore di Espressif, con sede a Shanghai, del popolarissimo e rivoluzionario microcontrollore ESP8266 per il pubblico degli hobbisti.

Un colosso tra i microcontrollori, le specifiche dell'ESP32 includono tutto tranne il lavello della cucina. È un prodotto system-on-a-chip (SoC) e richiede praticamente un sistema operativo per sfruttare tutte le sue funzionalità.

Questo tutorial ESP32 spiegherà e risolverà un particolare problema di campionamento del convertitore analogico-digitale (ADC) da un'interruzione del timer. Useremo l'IDE Arduino. Anche se è uno dei peggiori IDE in circolazione in termini di set di funzionalità, l'IDE Arduino è almeno facile da configurare e utilizzare per lo sviluppo di ESP32 e ha la più grande raccolta di librerie per una varietà di moduli hardware comuni. Tuttavia, utilizzeremo anche molte API ESP-IDF native invece di quelle Arduino, per motivi di prestazioni.

ESP32 Audio: timer e interrupt

L'ESP32 contiene quattro timer hardware, divisi in due gruppi. Tutti i timer sono gli stessi, con prescaler a 16 bit e contatori a 64 bit. Il valore di prescalatura viene utilizzato per limitare il segnale di clock hardware, che proviene da un clock interno di 80 MHz che entra nel timer, a ogni Nth tick. Il valore minimo di prescalatura è 2, il che significa che gli interrupt possono ufficialmente attivarsi al massimo a 40 MHz. Questo non è male, in quanto significa che alla massima risoluzione del timer, il codice del gestore deve essere eseguito in un massimo di 6 cicli di clock (240 MHz core/40 MHz). I timer hanno diverse proprietà associate:

  • divider : il valore di prescalatura della frequenza
  • counter_en —se il contatore a 64 bit associato al timer è abilitato (solitamente vero)
  • counter_dir —se il contatore viene incrementato o decrementato
  • alarm_en —se l'“allarme”, cioè l'azione del contatore, è abilitato
  • auto_reload : indica se il contatore viene azzerato quando viene attivato l'allarme

Alcune delle modalità timer distinte importanti sono:

  • Il timer è disabilitato. L'hardware non ticchetta affatto.
  • Il timer è abilitato, ma l'allarme è disabilitato. L'hardware del timer sta ticchettando, sta facoltativamente incrementando o decrementando il contatore interno, ma non sta succedendo nient'altro.
  • Il timer è abilitato e anche il suo allarme è abilitato. Come prima, ma questa volta viene eseguita un'azione quando il contatore del timer raggiunge un determinato valore configurato: il contatore viene azzerato e/o viene generato un interrupt.

I contatori dei timer possono essere letti da codice arbitrario, ma nella maggior parte dei casi siamo interessati a fare qualcosa periodicamente, e questo significa che configureremo l'hardware del timer per generare un interrupt e scriveremo il codice per gestirlo.

Una funzione di gestione degli interrupt deve terminare prima che venga generato l'interrupt successivo, il che ci dà un limite massimo su quanto può diventare complessa la funzione. In genere, un gestore di interrupt dovrebbe svolgere la minor quantità di lavoro possibile.

Per ottenere qualcosa di complicato in remoto, dovrebbe invece impostare un flag che viene controllato da un codice non di interruzione. Qualsiasi tipo di I/O più complesso della lettura o dell'impostazione di un singolo pin su un singolo valore è spesso meglio scaricato su un gestore separato.

Nell'ambiente ESP-IDF, la funzione FreeRTOS vTaskNotifyGiveFromISR() può essere utilizzata per notificare a un'attività che il gestore di interrupt (chiamato anche Interrupt Service Routine o ISR) ha qualcosa da fare. Il codice si presenta così:

 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: le funzioni utilizzate nel codice in questo articolo sono documentate con l'API ESP-IDF e nel progetto GitHub di base Arduino ESP32.

Cache della CPU e architettura di Harvard

Una cosa molto importante da notare è la clausola IRAM_ATTR nella definizione del gestore di interrupt onTimer() . Il motivo è che i core della CPU possono eseguire istruzioni (e accedere ai dati) solo dalla RAM incorporata, non dalla memoria flash in cui sono normalmente memorizzati il ​​codice del programma e i dati. Per ovviare a questo problema, una parte dei 520 KiB totali di RAM è dedicata come IRAM, una cache da 128 KiB utilizzata per caricare in modo trasparente il codice dalla memoria flash. L'ESP32 utilizza bus separati per codice e dati ("architettura Harvard"), quindi sono gestiti separatamente e ciò si estende alle proprietà della memoria: l'IRAM è speciale e si può accedere solo a limiti di indirizzo a 32 bit.

In effetti, la memoria ESP32 è molto non uniforme. Diverse regioni sono dedicate a scopi diversi: la regione continua massima ha una dimensione di circa 160 KiB e tutta la memoria "normale" accessibile dai programmi utente ammonta a solo circa 316 KiB.

Il caricamento dei dati dalla memoria flash è lento e può richiedere l'accesso al bus SPI, quindi qualsiasi codice che si basa sulla velocità deve fare attenzione a inserirsi nella cache IRAM e spesso molto più piccolo (meno di 100 KiB) poiché una parte di esso viene utilizzata dal sistema operativo. In particolare, il sistema genererà un'eccezione se il codice del gestore di interrupt non viene caricato nella cache quando si verifica un'interruzione. Sarebbe sia molto lento che un incubo logistico caricare qualcosa dalla memoria flash proprio mentre si verifica un'interruzione. L' IRAM_ATTR sul gestore onTimer() dice al compilatore e al linker di contrassegnare questo codice come speciale: verrà posizionato staticamente nell'IRAM e non verrà mai sostituito.

Tuttavia, IRAM_ATTR si applica solo alla funzione su cui è specificato: tutte le funzioni chiamate da quella funzione non sono interessate.

Campionamento dei dati audio ESP32 da un'interruzione del timer

Il solito modo in cui i segnali audio vengono campionati da un interrupt prevede il mantenimento di un buffer di memoria di campioni, il riempimento con dati campionati e quindi la notifica a un'attività del gestore che i dati sono disponibili.

L'ESP-IDF documenta la funzione adc1_get_raw() che misura i dati su un particolare canale ADC sulla prima periferica ADC (la seconda è utilizzata dal WiFi). Tuttavia, il suo utilizzo nel codice del gestore del timer risulta in un programma instabile, perché è una funzione complessa che chiama un numero non banale di altre funzioni IDF, in particolare quelle che si occupano di lock, e né adc1_get_raw() né le funzioni le chiamate sono contrassegnate con IRAM_ATTR . Il gestore degli interrupt si arresta in modo anomalo non appena viene eseguito un pezzo di codice sufficientemente grande da causare lo scambio delle funzioni ADC fuori dall'IRAM, e questo potrebbe essere lo stack WiFi-TCP/IP-HTTP o la libreria del file system SPIFFS, o qualsiasi altra cosa.

Nota: alcune funzioni IDF sono appositamente predisposte (e contrassegnate con IRAM_ATTR ) in modo che possano essere chiamate dai gestori di interrupt. La funzione vTaskNotifyGiveFromISR() dell'esempio sopra è una di queste funzioni.

Il modo più compatibile con l'IDF per aggirare questo problema è che il gestore degli interrupt notifichi un'attività quando è necessario prelevare un campione ADC e che questa attività esegua il campionamento e la gestione del buffer, con possibilmente un'altra attività utilizzata per l'analisi dei dati (o compressione o trasmissione o qualunque sia il caso). Sfortunatamente, questo è estremamente inefficiente. Sia il lato gestore (che notifica a un'attività che c'è del lavoro da svolgere) sia il lato attività (che raccoglie un'attività da svolgere) implicano interazioni con il sistema operativo e migliaia di istruzioni in esecuzione. Questo approccio, sebbene teoricamente corretto, può impantanare la CPU così tanto da lasciare poca potenza di riserva della CPU per altre attività.

Scavando attraverso il codice sorgente IDF

Il campionamento dei dati da un ADC è solitamente un compito semplice, quindi la strategia successiva è vedere come lo fa l'IDF e replicarlo direttamente nel nostro codice, senza chiamare l'API fornita. La funzione adc1_get_raw() è implementata nel file rtc_module.c dell'IDF e delle otto operazioni che esegue, solo una è effettivamente il campionamento dell'ADC, che viene eseguito da una chiamata ad adc_convert() . Fortunatamente, adc_convert() è una semplice funzione che campiona l'ADC manipolando i registri hardware periferici tramite una struttura globale denominata SENS .

Adattare questo codice in modo che funzioni nel nostro programma (e per imitare il comportamento di adc1_get_raw() ) è facile. Si presenta così:

 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; }

Il passaggio successivo consiste nell'includere le intestazioni rilevanti in modo che la variabile SENS diventi disponibile:

 #include <soc/sens_reg.h> #include <soc/sens_struct.h>

Infine, poiché adc1_get_raw() esegue alcuni passaggi di configurazione prima di campionare l'ADC, dovrebbe essere chiamato direttamente, subito dopo la configurazione dell'ADC. In questo modo è possibile eseguire la relativa configurazione prima dell'avvio del timer.

Lo svantaggio di questo approccio è che non funziona bene con altre funzioni IDF. Non appena viene chiamata qualche altra periferica, driver o un pezzo di codice casuale che ripristina la configurazione dell'ADC, la nostra funzione personalizzata non funzionerà più correttamente. Almeno WiFi, PWM, I2C e SPI non influenzano la configurazione dell'ADC. Nel caso in cui qualcosa lo influenzi, una chiamata ad adc1_get_raw() configurerà nuovamente ADC in modo appropriato.

Campionamento audio ESP32: il codice finale

Con la funzione local_adc_read() , il nostro codice del gestore del timer è simile al seguente:

 #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); }

Qui, adcTaskHandle è l'attività di FreeRTOS che verrebbe implementata per elaborare il buffer, seguendo la struttura della funzione complexHandler nel primo frammento di codice. Farebbe una copia locale del buffer audio e potrebbe quindi elaborarlo a suo piacimento. Ad esempio, potrebbe eseguire un algoritmo FFT sul buffer, oppure potrebbe comprimerlo e trasmetterlo tramite WiFi.

Paradossalmente, l'utilizzo dell'API Arduino invece dell'API ESP-IDF (cioè analogRead() invece di adc1_get_raw() ) funzionerebbe perché le funzioni Arduino sono contrassegnate con IRAM_ATTR . Tuttavia, sono molto più lenti di quelli ESP-IDF poiché forniscono un livello di astrazione più elevato. Parlando di prestazioni, la nostra funzione di lettura ADC personalizzata è circa due volte più veloce di quella ESP-IDF.

Progetti ESP32: al sistema operativo o non al sistema operativo

Quello che abbiamo fatto qui, reimplementare un'API del sistema operativo per aggirare alcuni problemi che non ci sarebbero nemmeno se non usassimo un sistema operativo, è una buona illustrazione dei pro e dei contro dell'utilizzo di un sistema operativo nel primo posto.

I microcontrollori più piccoli sono programmati direttamente, a volte in codice assembler, e gli sviluppatori hanno il controllo completo su ogni aspetto dell'esecuzione del programma, di ogni singola istruzione della CPU e di tutti gli stati di tutte le periferiche sul chip. Questo può naturalmente diventare noioso man mano che il programma diventa più grande e poiché utilizza sempre più hardware. Un microcontrollore complesso come l'ESP32, con un ampio set di periferiche, due core della CPU e un layout di memoria complesso e non uniforme, sarebbe impegnativo e laborioso da programmare da zero.

Sebbene ogni sistema operativo imponga alcuni limiti e requisiti al codice che utilizza i suoi servizi, di solito ne vale la pena: uno sviluppo più rapido e semplice. Tuttavia, a volte possiamo, e nello spazio incorporato spesso dovremmo, aggirarlo.

Correlati: come ho realizzato una stazione meteorologica Arduino completamente funzionale