Arbeiten mit ESP32 Audio Sampling
Veröffentlicht: 2022-03-11Der ESP32 ist ein WiFi- und Bluetooth-fähiger Mikrocontroller der nächsten Generation. Es ist der Nachfolger des in Shanghai ansässigen Espressif des sehr beliebten – und für das Bastlerpublikum revolutionären – ESP8266-Mikrocontrollers.
Ein Gigant unter den Mikrocontrollern, die Spezifikationen des ESP32 umfassen alles außer der Küchenspüle. Es ist ein System-on-a-Chip (SoC)-Produkt und erfordert praktisch ein Betriebssystem, um alle seine Funktionen nutzen zu können.
Dieses ESP32-Tutorial erklärt und löst ein bestimmtes Problem beim Abtasten des Analog-Digital-Wandlers (ADC) von einem Timer-Interrupt. Wir werden die Arduino IDE verwenden. Auch wenn es sich um eine der schlechtesten IDEs handelt, die es in Sachen Funktionsumfang gibt, ist die Arduino IDE zumindest einfach einzurichten und für die ESP32-Entwicklung zu verwenden, und sie verfügt über die größte Sammlung von Bibliotheken für eine Vielzahl gängiger Hardwaremodule. Aus Leistungsgründen werden wir jedoch auch viele native ESP-IDF-APIs anstelle von Arduino-APIs verwenden.
ESP32 Audio: Timer und Interrupts
Der ESP32 enthält vier Hardware-Timer, die in zwei Gruppen unterteilt sind. Alle Timer sind gleich und haben 16-Bit-Prescaler und 64-Bit-Zähler. Der Prescale-Wert wird verwendet, um das Hardware-Taktsignal – das von einem internen 80-MHz-Takt kommt, der in den Timer geht – auf jeden N-ten Tick zu begrenzen. Der minimale Prescale-Wert ist 2, was bedeutet, dass Interrupts offiziell bei höchstens 40 MHz ausgelöst werden können. Das ist nicht schlimm, denn es bedeutet, dass der Handler-Code bei der höchsten Timer-Auflösung in höchstens 6 Taktzyklen (240 MHz Kern/40 MHz) ausgeführt werden muss. Timer haben mehrere zugeordnete Eigenschaften:
-
divider
—der Frequenz-Prescale-Wert -
counter_en
– ob der zugehörige 64-Bit-Zähler des Timers aktiviert ist (normalerweise wahr) -
counter_dir
ob der Zähler inkrementiert oder dekrementiert wird -
alarm_en
ob der „Alarm“, dh die Aktion des Zählers, aktiviert ist -
auto_reload
ob der Zähler zurückgesetzt wird, wenn der Alarm ausgelöst wird
Einige der wichtigen unterschiedlichen Timer-Modi sind:
- Der Timer ist deaktiviert. Die Hardware tickt überhaupt nicht.
- Der Timer ist aktiviert, aber der Alarm ist deaktiviert. Die Timer-Hardware tickt, sie inkrementiert oder dekrementiert wahlweise den internen Zähler, aber sonst passiert nichts.
- Der Timer ist aktiviert und sein Alarm ist ebenfalls aktiviert. Wie zuvor, aber dieses Mal wird eine Aktion ausgeführt, wenn der Timer-Zähler einen bestimmten, konfigurierten Wert erreicht: Der Zähler wird zurückgesetzt und/oder ein Interrupt wird generiert.
Die Zähler von Timern können von beliebigem Code gelesen werden, aber in den meisten Fällen sind wir daran interessiert, regelmäßig etwas zu tun, und das bedeutet, dass wir die Timer-Hardware so konfigurieren, dass sie einen Interrupt generiert, und wir werden Code schreiben, um damit umzugehen.
Eine Interrupt-Handler-Funktion muss beendet werden, bevor der nächste Interrupt generiert wird, was uns eine harte Obergrenze dafür gibt, wie komplex die Funktion werden kann. Im Allgemeinen sollte ein Interrupt-Handler so wenig Arbeit wie möglich erledigen.
Um irgendetwas entfernt Komplexes zu erreichen, sollte es stattdessen ein Flag setzen, das durch Nicht-Interrupt-Code überprüft wird. Jede Art von E/A, die komplexer ist als das Lesen oder Setzen eines einzelnen Pins auf einen einzelnen Wert, wird oft besser an einen separaten Handler ausgelagert.
In der ESP-IDF-Umgebung kann die FreeRTOS-Funktion vTaskNotifyGiveFromISR()
verwendet werden, um eine Aufgabe darüber zu benachrichtigen, dass der Interrupt-Handler (auch als Interrupt Service Routine oder ISR bezeichnet) etwas zu tun hat. Der Code sieht so aus:
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); }
Hinweis: Funktionen, die im gesamten Code dieses Artikels verwendet werden, sind mit der ESP-IDF-API und im ESP32-Arduino-Core-GitHub-Projekt dokumentiert.
CPU-Caches und die Harvard-Architektur
Eine sehr wichtige Sache, die beachtet werden muss, ist die IRAM_ATTR
Klausel in der Definition des onTimer()
Interrupt-Handlers. Der Grund dafür ist, dass die CPU-Kerne nur Befehle aus dem eingebetteten RAM ausführen (und auf Daten zugreifen) können, nicht aus dem Flash-Speicher, wo normalerweise der Programmcode und die Daten gespeichert sind. Um dies zu umgehen, wird ein Teil der insgesamt 520 KB RAM als IRAM dediziert, ein 128-KB-Cache, der zum transparenten Laden von Code aus dem Flash-Speicher verwendet wird. Der ESP32 verwendet separate Busse für Code und Daten („Harvard-Architektur“), sodass sie sehr separat behandelt werden, und das erstreckt sich auf Speichereigenschaften: IRAM ist etwas Besonderes und kann nur an 32-Bit-Adressgrenzen zugegriffen werden.
Tatsächlich ist der ESP32-Speicher sehr uneinheitlich. Verschiedene Regionen davon sind für unterschiedliche Zwecke bestimmt: Die maximale kontinuierliche Region ist etwa 160 KiB groß, und der gesamte „normale“ Speicher, auf den Benutzerprogramme zugreifen können, beträgt nur etwa 316 KiB.
Das Laden von Daten aus dem Flash-Speicher ist langsam und kann einen SPI-Bus-Zugriff erfordern, daher muss jeder Code, der auf Geschwindigkeit angewiesen ist, darauf achten, in den IRAM-Cache zu passen, und oft viel kleiner (weniger als 100 KiB), da ein Teil davon von verwendet wird Betriebssystem. Insbesondere wird das System eine Ausnahme erzeugen, wenn Interrupt-Handler-Code nicht in den Cache geladen wird, wenn ein Interrupt auftritt. Es wäre sowohl sehr langsam als auch ein logistischer Albtraum, etwas aus dem Flash-Speicher zu laden, während ein Interrupt auftritt. Der IRAM_ATTR
Spezifizierer im onTimer()
Handler weist den Compiler und den Linker an, diesen Code als speziell zu markieren – er wird statisch im IRAM abgelegt und nie ausgetauscht.
Der IRAM_ATTR
gilt jedoch nur für die Funktion, für die er angegeben ist – alle Funktionen, die von dieser Funktion aufgerufen werden, sind nicht betroffen.
Abtasten von ESP32-Audiodaten von einem Timer-Interrupt
Die übliche Art und Weise, wie Audiosignale von einem Interrupt abgetastet werden, besteht darin, einen Speicherpuffer mit Abtastwerten zu führen, ihn mit abgetasteten Daten zu füllen und dann eine Handler-Task zu benachrichtigen, dass Daten verfügbar sind.

Das ESP-IDF dokumentiert die Funktion adc1_get_raw()
, die Daten auf einem bestimmten ADC-Kanal auf dem ersten ADC-Peripheriegerät misst (das zweite wird von WiFi verwendet). Die Verwendung im Timer-Handler-Code führt jedoch zu einem instabilen Programm, da es sich um eine komplexe Funktion handelt, die eine nicht triviale Anzahl anderer IDF-Funktionen aufruft – insbesondere diejenigen, die sich mit Sperren befassen – und weder adc1_get_raw()
noch die Funktionen Aufrufe sind mit IRAM_ATTR
gekennzeichnet. Der Interrupt-Handler stürzt ab, sobald ein ausreichend großer Code ausgeführt wird, der dazu führen würde, dass die ADC-Funktionen aus dem IRAM ausgelagert werden – und dies kann der WiFi-TCP/IP-HTTP-Stack oder die SPIFFS-Dateisystembibliothek sein. oder irgendetwas anderes.
Hinweis: Einige IDF-Funktionen sind speziell gestaltet (und mit IRAM_ATTR
gekennzeichnet), damit sie von Interrupt-Handlern aufgerufen werden können. Die Funktion vTaskNotifyGiveFromISR()
aus dem obigen Beispiel ist eine solche Funktion.
Der IDF-freundlichste Weg, dies zu umgehen, besteht darin, dass der Interrupt-Handler einen Task benachrichtigt, wenn ein ADC-Sample genommen werden muss, und diesen Task das Sampling und die Pufferverwaltung durchführen lässt, wobei möglicherweise ein anderer Task für die Datenanalyse verwendet wird (oder Komprimierung oder Übertragung oder was auch immer der Fall sein mag). Leider ist dies äußerst ineffizient. Sowohl die Handler-Seite (die eine Aufgabe benachrichtigt, dass Arbeit zu erledigen ist) als auch die Aufgabenseite (die eine zu erledigende Aufgabe aufnimmt) beinhalten Interaktionen mit dem Betriebssystem und Tausende von Anweisungen, die ausgeführt werden. Obwohl dieser Ansatz theoretisch korrekt ist, kann er die CPU so stark verlangsamen, dass wenig CPU-Leistung für andere Aufgaben übrig bleibt.
Graben durch den IDF-Quellcode
Das Abtasten von Daten von einem ADC ist normalerweise eine einfache Aufgabe, daher besteht die nächste Strategie darin, zu sehen, wie das IDF dies tut, und es direkt in unserem Code zu replizieren, ohne die bereitgestellte API aufzurufen. Die Funktion adc1_get_raw()
ist in der Datei rtc_module.c
des IDF implementiert, und von den ungefähr acht Dingen, die sie tut, tastet nur eines tatsächlich den ADC ab, was durch einen Aufruf von adc_convert()
erfolgt. Glücklicherweise ist adc_convert()
eine einfache Funktion, die den ADC abtastet, indem periphere Hardwareregister über eine globale Struktur namens SENS
manipuliert werden.
Es ist einfach, diesen Code so anzupassen, dass er in unserem Programm funktioniert (und das Verhalten von adc1_get_raw()
nachzuahmen). Es sieht aus wie das:
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; }
Der nächste Schritt besteht darin, die relevanten Header einzuschließen, damit die SENS
Variable verfügbar wird:
#include <soc/sens_reg.h> #include <soc/sens_struct.h>
Da schließlich adc1_get_raw()
einige Konfigurationsschritte durchführt, bevor der ADC abgetastet wird, sollte es direkt aufgerufen werden, kurz nachdem der ADC eingerichtet wurde. Auf diese Weise kann die entsprechende Konfiguration durchgeführt werden, bevor der Timer gestartet wird.
Der Nachteil dieses Ansatzes ist, dass er nicht gut mit anderen IDF-Funktionen zusammenspielt. Sobald ein anderes Peripheriegerät, ein Treiber oder ein zufälliger Code aufgerufen wird, der die ADC-Konfiguration zurücksetzt, funktioniert unsere benutzerdefinierte Funktion nicht mehr richtig. Zumindest beeinflussen WiFi, PWM, I2C und SPI die ADC-Konfiguration nicht. Falls etwas es beeinflusst, wird ein Aufruf von adc1_get_raw()
ADC wieder entsprechend konfigurieren.
ESP32-Audio-Sampling: Der endgültige Code
Mit der Funktion local_adc_read()
sieht unser Timer-Handler-Code so aus:
#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); }
Hier ist adcTaskHandle
die FreeRTOS-Aufgabe, die implementiert würde, um den Puffer zu verarbeiten, wobei die Struktur der „ complexHandler
“-Funktion im ersten Code-Snippet befolgt wird. Es würde eine lokale Kopie des Audiopuffers erstellen und könnte es dann nach Belieben verarbeiten. Beispielsweise könnte es einen FFT-Algorithmus auf dem Puffer ausführen oder es könnte ihn komprimieren und über WiFi übertragen.
Paradoxerweise würde die Verwendung der Arduino-API anstelle der ESP-IDF-API (dh analogRead()
anstelle von adc1_get_raw()
) funktionieren, da die Arduino-Funktionen mit IRAM_ATTR
gekennzeichnet sind. Sie sind jedoch viel langsamer als die von ESP-IDF, da sie eine höhere Abstraktionsebene bieten. Apropos Leistung: Unsere benutzerdefinierte ADC-Lesefunktion ist etwa doppelt so schnell wie die ESP-IDF-Funktion.
ESP32-Projekte: Zum Betriebssystem oder nicht zum Betriebssystem
Was wir hier getan haben – die Neuimplementierung einer API des Betriebssystems, um einige Probleme zu umgehen, die gar nicht da wären, wenn wir kein Betriebssystem verwenden – ist ein gutes Beispiel für die Vor- und Nachteile der Verwendung eines Betriebssystems in der erster Platz.
Kleinere Mikrocontroller werden direkt programmiert, manchmal im Assembler-Code, und die Entwickler haben die vollständige Kontrolle über jeden Aspekt der Programmausführung, jeden einzelnen CPU-Befehl und alle Zustände aller Peripheriegeräte auf dem Chip. Dies kann natürlich mühsam werden, wenn das Programm größer wird und immer mehr Hardware verwendet wird. Ein komplexer Mikrocontroller wie der ESP32 mit einer großen Anzahl von Peripheriegeräten, zwei CPU-Kernen und einem komplexen, uneinheitlichen Speicherlayout wäre eine Herausforderung und mühsam von Grund auf neu zu programmieren.
Während jedes Betriebssystem einige Einschränkungen und Anforderungen an den Code stellt, der seine Dienste nutzt, sind die Vorteile es normalerweise wert: schnellere und einfachere Entwicklung. Manchmal können wir jedoch, und im eingebetteten Raum sollten wir dies oft umgehen.