ESP32オーディオサンプリングの操作

公開: 2022-03-11

ESP32は、WiFiおよびBluetooth対応の次世代マイクロコントローラーです。 これは、上海を拠点とするEspressifの後継であり、非常に人気があり、趣味の聴衆にとっては革新的なESP8266マイクロコントローラーです。

マイクロコントローラーの中でも巨大なESP32の仕様には、台所の流し台以外のすべてが含まれています。 これはシステムオンチップ(SoC)製品であり、そのすべての機能を利用するには、実際にはオペレーティングシステムが必要です。

このESP32チュートリアルでは、タイマー割り込みからアナログ-デジタルコンバータ(ADC)をサンプリングする際の特定の問題について説明し、解決します。 ArduinoIDEを使用します。 機能セットの点で最悪のIDEの1つであっても、Arduino IDEは少なくともセットアップとESP32開発での使用が簡単であり、さまざまな一般的なハードウェアモジュール用のライブラリの最大のコレクションを備えています。 ただし、パフォーマンス上の理由から、Arduinoの代わりに多くのネイティブESP-IDFAPIも使用します。

ESP32オーディオ:タイマーと割り込み

ESP32には、2つのグループに分けられた4つのハードウェアタイマーが含まれています。 すべてのタイマーは同じで、16ビットのプリスケーラーと64ビットのカウンターがあります。 プリスケール値は、タイマーに入る内部80MHzクロックからのハードウェアクロック信号をN番目のティックごとに制限するために使用されます。 最小プリスケール値は2です。これは、割り込みが最大で40MHzで公式に発生する可能性があることを意味します。 これは悪いことではありません。これは、最高のタイマー解像度では、ハンドラーコードが最大6クロックサイクル(240MHzコア/40 MHz)で実行される必要があることを意味します。 タイマーには、いくつかの関連するプロパティがあります。

  • 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-IDFAPIおよびESP32ArduinoコアGitHubプロジェクトで文書化されています。

CPUキャッシュとハーバードアーキテクチャ

注意すべき非常に重要なことは、 onTimer()割り込みハンドラーの定義のIRAM_ATTR句です。 これは、CPUコアが命令を実行(およびデータにアクセス)できるのは組み込みRAMからのみであり、プログラムコードとデータが通常保存されているフラッシュストレージからではないためです。 これを回避するために、合計520 KiBのRAMの一部がIRAMとして使用されます。これは、フラッシュストレージからコードを透過的にロードするために使用される128KiBのキャッシュです。 ESP32は、コードとデータに別々のバスを使用するため(「ハーバードアーキテクチャ」)、それらは非常に別々に処理され、メモリプロパティにまで拡張されます。IRAMは特別であり、32ビットアドレス境界でのみアクセスできます。

実際、ESP32メモリは非常に不均一です。 そのさまざまな領域がさまざまな目的に使用されます。最大連続領域のサイズは約160KiBであり、ユーザープログラムがアクセスできるすべての「通常の」メモリは合計で約316KiBにすぎません。

フラッシュストレージからのデータの読み込みは遅く、SPIバスアクセスが必要になる可能性があるため、速度に依存するコードはIRAMキャッシュに収まるように注意する必要があり、その一部はオペレーティング・システム。 特に、割り込みが発生したときに割り込みハンドラコードがキャッシュにロードされていない場合、システムは例外を生成します。 割り込みが発生したときにフラッシュストレージから何かをロードすることは、非常に遅く、ロジスティックの悪夢でもあります。 onTimer()ハンドラーのIRAM_ATTR指定子は、コンパイラーとリンカーに、このコードを特殊としてマークするように指示します。このコードはIRAMに静的に配置され、スワップアウトされることはありません。

ただし、 IRAM_ATTRは、指定された関数にのみ適用されます。その関数から呼び出された関数は影響を受けません。

タイマー割り込みからのESP32オーディオデータのサンプリング

割り込みからオーディオ信号をサンプリングする通常の方法では、サンプルのメモリバッファを維持し、サンプリングされたデータを入力してから、データが利用可能であることをハンドラタスクに通知します。

ESP-IDFは、最初のADCペリフェラルの特定のADCチャネルでデータを測定するadc1_get_raw()関数を文書化します(2番目のADCはWiFiで使用されます)。 ただし、タイマーハンドラーコードで使用すると、プログラムが不安定になります。これは、他のIDF関数(特にロックを処理する関数)の自明ではない数を呼び出す複雑な関数であり、 adc1_get_raw()も関数も呼び出さないためです。呼び出しはIRAM_ATTRでマークされます。 割り込みハンドラーは、ADC機能がIRAMからスワップアウトされる原因となる十分な大きさのコードが実行されるとすぐにクラッシュします。これは、WiFi-TCP / IP-HTTPスタック、またはSPIFFSファイルシステムライブラリである可能性があります。または他の何か。

注:一部のIDF関数は、割り込みハンドラーから呼び出すことができるように特別に作成されています( IRAM_ATTRでマークされています)。 上記の例のvTaskNotifyGiveFromISR()関数は、そのような関数の1つです。

これを回避するための最もIDFに適した方法は、ADCサンプルを取得する必要があるときに割り込みハンドラーがタスクに通知し、このタスクにサンプリングとバッファー管理を行わせ、場合によっては別のタスクをデータ分析に使用することです(または圧縮または送信、またはどのような場合でも)。 残念ながら、これは非常に非効率的です。 ハンドラー側(実行する作業があることをタスクに通知する)とタスク側(実行するタスクを取得する)の両方で、オペレーティングシステムとの対話と実行中の数千の命令が関係します。 このアプローチは、理論的には正しいものの、CPUを大幅に停止させる可能性があるため、他のタスクのために予備のCPUパワーをほとんど残しません。

IDFソースコードを掘り下げる

ADCからのデータのサンプリングは通常簡単な作業であるため、次の戦略はIDFがどのように行うかを確認し、提供されたAPIを呼び出さずにコードに直接複製することです。 adc1_get_raw()関数はIDFのrtc_module.cファイルに実装されており、8つほどの機能のうち、実際にADCをサンプリングしているのは1つだけで、これは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経由で送信したりできます。

逆説的ですが、Arduino関数はIRAM_ATTRでマークされているため、ESP-IDFAPIの代わりにArduinoAPIを使用すると(つまり、 analogRead()の代わりにadc1_get_raw() )機能します。 ただし、それらはより高いレベルの抽象化を提供するため、ESP-IDFのものよりもはるかに低速です。 パフォーマンスについて言えば、カスタムADC読み取り機能はESP-IDF機能の約2倍の速度です。

ESP32プロジェクト:OSへまたはOSへではない

ここで行ったことは、オペレーティングシステムのAPIを再実装して、オペレーティングシステムを使用しなかった場合でも発生しない問題を回避することです。これは、オペレーティングシステムを使用することの長所と短所を示す良い例です。最初の場所。

小型のマイクロコントローラーは、場合によってはアセンブラーコードで直接プログラムされ、開発者は、プログラムの実行のあらゆる側面、すべての単一CPU命令、およびチップ上のすべての周辺機器のすべての状態を完全に制御できます。 プログラムが大きくなり、使用するハードウェアが増えるにつれて、これは当然退屈になる可能性があります。 多数の周辺機器、2つのCPUコア、および複雑で不均一なメモリレイアウトを備えた、ESP32などの複雑なマイクロコントローラは、最初からプログラミングするのが困難で面倒です。

すべてのオペレーティングシステムは、そのサービスを使用するコードにいくつかの制限と要件を課していますが、通常、その利点は価値があります。つまり、開発がより速く、より簡単になります。 ただし、場合によっては回避でき、埋め込みスペースでは回避できることがよくあります。

関連:完全に機能するArduinoウェザーステーションの作り方