Travailler avec l'échantillonnage audio ESP32
Publié: 2022-03-11L'ESP32 est un microcontrôleur de nouvelle génération compatible WiFi et Bluetooth. C'est le successeur d'Espressif, basé à Shanghai, du microcontrôleur ESP8266 très populaire et, pour le public amateur, révolutionnaire.
Un monstre parmi les microcontrôleurs, les spécifications de l'ESP32 incluent tout sauf l'évier de la cuisine. Il s'agit d'un produit système sur puce (SoC) et nécessite pratiquement un système d'exploitation pour utiliser toutes ses fonctionnalités.
Ce tutoriel ESP32 expliquera et résoudra un problème particulier d'échantillonnage du convertisseur analogique-numérique (ADC) à partir d'une interruption de minuterie. Nous utiliserons l'IDE Arduino. Même s'il s'agit de l'un des pires IDE en termes d'ensembles de fonctionnalités, l'IDE Arduino est au moins facile à configurer et à utiliser pour le développement ESP32, et il possède la plus grande collection de bibliothèques pour une variété de modules matériels courants. Cependant, nous utiliserons également de nombreuses API ESP-IDF natives au lieu de celles d'Arduino, pour des raisons de performances.
ESP32 Audio : minuteries et interruptions
L'ESP32 contient quatre minuteries matérielles, divisées en deux groupes. Tous les temporisateurs sont identiques, avec des prédiviseurs 16 bits et des compteurs 64 bits. La valeur de pré-échelle est utilisée pour limiter le signal d'horloge matérielle, qui provient d'une horloge interne de 80 MHz entrant dans le temporisateur, à chaque Nième tick. La valeur de pré-échelle minimale est de 2, ce qui signifie que les interruptions peuvent officiellement se déclencher à 40 MHz au maximum. Ce n'est pas mal, car cela signifie qu'à la résolution de minuterie la plus élevée, le code du gestionnaire doit s'exécuter en 6 cycles d'horloge au maximum (cœur 240 MHz/40 MHz). Les temporisateurs ont plusieurs propriétés associées :
-
divider
— la valeur de pré-échelonnement de la fréquence -
counter_en
— indique si le compteur 64 bits associé au temporisateur est activé (généralement vrai) -
counter_dir
le compteur est incrémenté ou décrémenté -
alarm_en
— indique si « l'alarme », c'est-à-dire l'action du compteur, est activée -
auto_reload
le compteur est réinitialisé lorsque l'alarme est déclenchée
Certains des modes de minuterie distincts importants sont :
- La minuterie est désactivée. Le matériel ne fonctionne pas du tout.
- La minuterie est activée, mais l'alarme est désactivée. Le matériel de la minuterie fonctionne, il incrémente ou décrémente éventuellement le compteur interne, mais rien d'autre ne se passe.
- La minuterie est activée et son alarme est également activée. Comme avant, mais cette fois, une action est effectuée lorsque le compteur du temporisateur atteint une valeur configurée particulière : le compteur est réinitialisé et/ou une interruption est générée.
Les compteurs des temporisateurs peuvent être lus par du code arbitraire, mais dans la plupart des cas, nous sommes intéressés à faire quelque chose périodiquement, et cela signifie que nous configurerons le matériel du temporisateur pour générer une interruption, et nous écrirons du code pour le gérer.
Une fonction de gestionnaire d'interruption doit se terminer avant que la prochaine interruption ne soit générée, ce qui nous donne une limite supérieure stricte sur la complexité de la fonction. En règle générale, un gestionnaire d'interruptions doit effectuer le moins de travail possible.
Pour réaliser quoi que ce soit de complexe à distance, il convient plutôt de définir un indicateur qui est vérifié par un code sans interruption. Tout type d'E/S plus complexe que la lecture ou la définition d'une seule broche sur une seule valeur est souvent mieux déchargé sur un gestionnaire séparé.
Dans l'environnement ESP-IDF, la fonction FreeRTOS vTaskNotifyGiveFromISR()
peut être utilisée pour notifier à une tâche que le gestionnaire d'interruption (également appelé Interrupt Service Routine, ou ISR) a quelque chose à faire. Le code ressemble à ceci :
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); }
Remarque : les fonctions utilisées dans le code tout au long de cet article sont documentées avec l'API ESP-IDF et le projet ESP32 Arduino core GitHub.
Caches CPU et architecture Harvard
Une chose très importante à noter est la clause IRAM_ATTR
dans la définition du gestionnaire d'interruption onTimer()
. La raison en est que les cœurs du processeur ne peuvent exécuter des instructions (et accéder aux données) qu'à partir de la RAM intégrée, et non à partir du stockage flash où le code de programme et les données sont normalement stockés. Pour contourner ce problème, une partie du total de 520 Ko de RAM est dédiée à l'IRAM, un cache de 128 Ko utilisé pour charger de manière transparente le code à partir du stockage flash. L'ESP32 utilise des bus séparés pour le code et les données ("architecture Harvard"), ils sont donc très bien gérés séparément, et cela s'étend aux propriétés de la mémoire : l'IRAM est spéciale et n'est accessible qu'à des limites d'adresse de 32 bits.
En fait, la mémoire ESP32 est très non uniforme. Différentes régions de celui-ci sont dédiées à des fins différentes : la région continue maximale a une taille d'environ 160 Kio, et toute la mémoire "normale" accessible par les programmes utilisateur ne totalise qu'environ 316 Kio.
Le chargement des données à partir du stockage flash est lent et peut nécessiter un accès au bus SPI, de sorte que tout code qui repose sur la vitesse doit veiller à s'intégrer dans le cache IRAM, et souvent beaucoup plus petit (moins de 100 Ko) puisqu'une partie de celui-ci est utilisée par le système opérateur. Notamment, le système générera une exception si le code du gestionnaire d'interruption n'est pas chargé dans le cache lorsqu'une interruption se produit. Ce serait à la fois très lent et un cauchemar logistique de charger quelque chose à partir du stockage flash juste au moment où une interruption se produit. Le spécificateur IRAM_ATTR
sur le gestionnaire onTimer()
indique au compilateur et à l'éditeur de liens de marquer ce code comme spécial - il sera placé statiquement dans IRAM et ne sera jamais échangé.
Cependant, IRAM_ATTR
ne s'applique qu'à la fonction sur laquelle il est spécifié - toutes les fonctions appelées à partir de cette fonction ne sont pas affectées.

Échantillonnage de données audio ESP32 à partir d'une interruption de minuterie
La manière habituelle dont les signaux audio sont échantillonnés à partir d'une interruption consiste à maintenir une mémoire tampon d'échantillons, à la remplir avec des données échantillonnées, puis à notifier à une tâche de gestionnaire que les données sont disponibles.
L'ESP-IDF documente la fonction adc1_get_raw()
qui mesure les données sur un canal ADC particulier sur le premier périphérique ADC (le second est utilisé par WiFi). Cependant, l'utiliser dans le code du gestionnaire de minuterie entraîne un programme instable, car il s'agit d'une fonction complexe qui appelle un nombre non négligeable d'autres fonctions IDF - en particulier celles qui traitent des verrous - et ni adc1_get_raw()
ni les fonctions il appelle sont marqués avec IRAM_ATTR
. Le gestionnaire d'interruption plantera dès qu'un morceau de code suffisamment volumineux sera exécuté, ce qui entraînerait l'échange des fonctions ADC hors de l'IRAM, et cela peut être la pile WiFi-TCP/IP-HTTP ou la bibliothèque du système de fichiers SPIFFS, ou quoi que ce soit d'autre.
Remarque : certaines fonctions IDF sont spécialement conçues (et marquées avec IRAM_ATTR
) pour pouvoir être appelées à partir de gestionnaires d'interruptions. La fonction vTaskNotifyGiveFromISR()
de l'exemple ci-dessus est l'une de ces fonctions.
La façon la plus conviviale pour IDF de contourner ce problème est que le gestionnaire d'interruption notifie une tâche lorsqu'un échantillon ADC doit être prélevé, et que cette tâche effectue l'échantillonnage et la gestion de la mémoire tampon, avec éventuellement une autre tâche utilisée pour l'analyse des données (ou compression ou transmission ou quoi que ce soit). Malheureusement, cela est extrêmement inefficace. Le côté gestionnaire (qui notifie une tâche qu'il y a du travail à faire) et le côté tâche (qui récupère une tâche à faire) impliquent des interactions avec le système d'exploitation et des milliers d'instructions en cours d'exécution. Cette approche, bien que théoriquement correcte, peut tellement enliser le CPU qu'elle laisse peu de puissance CPU disponible pour d'autres tâches.
Fouiller dans le code source IDF
L'échantillonnage des données d'un ADC est généralement une tâche simple, donc la stratégie suivante consiste à voir comment l'IDF le fait et à le répliquer directement dans notre code, sans appeler l'API fournie. La fonction adc1_get_raw()
est implémentée dans le fichier rtc_module.c
de l'IDF, et sur les huit choses qu'elle fait, une seule échantillonne réellement l'ADC, ce qui est fait par un appel à adc_convert()
. Heureusement, adc_convert()
est une fonction simple qui échantillonne l'ADC en manipulant les registres matériels périphériques via une structure globale nommée SENS
.
Adapter ce code pour qu'il fonctionne dans notre programme (et pour imiter le comportement de adc1_get_raw()
) est facile. Il ressemble à ceci :
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; }
L'étape suivante consiste à inclure les en-têtes pertinents afin que la variable SENS
devienne disponible :
#include <soc/sens_reg.h> #include <soc/sens_struct.h>
Enfin, étant donné que adc1_get_raw()
effectue certaines étapes de configuration avant d'échantillonner l'ADC, il doit être appelé directement, juste après la configuration de l'ADC. De cette façon, la configuration appropriée peut être effectuée avant le démarrage de la minuterie.
L'inconvénient de cette approche est qu'elle ne fonctionne pas bien avec les autres fonctions IDF. Dès qu'un autre périphérique, un pilote ou un morceau de code aléatoire est appelé, ce qui réinitialise la configuration ADC, notre fonction personnalisée ne fonctionnera plus correctement. Au moins WiFi, PWM, I2C et SPI n'influencent pas la configuration ADC. Au cas où quelque chose l'influencerait, un appel à adc1_get_raw()
configurera à nouveau ADC de manière appropriée.
Échantillonnage audio ESP32 : le code final
Avec la fonction local_adc_read()
en place, notre code de gestionnaire de minuterie ressemble à ceci :
#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); }
Ici, adcTaskHandle
est la tâche FreeRTOS qui serait implémentée pour traiter le tampon, en suivant la structure de la fonction complexHandler
dans le premier extrait de code. Il ferait une copie locale du tampon audio et pourrait ensuite le traiter à sa guise. Par exemple, il peut exécuter un algorithme FFT sur le tampon, ou il peut le compresser et le transmettre via WiFi.
Paradoxalement, l'utilisation de l'API Arduino au lieu de l'API ESP-IDF (c'est-à-dire analogRead()
au lieu de adc1_get_raw()
) fonctionnerait car les fonctions Arduino sont marquées avec IRAM_ATTR
. Cependant, ils sont beaucoup plus lents que ceux ESP-IDF car ils fournissent un niveau d'abstraction plus élevé. En parlant de performances, notre fonction de lecture ADC personnalisée est environ deux fois plus rapide que celle ESP-IDF.
Projets ESP32 : vers le système d'exploitation ou non vers le système d'exploitation
Ce que nous avons fait ici - réimplémenter une API du système d'exploitation pour contourner certains problèmes qui n'existeraient même pas si nous n'utilisions pas de système d'exploitation - est une bonne illustration des avantages et des inconvénients de l'utilisation d'un système d'exploitation dans le première place.
Les microcontrôleurs plus petits sont programmés directement, parfois en code assembleur, et les développeurs ont un contrôle total sur chaque aspect de l'exécution du programme, sur chaque instruction CPU et sur tous les états de tous les périphériques sur la puce. Cela peut naturellement devenir fastidieux à mesure que le programme grossit et qu'il utilise de plus en plus de matériel. Un microcontrôleur complexe tel que l'ESP32, avec un grand nombre de périphériques, deux cœurs de processeur et une disposition de mémoire complexe et non uniforme, serait difficile et laborieux à programmer à partir de zéro.
Bien que chaque système d'exploitation impose certaines limites et exigences au code qui utilise ses services, les avantages en valent généralement la peine : un développement plus rapide et plus simple. Cependant, nous pouvons parfois, et dans l'espace embarqué, nous devrions souvent le contourner.