ESP32: оцифровка звука Печать
Добавил(а) microsin   

Это руководство (перевод [1]) показывает практическое применение аналого-цифрового преобразователя (analog-to-digital converter, ADC) микроконтроллера ESP32 для оцифровки звука с помощью прерывания таймера. Для этой цели мы будем использовать популярную среду программирования Arduino IDE. Пусть это одна из самых плохих IDE с точки зрения набора функциональных возможностей, Arduino IDE по крайней мере проста в настройке и использовании в разработке для ESP32, и в ней присутствует большая коллекция библиотек для нескольких популярных аппаратных модулей. Однако из соображений производительности мы будем также использовать многие традиционные API-вызовы системы разработки ESP-IDF вместо функций Arduino.

[ESP32 Audio: таймеры и прерывания]

У ESP32 есть 4 аппаратных таймера общего назначения, распределенных по двум группам [2]. Все эти таймеры одинаковые, и оборудованы 16-разрядными прескалерами тактовой частоты и 64-разрядными счетчиками. Программированием прескалера можно поделить внутреннюю системную частоту 80 МГц для тактов таймера. Минимальное значение прескалера 2, это означает, что прерывания могут срабатывать с максимальной частотой 40 МГц. Это значит, что на самой высокой частоте тактирования таймера код обработчика должен выполняться не более чем за 6 тактов ядра процессора (при тактах ядра 240 МГц и частоте тактирования таймера 40 МГц, 240/40 = 6). У таймеров есть несколько связанных с ними свойств:

divider — значение коэффициента деления прескалера.
counter_en — определяет, включен ли соответствующий 64-битный счетчик таймера (обычно true при использовании таймера).
counter_dir — направление счета - инкремент или декремент счетчика с каждым тактом.
alarm_en — разрешено ли событие "alarm", т. е. действие по событию счетчика.
auto_reload — должен ли счетчик сбрасываться при событии alarm.

Некоторые важные режимы таймера:

Таймер запрещен. Счетчик таймера не работает.

Таймер разрешен, но функция alarm запрещена. Счетчик таймера работает, и увеличивает или уменьшает с каждым тактом свое значение, но больше ничего не происходит.

Таймер разрешен, и также разрешена генерация событий alarm. То же самое, что и в предыдущем режиме, однако при достижении счетчика определенного запрограммированного значения счетчик сбрасывается и/или генерируется прерывание.

Счетчики таймеров могут произвольно считываться любым кодом, но в большинстве случаев нам нужно будет это делать периодически, чтобы в строгие интервалы времени выполнять повторяющиеся действия. Обычно это делается для оцифровки сигналов с целью их дальнейшей программной обработки. Для этого на событие alarm таймера настраивается обработчик прерывания (Interrupt Service Routine, ISR).

Функция ISR должна завершаться как можно быстрее, до наступления момента следующего прерывания. В случае высокой частоты прерываний это накладывает жесткие ограничения на то, насколько сложным может быть этот ISR. Обычно ISR должен выполнять как можно меньше действий, чтобы успеть завершиться до наступления своего следующего запуска, и максимально разгрузить процессор для выполнения основной программы. Чаще всего ISR просто заполняет буфер данными, считанными с ADC, и после его заполнения взводит какой-нибудь флаг, оповещая фоновую обработку (задачу RTOS) о готовности данных.

В проектах среды разработки ESP-IDF в коде ISR может использоваться функция FreeRTOS vTaskNotifyGiveFromISR(), чтобы оповестить задачу о необходимости обработки буфера. Это может выглядеть примерно так:

portMUX_TYPE DRAM_ATTR timerMux = portMUX_INITIALIZER_UNLOCKED; 
TaskHandle_t complexHandlerTask;
hw_timer_t * adcTimer = NULL; // наш таймер
 
// Задача FreeRTOS, которая должна делать сложную обработку:
void complexHandlerTask (void *param)
{
   while (true)
   {
      // Задача блокируется на вызове ulTaskNotifyTake с таймаутом
      // в 1 секунду, если ISR не разблокирует её вызовом
      // функции vTaskNotifyGiveFromISR:
      uint32_t tcount = ulTaskNotifyTake(pdFALSE, pdMS_TO_TICKS(1000));  
      if (check_for_work)
      {
         // Здесь могут выполняться интенсивные вычисления, например
         // БПФ на данными буфера:
         ...
      }
   }
}
 
// Обработчик прерывания таймера:
void IRAM_ATTR onTimer()
{
   // Мьютекс защищает обработчик от повторного входа (это не должно
   // никогда произойти, но эта защита предоставлена на всякий случай):
   portENTER_CRITICAL_ISR(&timerMux);
 
   // Здесь выполняются какие-либо действия, например считывание ножки.
   if (some_condition)
   { 
      // Оповещение задачи complexHandlerTask, что буфер заполнен.
      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);
   // Для простоты настроим частоту тактирования таймера равной
   // 80 МГц / 80 = 1 МГц:
   adcTimer = timerBegin(3, 80, true);
   // Подключение к таймеру функции ISR:
   timerAttachInterrupt(adcTimer, &onTimer, true);
   // Прерывания будут срабатывать, когда счетчик == 45,
   // т. е. 22222 раз в секунду:
   timerAlarmWrite(adcTimer, 45, true);
   timerAlarmEnable(adcTimer);
}

Примечание: функции, используемые в этом коде, задокументированы в справочнике ESP-IDF API [3] и опубликованы на GitHub [4].

[Кэши CPU и Гарвардская архитектура]

Важно отметить, значение атрибута IRAM_ATTR в определении ISR onTimer(). Причина в том, что ядра CPU могут выполнять инструкции (и получать доступ к данным) только из встроенной памяти RAM, но не из хранилища flash, где обычно хранятся программа и данные. Чтобы обойти это ограничение, часть из общего объема 520 KB RAM выделяется как IRAM, кэш 128 KB, чтобы прозрачно загружать код из хранилища flash. ESP32 использует для этого отдельные шины для кода и данных (Гарвардская архитектура), так что они работают раздельно, и это распространяется на свойства памяти: IRAM это специальная область, и к ней возможен доступ только в пределах 32-битного адреса.

На самом деле память ESP32 очень неоднородна. Различные её регионы выделены для разных целей: максимальная непрерывная область составляет около 160 KB, а вся "нормальная" область, доступная программам пользователя, составляет только около 316 KB.

Загрузка данных из хранилища flash может потребовать доступа по шине SPI, так что любой код, который требует быстрого запуска и выполнения, должен позаботиться о том, чтобы поместиться в кэш IRAM, и часто должен быть намного меньше (менее 100 KB), поскольку часть IRAM используется RTOS. Следует отметить, что система сгенерирует исключение, если при возникновении прерывания код ISR не был загружен в кэш. Загружать что-то в кэш при возникновении прерывания было бы очень медленно, и реализовать слишком сложно. Спецификатор IRAM_ATTR обработчика onTimer() говорит компилятору и линкеру, что этот код особенный, и должен быть статически быть размещен в IRAM, чтобы никогда не выгружаться оттуда.

Важный момент: атрибут IRAM_ATTR применяется только к функции, которая с ним определена, на затрагивая любые функции и подпрограммы, которые вызываются из IRAM_ATTR-функции.

[Оцифровка звука из прерывания таймера ESP32]

Стандартная техника оцифровки звукового сигнала основана на прерывании таймера, которое заполняет некий буфер аудиоданными, и затем оповещает какую-либо задачу, что данные в буфере готовы для обработки.

ESP-IDF предоставляет функцию драйвера adc1_get_raw(), которая считывает данные на определенном канале ADC первого периферийного устройства ADC (второе из них используется для WiFi). Однако использование этой функции в ISR таймера приведет к нестабильному поведению программы, поскольку это сложная функция, вызывающая нетривиальное количество других функций IDF (в частности тех, что имеют дело с блокировками), и ни функция adc1_get_raw(), ни вызываемые ей функции не помечены атрибутом IRAM_ATTR. ISR рухнет, как только начнет выполняться большой объем кода, что приведет к обновлению кэша программного кода с выгрузкой из кэша функций ADC. Вытеснять их из кэша IRAM могут другие функции, такие как стек WiFi, TCP/IP-HTTP, или библиотека файловой системы SPIFFS, или что-нибудь еще.

Замечание: некоторые функции IDF специально созданы (и помечены атрибутом IRAM_ATTR) таким образом, чтобы они могли вызываться из кода ISR. Примером такой функции может служить вышеупомянутая vTaskNotifyGiveFromISR().

В IDF чаще всего решают проблему кода ISR так, чтобы переложить сложные вычисления (например, сжатие данных или перекодировка звука) на задачи фоновой обработки. К сожалению это бывает очень неэффективно. Как код ISR, так и оповещаемая им задача связаны с взаимодействием с операционной системой и тысячами выполняемых инструкций. Такой подход, хотя и являющийся теоретически верным, может снизить производительность CPU настолько, что у него может не хватить вычислительной мощности для других задач.

[Погружение в исходный код IDF]

Считывание данных с ADC обычно простая задача, и наша стратегия заключается в том, чтобы подсмотреть, как это делает IDF, и скопипастить часть её кода, чтобы не допустить вызовов предоставленного API. Функция adc1_get_raw() реализована в файле components\driver\adc.c среды разработки ESP-IDF, и из того, что она делает, нам нужен только код, который реально считывает выборку из ADC, что делается вызовом adc_hal_convert(). К счастью, функция adc_hal_convert() простая, она просто работает с ADC путем манипуляции с его регистрами периферийного устройства через глобальную структуру, которая называется SENS.

Можно адаптировать код IDF в нашей программе (имитируя поведение adc1_get_raw()). Это будет выглядеть примерно так:

int IRAM_ATTR local_adc1_read(int channel)
{
   uint16_t adc_value;
   // Будет выбран только один канал:
   SENS.sar_meas_start1.sar1_en_pad = (1 << channel);
   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;
}

Чтобы переменная SENS стала доступной, необходимо подключить заголовки:

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

И наконец, поскольку adc1_get_raw() выполняет некоторые шаги по конфигурированию перед тем, как сделать чтение ADC, это должно быть сделано отдельно, например перед запуском таймера.

Недостаток такого подхода в том, что мы теряем гибкости взаимодействия с другими функциями IDF. Как только другое периферийное устройство, драйвер, или случайная часть кода сбросит конфигурацию ADC, наша функция перестанет правильно работать. На конфигурацию ADC не повлияют как минимум WiFi, PWM, I2C и SPI.

Использованием нашей функции local_adc1_read код ISR таймера будет выглядеть следующим образом:

#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;
 
      // Оповещение задачи adcTaskHandle о том, что буфер заполнен.
      BaseType_t xHigherPriorityTaskWoken = pdFALSE;
      vTaskNotifyGiveFromISR(adcTaskHandle, &xHigherPriorityTaskWoken);
      if (xHigherPriorityTaskWoken)
      {
         portYIELD_FROM_ISR();
      }
   }
   portEXIT_CRITICAL_ISR(&timerMux);
}

Здесь adcTaskHandle это задача FreeRTOS, которая должна быть реализована для обработки буфера, она соответствует принципу обработки, показанному ранее в complexHandler. Здесь может быть сделана копия аудиобуфера, чтобы потом эти данные можно было обработать. Например, может быть запущен алгоритм БПФ, или данные звука могут быть сжаты для передачи через WiFi.

Парадоксально, что использование Arduino API вместо ESP-IDF API (например, analogRead() вместо adc1_get_raw()) работает, потому что функции Arduino помечены IRAM_ATTR. Однако они намного медленнее, чем функции ESP-IDF, потому что предоставляют дополнительный уровень абстракции. Если говорить о производительности, то наша кустарная функция чтения ADC работает примерно в 2 раза быстрее, чем функция ESP-IDF.

[За и против использования RTOS в проектах ESP32]

То, что мы тут проделали - заново реализовали API операционной системы, чтобы обойти некоторые проблемы, которых при отсутствии операционной системы не было бы в принципе - хорошая иллюстрация плюсов и минусов использования в первую очередь RTOS.

Маленькие микроконтроллеры программируются напрямую, иногда даже на ассемблере, и у разработчиков имеется полное управление над каждым аспектом выполнения программы - над каждой инструкцией CPU и над каждым состоянием всех периферийных устройств чипа. Конечно это реально становится утомительным при разрастании программы, когда нужно подключать в приложении все большего количества оборудования и программных стеков (USB, Wi-Fi, HTTP, файловые системы и т. д.). Сложные микроконтроллеры, такие как ESP32, с большим набором периферийных устройств и функций, с двумя ядрами CPU и сложной, неоднородной компоновкой памяти, программировать "с нуля" становится практически невозможным, если не использовать FreeRTOS и сторонние библиотеки.

Хотя каждая операционная система накладывает определенные ограничения и требования к коду (и это плата за предоставляемые сервисы), её достоинства обычно перевешивают: разработка приложения ускоряется и упрощается. Однако мы можем работать с аппаратными ресурсами напрямую, и иногда должны это делать при проектировании встраиваемых систем.

[Ссылки]

1. Working with ESP32 Audio Sampling site:toptal.com.
2. ESP32 General Purpose Timer.
3. ESP32 API Reference site:docs.espressif.com.
4. espressif / arduino-esp32 site:github.com.
5ESP32 ADC.