使用 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 内核和复杂、不统一的内存布局,从头开始编程将是具有挑战性和费力的。
虽然每个操作系统都对使用其服务的代码设置了一些限制和要求,但好处通常是值得的:更快、更简单的开发。 但是,有时我们可以并且在嵌入式空间中通常应该绕过它。