การทำงานกับ ESP32 Audio Sampling
เผยแพร่แล้ว: 2022-03-11ESP32 เป็นไมโครคอนโทรลเลอร์รุ่นใหม่ รองรับ WiFi และ Bluetooth เป็นผู้สืบทอดของ Espressif ที่ได้รับความนิยมอย่างมากในเซี่ยงไฮ้และสำหรับผู้ชมที่เป็นงานอดิเรกคือไมโครคอนโทรลเลอร์ ESP8266 ที่ปฏิวัติวงการ
สเปคของ ESP32 ที่เหนือชั้นในหมู่ไมโครคอนโทรลเลอร์นั้นมีทุกอย่าง ยกเว้นอ่างล้างจาน เป็นผลิตภัณฑ์ system-on-a-chip (SoC) และจำเป็นต้องใช้ระบบปฏิบัติการเพื่อใช้งานคุณลักษณะทั้งหมดของมัน
บทช่วยสอน ESP32 นี้จะอธิบายและแก้ปัญหาเฉพาะของการสุ่มตัวอย่างตัวแปลงแอนะล็อกเป็นดิจิทัล (ADC) จากการขัดจังหวะของตัวจับเวลา เราจะใช้ Arduino IDE แม้ว่าจะเป็นหนึ่งใน IDE ที่แย่ที่สุดในแง่ของชุดคุณลักษณะ แต่อย่างน้อย Arduino IDE นั้นง่ายต่อการติดตั้งและใช้งานสำหรับการพัฒนา ESP32 และมีคอลเล็กชันไลบรารีที่ใหญ่ที่สุดสำหรับโมดูลฮาร์ดแวร์ทั่วไปที่หลากหลาย อย่างไรก็ตาม เราจะใช้ API ดั้งเดิมของ ESP-IDF แทน Arduino ด้วยเหตุผลด้านประสิทธิภาพ
เสียง ESP32: ตัวจับเวลาและการขัดจังหวะ
ESP32 ประกอบด้วยตัวจับเวลาฮาร์ดแวร์สี่ตัว แบ่งออกเป็นสองกลุ่ม ตัวจับเวลาทั้งหมดเหมือนกัน โดยมีพรีสเกลเลอร์ 16 บิตและตัวนับ 64 บิต ค่าพรีสเกลใช้เพื่อจำกัดสัญญาณนาฬิกาของฮาร์ดแวร์ ซึ่งมาจากนาฬิกา 80 MHz ภายในที่เข้าสู่ตัวจับเวลา จนถึงขีดที่ N ทุกอัน ค่าพรีสเกลขั้นต่ำคือ 2 ซึ่งหมายความว่าอินเตอร์รัปต์สามารถเริ่มทำงานที่ 40 MHz ได้มากที่สุด ไม่ได้แย่เพราะหมายความว่าที่ความละเอียดสูงสุดของตัวจับเวลา รหัสตัวจัดการต้องดำเนินการไม่เกิน 6 รอบสัญญาณนาฬิกา (240 MHz core/40 MHz) ตัวจับเวลามีคุณสมบัติที่เกี่ยวข้องหลายประการ:
-
divider
— ค่าพรีสเกลความถี่ -
counter_en
—ไม่ว่าจะเปิดใช้งานตัวนับ 64 บิตที่เกี่ยวข้องของตัวจับเวลาหรือไม่ (ปกติจะเป็นจริง) -
counter_dir
— ไม่ว่าตัวนับจะเพิ่มขึ้นหรือลดลง -
alarm_en
—ไม่ว่าจะเปิดใช้งาน “นาฬิกาปลุก” เช่น การกระทำของตัวนับหรือไม่ -
auto_reload
— ตัวนับจะถูกรีเซ็ตเมื่อมีการเตือนหรือไม่
โหมดจับเวลาที่แตกต่างกันที่สำคัญบางโหมด ได้แก่:
- ตัวจับเวลาถูกปิดใช้งาน ฮาร์ดแวร์ไม่ฟ้องเลย
- เปิดใช้งานตัวจับเวลา แต่ปิดการเตือน ฮาร์ดแวร์ตัวจับเวลากำลังฟ้อง เป็นทางเลือกในการเพิ่มหรือลดค่าตัวนับภายใน แต่ไม่มีอะไรเกิดขึ้นอีก
- เปิดใช้งานตัวจับเวลาและเปิดใช้งานการเตือนด้วย เหมือนเมื่อก่อน แต่คราวนี้มีการดำเนินการบางอย่างเมื่อตัวนับเวลาถึงค่าที่กำหนดโดยเฉพาะ: ตัวนับถูกรีเซ็ตและ/หรือสร้างการขัดจังหวะ
ตัวนับของตัวจับเวลาสามารถอ่านได้ด้วยรหัสที่กำหนดเอง แต่โดยส่วนใหญ่แล้ว เราสนใจที่จะทำบางสิ่งเป็นระยะๆ ซึ่งหมายความว่าเราจะกำหนดค่าฮาร์ดแวร์ตัวจับเวลาเพื่อสร้างการขัดจังหวะ และเราจะเขียนโค้ดเพื่อจัดการกับมัน
ฟังก์ชันตัวจัดการขัดจังหวะต้องเสร็จสิ้นก่อนที่จะสร้างอินเตอร์รัปต์ถัดไป ซึ่งทำให้เรามีขีดจำกัดสูงสุดสำหรับความซับซ้อนของฟังก์ชันที่จะได้รับ โดยทั่วไป ตัวจัดการการขัดจังหวะควรทำงานให้น้อยที่สุดเท่าที่จะทำได้
เพื่อให้ได้สิ่งที่ซับซ้อนจากระยะไกล คุณควรตั้งค่าสถานะซึ่งตรวจสอบด้วยรหัสที่ไม่ขัดจังหวะแทน I/O ชนิดใดก็ตามที่มีความซับซ้อนมากกว่าการอ่านหรือการตั้งค่าพินเดียวให้เป็นค่าเดียวมักจะถูกถ่ายโอนไปยังตัวจัดการแยกต่างหากได้ดีกว่า
ในสภาพแวดล้อม ESP-IDF สามารถใช้ฟังก์ชัน vTaskNotifyGiveFromISR()
เพื่อแจ้งงานที่ตัวจัดการการขัดจังหวะ (เรียกอีกอย่างว่า Interrupt Service Routine หรือ 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 และสถาปัตยกรรมฮาร์วาร์ด
สิ่งสำคัญที่ควรสังเกตคือ IRAM_ATTR
clause ในคำจำกัดความของตัวจัดการการขัดจังหวะ onTimer()
เหตุผลก็คือแกนประมวลผลของ CPU สามารถรันคำสั่ง (และเข้าถึงข้อมูล) ได้จาก RAM ที่ฝังอยู่เท่านั้น ไม่ใช่จากที่เก็บข้อมูลแฟลชซึ่งปกติแล้วรหัสโปรแกรมและข้อมูลจะถูกเก็บไว้ เพื่อแก้ไขปัญหานี้ ส่วนหนึ่งของ RAM ทั้งหมด 520 KiB นั้นถูกใช้เป็น IRAM ซึ่งเป็นแคช 128 KiB ที่ใช้โหลดโค้ดจากที่เก็บข้อมูลแฟลชอย่างโปร่งใส ESP32 ใช้บัสแยกกันสำหรับโค้ดและข้อมูล (“สถาปัตยกรรมฮาร์วาร์ด”) ดังนั้นจึงมีการจัดการแยกกันอย่างมาก และขยายไปถึงคุณสมบัติของหน่วยความจำ: IRAM เป็นแบบพิเศษ และสามารถเข้าถึงได้ที่ขอบเขตที่อยู่แบบ 32 บิตเท่านั้น
อันที่จริงหน่วยความจำ ESP32 นั้นไม่สม่ำเสมอมาก ภูมิภาคต่างๆ ของมันถูกใช้เพื่อจุดประสงค์ที่แตกต่างกัน: พื้นที่ต่อเนื่องสูงสุดมีขนาดประมาณ 160 KiB และหน่วยความจำ "ปกติ" ทั้งหมดที่เข้าถึงได้โดยโปรแกรมผู้ใช้มีทั้งหมดประมาณ 316 KiB เท่านั้น
การโหลดข้อมูลจากที่เก็บข้อมูลแฟลชนั้นช้าและอาจต้องใช้การเข้าถึงบัส SPI ดังนั้นโค้ดใดๆ ที่อาศัยความเร็วจะต้องดูแลให้พอดีกับแคช IRAM และมักจะมีขนาดเล็กกว่ามาก (น้อยกว่า 100 KiB) เนื่องจากส่วนหนึ่งของมันถูกใช้งานโดย ระบบปฏิบัติการ. โดยเฉพาะอย่างยิ่ง ระบบจะสร้างข้อยกเว้นหากไม่มีการโหลดโค้ดตัวจัดการการขัดจังหวะลงในแคชเมื่อมีการขัดจังหวะ การโหลดบางอย่างจากที่จัดเก็บข้อมูลแฟลชจะช้ามากและเป็นฝันร้ายด้านลอจิสติกส์เช่นเดียวกับการขัดจังหวะเกิดขึ้น ตัวระบุ IRAM_ATTR
บนตัวจัดการ onTimer()
บอกให้คอมไพเลอร์และลิงเกอร์ทำเครื่องหมายโค้ดนี้ว่าเป็นโค้ดพิเศษ ซึ่งจะถูกวางไว้ใน 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()
ถูกใช้งานในไฟล์ rtc_module.c
ของ IDF และจากการทำงานแปดอย่างหรือมากกว่านั้น มีเพียงอันเดียวเท่านั้นที่สุ่มตัวอย่าง ADC ซึ่งทำได้โดยการเรียก adc_convert()
โชคดีที่ adc_convert()
เป็นฟังก์ชันง่ายๆ ที่สุ่มตัวอย่าง ADC โดยจัดการการลงทะเบียนฮาร์ดแวร์อุปกรณ์ต่อพ่วงผ่านโครงสร้างส่วนกลางที่ชื่อว่า SENS
การปรับโค้ดนี้เพื่อให้ทำงานในโปรแกรมของเรา (และเพื่อเลียนแบบพฤติกรรมของ 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 analogRead()
แทน adc1_get_raw()
) จะทำงานได้เนื่องจากฟังก์ชัน Arduino ถูกทำเครื่องหมายด้วย IRAM_ATTR
อย่างไรก็ตาม มันช้ากว่า ESP-IDF มาก เนื่องจากมีระดับนามธรรมที่สูงกว่า เมื่อพูดถึงประสิทธิภาพ ฟังก์ชันการอ่าน ADC แบบกำหนดเองของเรานั้นเร็วเป็นสองเท่าของ ESP-IDF
โครงการ ESP32: ไปยัง OS หรือไม่ OS
สิ่งที่เราทำที่นี่—การนำ API ของระบบปฏิบัติการกลับมาใช้ใหม่เพื่อแก้ไขปัญหาบางอย่างซึ่งจะไม่เกิดขึ้นเลยหากเราไม่ได้ใช้ระบบปฏิบัติการ—เป็นตัวอย่างที่ดีของข้อดีและข้อเสียของการใช้ระบบปฏิบัติการใน ที่แรก.
ไมโครคอนโทรลเลอร์ที่มีขนาดเล็กกว่าได้รับการตั้งโปรแกรมโดยตรง บางครั้งในโค้ดแอสเซมเบลอร์ และนักพัฒนาสามารถควบคุมการทำงานของโปรแกรมได้ในทุกแง่มุมของคำสั่ง CPU ทุกคำสั่ง และสถานะทั้งหมดของอุปกรณ์ต่อพ่วงทั้งหมดบนชิป สิ่งนี้อาจกลายเป็นเรื่องน่าเบื่อโดยธรรมชาติเมื่อโปรแกรมมีขนาดใหญ่ขึ้นและใช้ฮาร์ดแวร์มากขึ้นเรื่อยๆ ไมโครคอนโทรลเลอร์ที่ซับซ้อน เช่น ESP32 ที่มีชุดอุปกรณ์ต่อพ่วงขนาดใหญ่ คอร์ CPU สองคอร์ และเลย์เอาต์หน่วยความจำที่ซับซ้อนและไม่สม่ำเสมอ จะเป็นสิ่งที่ท้าทายและต้องใช้ความพยายามอย่างมากในการเขียนโปรแกรมตั้งแต่เริ่มต้น
แม้ว่าระบบปฏิบัติการทุกระบบจะมีข้อจำกัดและข้อกำหนดบางประการเกี่ยวกับโค้ดที่ใช้บริการของตน แต่ประโยชน์มักจะคุ้มค่า นั่นคือ การพัฒนาที่รวดเร็วและง่ายกว่า อย่างไรก็ตาม บางครั้งเราสามารถทำได้และในพื้นที่ฝังตัวมักจะควรหลีกเลี่ยง