使用 ESP32 音頻採樣
已發表: 2022-03-11ESP32 是支持 WiFi 和藍牙的下一代微控制器。 它是位於上海的 Espressif 非常流行的繼任者,對於愛好者來說,它是革命性的 ESP8266 微控制器。
作為微控制器中的龐然大物,ESP32 的規格包括除了廚房水槽之外的所有東西。 它是一種片上系統 (SoC) 產品,實際上需要操作系統才能利用其所有功能。
本 ESP32 教程將解釋和解決從定時器中斷中對模數轉換器 (ADC) 進行採樣的特定問題。 我們將使用 Arduino IDE。 即使它是功能集方面最差的 IDE 之一,Arduino IDE 至少易於設置和用於 ESP32 開發,並且它擁有用於各種常見硬件模塊的最大庫集合。 但是,出於性能原因,我們還將使用許多原生 ESP-IDF API 而非 Arduino 的 API。
ESP32 音頻:定時器和中斷
ESP32 包含四個硬件定時器,分為兩組。 所有定時器都相同,具有 16 位預分頻器和 64 位計數器。 預分頻值用於將硬件時鐘信號(來自進入定時器的內部 80 MHz 時鐘)限制為每第 N 個滴答聲。 最小預分頻值為 2,這意味著中斷最多可以以 40 MHz 正式觸發。 這還不錯,因為這意味著在最高計時器分辨率下,處理程序代碼必須在最多 6 個時鐘週期內執行(240 MHz 內核/40 MHz)。 定時器有幾個相關的屬性:
-
divider
— 頻率預分頻值 counter_en
— 定時器相關的 64 位計數器是否啟用(通常為真)-
counter_dir
— 計數器是遞增還是遞減 alarm_en
— 是否啟用“警報”,即計數器的動作auto_reload
— 觸發警報時是否重置計數器
一些重要的不同定時器模式是:
- 定時器被禁用。 硬件根本沒有滴答作響。
- 定時器已啟用,但警報已禁用。 計時器硬件正在滴答作響,它可以選擇增加或減少內部計數器,但沒有其他任何事情發生。
- 定時器被啟用並且它的警報也被啟用。 像以前一樣,但這次當定時器計數器達到特定的配置值時會執行一些操作:計數器被重置和/或產生中斷。
定時器的計數器可以被任意代碼讀取,但在大多數情況下,我們有興趣定期做一些事情,這意味著我們將配置定時器硬件以產生中斷,我們將編寫代碼來處理它。
中斷處理函數必須在下一個中斷產生之前完成,這給了我們一個函數複雜度的硬上限。 一般來說,中斷處理程序應該做盡可能少的工作。
為了實現任何遠程複雜的東西,它應該設置一個由非中斷代碼檢查的標誌。 任何比讀取單個引腳或將單個引腳設置為單個值更複雜的 I/O 通常最好卸載到單獨的處理程序。
在 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 項目中。
CPU 緩存和哈佛架構
需要注意的一個非常重要的事情是onTimer()
中斷處理程序定義中的IRAM_ATTR
子句。 這樣做的原因是 CPU 內核只能從嵌入式 RAM 執行指令(和訪問數據),而不能從通常存儲程序代碼和數據的閃存存儲器執行指令(和訪問數據)。 為了解決這個問題,總 520 KiB RAM 的一部分專用於 IRAM,這是一個 128 KiB 緩存,用於透明地從閃存加載代碼。 ESP32 對代碼和數據使用單獨的總線(“哈佛架構”),因此它們在很大程度上是分開處理的,並且延伸到內存屬性:IRAM 是特殊的,只能在 32 位地址邊界處訪問。
事實上,ESP32 的內存非常不均勻。 它的不同區域專用於不同用途:最大連續區域大小約為 160 KiB,用戶程序可訪問的所有“正常”內存總計僅約 316 KiB。
從閃存加載數據很慢,並且可能需要 SPI 總線訪問,因此任何依賴速度的代碼都必須注意適應 IRAM 緩存,並且通常要小得多(小於 100 KiB),因為其中一部分由操作系統。 值得注意的是,如果在發生中斷時沒有將中斷處理程序代碼加載到緩存中,系統將產生異常。 在中斷發生時從閃存加載某些內容既非常緩慢,又是一場後勤噩夢。 onTimer()
處理程序上的IRAM_ATTR
說明符告訴編譯器和鏈接器將此代碼標記為特殊代碼 - 它將靜態放置在 IRAM 中並且永遠不會換出。
但是, IRAM_ATTR
僅適用於它指定的函數——從該函數調用的任何函數都不受影響。

從定時器中斷中採樣 ESP32 音頻數據
從中斷中對音頻信號進行採樣的常用方法包括維護一個內存緩衝區的樣本,用採樣數據填充它,然後通知處理程序任務數據可用。
ESP-IDF 記錄了adc1_get_raw()
函數,該函數測量第一個 ADC 外設(第二個由 WiFi 使用)上特定 ADC 通道上的數據。 但是,在計時器處理程序代碼中使用它會導致程序不穩定,因為它是一個複雜的函數,它調用大量其他 IDF 函數——尤其是那些處理鎖的函數——並且既不是adc1_get_raw()
也不是函數它調用標有IRAM_ATTR
。 一旦執行了足夠大的代碼,中斷處理程序就會崩潰,這會導致 ADC 函數從 IRAM 中換出——這可能是 WiFi-TCP/IP-HTTP 堆棧或 SPIFFS 文件系統庫,或其他任何東西。
注意:一些 IDF 函數是特製的(並用IRAM_ATTR
標記),因此可以從中斷處理程序中調用它們。 上例中的vTaskNotifyGiveFromISR()
函數就是這樣一個函數。
解決此問題的最適合 IDF 的方法是中斷處理程序在需要進行 ADC 採樣時通知任務,並讓該任務執行採樣和緩衝區管理,並可能將另一個任務用於數據分析(或壓縮或傳輸或任何情況下)。 不幸的是,這是非常低效的。 處理程序端(通知任務有工作要做)和任務端(選擇要執行的任務)都涉及與操作系統的交互以及正在執行的數千條指令。 這種方法雖然在理論上是正確的,但會使 CPU 陷入如此多的困境,以至於幾乎沒有多餘的 CPU 資源用於其他任務。
挖掘 IDF 源代碼
從 ADC 採樣數據通常是一項簡單的任務,因此下一個策略是查看 IDF 是如何做到的,並直接在我們的代碼中復制它,而無需調用提供的 API。 adc1_get_raw()
函數在 IDF 的rtc_module.c
文件中實現,在它所做的大約八件事中,實際上只有一個是對 ADC 進行採樣,這是通過調用adc_convert()
來完成的。 幸運的是, adc_convert()
是一個簡單的函數,它通過一個名為SENS
的全局結構操作外圍硬件寄存器來對 ADC 進行採樣。
修改此代碼使其在我們的程序中工作(並模仿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 函數配合使用。 一旦調用了其他一些外圍設備、驅動程序或隨機代碼來重置 ADC 配置,我們的自定義函數將不再正常工作。 至少 WiFi、PWM、I2C 和 SPI 不會影響 ADC 配置。 萬一有什麼影響它,調用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
函數的結構來實現處理緩衝區。 它將製作音頻緩衝區的本地副本,然後可以在閒暇時處理它。 例如,它可能在緩衝區上運行 FFT 算法,或者它可以壓縮它並通過 WiFi 傳輸它。
矛盾的是,使用 Arduino API 而不是 ESP-IDF API(即, analogRead()
而不是adc1_get_raw()
)會起作用,因為 Arduino 函數用IRAM_ATTR
標記。 但是,它們比 ESP-IDF 慢得多,因為它們提供了更高級別的抽象。 說到性能,我們的自定義 ADC 讀取功能大約是 ESP-IDF 的兩倍。
ESP32 項目:到 OS 還是不到 OS
我們在這裡所做的——重新實現操作系統的 API 來解決一些如果我們不使用操作系統甚至不會出現的問題——很好地說明了在第一名。
較小的微控制器直接編程,有時使用彙編代碼,開發人員可以完全控製程序執行的各個方面、每條 CPU 指令以及芯片上所有外設的所有狀態。 隨著程序變得越來越大並且使用越來越多的硬件,這自然會變得乏味。 像 ESP32 這樣的複雜微控制器,具有大量外圍設備、兩個 CPU 內核和復雜、不統一的內存佈局,從頭開始編程將是具有挑戰性和費力的。
雖然每個操作系統都對使用其服務的代碼設置了一些限制和要求,但好處通常是值得的:更快、更簡單的開發。 但是,有時我們可以並且在嵌入式空間中通常應該繞過它。