Bekerja dengan Pengambilan Sampel Audio ESP32
Diterbitkan: 2022-03-11ESP32 adalah mikrokontroler berkemampuan WiFi dan Bluetooth generasi berikutnya. Ini adalah penerus Espressif yang berbasis di Shanghai dari mikrokontroler ESP8266 yang sangat populer—dan, untuk audiens penggemar, revolusioner.
Raksasa di antara mikrokontroler, spesifikasi ESP32 mencakup segalanya kecuali wastafel dapur. Ini adalah produk system-on-a-chip (SoC) dan secara praktis membutuhkan sistem operasi untuk menggunakan semua fitur-fiturnya.
Tutorial ESP32 ini akan menjelaskan dan memecahkan masalah tertentu dalam pengambilan sampel konverter analog-ke-digital (ADC) dari interupsi timer. Kami akan menggunakan Arduino IDE. Bahkan jika itu adalah salah satu IDE terburuk di luar sana dalam hal set fitur, IDE Arduino setidaknya mudah diatur dan digunakan untuk pengembangan ESP32, dan memiliki koleksi perpustakaan terbesar untuk berbagai modul perangkat keras umum. Namun, kami juga akan menggunakan banyak API ESP-IDF asli alih-alih yang Arduino, untuk alasan kinerja.
Audio ESP32: Timer dan Interupsi
ESP32 berisi empat pengatur waktu perangkat keras, dibagi menjadi dua kelompok. Semua timer adalah sama, memiliki prescaler 16-bit dan counter 64-bit. Nilai pra-skala digunakan untuk membatasi sinyal jam perangkat keras—yang berasal dari jam internal 80 MHz yang masuk ke pengatur waktu—untuk setiap centang ke-N. Nilai pra-skala minimum adalah 2, yang berarti interupsi secara resmi dapat menyala paling banyak 40 MHz. Ini tidak buruk, karena ini berarti bahwa pada resolusi timer tertinggi, kode handler harus dieksekusi paling banyak 6 siklus clock (240 MHz core/40 MHz). Timer memiliki beberapa properti terkait:
-
divider
— nilai pra-skala frekuensi -
counter_en
—apakah penghitung 64-bit terkait pengatur waktu diaktifkan (biasanya benar) -
counter_dir
—apakah penghitung bertambah atau berkurang -
alarm_en
—apakah "alarm", yaitu tindakan penghitung, diaktifkan -
auto_reload
—apakah penghitung disetel ulang saat alarm dipicu
Beberapa mode pengatur waktu penting yang berbeda adalah:
- Timer dinonaktifkan. Perangkat keras tidak berdetak sama sekali.
- Timer diaktifkan, tetapi alarm dinonaktifkan. Perangkat keras pengatur waktu terus berdetak, secara opsional menambah atau mengurangi penghitung internal, tetapi tidak ada hal lain yang terjadi.
- Timer diaktifkan dan alarmnya juga diaktifkan. Seperti sebelumnya, tetapi kali ini beberapa tindakan dilakukan ketika penghitung waktu mencapai nilai tertentu yang dikonfigurasi: Penghitung direset dan/atau interupsi dihasilkan.
Penghitung penghitung waktu dapat dibaca dengan kode arbitrer, tetapi dalam banyak kasus, kami tertarik untuk melakukan sesuatu secara berkala, dan ini berarti kami akan mengonfigurasi perangkat keras pengatur waktu untuk menghasilkan interupsi, dan kami akan menulis kode untuk menanganinya.
Fungsi pengendali interupsi harus selesai sebelum interupsi berikutnya dihasilkan, yang memberi kita batas atas yang sulit tentang seberapa kompleks fungsi tersebut. Umumnya, penangan interupsi harus melakukan pekerjaan paling sedikit yang bisa dilakukan.
Untuk mencapai sesuatu yang rumit dari jarak jauh, itu harus menetapkan tanda yang diperiksa oleh kode non-interupsi. Setiap jenis I/O yang lebih kompleks daripada membaca atau menyetel satu pin ke satu nilai seringkali lebih baik diturunkan ke penangan yang terpisah.
Di lingkungan ESP-IDF, fungsi FreeRTOS vTaskNotifyGiveFromISR()
dapat digunakan untuk memberi tahu tugas bahwa pengendali interupsi (juga disebut Interrupt Service Routine, atau ISR) memiliki sesuatu untuk dilakukan. Kodenya terlihat seperti ini:
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); }
Catatan: Fungsi yang digunakan dalam kode di seluruh artikel ini didokumentasikan dengan API ESP-IDF dan di proyek inti GitHub Arduino ESP32.
Cache CPU dan Arsitektur Harvard
Hal yang sangat penting untuk diperhatikan adalah klausa IRAM_ATTR
dalam definisi pengendali interupsi onTimer()
. Alasan untuk ini adalah bahwa inti CPU hanya dapat menjalankan instruksi (dan mengakses data) dari RAM yang tertanam, bukan dari penyimpanan flash tempat kode program dan data biasanya disimpan. Untuk menyiasatinya, sebagian dari total 520 KiB RAM didedikasikan sebagai IRAM, cache 128 KiB yang digunakan untuk memuat kode secara transparan dari penyimpanan flash. ESP32 menggunakan bus terpisah untuk kode dan data ("Arsitektur Harvard") sehingga sangat banyak ditangani secara terpisah, dan itu meluas ke properti memori: IRAM khusus, dan hanya dapat diakses pada batas alamat 32-bit.
Faktanya, memori ESP32 sangat tidak seragam. Wilayah yang berbeda didedikasikan untuk tujuan yang berbeda: Wilayah kontinu maksimum berukuran sekitar 160 KiB, dan semua memori "normal" yang dapat diakses oleh program pengguna hanya berjumlah sekitar 316 KiB.
Pemuatan data dari penyimpanan flash lambat dan dapat memerlukan akses bus SPI, jadi kode apa pun yang bergantung pada kecepatan harus berhati-hati agar sesuai dengan cache IRAM, dan seringkali jauh lebih kecil (kurang dari 100 KiB) karena sebagian digunakan oleh sistem operasi. Khususnya, sistem akan menghasilkan pengecualian jika kode pengendali interupsi tidak dimuat ke dalam cache saat interupsi terjadi. Ini akan menjadi sangat lambat dan mimpi buruk logistik untuk memuat sesuatu dari penyimpanan flash tepat saat interupsi terjadi. IRAM_ATTR
pada handler onTimer()
memberitahu compiler dan linker untuk menandai kode ini sebagai spesial—ini akan ditempatkan secara statis di IRAM dan tidak pernah ditukar.
Namun, IRAM_ATTR
hanya berlaku untuk fungsi yang ditentukan—fungsi apa pun yang dipanggil dari fungsi itu tidak terpengaruh.

Pengambilan Sampel Data Audio ESP32 dari Interupsi Timer
Cara yang biasa untuk mengambil sampel sinyal audio dari interupsi melibatkan pemeliharaan buffer memori sampel, mengisinya dengan data sampel, dan kemudian memberi tahu tugas handler bahwa data tersedia.
ESP-IDF mendokumentasikan fungsi adc1_get_raw()
yang mengukur data pada saluran ADC tertentu pada periferal ADC pertama (yang kedua digunakan oleh WiFi). Namun, menggunakannya dalam kode pengatur pengatur waktu menghasilkan program yang tidak stabil, karena ini adalah fungsi kompleks yang memanggil nomor non-sepele dari fungsi IDF lainnya—khususnya yang berhubungan dengan kunci—dan bukan adc1_get_raw()
maupun fungsi panggilannya ditandai dengan IRAM_ATTR
. Penangan interupsi akan mogok segera setelah potongan kode yang cukup besar dieksekusi yang akan menyebabkan fungsi ADC ditukar dari IRAM—dan ini mungkin tumpukan WiFi-TCP/IP-HTTP, atau pustaka sistem file SPIFFS, atau sesuatu yang lain.
Catatan: Beberapa fungsi IDF dibuat secara khusus (dan ditandai dengan IRAM_ATTR
) sehingga dapat dipanggil dari penangan interupsi. Fungsi vTaskNotifyGiveFromISR()
dari contoh di atas adalah salah satu fungsi tersebut.
Cara yang paling ramah IDF untuk menyiasatinya adalah dengan memberi tahu penangan interupsi saat sampel ADC perlu diambil, dan meminta tugas ini melakukan pengambilan sampel dan manajemen buffer, dengan kemungkinan tugas lain digunakan untuk analisis data (atau kompresi atau transmisi atau apa pun masalahnya). Sayangnya, ini sangat tidak efisien. Baik sisi handler (yang memberi tahu tugas bahwa ada pekerjaan yang harus diselesaikan) dan sisi tugas (yang mengambil tugas yang harus dilakukan) melibatkan interaksi dengan sistem operasi dan ribuan instruksi yang dieksekusi. Pendekatan ini, meskipun secara teori benar, dapat sangat membebani CPU sehingga hanya menyisakan sedikit daya CPU cadangan untuk tugas-tugas lain.
Menggali melalui Kode Sumber IDF
Pengambilan sampel data dari ADC biasanya merupakan tugas yang sederhana, jadi strategi selanjutnya adalah melihat bagaimana IDF melakukannya, dan mereplikasinya dalam kode kita secara langsung, tanpa memanggil API yang disediakan. Fungsi adc1_get_raw()
diimplementasikan dalam file rtc_module.c
dari IDF, dan dari delapan atau lebih hal yang dilakukannya, hanya satu yang benar-benar mengambil sampel ADC, yang dilakukan dengan panggilan ke adc_convert()
. Untungnya, adc_convert()
adalah fungsi sederhana yang mengambil sampel ADC dengan memanipulasi register perangkat keras periferal melalui struktur global bernama SENS
.
Mengadaptasi kode ini agar berfungsi di program kami (dan meniru perilaku adc1_get_raw()
) itu mudah. Ini terlihat seperti ini:
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; }
Langkah selanjutnya adalah memasukkan header yang relevan sehingga variabel SENS
menjadi tersedia:
#include <soc/sens_reg.h> #include <soc/sens_struct.h>
Terakhir, karena adc1_get_raw()
melakukan beberapa langkah konfigurasi sebelum mengambil sampel ADC, itu harus dipanggil secara langsung, tepat setelah ADC disiapkan. Dengan begitu konfigurasi yang relevan dapat dilakukan sebelum timer dimulai.
Kelemahan dari pendekatan ini adalah tidak cocok dengan fungsi IDF lainnya. Segera setelah beberapa periferal, driver, atau kode acak dipanggil yang mengatur ulang konfigurasi ADC, fungsi kustom kami tidak akan lagi berfungsi dengan benar. Setidaknya WiFi, PWM, I2C, dan SPI tidak mempengaruhi konfigurasi ADC. Jika ada sesuatu yang memengaruhinya, panggilan ke adc1_get_raw()
akan mengonfigurasi ADC dengan tepat lagi.
Pengambilan Sampel Audio ESP32: Kode Terakhir
Dengan fungsi local_adc_read()
di tempat, kode pengatur waktu kita terlihat seperti ini:
#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); }
Di sini, adcTaskHandle
adalah tugas FreeRTOS yang akan diterapkan untuk memproses buffer, mengikuti struktur fungsi complexHandler
dalam cuplikan kode pertama. Itu akan membuat salinan lokal dari buffer audio, dan kemudian dapat memprosesnya di waktu luangnya. Misalnya, mungkin menjalankan algoritme FFT pada buffer, atau dapat mengompresnya dan mengirimkannya melalui WiFi.
Paradoksnya, menggunakan API Arduino sebagai ganti API ESP-IDF (yaitu analogRead()
alih-alih adc1_get_raw()
) akan berfungsi karena fungsi Arduino ditandai dengan IRAM_ATTR
. Namun, mereka jauh lebih lambat daripada yang ESP-IDF karena mereka memberikan tingkat abstraksi yang lebih tinggi. Berbicara tentang kinerja, fungsi baca ADC khusus kami sekitar dua kali lebih cepat dari ESP-IDF.
Proyek ESP32: Ke OS atau Tidak ke OS
Apa yang kami lakukan di sini—mengimplementasikan kembali API sistem operasi untuk mengatasi beberapa masalah yang bahkan tidak akan ada jika kami tidak menggunakan sistem operasi—adalah ilustrasi yang baik tentang pro dan kontra menggunakan sistem operasi di tempat pertama.
Mikrokontroler yang lebih kecil diprogram secara langsung, terkadang dalam kode assembler, dan pengembang memiliki kendali penuh atas setiap aspek eksekusi program, setiap instruksi CPU dan semua status periferal pada chip. Ini secara alami dapat menjadi membosankan karena program menjadi lebih besar dan karena menggunakan lebih banyak perangkat keras. Mikrokontroler yang kompleks seperti ESP32, dengan seperangkat periferal yang besar, dua inti CPU, dan tata letak memori yang kompleks dan tidak seragam, akan menantang dan melelahkan untuk diprogram dari awal.
Sementara setiap sistem operasi menempatkan beberapa batasan dan persyaratan pada kode yang menggunakan layanannya, manfaatnya biasanya sepadan: pengembangan yang lebih cepat dan lebih sederhana. Namun, terkadang kita bisa, dan di ruang yang disematkan seringkali harus, menyiasatinya.