Lucrul cu ESP32 Audio Sampling
Publicat: 2022-03-11ESP32 este un microcontroler de ultimă generație, compatibil WiFi și Bluetooth. Este succesorul Espressif, cu sediul în Shanghai, al foarte popularului microcontroler ESP8266 și, pentru publicul pasionat, revoluționar.
Un uriaș printre microcontrolere, specificațiile ESP32 includ totul, în afară de chiuveta de bucătărie. Este un produs system-on-a-chip (SoC) și practic necesită un sistem de operare pentru a utiliza toate caracteristicile sale.
Acest tutorial ESP32 va explica și rezolva o problemă specială de eșantionare a convertorului analog-digital (ADC) dintr-o întrerupere a temporizatorului. Vom folosi IDE-ul Arduino. Chiar dacă este unul dintre cele mai proaste IDE-uri existente în ceea ce privește seturile de caracteristici, IDE-ul Arduino este cel puțin ușor de configurat și utilizat pentru dezvoltarea ESP32 și are cea mai mare colecție de biblioteci pentru o varietate de module hardware comune. Cu toate acestea, vom folosi și multe API-uri ESP-IDF native în loc de cele Arduino, din motive de performanță.
Audio ESP32: temporizatoare și întreruperi
ESP32 conține patru cronometre hardware, împărțite în două grupuri. Toate cronometrele sunt aceleași, având prescalere de 16 biți și contoare de 64 de biți. Valoarea de prescalare este utilizată pentru a limita semnalul de ceas hardware - care provine de la un ceas intern de 80 MHz care intră în cronometru - la fiecare bifă a N-a. Valoarea minimă de prescalare este 2, ceea ce înseamnă că întreruperile se pot declanșa oficial la cel mult 40 MHz. Acest lucru nu este rău, deoarece înseamnă că la cea mai mare rezoluție a temporizatorului, codul de gestionare trebuie să se execute în cel mult 6 cicluri de ceas (240 MHz miez/40 MHz). Temporizatoarele au mai multe proprietăți asociate:
-
divider
— valoarea prescalării frecvenței -
counter_en
—dacă este activat contorul de 64 de biți asociat temporizatorului (de obicei adevărat) -
counter_dir
—dacă contorul este incrementat sau decrementat -
alarm_en
„alarma”, adică acțiunea contorului, este activată -
auto_reload
— dacă contorul este resetat atunci când alarma este declanșată
Unele dintre modurile de cronometru distincte importante sunt:
- Cronometrul este dezactivat. Hardware-ul nu merge deloc.
- Cronometrul este activat, dar alarma este dezactivată. Hardware-ul temporizatorului bifează, opțional crește sau scade contorul intern, dar nu se întâmplă nimic altceva.
- Cronometrul este activat și alarma lui este, de asemenea, activată. Ca și înainte, dar de data aceasta se efectuează o acțiune când contorul temporizatorului atinge o anumită valoare configurată: contorul este resetat și/sau este generată o întrerupere.
Contoarele temporizatoarelor pot fi citite prin cod arbitrar, dar în cele mai multe cazuri, suntem interesați să facem ceva periodic, iar asta înseamnă că vom configura hardware-ul temporizatorului pentru a genera o întrerupere și vom scrie cod pentru a o gestiona.
O funcție de gestionare a întreruperilor trebuie să se termine înainte ca următoarea întrerupere să fie generată, ceea ce ne oferă o limită superioară strictă a cât de complexă poate deveni funcția. În general, un operator de întrerupere ar trebui să facă cea mai mică cantitate de muncă posibilă.
Pentru a realiza ceva de la distanță complex, ar trebui să seteze un semnalizator care este verificat prin cod fără întrerupere. Orice fel de I/O mai complex decât citirea sau setarea unui singur pin la o singură valoare este adesea mai bine descărcat către un handler separat.
În mediul ESP-IDF, funcția vTaskNotifyGiveFromISR()
poate fi utilizată pentru a notifica o sarcină că handler-ul de întrerupere (numit și Rutina de servicii de întrerupere sau ISR) are ceva de făcut. Codul arată astfel:
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); }
Notă: Funcțiile utilizate în cod în acest articol sunt documentate cu API-ul ESP-IDF și cu proiectul ESP32 Arduino GitHub.
Cache-urile CPU și arhitectura Harvard
Un lucru foarte important de observat este clauza IRAM_ATTR
din definiția handler-ului de întrerupere onTimer()
. Motivul pentru aceasta este că nucleele procesorului pot executa instrucțiuni (și accesa date) doar din memoria RAM încorporată, nu din memoria flash unde codul programului și datele sunt în mod normal stocate. Pentru a evita acest lucru, o parte din totalul de 520 KiB de RAM este dedicată ca IRAM, un cache de 128 KiB folosit pentru a încărca codul transparent din stocarea flash. ESP32 folosește magistrale separate pentru cod și date („arhitectura Harvard”), astfel încât acestea sunt foarte mult tratate separat, iar asta se extinde la proprietățile memoriei: IRAM este special și poate fi accesat doar la limitele adresei de 32 de biți.
De fapt, memoria ESP32 este foarte neuniformă. Diferite regiuni ale acestuia sunt dedicate pentru scopuri diferite: Regiunea continuă maximă este de aproximativ 160 KiB în dimensiune, iar toată memoria „normală” accesibilă de către programele utilizatorului totalizează doar aproximativ 316 KiB.
Încărcarea datelor din stocarea flash este lentă și poate necesita acces la magistrala SPI, așa că orice cod care se bazează pe viteză trebuie să aibă grijă să se încadreze în memoria cache IRAM și adesea mult mai mic (mai puțin de 100 KiB), deoarece o parte din acesta este utilizată de către sistem de operare. În special, sistemul va genera o excepție dacă codul de gestionare a întreruperilor nu este încărcat în cache atunci când are loc o întrerupere. Ar fi atât foarte lent, cât și un coșmar logistic să încărcați ceva din stocarea flash exact când se întâmplă o întrerupere. Specificatorul IRAM_ATTR
de pe handler-ul onTimer()
spune compilatorului și linkerului să marcheze acest cod ca special - va fi plasat static în IRAM și nu va fi niciodată schimbat.
Cu toate acestea, IRAM_ATTR
se aplică numai funcției pe care este specificată - orice funcții apelate din acea funcție nu sunt afectate.

Eșantionarea datelor audio ESP32 dintr-o întrerupere a temporizatorului
Modul obișnuit în care semnalele audio sunt eșantionate dintr-o întrerupere implică menținerea unui buffer de memorie de mostre, completarea acestuia cu date eșantionate și apoi notificarea unei sarcini de gestionare că datele sunt disponibile.
ESP-IDF documentează funcția adc1_get_raw()
care măsoară datele pe un anumit canal ADC pe primul periferic ADC (al doilea este utilizat de WiFi). Cu toate acestea, folosirea acestuia în codul de gestionare a temporizatorului are ca rezultat un program instabil, deoarece este o funcție complexă care apelează un număr non-trivial de alte funcții IDF - în special cele care se ocupă de blocări - și nici adc1_get_raw()
și nici funcțiile apelurile sunt marcate cu IRAM_ATTR
. Managerul de întrerupere se va bloca de îndată ce este executată o bucată de cod suficient de mare care ar determina ca funcțiile ADC să fie schimbate din IRAM - și aceasta poate fi stiva WiFi-TCP/IP-HTTP sau biblioteca sistemului de fișiere SPIFFS, sau orice altceva.
Notă: Unele funcții IDF sunt special concepute (și marcate cu IRAM_ATTR
) astfel încât să poată fi apelate de la manipulatorii de întreruperi. Funcția vTaskNotifyGiveFromISR()
din exemplul de mai sus este o astfel de funcție.
Cel mai prietenos mod IDF de a ocoli acest lucru este ca responsabilul de întrerupere să notifice o sarcină atunci când trebuie prelevată un eșantion ADC și ca această sarcină să facă eșantionarea și gestionarea tamponului, eventual o altă sarcină fiind utilizată pentru analiza datelor (sau compresie sau transmisie sau oricare ar fi cazul). Din păcate, acest lucru este extrem de ineficient. Atât partea de gestionare (care notifică o sarcină că mai este de făcut), cât și partea de activitate (care preia o sarcină de făcut) implică interacțiuni cu sistemul de operare și mii de instrucțiuni care sunt executate. Această abordare, deși teoretic corectă, poate bloca CPU atât de mult încât lasă puțină putere de rezervă pentru alte sarcini.
Săpat prin codul sursă IDF
Eșantionarea datelor dintr-un ADC este de obicei o sarcină simplă, așa că următoarea strategie este să vedem cum o face IDF și să le replici direct în codul nostru, fără a apela API-ul furnizat. Funcția adc1_get_raw()
este implementată în fișierul rtc_module.c
al IDF, iar dintre cele opt lucruri pe care le face, doar unul eșantionează de fapt ADC, care se face printr-un apel la adc_convert()
. Din fericire, adc_convert()
este o funcție simplă care eșantionează ADC prin manipularea registrelor hardware periferice printr-o structură globală numită SENS
.
Adaptarea acestui cod astfel încât să funcționeze în programul nostru (și să imitem comportamentul lui adc1_get_raw()
) este ușoară. Arata cam asa:
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; }
Următorul pas este să includeți anteturile relevante, astfel încât variabila SENS
să devină disponibilă:
#include <soc/sens_reg.h> #include <soc/sens_struct.h>
În cele din urmă, deoarece adc1_get_raw()
efectuează câțiva pași de configurare înainte de eșantionarea ADC-ului, acesta ar trebui apelat direct, imediat după ce ADC-ul este configurat. În acest fel, configurația relevantă poate fi efectuată înainte de pornirea temporizatorului.
Dezavantajul acestei abordări este că nu funcționează bine cu alte funcții IDF. De îndată ce un alt periferic, driver sau o bucată aleatorie de cod este numită care resetează configurația ADC, funcția noastră personalizată nu va mai funcționa corect. Cel puțin WiFi, PWM, I2C și SPI nu influențează configurația ADC. În cazul în care ceva îl influențează, un apel la adc1_get_raw()
va configura ADC-ul corespunzător din nou.
Eșantionare audio ESP32: Codul final
Cu funcția local_adc_read()
, codul nostru de gestionare a cronometrului arată astfel:
#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); }
Aici, adcTaskHandle
este sarcina FreeRTOS care ar fi implementată pentru a procesa tamponul, urmând structura funcției complexHandler
din primul fragment de cod. Ar face o copie locală a buffer-ului audio și ar putea apoi să o proceseze după bunul plac. De exemplu, ar putea rula un algoritm FFT pe buffer sau l-ar putea comprima și transmite prin WiFi.
Paradoxal, utilizarea API-ului Arduino în loc de API-ul ESP-IDF (adică analogRead()
în loc de adc1_get_raw()
) ar funcționa deoarece funcțiile Arduino sunt marcate cu IRAM_ATTR
. Cu toate acestea, ele sunt mult mai lente decât cele ESP-IDF, deoarece oferă un nivel mai ridicat de abstractizare. Apropo de performanță, funcția noastră personalizată de citire ADC este de aproximativ două ori mai rapidă decât cea ESP-IDF.
Proiecte ESP32: la OS sau Nu la OS
Ceea ce am făcut aici – reimplementarea unui API al sistemului de operare pentru a rezolva unele probleme care nici măcar nu ar exista dacă nu am folosi un sistem de operare – este o ilustrare bună a avantajelor și dezavantajelor utilizării unui sistem de operare în primul loc.
Microcontrolerele mai mici sunt programate direct, uneori în cod de asamblare, iar dezvoltatorii au control complet asupra fiecărui aspect al execuției programului, asupra fiecărei instrucțiuni CPU și asupra tuturor stărilor tuturor perifericelor de pe cip. Acest lucru poate deveni obositor pe măsură ce programul devine mai mare și pe măsură ce folosește tot mai mult hardware. Un microcontroler complex, cum ar fi ESP32, cu un set mare de periferice, două nuclee CPU și un aspect de memorie complex, neuniform, ar fi dificil și laborios de programat de la zero.
În timp ce fiecare sistem de operare impune anumite limite și cerințe codului care își folosește serviciile, beneficiile merită de obicei: dezvoltare mai rapidă și mai simplă. Cu toate acestea, uneori putem, și în spațiul încorporat adesea ar trebui, să o ocolim.