ESP32 오디오 샘플링 작업

게시 됨: 2022-03-11

ESP32는 차세대 WiFi 및 Bluetooth 지원 마이크로컨트롤러입니다. 그것은 상하이에 기반을 둔 Espressif의 매우 인기 있는(그리고 취미 애호가들에게는 혁명적인) ESP8266 마이크로컨트롤러의 후속 제품입니다.

마이크로컨트롤러 중 거물인 ESP32의 사양에는 주방 싱크대를 제외한 모든 것이 포함됩니다. SoC(System-on-a-Chip) 제품이며 모든 기능을 사용하려면 실제로 운영 체제가 필요합니다.

이 ESP32 자습서는 타이머 인터럽트에서 아날로그-디지털 변환기(ADC)를 샘플링하는 특정 문제를 설명하고 해결합니다. 우리는 Arduino IDE를 사용할 것입니다. 기능 세트 측면에서 최악의 IDE 중 하나일지라도 Arduino IDE는 ESP32 개발을 위해 최소한 설정 및 사용하기 쉽고 다양한 공통 하드웨어 모듈에 대한 가장 큰 라이브러리 컬렉션을 보유하고 있습니다. 그러나 성능상의 이유로 Arduino 대신 많은 기본 ESP-IDF API도 사용할 것입니다.

ESP32 오디오: 타이머 및 인터럽트

ESP32에는 2개의 그룹으로 나누어진 4개의 하드웨어 타이머가 포함되어 있습니다. 16비트 프리스케일러와 64비트 카운터가 있는 모든 타이머는 동일합니다. 프리스케일 값은 타이머로 들어가는 내부 80MHz 클럭에서 나오는 하드웨어 클럭 신호를 N 번째 틱마다 제한하는 데 사용됩니다. 최소 프리스케일 값은 2이며, 인터럽트가 공식적으로 최대 40MHz에서 발생할 수 있음을 의미합니다. 이것은 가장 높은 타이머 분해능에서 핸들러 코드가 최대 6 클럭 사이클(240MHz 코어/40MHz)에서 실행되어야 함을 의미하므로 나쁘지 않습니다. 타이머에는 몇 가지 관련 속성이 있습니다.

  • divider — 주파수 프리스케일 값
  • 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, 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에서만 명령을 실행(및 데이터 액세스)할 수 있기 때문입니다. 이 문제를 해결하기 위해 총 520KiB의 RAM 중 일부가 플래시 스토리지에서 코드를 투명하게 로드하는 데 사용되는 128KiB 캐시인 IRAM으로 전용됩니다. ESP32는 코드와 데이터("Harvard 아키텍처")에 대해 별도의 버스를 사용하므로 매우 별도로 처리되며 메모리 속성으로 확장됩니다. IRAM은 특별하고 32비트 주소 경계에서만 액세스할 수 있습니다.

실제로 ESP32 메모리는 매우 균일하지 않습니다. 그것의 다른 영역은 다른 목적을 위해 사용됩니다. 최대 연속 영역 크기는 약 160KiB이고 사용자 프로그램에서 액세스할 수 있는 모든 "일반" 메모리는 총 약 316KiB에 불과합니다.

플래시 스토리지에서 데이터를 로드하는 것은 느리고 SPI 버스 액세스가 필요할 수 있으므로 속도에 의존하는 모든 코드는 IRAM 캐시에 맞게 주의해야 하며 일부는 운영 체제. 특히, 인터럽트가 발생할 때 인터럽트 핸들러 코드가 캐시에 로드되지 않으면 시스템은 예외를 생성합니다. 인터럽트가 발생했을 때 플래시 스토리지에서 무언가를 로드하는 것은 매우 느리고 논리적인 악몽이 될 것입니다. onTimer() 핸들러의 IRAM_ATTR 지정자는 컴파일러와 링커에 이 코드를 특수 코드로 표시하도록 지시합니다. 이 코드는 IRAM에 정적으로 배치되고 절대 스왑되지 않습니다.

그러나 IRAM_ATTR 은 지정된 함수에만 적용되며 해당 함수에서 호출된 함수는 영향을 받지 않습니다.

타이머 인터럽트에서 ESP32 오디오 데이터 샘플링

오디오 신호가 인터럽트에서 샘플링되는 일반적인 방법에는 샘플의 메모리 버퍼를 유지 관리하고 샘플링된 데이터로 채우고 데이터를 사용할 수 있음을 핸들러 작업에 알리는 것이 포함됩니다.

ESP-IDF는 첫 번째 ADC 주변 장치(두 번째 장치는 WiFi에서 사용됨)의 특정 ADC 채널에서 데이터를 측정하는 adc1_get_raw() 함수를 문서화합니다. 그러나 adc1_get_raw() 핸들러 코드에서 이를 사용하면 프로그램이 불안정해집니다. 왜냐하면 이것은 다른 IDF 함수, 특히 잠금을 처리하는 함수를 호출하는 복잡한 함수이기 때문입니다. 호출은 IRAM_ATTR 로 표시됩니다. 인터럽트 핸들러는 ADC 기능이 IRAM 외부로 스왑되도록 하는 충분히 큰 코드 조각이 실행되는 즉시 충돌합니다. 이는 WiFi-TCP/IP-HTTP 스택 또는 SPIFFS 파일 시스템 라이브러리일 수 있습니다. 또는 다른 무엇이든.

참고: 일부 IDF 함수는 인터럽트 처리기에서 호출할 수 있도록 특별히 제작되고 IRAM_ATTR 로 표시됩니다. 위 예제의 vTaskNotifyGiveFromISR() 함수가 그러한 함수 중 하나입니다.

이 문제를 해결하는 가장 IDF 친화적인 방법은 ADC 샘플을 가져와야 할 때 인터럽트 핸들러가 작업을 알리고 이 작업이 샘플링 및 버퍼 관리를 수행하도록 하고 데이터 분석(또는 압축 또는 전송 또는 모든 경우). 불행히도 이것은 매우 비효율적입니다. 처리기 측(작업에 수행해야 할 작업이 있음을 알림)과 작업 측(해야 할 작업을 선택함) 모두 운영 체제 및 실행 중인 수천 개의 명령과의 상호 작용을 포함합니다. 이 접근 방식은 이론적으로는 정확하지만 다른 작업을 위한 여분의 CPU 전원을 거의 남기지 않을 정도로 CPU가 느려질 수 있습니다.

IDF 소스 코드 파기

ADC에서 데이터를 샘플링하는 것은 일반적으로 간단한 작업이므로 다음 전략은 제공된 API를 호출하지 않고 IDF가 수행하는 방식을 확인하고 코드에서 직접 복제하는 것입니다. adc1_get_raw() 함수는 IDF의 rtc_module.c 파일에 구현되어 있으며, 8개 정도 중 하나만 실제로 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 은 첫 번째 코드 조각의 complexHandler 함수 구조에 따라 버퍼를 처리하기 위해 구현되는 FreeRTOS 작업입니다. 오디오 버퍼의 로컬 복사본을 만든 다음 여유 시간에 처리할 수 있습니다. 예를 들어 버퍼에서 FFT 알고리즘을 실행하거나 압축하여 WiFi를 통해 전송할 수 있습니다.

역설적이게도 ESP-IDF API 대신 Arduino API를 사용하면(즉 analogRead() 대신 adc1_get_raw() ) Arduino 함수가 IRAM_ATTR 로 표시되기 때문에 작동합니다. 그러나 더 높은 수준의 추상화를 제공하기 때문에 ESP-IDF보다 훨씬 느립니다. 성능에 관해 말하자면, 우리의 맞춤형 ADC 읽기 기능은 ESP-IDF보다 약 2배 빠릅니다.

ESP32 프로젝트: OS로 또는 OS로 아님

운영 체제를 사용하지 않았다면 없었을 몇 가지 문제를 해결하기 위해 운영 체제의 API를 다시 구현한 것은 운영 체제에서 운영 체제를 사용할 때의 장단점을 잘 보여줍니다. 처음.

더 작은 마이크로컨트롤러는 때로는 어셈블러 코드로 직접 프로그래밍되며 개발자는 프로그램 실행의 모든 ​​측면, 모든 단일 CPU 명령 및 칩에 있는 모든 주변 장치의 모든 상태를 완벽하게 제어할 수 있습니다. 이것은 프로그램이 커지고 점점 더 많은 하드웨어를 사용함에 따라 자연스럽게 지루해질 수 있습니다. 많은 주변 장치 세트, 2개의 CPU 코어, 복잡하고 균일하지 않은 메모리 레이아웃이 있는 ESP32와 같은 복잡한 마이크로컨트롤러는 처음부터 프로그래밍하기가 어렵고 힘들 수 있습니다.

모든 운영 체제는 서비스를 사용하는 코드에 몇 가지 제한과 요구 사항을 적용하지만 일반적으로 더 빠르고 간단한 개발이라는 이점이 있습니다. 그러나 때때로 우리는 할 수 있고 임베디드 공간에서 종종 주위를 둘러봐야 합니다.

관련: 완전한 기능의 Arduino 기상 관측소를 만든 방법