Работа с аудиосэмплированием ESP32

Опубликовано: 2022-03-11

ESP32 — это микроконтроллер нового поколения с поддержкой Wi-Fi и Bluetooth. Это шанхайская компания Espressif, преемник очень популярного — и революционного для любителей — микроконтроллера ESP8266.

Спецификации ESP32, бегемота среди микроконтроллеров, включают в себя все, кроме кухонной раковины. Это продукт системы на кристалле (SoC), и для использования всех его функций практически требуется операционная система.

В этом руководстве по ESP32 будет объяснена и решена конкретная проблема выборки аналого-цифрового преобразователя (АЦП) из прерывания таймера. Мы будем использовать Arduino IDE. Даже если это одна из худших IDE с точки зрения набора функций, Arduino IDE, по крайней мере, легко настроить и использовать для разработки ESP32, и она имеет самую большую коллекцию библиотек для различных распространенных аппаратных модулей. Однако мы также будем использовать многие нативные API-интерфейсы ESP-IDF вместо API-интерфейсов Arduino из соображений производительности.

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

ESP32 содержит четыре аппаратных таймера, разделенных на две группы. Все таймеры одинаковые, с 16-битными прескалерами и 64-битными счетчиками. Значение предварительной шкалы используется для ограничения аппаратного тактового сигнала, поступающего от внутренних тактовых импульсов с частотой 80 МГц, поступающих в таймер, до каждого N-го такта. Минимальное предварительно масштабированное значение равно 2, что означает, что прерывания официально могут срабатывать не более чем на частоте 40 МГц. Это неплохо, так как означает, что при максимальном разрешении таймера код обработчика должен выполняться не более чем за 6 тактовых циклов (ядро 240 МГц/40 МГц). Таймеры имеют несколько связанных свойств:

  • divider — значение предварительной шкалы частоты
  • counter_en — включен ли связанный с таймером 64-битный счетчик (обычно верно)
  • counter_dir — увеличивается или уменьшается счетчик
  • alarm_en — включена ли «авария», т.е. действие счетчика
  • auto_reload — сбрасывается ли счетчик при срабатывании тревоги

Некоторые из важных отдельных режимов таймера:

  • Таймер отключен. Аппаратура вообще не тикает.
  • Таймер включен, но будильник отключен. Аппаратный таймер тикает, он может увеличивать или уменьшать внутренний счетчик, но больше ничего не происходит.
  • Таймер включен, и его будильник также включен. Как и раньше, но на этот раз выполняются некоторые действия, когда счетчик таймера достигает определенного настроенного значения: счетчик сбрасывается и/или генерируется прерывание.

Счетчики таймеров могут быть прочитаны произвольным кодом, но в большинстве случаев мы заинтересованы в том, чтобы делать что-то периодически, и это означает, что мы настроим оборудование таймера для генерации прерывания и напишем код для его обработки.

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

Чтобы достичь чего-то отдаленно сложного, он должен вместо этого установить флаг, который проверяется кодом без прерывания. Любой тип ввода-вывода, более сложный, чем чтение или установка одного вывода на одно значение, часто лучше передать отдельному обработчику.

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

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

Примечание. Функции, используемые в коде в этой статье, задокументированы с помощью ESP-IDF API и основного проекта ESP32 Arduino на GitHub.

Кэши ЦП и Гарвардская архитектура

Очень важно обратить внимание на предложение IRAM_ATTR в определении обработчика прерывания onTimer() . Причина этого в том, что ядра ЦП могут выполнять инструкции (и получать доступ к данным) только из встроенной оперативной памяти, а не из флэш-памяти, где обычно хранятся программный код и данные. Чтобы обойти это, часть от общего объема 520 КБ ОЗУ выделена как IRAM, кэш-память 128 КБ, используемая для прозрачной загрузки кода из флэш-памяти. ESP32 использует отдельные шины для кода и данных («гарвардская архитектура»), поэтому они в значительной степени обрабатываются отдельно, и это распространяется на свойства памяти: IRAM особенная, и к ней можно получить доступ только по 32-битным границам адресов.

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

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

Однако IRAM_ATTR применяется только к той функции, для которой он указан — никакие функции, вызываемые из этой функции, не затрагиваются.

Выборка аудиоданных ESP32 из прерывания таймера

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

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

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

Самый дружественный к IDF способ обойти это — обработчик прерываний уведомляет задачу, когда необходимо взять выборку ADC, и позволяет этой задаче выполнять выборку и управление буфером, при этом, возможно, другая задача используется для анализа данных (или сжатие или передача или что бы то ни было). К сожалению, это крайне неэффективно. И сторона обработчика (которая уведомляет задачу о том, что нужно выполнить работу), и сторона задачи (которая выбирает задачу для выполнения) включают взаимодействие с операционной системой и тысячи выполняемых инструкций. Этот подход, хотя теоретически правильный, может настолько сильно загрузить ЦП, что оставит мало резервной мощности ЦП для других задач.

Копание в исходном коде IDF

Выборка данных из ADC обычно является простой задачей, поэтому следующая стратегия — посмотреть, как это делает IDF, и воспроизвести их напрямую в нашем коде, не вызывая предоставленный API. Функция adc1_get_raw() реализована в файле rtc_module.c IDF, и из восьми или около того вещей, которые она делает, только одна фактически производит выборку АЦП, которая выполняется вызовом adc_convert() . К счастью, adc_convert() — это простая функция, которая производит выборку АЦП, манипулируя регистрами периферийного оборудования через глобальную структуру с именем SENS .

Адаптировать этот код так, чтобы он работал в нашей программе (и имитировать поведение adc1_get_raw() ), несложно. Это выглядит так:

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

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

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

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

Недостатком этого подхода является то, что он плохо сочетается с другими функциями IDF. Как только вызывается какое-либо другое периферийное устройство, драйвер или случайный фрагмент кода, который сбрасывает конфигурацию АЦП, наша пользовательская функция перестает работать корректно. По крайней мере WiFi, PWM, I2C и SPI не влияют на конфигурацию АЦП. В случае, если что-то влияет на это, вызов adc1_get_raw() снова соответствующим образом настроит ADC.

Сэмплирование звука ESP32: окончательный код

С local_adc_read() наш код обработчика таймера выглядит следующим образом:

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

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

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

Проекты ESP32: в ОС или не в ОС

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

Меньшие микроконтроллеры программируются напрямую, иногда в коде на ассемблере, и разработчики имеют полный контроль над каждым аспектом выполнения программы, каждой отдельной инструкцией ЦП и всеми состояниями всех периферийных устройств на кристалле. Естественно, это может стать утомительным по мере того, как программа становится больше и использует все больше и больше оборудования. Сложный микроконтроллер, такой как ESP32, с большим набором периферийных устройств, двумя ядрами ЦП и сложной, неоднородной структурой памяти, было бы сложно и трудоемко программировать с нуля.

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

По теме: Как я сделал полнофункциональную метеостанцию ​​на Arduino