Praca z próbkowaniem dźwięku ESP32

Opublikowany: 2022-03-11

ESP32 to nowej generacji mikrokontroler obsługujący Wi-Fi i Bluetooth. Jest to powstały w Szanghaju następca bardzo popularnego i rewolucyjnego dla hobbystów mikrokontrolera ESP8266.

ESP32, gigant wśród mikrokontrolerów, zawiera wszystko oprócz zlewu kuchennego. Jest to produkt typu system-on-a-chip (SoC) i praktycznie wymaga systemu operacyjnego, aby korzystać ze wszystkich jego funkcji.

Ten samouczek ESP32 wyjaśni i rozwiąże konkretny problem próbkowania przetwornika analogowo-cyfrowego (ADC) z przerwania czasowego. Wykorzystamy Arduino IDE. Nawet jeśli jest to jedno z najgorszych IDE pod względem zestawów funkcji, Arduino IDE jest co najmniej łatwe w konfiguracji i obsłudze do rozwoju ESP32 i ma największą kolekcję bibliotek dla różnych popularnych modułów sprzętowych. Jednak ze względów wydajnościowych użyjemy również wielu natywnych interfejsów API ESP-IDF zamiast Arduino.

ESP32 Audio: timery i przerwania

ESP32 zawiera cztery zegary sprzętowe, podzielone na dwie grupy. Wszystkie zegary są takie same, posiadają 16-bitowe preskalery i 64-bitowe liczniki. Wartość prescale służy do ograniczenia sprzętowego sygnału zegara — który pochodzi z wewnętrznego zegara 80 MHz wchodzącego do timera — do każdego N-tego taktu. Minimalna wartość prescale wynosi 2, co oznacza, że ​​przerwania mogą oficjalnie odpalać z częstotliwością co najwyżej 40 MHz. Nie jest to złe, ponieważ oznacza to, że przy najwyższej rozdzielczości timera, kod obsługi musi wykonać co najwyżej 6 cykli zegara (rdzeń 240 MHz/40 MHz). Timery mają kilka powiązanych właściwości:

  • divider —wartość przeskalowania częstotliwości
  • counter_en — czy skojarzony z licznikiem 64-bitowy licznik jest włączony (zazwyczaj prawda)
  • counter_dir — czy licznik jest zwiększany czy zmniejszany
  • alarm_en — czy „alarm”, czyli akcja licznika, jest włączony
  • auto_reload — czy licznik jest resetowany po wyzwoleniu alarmu

Niektóre z ważnych odrębnych trybów timera to:

  • Zegar jest wyłączony. Sprzęt w ogóle nie tyka.
  • Zegar jest włączony, ale alarm jest wyłączony. Sprzęt zegarowy tyka, opcjonalnie zwiększa lub zmniejsza wewnętrzny licznik, ale nic więcej się nie dzieje.
  • Timer jest włączony i jego alarm jest również włączony. Tak jak poprzednio, ale tym razem jakaś akcja jest wykonywana, gdy licznik timera osiągnie określoną, skonfigurowaną wartość: Licznik jest resetowany i/lub generowane jest przerwanie.

Liczniki timerów mogą być odczytywane przez dowolny kod, ale w większości przypadków jesteśmy zainteresowani robieniem czegoś okresowo, a to oznacza, że ​​skonfigurujemy sprzęt timera do generowania przerwania i napiszemy kod, który to obsłuży.

Funkcja obsługi przerwań musi zakończyć się przed wygenerowaniem następnego przerwania, co daje nam sztywny górny limit stopnia złożoności funkcji. Ogólnie rzecz biorąc, obsługa przerwań powinna wykonać jak najmniej pracy.

Aby osiągnąć coś zdalnie skomplikowanego, należy zamiast tego ustawić flagę, która jest sprawdzana przez kod nieprzerwany. Każdy rodzaj we/wy bardziej złożony niż odczytywanie lub ustawianie pojedynczego pinu na pojedynczą wartość jest często lepiej odciążony do oddzielnego modułu obsługi.

W środowisku ESP-IDF funkcja FreeRTOS vTaskNotifyGiveFromISR() może być użyta do powiadomienia zadania, że ​​procedura obsługi przerwań (zwana również procedurą obsługi przerwań lub ISR) ma coś do zrobienia. Kod wygląda tak:

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

Uwaga: funkcje użyte w kodzie w tym artykule są udokumentowane za pomocą interfejsu API ESP-IDF i projektu GitHub rdzenia Arduino ESP32.

Pamięć podręczna procesora i architektura Harvarda

Bardzo ważną rzeczą do zauważenia jest klauzula IRAM_ATTR w definicji obsługi przerwań onTimer() . Powodem tego jest to, że rdzenie procesora mogą wykonywać instrukcje (i uzyskiwać dostęp do danych) tylko z wbudowanej pamięci RAM, a nie z pamięci flash, w której zwykle przechowywany jest kod programu i dane. Aby obejść ten problem, część z 520 KB pamięci RAM jest dedykowana jako IRAM, 128 KB pamięci podręcznej używanej do przezroczystego ładowania kodu z pamięci flash. ESP32 używa oddzielnych szyn dla kodu i danych („Architektura Harvarda”), więc są one w dużej mierze obsługiwane oddzielnie, co rozciąga się na właściwości pamięci: IRAM jest wyjątkowy i można uzyskać do niego dostęp tylko w 32-bitowych granicach adresów.

W rzeczywistości pamięć ESP32 jest bardzo niejednolita. Różne jego regiony są przeznaczone do różnych celów: maksymalny ciągły region ma rozmiar około 160 KiB, a cała „normalna” pamięć dostępna przez programy użytkownika wynosi tylko około 316 KiB.

Ładowanie danych z pamięci flash jest powolne i może wymagać dostępu do magistrali SPI, więc każdy kod, który opiera się na szybkości, musi zmieścić się w pamięci podręcznej IRAM i często jest znacznie mniejszy (mniej niż 100 KiB), ponieważ część z niego jest używana przez system operacyjny. Warto zauważyć, że system wygeneruje wyjątek, jeśli kod obsługi przerwań nie zostanie załadowany do pamięci podręcznej, gdy wystąpi przerwanie. Załadowanie czegoś z pamięci flash w momencie wystąpienia przerwania byłoby zarówno bardzo powolne, jak i logistycznym koszmarem. Specyfikator IRAM_ATTR w module obsługi onTimer() mówi kompilatorowi i konsolidatorowi, aby oznaczyli ten kod jako specjalny — zostanie on statycznie umieszczony w pamięci IRAM i nigdy nie zostanie zamieniony.

Jednak IRAM_ATTR dotyczy tylko funkcji, w której jest określony — nie ma to wpływu na żadne funkcje wywoływane z tej funkcji.

Próbkowanie danych audio ESP32 z przerwania timera

Typowy sposób próbkowania sygnałów audio z przerwania obejmuje utrzymywanie bufora pamięci próbek, wypełnienie go próbkowanymi danymi, a następnie powiadomienie zadania obsługi, że dane są dostępne.

ESP-IDF dokumentuje funkcję adc1_get_raw() , która mierzy dane w określonym kanale ADC na pierwszym urządzeniu peryferyjnym ADC (drugi jest używany przez Wi-Fi). Jednak użycie go w kodzie obsługi licznika skutkuje niestabilnym programem, ponieważ jest to złożona funkcja, która wywołuje nietrywialną liczbę innych funkcji IDF — w szczególności tych, które zajmują się blokadami — i ani adc1_get_raw() , ani funkcji jego połączenia są oznaczone przez IRAM_ATTR . Obsługa przerwań ulegnie awarii, gdy tylko zostanie wykonany wystarczająco duży fragment kodu, który spowodowałby zamianę funkcji ADC z pamięci IRAM — może to być stos WiFi-TCP/IP-HTTP lub biblioteka systemu plików SPIFFS, albo coś innego.

Uwaga: Niektóre funkcje IDF są specjalnie spreparowane (i oznaczone jako IRAM_ATTR ), aby można je było wywoływać z programów obsługi przerwań. Jedną z takich funkcji jest funkcja vTaskNotifyGiveFromISR() z powyższego przykładu.

Najbardziej przyjaznym dla IDF sposobem obejścia tego jest powiadamianie zadania przez obsługę przerwań, kiedy próbka ADC musi zostać pobrana i zlecenie temu zadaniu próbkowania i zarządzania buforami, z możliwym użyciem innego zadania do analizy danych (lub kompresja lub transmisja lub jakikolwiek przypadek). Niestety jest to wyjątkowo nieefektywne. Zarówno strona obsługi (która powiadamia zadanie, że jest praca do wykonania), jak i strona zadań (która odbiera zadanie do wykonania) obejmują interakcje z systemem operacyjnym i tysiące wykonywanych instrukcji. Takie podejście, choć teoretycznie poprawne, może tak bardzo spowolnić procesor, że pozostawia niewiele wolnej mocy procesora na inne zadania.

Przekopywanie się przez kod źródłowy IDF

Próbkowanie danych z ADC jest zwykle prostym zadaniem, więc następną strategią jest sprawdzenie, jak robi to IDF, i replikacja ich bezpośrednio w naszym kodzie, bez wywoływania dostarczonego API. Funkcja adc1_get_raw() jest zaimplementowana w pliku rtc_module.c IDF, a z około ośmiu rzeczy, które robi, tylko jedna faktycznie próbkuje ADC, co jest wykonywane przez wywołanie funkcji adc_convert() . Na szczęście adc_convert() jest prostą funkcją próbkującą ADC poprzez manipulowanie rejestrami sprzętu peryferyjnego za pośrednictwem globalnej struktury o nazwie SENS .

Dostosowanie tego kodu tak, aby działał w naszym programie (i naśladowanie zachowania adc1_get_raw() ) jest łatwe. To wygląda tak:

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

Następnym krokiem jest uwzględnienie odpowiednich nagłówków, aby zmienna SENS stała się dostępna:

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

Wreszcie, ponieważ adc1_get_raw() wykonuje pewne kroki konfiguracyjne przed próbkowaniem ADC, powinno być wywoływane bezpośrednio, zaraz po skonfigurowaniu ADC. W ten sposób odpowiednią konfigurację można przeprowadzić przed uruchomieniem timera.

Wadą tego podejścia jest to, że nie współgra z innymi funkcjami IDF. Gdy tylko zostanie wywołane jakieś inne urządzenie peryferyjne, sterownik lub losowy fragment kodu, który resetuje konfigurację ADC, nasza funkcja niestandardowa przestanie działać poprawnie. Przynajmniej WiFi, PWM, I2C i SPI nie mają wpływu na konfigurację ADC. Jeśli coś ma na to wpływ, wywołanie adc1_get_raw() ponownie odpowiednio skonfiguruje ADC.

Próbkowanie dźwięku ESP32: ostateczny kod

Po wdrożeniu funkcji local_adc_read() nasz kod obsługi licznika czasu wygląda tak:

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

Tutaj adcTaskHandle jest zadaniem FreeRTOS, które zostałoby zaimplementowane do przetwarzania bufora, zgodnie ze strukturą funkcji complexHandler w pierwszym fragmencie kodu. Stworzyłby lokalną kopię bufora audio, a następnie mógłby go przetwarzać w wolnym czasie. Na przykład może uruchomić algorytm FFT w buforze lub skompresować go i przesłać przez Wi-Fi.

Paradoksalnie, użycie Arduino API zamiast ESP-IDF API (tj analogRead() zamiast adc1_get_raw() ) zadziałałoby, ponieważ funkcje Arduino są oznaczone jako IRAM_ATTR . Są jednak znacznie wolniejsze niż te ESP-IDF, ponieważ zapewniają wyższy poziom abstrakcji. Mówiąc o wydajności, nasza niestandardowa funkcja odczytu ADC jest około dwa razy szybsza niż funkcja ESP-IDF.

Projekty ESP32: do systemu operacyjnego lub nie do systemu operacyjnego

To, co tutaj zrobiliśmy — reimplementacja API systemu operacyjnego w celu obejścia pewnych problemów, których nawet by nie było, gdybyśmy nie używali systemu operacyjnego — jest dobrą ilustracją zalet i wad korzystania z systemu operacyjnego w pierwsze miejsce.

Mniejsze mikrokontrolery są programowane bezpośrednio, czasami w kodzie asemblera, a programiści mają pełną kontrolę nad każdym aspektem wykonywania programu, każdą pojedynczą instrukcją procesora i wszystkimi stanami wszystkich urządzeń peryferyjnych w układzie. Może to naturalnie stać się nużące, gdy program staje się większy i wykorzystuje coraz więcej sprzętu. Złożony mikrokontroler, taki jak ESP32, z dużym zestawem urządzeń peryferyjnych, dwoma rdzeniami procesora i złożonym, niejednorodnym układem pamięci, byłby trudny i pracochłonny przy programowaniu od podstaw.

Chociaż każdy system operacyjny nakłada pewne ograniczenia i wymagania na kod, który korzysta z jego usług, korzyści są zazwyczaj tego warte: szybszy i prostszy rozwój. Czasami jednak możemy, a w osadzonej przestrzeni często powinniśmy to obejść.

Powiązane: Jak stworzyłem w pełni funkcjonalną stację pogodową Arduino?