การทำงานกับ ESP32 Audio Sampling

เผยแพร่แล้ว: 2022-03-11

ESP32 เป็นไมโครคอนโทรลเลอร์รุ่นใหม่ รองรับ 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 สองคอร์ และเลย์เอาต์หน่วยความจำที่ซับซ้อนและไม่สม่ำเสมอ จะเป็นสิ่งที่ท้าทายและต้องใช้ความพยายามอย่างมากในการเขียนโปรแกรมตั้งแต่เริ่มต้น

แม้ว่าระบบปฏิบัติการทุกระบบจะมีข้อจำกัดและข้อกำหนดบางประการเกี่ยวกับโค้ดที่ใช้บริการของตน แต่ประโยชน์มักจะคุ้มค่า นั่นคือ การพัฒนาที่รวดเร็วและง่ายกว่า อย่างไรก็ตาม บางครั้งเราสามารถทำได้และในพื้นที่ฝังตัวมักจะควรหลีกเลี่ยง

ที่เกี่ยวข้อง: ฉันสร้างสถานีตรวจอากาศ Arduino ที่ใช้งานได้อย่างสมบูรณ์ได้อย่างไร