akasaka が 2024年11月20日22時26分18秒 に編集
初版
タイトルの変更
ESP32でLEDマトリクスをDMAで動かしてみた(CPUを使用せずにダイナミック点灯の実装)
タグの変更
ESP32
LEDマトリクス
DMA
SPI
メイン画像の変更
記事種類の変更
セットアップや使用方法
ライセンスの変更
(CC BY-NC-SA 4+) Creative Commons Attribution-NonCommercial-ShareAlike CC BY-NC-SA version 4.0 or later
本文の変更
数ヶ月前に秋月電子アウトレットからLEDマトリクスモジュールを購入しました。  今回はESP32から制御して点灯させたいと思います。 # はじめに 先ずは[データシート](https://akizukidenshi.com/img/contents/kairo/%E3%83%87%E3%83%BC%E3%82%BF/%E8%A1%A8%E7%A4%BA%E8%A3%85%E7%BD%AE/LED%E9%9B%BB%E5%85%89%E6%8E%B2%E7%A4%BA.pdf)から制御信号の例を確認しましょう。  モジュールの中身は[シフトレジスタ](https://ja.wikipedia.org/wiki/%E3%82%B7%E3%83%95%E3%83%88%E3%83%AC%E3%82%B8%E3%82%B9%E3%82%BF)の3つとなります。 SIN2に打ち込んだパターンはLED1(左側)、SIN3に打ち込んだパターンはLED2(右側)にSIN1に打ち込んだ縦データに従って有効にした行にてLATCHをLにした瞬間に表示されます。 そしてビットの並び順は、 * SIN1: MSB = 上 → LSB = 下 * SIN2, SIN3: MSB = 左 → LSB = 右 上記の信号を入力させると * SIN1 (横) = `1000 0000 0000 0000` * SIN2 (LED1) = `0000 0000 0000 0011` * SIN3 (LED2) = `1011 0000 0000 0000` 以下のパターンで点灯します:  そして速く繰り返すと人の目には全て行が同時に光っているっぽいになることで画像などのパターンを表示することができます。  # SPIでの制御 無限ループで `digitalWrite()` などで繰り返してパターンを打ち込むともちろん何とかなるはずので、固定画像や文字の場合はそれで十分です。 今回は、このモジュールを[前の記事に紹介した時計](https://elchika.com/article/b9f39c29-64aa-42ab-8f73-e6e27a72bd0e/)のOS([PIS-OS](https://github.com/vladkorotnev/plasma-clock/tree/develop?tab=readme-ov-file#pis-os))に対応したいのでこんなやり方で制御するためのCPU時間の余裕はありません。 だから、今回はESP32のSPIコントローラーを使用したいと思います。 SPIコントローラー使用の重要メリットは、**DMA**というものです。簡単に説明すると、ファームウェア側はメモリー中に送信したいデータを準備してSPI送信処理を実行したら実際の通信は完全にハードウェア側で行います。ファームウェア側では送信終了まで待つ必要がありません。 ということで、先ずはSPIバスを初期化しましょう。 ```c spi_bus_config_t bus_cfg = { .data0_io_num = SIN1_PIN, // SIN1 .data1_io_num = SIN2_PIN, // SIN2 .sclk_io_num = CLOCK_PIN, // CLOCK .data2_io_num = SIN3_PIN, // SIN3 .data3_io_num = SACRIFICIAL_UNUSE_PIN, // 今のところ未使用。指定しないと初期化失敗する。 .data4_io_num = -1, // 4~7は初代ESP32では非対応 .data5_io_num = -1, .data6_io_num = -1, .data7_io_num = -1, .max_transfer_sz = 0, .flags = SPICOMMON_BUSFLAG_MASTER | SPICOMMON_BUSFLAG_QUAD /* データ線4本を使用することを指定 */ | SPICOMMON_BUSFLAG_GPIO_PINS }; esp_err_t res = spi_bus_initialize( spi, &bus_cfg, SPI_DMA_CH_AUTO ); if(res != ESP_OK) { ESP_LOGE(LOG_TAG, "SPI Init Error %i: %s", res, esp_err_to_name(res)); return; } else { spi_device_interface_config_t dev_cfg = { .command_bits = 0, // データ以外は送信しませんので command, address, dummyのサイズがすべて0 .address_bits = 0, .dummy_bits = 0, .mode = 3, .duty_cycle_pos = 0, .cs_ena_pretrans = 0, .cs_ena_posttrans = 0, .clock_speed_hz = PIXEL_CLOCK_HZ, // ダイナミック点灯の周波数。60 FPS * 192 px 横 * 16 px 縦 の場合は ≈200kHz以上がいい。 .input_delay_ns = 0, .spics_io_num = LATCH_PIN, .flags = SPI_DEVICE_NO_DUMMY /* ダミークロック無効 */ | SPI_DEVICE_HALFDUPLEX /* 同時送受信無効 */ | SPI_DEVICE_BIT_LSBFIRST | SPI_DEVICE_POSITIVE_CS /*送信中にLATCHはHにすべきのでCSをポジティブに設定する */, .queue_size = 128, .pre_cb = nullptr, .post_cb = nullptr }; res = spi_bus_add_device(spi, &dev_cfg, &hDev); if(res != ESP_OK) { ESP_LOGE(LOG_TAG, "SPI Dev Init Error %i: %s", res, esp_err_to_name(res)); return; } } ``` このように初期化すると、データ配列のフォーマットは以下になります | MSB | b6 | b5 | b4 | b3 | b2 | b1 | LSB | |:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:| | 未使用 | 先のSIN3 | 先のSIN2 | 先のSIN1 | 未使用 | 後のSIN3 | 後のSIN2 | 後のSIN1 | こんな形にフレームバッファを変換してみます。 ```c // コラム毎2byteのフレームバッファ(表示したい画像データ) const uint16_t * columns = (const uint16_t*) strides; for(int row = 0; row < rows; row++) { // 出力バッファーの初期化 uint8_t *row_array = &data[row * total_bytes_per_row]; memset(row_array, 0, total_bytes_per_row); for(int col_idx = 0; col_idx < total_bytes_per_row * 2; col_idx++) { // (row, col_idx) に従って出力バッファーでのアドレスを計算する size_t byte_idx = col_idx / 2; uint8_t nibble_idx = col_idx % 2; // LED1とLED2用の元データの位置を計算する int index1 = (count / 2) - (((col_idx / (columns_per_panel / 2)) * columns_per_panel) + (col_idx % (columns_per_panel / 2))) - 1; int index2 = (count / 2) - (((col_idx / (columns_per_panel / 2)) * columns_per_panel) + ((columns_per_panel / 2) + (col_idx % (columns_per_panel / 2)))) - 1; // LED1/LED2(SIN2/SIN3)のデータを取得 uint16_t led2 = columns[index1]; uint16_t led1 = columns[index2]; // 先ほどのフォーマットに変換する row_array[byte_idx] |= ( ((led1 & (1 << row)) == 0 ? 0 : 0b010) | ((led2 & (1 << row)) == 0 ? 0 : 0b100) ) << (nibble_idx ? 4 : 0); } // 行番号によってSIN1のデータを追加する uint8_t byte_no = row / 2; uint8_t nibble_no = row % 2; for(int i = 0; i < PANEL_COUNT; i++) { row_array[byte_no + i * bus_cycles_per_panel / bus_cycles_per_byte] |= 0b001 << (nibble_no == 0 ? 0 : 4); } // DMAを使用して非同期送信をする _txn->tx_buffer = row_array; esp_err_t res = spi_device_queue_trans(hDev, _txn, portMAX_DELAY); if(res != ESP_OK) ESP_LOGE(LOG_TAG, "SPI Dev Txn Error %i: %s", res, esp_err_to_name(res)); } ``` これで実行してみたらちゃんと点灯しました。  これで大丈夫そうだと思って箱に組み立てて・・     完成!と言いたかったのに・・・ # 無限DMAを使用すべき ・・・しばらく放置したら、そろそろ死にそうなブラウン管テレビっぽいのフリッカーに気が付いた。  理由は恐らく、ESP32に実行されているタスクが多すぎること。 先ほどのソースでは全ての行データをキューイングして送信していますが、ハードウェア側ではキューというものが存在しません。 なので、各行を送信し終わった瞬間にSPIコントローラーから割り込み処理が実行されてファームウェアに埋め込んでいるドライバー側は次ぎのデータブロックを準備して通信を開始している。 こういう動作を繰り返しまくって、ある時にSPIコントローラーより優先度高い割り込みが来たら次の行のデータ出力が遅延されてしまいます。この遅延によって送信したばかりの行はちょっとだけ長く点灯した状態に残るので、人の目にフリッカーとして見えます。 対策としてはDMA送信を一度だけ初期化して無限に実行させてみましょう。初期化処理を以下のように変更します: ```c // 自分でSPIコントローラーを初期化しようだとしても何回やっても失敗したのでダミーデータを送信することでSPIコントローラーを準備します res = spi_device_queue_trans(hDev, &trans, portMAX_DELAY); if(res != ESP_OK) ESP_LOGE(LOG_TAG, "SPI Dev Txn Error %i: %s", res, esp_err_to_name(res)); else { spi_dev_t* const spiHw = SPI_LL_GET_HW(spi); _spi = spiHw; spi_transaction_t * t = &trans; // ダミーデータの送信終了まで待つ res = spi_device_get_trans_result(hDev, &t, portMAX_DELAY); if(res != ESP_OK) ESP_LOGE(LOG_TAG, "SPI Dev Wait Error %i: %s", res, esp_err_to_name(res)); // DMAディスクリプター構造体を生成する lldesc_s * lldescs = (lldesc_s*) heap_caps_calloc(1, sizeof(lldesc_s) * (rows+1), MALLOC_CAP_8BIT | MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA); for(int i = 0; i < rows + 1; i++) { lldesc_s * d = &lldescs[i]; // デスクリプター1件につきデータの1行分を送信 d->buf = &data[i * total_bytes_per_row]; d->length = total_bytes_per_row; d->size = total_bytes_per_row; d->owner = LLDESC_HW_OWNED; d->eof = 1; //<- このディスクリプターの分が送信されたらEOF割り込みを有効にする // ディスクリプターリンクリストを無限ループにする if(i == rows - 1) { d->qe.stqe_next = &lldescs[0]; } else { d->qe.stqe_next = &lldescs[i + 1]; } } // ダミー送信が終わったらQIOモード(データ線4本の使用)が無効になったので強制的にQIOモードにする spi_line_mode_t lm = { .cmd_lines = 1, .addr_lines = 1, .data_lines = 4, }; spi_ll_master_set_line_mode(spiHw, lm); // DMAのディスクリプターを指定する spiHw->dma_out_link.addr = (int)(lldescs) & 0xFFFFF; spiHw->dma_conf.dma_tx_stop = 0; // DMAからSPIのバッファーに書き込みを有効する // 無限書き込みを許可する // https://www.esp32.com/viewtopic.php?f=2&t=4011#p18107 // > yes, in SPI DMA mode, SPI will alway transmit and receive // > data when you set the SPI_DMA_CONTINUE(BIT16) of SPI_DMA_CONF_REG. spiHw->dma_conf.dma_continue = 1; spiHw->dma_out_link.start = 1; // DMAからSPIに書き込みを開始する spiHw->cmd.usr = 1; // SPI通信を開始する spiHw->dma_int_clr.val = spiHw->dma_int_st.val; // DMAコントローラーの割り込みをすべて無効にする spiHw->dma_conf.out_eof_mode = 1; // EOF割り込み発進モード:RAMから読み込み終了のときに実行 // EOF割り込みの関数を設定する res = esp_intr_alloc(ETS_SPI3_DMA_INTR_SOURCE, 0, isr, nullptr, nullptr); if(res != ESP_OK) ESP_LOGE(LOG_TAG, "SPI Dev Intr Alloc Error %i: %s", res, esp_err_to_name(res)); // EOF割り込みを有効にする spiHw->dma_int_ena.out_eof = 1; } ``` これで行データは勝手に送信されまくって、行ごとに割り込み関数が実行される。そして割り込み関数にていったんLATCHを操作して出力した行を点灯させる: ```c static IRAM_ATTR void isr(void*) { _spi->dma_int_clr.out_eof = 1; // <- 割り込みフラグをクリアしないとこの関数から出られなくなる // LATCHをいったんLにする gpio_set_level(_latch, 0); gpio_set_level(_latch, 1); } ``` これで動かしてみたら・・フリッカーがないのに変なボケみたいなエフェクトが発生してしまう (゜U。)  [動画](https://www.youtube.com/watch?v=Q84Owe7q87E) 理由は全然一緒です。LATCHを変更する割り込み処理が遅延してしまって、LEDを点灯させた時点にSPIコントローラーはもうレジスターに次の行のデータを打ち込んでいる。 完全DMA化するために、 * EOF割り込みを無効にしてISR関数を削除する * 初期化処理にて未使用の`data3_io_num`としてLATCHのpinを指定する * 出力データには各行分の後にMSBだけがセットされているbyteを入れてSPIからLATCHを制御する 変換処理を以下のように変更します: ```c for(int row = 0; row < rows; row++) { uint8_t *row_array = &scratch_buffer[row * (total_bytes_per_row + 1)]; memset(row_array, 0, (total_bytes_per_row + 1)); for(int col_idx = 0; col_idx < total_bytes_per_row * 2; col_idx++) { size_t byte_idx = col_idx / 2; uint8_t nibble_idx = col_idx % 2; int index1 = (count / 2) - (((col_idx / (columns_per_panel / 2)) * columns_per_panel) + (col_idx % (columns_per_panel / 2))) - 1; int index2 = (count / 2) - (((col_idx / (columns_per_panel / 2)) * columns_per_panel) + ((columns_per_panel / 2) + (col_idx % (columns_per_panel / 2)))) - 1; uint16_t led2 = columns[index1]; uint16_t led1 = columns[index2]; row_array[byte_idx] |= ( ((led1 & (1 << row)) == 0 ? 0 : QIO_BITVAL_SIN2) | ((led2 & (1 << row)) == 0 ? 0 : QIO_BITVAL_SIN3) | QIO_BITVAL_LATCH // <- 常にLATCHはHレベル ) << (nibble_idx ? 4 : 0); } uint8_t byte_no = row / 2; uint8_t nibble_no = row % 2; for(int i = 0; i < PANEL_COUNT; i++) { row_array[byte_no + i * bus_cycles_per_panel / bus_cycles_per_byte] |= QIO_BITVAL_SIN1 << (nibble_no == 0 ? 0 : 4); } // 最後のbyteだけにLATCHをいったんLにさせる row_array[total_bytes_per_row] = (QIO_BITVAL_LATCH << 4); } ``` これで・・さっきの現象にも改善がなくて画像は定期的に上下にずれてしまう (゜U。) (撮影忘れた...) # とき(CLOCK)を止めて やっぱり今回の原因は、LATCHがLにしてもCLOCKが発信し続けてるから 点灯した時点で見えるのは固定したパターンじゃなくて右上にずれていくパターン。 ということでLATCHをLにする間はCLOCKを止めないといけない。が、SPI通信は先ほど無限ループに設定したので割り込みとか使わないと勝手に止められない。 LATCH信号はアクティブローでありCLOCKはアクティブハイであるため、 ハード側の対策としてLEDモジュールに入力するCLOCK’信号を (CLOCK && LATCH)にしたら解決できます。  もちろん74HC08などのICで解決できますが、基盤にもう一個のICを増やすことがめんどくさい。 だから今回はESP32内蔵の**モーターコントローラー**(MCPWM)を使用していきます!  まずはデューティ比を100%に設定して出力を常にHレベルにします。 ```c mcpwm_config_t mcpwm_config = { .frequency = 500000, .cmpr_a = 100.0, .cmpr_b = 100.0, .duty_mode = MCPWM_DUTY_MODE_0, .counter_mode = MCPWM_UP_COUNTER, }; ESP_ERROR_CHECK(mcpwm_init(MCPWM_UNIT_0, MCPWM_TIMER_0, &mcpwm_config)); ``` そして異常信号をアクティブローにして非常停止方法は出力を0にすることに設定します。 ```c // 異常信号入力はアクティブロー ESP_ERROR_CHECK(mcpwm_fault_init(MCPWM_UNIT_0, MCPWM_LOW_LEVEL_TGR, MCPWM_SELECT_F0)); ESP_ERROR_CHECK(mcpwm_fault_init(MCPWM_UNIT_0, MCPWM_LOW_LEVEL_TGR, MCPWM_SELECT_F1)); // 異常の時出力をLレベルにする ESP_ERROR_CHECK(mcpwm_fault_set_cyc_mode(MCPWM_UNIT_0, MCPWM_TIMER_0, MCPWM_SELECT_F0, MCPWM_ACTION_FORCE_LOW, MCPWM_ACTION_FORCE_LOW)); ESP_ERROR_CHECK(mcpwm_fault_set_cyc_mode(MCPWM_UNIT_0, MCPWM_TIMER_0, MCPWM_SELECT_F1, MCPWM_ACTION_FORCE_LOW, MCPWM_ACTION_FORCE_LOW)); // モーターコントローラーの異常信号割り込みと他の割り込みを一斉に無効にする mcpwm_ll_intr_disable_all(&MCPWM0); ``` ということでMCPWMから常にHレベルが出力されてF0かF1のどれかがLレベルのなりましたら出力もLになります。どっかに見たことあるんじゃないこれ?  次はGPIOのCLOCK、LATCH、そして未使用pinの一つを入出力として初期化します。 ```c PIN_FUNC_SELECT(GPIO_PIN_MUX_REG[CLOCK_PIN], PIN_FUNC_GPIO); PIN_FUNC_SELECT(GPIO_PIN_MUX_REG[SACRIFICIAL_UNUSE_PIN], PIN_FUNC_GPIO); PIN_FUNC_SELECT(GPIO_PIN_MUX_REG[LATCH_PIN], PIN_FUNC_GPIO); gpio_set_direction(CLOCK_PIN, GPIO_MODE_INPUT_OUTPUT); gpio_set_direction(SACRIFICIAL_UNUSE_PIN, GPIO_MODE_INPUT_OUTPUT); gpio_set_direction(LATCH_PIN, GPIO_MODE_INPUT_OUTPUT); ``` SPIコントローラーの初期化にクロック出力をCLOCK_PINよりSACRIFICIAL_UNUSE_PINに切り替わることも忘れないようにご注意ください。 最後に「回路」の「組立」を行います。 ```c // [SPI]クロック出力 --------> 未使用ピン --------> 異常信号入力FAULT0[MCPWM] gpio_matrix_out(SACRIFICIAL_UNUSE_PIN, SPI_LL_GET_CLK(spi), false, false); gpio_matrix_in(SACRIFICIAL_UNUSE_PIN, PWM0_F0_IN_IDX, false); // [SPI]HD(QDATA3)出力 --------> LATCHピン(LEDモジュールへ) --------> 異常信号入力FAULT1[MCPWM] gpio_matrix_out(LATCH_PIN, SPI_LL_GET_HD(spi), false, false); gpio_matrix_in(LATCH_PIN, PWM0_F1_IN_IDX, false); // [MCPWM]OUT0A出力 --------> CLOCKピン(LEDモジュールへ) gpio_matrix_out(CLOCK_PIN, PWM0_OUT0A_IDX, false, false); // 結果としては次のように接続されています: // // [SPI]CLK o-------\ MCPWM // \ +----------------+ // ---| F0 | // | OUT|---------o DISP_CLOCK' // ---| F1 | // / +----------------+ // [SPI]HD(QD3) o----o-------------------------------o DISP_LATCH // ``` なんか、ESP32の中にちっちゃいFPGAが隠されていることの気がしてきた・・・ クロック信号は未使用ピンに繋がってるからそのGPIOを他の用途に使用できなくなりますが、今回の基盤にてディスプレイコネクターのLEDモジュールまで引いてないピンとなりますのでGPIO不足とかの問題になりませんでした。 ついに動かすと・・・ ([動画](https://www.youtube.com/watch?v=4sID_2LK8-w))  ちゃんと表示できます!! 完成版のドライバーは[PIS-OSのGithub](https://github.com/vladkorotnev/plasma-clock/blob/develop/src/display/akizuki_k875.cpp)にて公開しました。 ## 参考資料 * t-mat様の[VGA出力サンプルコード](https://github.com/t-mat/esp32-vga-experiment) (SPIの無限送信) * [MCPWM仕様書](https://docs.espressif.com/projects/esp-idf/en/v5.3.1/esp32/api-reference/peripherals/mcpwm.html) * [LEDモジュールのデータシート](https://akizukidenshi.com/img/contents/kairo/%E3%83%87%E3%83%BC%E3%82%BF/%E8%A1%A8%E7%A4%BA%E8%A3%85%E7%BD%AE/LED%E9%9B%BB%E5%85%89%E6%8E%B2%E7%A4%BA.pdf) (P10~)