esp32-cameraライブラリを読み解く ~OV2640, SCCB, DMA, I2S 編~

esp32,と,OV2640,でDMA,,I2S転送をやってみた ESP32 ( ESP-WROOM-32 )

DMA、I2Sによるデータ転送

さて、いよいよ当ブログで初めて扱う専門用語、DMA と I2S についてです。

これも正直言って勉強し始めたばかりで、素人解釈ですから間違えているかもしれません。
間違えていたらコメント投稿等でご連絡ください。

先で説明したように、SCCBインターフェースでOV2640の初期化ができて、フレームサイズも決定したら、今度はOV2640から送られてきた画素データをESP32のメモリに取り込みます。

取り込むと言っても、アマチュアの私がやってきた今までの方法とはまるで異なります。
それはDMA転送と言って、CPUを介さずに自動でメモリに高速で取り込む方式です。

DMA転送とは、Direct Memory Access の略です。
Twitterで「らびやん」さんが M5Stack でいろいろ追求していて話題になっているので、ご存知の方は多いと思います。
私自身も興味があるものの、当時は仕組みがサッパリ分らなかったので何も反応できませんでした。

でも、今回、M5Camera ( M5Stack社製 ) を調べていて、esp32-cameraライブラリがDMA転送していることを知って、どういう仕組みなのか追求せざるを得なくなったというわけです。
ちょっと勉強すればすぐに理解できるかなと思ったのですが、とんでもなく難解でした。
改めて「らびやん」さんの作っているものはスゲー!と思いました。

では、DMAについて良く分からないながらも自分なりに順に説明していきます。

DMAについてはESP32 Technical Reference Manual に書いてありました。
英語版の原本は以下のリンクにあります。

https://www.mgo-tec.com/my-japanese-esp32-technical-reference-manual
(2019/08現在ではv4.0です)

英語版では私の頭では理解し難いので、Google翻訳の力を借りて独自に翻訳してみました。

ESP32 Technical Reference Manual 何となく和訳してみた(抜粋)

まだ一部分しか訳していませんが、今後追加していきたいです。

DMA転送する際には、I2Sインターフェースというプロトコルを使って、ESP32のメモリに転送します。

I2Sインターフェースについて

ESP32 Technical Reference Manual独自和訳 によると、ESP32 の DMA転送には以下の3つのプロトコルがあるそうです。

1.UART インターフェース
2.SPI インターフェース
3.I2S インターフェース

そのうち、今回はI2Sを使います。

I2Sとは、Inter-IC Soundの略らしく、デジタル音楽のデータ転送に使われるプロトコルです。(正直、私は詳しくありません。)
オーディオマニアならば、S/PDIF や AES/EBU とかいうハイレゾオーディオのデジタル転送をご存知だと思いますが、それに使われているプロトコルと似たようなものだと思われます。

ESP32には2つの独立したI2Sモジュールがあります。
I2S0 と I2S1 というモジュール名です。

レジスタ設定でGPIOにI2S信号入力を設定してやると、自動的にビット列に変換してくれる機能が装備されているようです。
これは便利ですね。

そして、ESP32 の I2S インターフェースには、以下の2つのモードがあります。

1.I2Sモード
(たぶん主に音楽のデータ転送に使う)

2.LCDモード
(LCDディスプレイやカメラのデータ転送に使う)

その内、今回は LCDモードを使います。

さらにそのLCDモードの中に以下の3つモードがあります。

1.LCD Master Transmitting Mode
2.Camera Slave Receiving Mode
3.ADC/DAC mode

その内、今回は Camera Slave Receiving Mode を使います。

Camera Slave Receiving Mode はとっても便利で、16チャンネル分(16bit)の画素データを扱えて、その他に VSYNC、HREF、PCLK を一括で処理できるモードだそうです。
仕組みは良く分かりませんが、マイコンで一括制御してくれているのでしょう。
今回のOV2640の画素データは8bitデータ分を使います。

最近のマイコンって凄いっすね!

DMA転送されたデータはFIFOメモリに書き込まれる

ESP32 Technical Reference Manual独自和訳を見ると、DMA用に設定した GPIO インプット端子の受信データは、I2Sインターフェースで処理され、ビット列に変換してFIFO というメモリにCPUを介さずに直接保存されるそうです。
32bit データ単位で保存されるとのことです。

FIFO という見知らぬ専門用語がまた出てきましたが、これは、First-in First-out の略で、メモリに記憶した順番で取り出す仕組みになっていて、いわゆる先入れ先出しの仕組みになっている専用メモリのことだそうです。
FIFOメモリが満杯の時は、先に入れたデータを取り出さないと新しいデータを保存できないようになっているらしいです。
(自信ないので間違えていたらすいません・・・)
これは画像データによく使われる専用のメモリだそうです。
有難いことに、予め ESP32 には FIFOメモリが装備されているようです。

確かに、イメージセンサから取得した画像データはDMAで次々にメモリに多量に保存されてくるので、ディスプレイに表示させる場合、ところてん方式でメモリに読み出していかないと、画素を取りこぼしてしまう可能性がありますね。

普通のメモリならば、ディスプレイ表示処理が遅いと処理を終える前にメモリが書き換えられてしまう可能性があります。
そうすると、FIFOメモリのようなものは、画像処理用によく考えて作られているんですね。
なーるほど・・・。

ただ、その記憶可能な最大バイト数はいくつなんでしょうか?

データシートのどこかに書かれていると思うのですが、残念ながら私は見つけられませんでした。
解る方がいらっしゃったらコメント投稿等でお知らせいただけると助かります。
恐らく、SRAMメモリサイズ内が限界だろうと思われますが・・・。

DMA, I2S初期化設定(Camera Slave Receiving Mode)

ESP32 の DMA, I2S, FIFOメモリを動作させるためのレジスタを叩いてやれば、DMA転送が実現できるわけです。
でも、それをどうやったら良いかわかりませんよね。

実は既に Arduino core for the ESP32 のライブラリに、DMAレジスタを叩く関数や構造体が整備されておりました。
以下はDMA, I2SのCamera Slave Receiving Mode の初期化設定例です。
esp32-cameraライブラリから抜粋して、自分なりにほんのちょっと改変しました。
(単体では動作しませんのでご注意ください)

【ソースコード】 (※無保証 ※PCの場合、ダブルクリックすればコード全体を選択できます)

/* This is a modified version of mgo extracted from camera.c from the esp32-camera library.
*
* Copyright 2015-2016 Espressif Systems (Shanghai) PTE LTD
* Licensed under the Apache License, Version 2.0 (the "License");
*/

typedef enum {
  SM_0A0B_0B0C = 0,
  SM_0A0B_0C0D = 1,
  SM_0A00_0B00 = 3,
} i2s_sampling_mode_t;

void initI2S(){
  gpio_num_t pins[] = {
    (gpio_num_t)cam_pin_D7,
    (gpio_num_t)cam_pin_D6,
    (gpio_num_t)cam_pin_D5,
    (gpio_num_t)cam_pin_D4,
    (gpio_num_t)cam_pin_D3,
    (gpio_num_t)cam_pin_D2,
    (gpio_num_t)cam_pin_D1,
    (gpio_num_t)cam_pin_D0,
    (gpio_num_t)cam_pin_VSYNC,
    (gpio_num_t)cam_pin_HREF,
    (gpio_num_t)cam_pin_PCLK
  };
  gpio_config_t conf;
  conf.mode = GPIO_MODE_INPUT;
  conf.pull_up_en = GPIO_PULLUP_ENABLE;
  conf.pull_down_en = GPIO_PULLDOWN_DISABLE;
  conf.intr_type = GPIO_INTR_DISABLE;

  for (int i = 0; i < sizeof(pins) / sizeof(gpio_num_t); ++i) {
    if (rtc_gpio_is_valid_gpio(pins[i])) {
      rtc_gpio_deinit(pins[i]);
    }
    conf.pin_bit_mask = 1LL << pins[i];
    gpio_config(&conf);
  }

  gpio_matrix_in(cam_pin_D0, I2S0I_DATA_IN0_IDX, false);
  gpio_matrix_in(cam_pin_D1, I2S0I_DATA_IN1_IDX, false);
  gpio_matrix_in(cam_pin_D2, I2S0I_DATA_IN2_IDX, false);
  gpio_matrix_in(cam_pin_D3, I2S0I_DATA_IN3_IDX, false);
  gpio_matrix_in(cam_pin_D4, I2S0I_DATA_IN4_IDX, false);
  gpio_matrix_in(cam_pin_D5, I2S0I_DATA_IN5_IDX, false);
  gpio_matrix_in(cam_pin_D6, I2S0I_DATA_IN6_IDX, false);
  gpio_matrix_in(cam_pin_D7, I2S0I_DATA_IN7_IDX, false);
  gpio_matrix_in(cam_pin_VSYNC, I2S0I_V_SYNC_IDX, false);
  gpio_matrix_in(0x38, I2S0I_H_SYNC_IDX, false);
  gpio_matrix_in(cam_pin_HREF, I2S0I_H_ENABLE_IDX, false);
  gpio_matrix_in(cam_pin_PCLK, I2S0I_WS_IN_IDX, false);

  // Enable and configure I2S peripheral
  periph_module_enable(PERIPH_I2S0_MODULE);
  // Toggle some reset bits in LC_CONF register
  // Toggle some reset bits in CONF register
  resetI2Sconf();
  // Enable slave mode (sampling clock is external)
  I2S0.conf.rx_slave_mod = 1;
  // Enable parallel mode
  I2S0.conf2.lcd_en = 1;
  // Use HSYNC/VSYNC/HREF to control sampling
  I2S0.conf2.camera_en = 1;
  // Configure clock divider
  I2S0.clkm_conf.clkm_div_a = 1;
  I2S0.clkm_conf.clkm_div_b = 0;
  I2S0.clkm_conf.clkm_div_num = 2;
  // FIFO will sink data to DMA
  I2S0.fifo_conf.dscr_en = 1;
  // FIFO configuration
  I2S0.fifo_conf.rx_fifo_mod = SM_0A00_0B00; //fifo mode = 3
  I2S0.fifo_conf.rx_fifo_mod_force_en = 1;
  I2S0.conf_chan.rx_chan_mod = 1;
  // Clear flags which are used in I2S serial mode
  I2S0.sample_rate_conf.rx_bits_mod = 0;
  I2S0.conf.rx_right_first = 0;
  I2S0.conf.rx_msb_right = 0;
  I2S0.conf.rx_msb_shift = 0;
  I2S0.conf.rx_mono = 0;
  I2S0.conf.rx_short_sync = 0;
  I2S0.timing.val = 0;
  I2S0.timing.rx_dsync_sw = 1;

  // Allocate I2S interrupt, keep it disabled
  esp_intr_alloc(ETS_I2S0_INTR_SOURCE,
                 ESP_INTR_FLAG_INTRDISABLED | ESP_INTR_FLAG_LEVEL1 | ESP_INTR_FLAG_IRAM,
                 &i2s_isr, NULL, &i2s_intr_handle);
}

static inline void resetI2Sconf(){
  const uint32_t lc_conf_reset_flags = I2S_IN_RST_M | I2S_AHBM_RST_M
                                       | I2S_AHBM_FIFO_RST_M;
  I2S0.lc_conf.val |= lc_conf_reset_flags;
  I2S0.lc_conf.val &= ~lc_conf_reset_flags;

  const uint32_t conf_reset_flags = I2S_RX_RESET_M | I2S_RX_FIFO_RESET_M
                                    | I2S_TX_RESET_M | I2S_TX_FIFO_RESET_M;
  I2S0.conf.val |= conf_reset_flags;
  I2S0.conf.val &= ~conf_reset_flags;
  while (I2S0.state.rx_fifo_reset_back) {
    ;
  }
}

これでES32 のDMA、I2S転送のCamera Slave Receiving Modeの初期設定することができます。
I2S0 という名前が多量に出てきますが、DMAにはI2Sインターフェースを使ってGPIOで受信したデータをビット列に変換して、FIFOメモリに自動で書き込みます。
I2Sには2つのチャンネルがあり、今回は0チャンネルを使うために I2S0 というわけです。
Arduino core for the ESP32 に含まれている i2s.h 等をインクルードすれば、既にI2S関連のレジスタ設定関数や構造体が使えるわけです。

gpio_matrix関数では、

I2S0I_DATA_IN0_IDX

という名前定義があります。
これは、ESP32 Technical Reference Manual に書いてあるように、ESP32には既に
I2S0I_DATA_in0
という名前で番号が割り当てられていて、Arduino core for the ESP32 のライブラリで定義されています。
これは2つのI2Sモジュール(I2S0, I2S1)のうち、I2S0 モジュールのデータ0番という番号です。
このI2S専用の番号をESP32のGPIOに割り当てておきます。

下図の様に、OV2640から送られてくるデータと照合しておきます。

VSYNC は I2S0_V_SYNC に入力し、HREF は I2S0_H_ENABLE に入力してやります。

I2S0_H_SYNC は使わないのですが、esp32-cameraライブラリでなぜか0x38に割り当てられていたので、それに習います。
PCLK信号は、I2S0I_WS_in に入力します。

GPIO matrix設定ができたら、
periph_module_enable(PERIPH_I2S0_MODULE);
と指定してやれば、ESP32 の I2S0モジュールが使用可能になります。

そして、ESP32 Technical Reference Manual 和訳 に書いてあるように、I2S のクロック設定をしてやります。
以下のところで設定しています。

I2S0.clkm_conf.clkm_div_a = 1;
I2S0.clkm_conf.clkm_div_b = 0;
I2S0.clkm_conf.clkm_div_num = 2;

しかし、私はデータシートを和訳しても、クロック分周器の設定は意味不明です。
これは恐らくXVCLKマスタークロックの2分周で10MHzだと思われます。

そして、同じく、ESP32 Technical Reference Manual 和訳 に書いてあるように、ESP32をCamera Slave Receiving Modeで動作させるためには、以下のようにします。

I2S0.conf2.lcd_en = 1;
I2S0.conf2.camera_en = 1;
I2S0.conf.rx_slave_mod = 1;
I2S0.conf.rx_msb_right = 0;
I2S0.conf.rx_right_first = 0;

更に、データを正しく受信するために、I2S_RX_CHAN_MODや、I2S_RX_FIFO_MODなどの設定が必要と書いてありますが、それは良く分かりません。

ただ、
I2S0.fifo_conf.rx_fifo_mod = SM_0A00_0B00;
というところが特に重要です。

このソースコードの最初にあるように、esp32-cameraライブラリ内に
i2s_sampling_mode_t
というenum定義されていて、
SM_0A00_0B00 = 3
となっています。
つまり、FIFOメモリのI2S受信モードが3に設定されているということです。
これは、esp32 のDMA専用のFIFOメモリに以下のようにデータが格納されることを意味します。

このように、常に値がゼロで無効なダミーデータが間にあり、飛び飛びで実際の画素データが格納されています。
RGB565データでも、1バイトずつ飛び飛びで保存されるということです。

ただ、FIFOメモリに保存されているデータをプログラミングで見てみたところ、実際は以下のように保存されていました。

最初の0byte目に無効なダミーデータが追加されてありました。
私のやり方が間違えているかもしれませんが、これは謎で不明です。
解る人がいたら教えていただけると助かります。

その他はまだ良く分からないので割愛しますが、だいたいソースコード通りに記述すれば、ESP32 のDMA, I2S Camera Slave Receiving Mode 初期化設定完了です。

そして、初期化後に、実際にデータをDMA受信させる際には、プログラム中に以下のコードを指定すると、DMA受信開始します。

lldesc_t *d = dma_desc;
I2S0.rx_eof_num = dma_buf_size;
I2S0.in_link.addr = (uint32_t)dma_desc;
I2S0.in_link.start = 1;
I2S0.conf.rx_start = 1;

これは、lldesc_t型という、DMAディスクプリタのポインタを定義しています。
DMAディスクプリタっていうのは、実はよくわかりません。
たぶん、FIFOメモリのアドレスポインタやメモリサイズ、データの終了位置等のデータを格納する構造体で、実際の画素に関わるデータのアドレスが格納されています。
アドレスを格納する構造体ですので、画素の実データはありません。
これは、Arduino core for the ESP32 の中の lldesc.h に定義されています。

実はいまいちFIFOメモリ格納の実体が良く分からないのですが、
I2S0.in_link.addr = (uint32_t)dma_desc;
というところがポイントで、そのアドレスの指し示すところに画素の実データが保存される、つまりFIFOメモリを示しているんだと考えられます。

この様にして、I2SインターフェースでFIFOメモリのポインタ(先頭アドレス)を指定して、受信スタートレジスタを叩いてDMA転送開始しているわけです。
すると、ESP32の8系統のGPIOで OV2640 からの画像データをパラレル通信で受信し、8bitの画素データに変換してCPUを介さずに自動でFIFOメモリに保存してくれるというわけです。

イメージセンサの実際の画素データは
d->buf
あるいは
dma_desc->buf
へ保存されているので、実データを取り出す際は、
(uint8_t)*(d->buf + i)
として、forループで、i=2~buf_size まで、ポインタを4byte単位で進めていけば取り出せます。(実際のプログラム例は後述します)

DMA, I2S は、自分でGPIO の HIGH-LOWを検出するプログラムを組む必要が無いところがミソです。
後で紹介する割り込みモードもあって、それを設定すれば、更に勝手にHREFやPCLKの信号を検知してFIFOメモリに取り込んでくれます。
しかも、マルチコアのCPUに関係ないのです。
これは便利ですね。
DMAは組み込み開発やマイコン分野では意外と昔からあって、プロの方々の間では当たり前のように使われていたらしいですね。
レジスタ操作が難しいようで、なかなかアマチュアの間では一般的にならないのかもしれませんね。

さて、FIFO メモリから自分でプログラミングしてデータを抽出すれば、自分の好きなようにカメラの画像を表示させることができるわけでが、その FIFO メモリは、SRAMに一時保存されるだけなので、容量はかなり少ないです。
注意しなければならないのは、基本的に水平 1列分のデータしか保存されません

そのデータは常時絶え間なく新しいデータに書き換えられてしまう危険性があります。
(本当は、FIFOデータが満杯になると、先に書き込んだデータを読み出さないと次のデータが書き込めないらしいのですが、詳しい仕組みはよく分かりません)
ですから、FIFOデータが満杯になったら、DMA転送を一時停止して、そのデータがディスプレイに表示し切るまでデータが書き換えられない様にする必要があります。
その FIFOデータの効率の良い使い方はまだ良く知らないので、今後の課題にしたいと思います。

以上、DMA と I2S のザッとした概要でした。
ESP32 の DMA転送には、このようにかなり沢山の複数の機能があって、アマチュアの私にとってはかなり難解でした。
と、それと同時に
「ESP32ってスゲーなぁ!」
って思いますね。
何でもできそうな隠れた機能が盛り沢山です。

ただ、これだけの高機能をArduino ライブラリ化して、ユーザーに使いやすいように簡単化するのはとっても大変で労力が要ることだと思いますね。
特に、イメージセンサを動かすライブラリや、DMA転送を実現するライブラリを作ろうとすると、簡単化することがとても難しいように思いました。
ですから、ライブラリでブラックボックス化するのも良く分かりますね。
とにかく改めて、Arduino core for the ESP32 の開発者チームに敬意を表したいと思います。

今回はとっても勉強になりました。

コメント

  1. Gustavo Murta より:

    Hi aMiGO,
    Excellent job! I appreciate it very much.
    Although you don’t know much about DMA, you have come a long way.
    Congratulations

    Thanks
    Gustavo Murta (from Brazil)
    amigo (portuguese) = friend

タイトルとURLをコピーしました