本文概述
ESP32是下一代的, 具有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)中执行。计时器具有几个关联的属性:
- 分频器—频率预分频值
- counter_en-计时器的关联64位计数器是否已启用(通常为true)
- 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, e.g. 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, i.e. 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外设上特定ADC通道上的数据(第二个外设由WiFi使用)。但是, 在计时器处理程序代码中使用它会导致程序不稳定, 因为它是一个复杂的函数, 会调用大量其他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内核以及复杂, 不一致的内存布局, 从头开始编程将非常困难且费力。
尽管每个操作系统对使用其服务的代码都设置了一些限制和要求, 但通常值得这样做的好处是:更快, 更简单的开发。但是, 有时我们可以并且通常应该在嵌入式空间中绕开它。
相关:我如何制作功能齐全的Arduino气象站
评论前必须登录!
注册