DMA, FIFOメモリ初期化と自己参照構造体について
ESP32にはFIFO(First-in First-out) メモリが装備されていますが、正直なところ私自身よく解っていません。
今の時点で理解している言葉で紹介してみます。
間違えていたらスミマセン。
FIFO(First-in First-out) メモリは、以前のこちらの記事でも触れましたが、メモリに書き込んだ順番に読み出さないといけない特殊なメモリです。
つまり、ランダムに読み込んでもダメだし、書き込むこともできません。
書き込んだ領域に新たなデータを書き込むには、そこの領域のデータが順番に読み出されていなければ、新たなデータを書き込むことができないようになっているようです。
(たぶん。。。)
不思議なメモリです。
OV2640からパラレルで出力されたJPEGデータは、CPUを介さずにDMA (Direct Memory Access)で自動的にFIFOメモリに書き込んでいきますが、書き込んだ領域に新たなデータを書き込むには、既に順番に読み出されていなければなりません。
こういう特徴は、動画を扱う場合には、最適なメモリなんだろうと思います。
ただ、自分自身、まだ良く解っていないことが多いので、ここではFIFOメモリの設定方法は割愛しますが、初期化についてはソースコードを見て頂ければと思います。
さて、FIFOメモリのへの画像バッファのアドレスポインタやサイズ等は、DMAディスクプリタ構造体で定義します。
ディスクプリタっていう言葉も良く解っていないのですが、それは、Arduino core ESP32 の lldesc.h というヘッダファイルに定義されていました。
Windows 10の場合のパスは以下です。
C:\Users\ご自分のユーザー名\AppData\Local\Arduino15\packages\esp32\hardware\esp32\1.0.4\tools\sdk\include\esp32\rom\lldesc.h
/* this bitfield is start from the LSB!!! */ typedef struct lldesc_s { volatile uint32_t size :12, length:12, offset: 5, /* h/w reserved 5bit, s/w use it as offset in buffer */ sosf : 1, /* start of sub-frame */ eof : 1, /* end of frame */ owner : 1; /* hw or sw */ volatile uint8_t *buf; /* point to buffer data */ union{ volatile uint32_t empty; STAILQ_ENTRY(lldesc_s) qe; /* pointing to the next desc */ }; } lldesc_t;
このうち、STAILQ_ENTRY という意味不明なものがありますが、それは queue.h というヘッダファイルに定義されていました。
パスは以下です。
C:\Users\ご自分のユーザー名\AppData\Local\Arduino15\packages\esp32\hardware\esp32\1.0.4\tools\sdk\include\esp32\rom\queue.h
#define STAILQ_ENTRY(type) \ struct { \ struct type *stqe_next; /* next element */ \ }
これを読み解いていくと、next element コメントの意味不明なところがあり、これについては随分長い間悩まされて、ようやく最近知ったのですが、自己参照構造体というものだそうです。
今回いろいろ実験したところ、やっと少し理解することができました。
まず、FIFOメモリ用のDMAディスクプリタバッファを確保して初期化するところをesp32-cameraライブラリから抜粋して、自分流に書き換えてみました。
esp_err_t initDMAdesc(){ Serial.println("initDMAdesc"); assert(out_camera_w % 4 == 0); //幅pixelが4で割り切れるかどうかを事前判定 Serial.printf("DMA buffer size: %d\r\n", dma_desc_buf_size); //dma_desc_count = 4; DMAディスクプリタ用メモリ4つ分の領域確保 dma_desc = (lldesc_t*) malloc(sizeof(lldesc_t) * dma_desc_count); if (dma_desc == NULL) { return ESP_ERR_NO_MEM; } Serial.printf("Allocating DMA buffer size=%d\r\n", dma_desc_buf_size); for(int i = 0; i < dma_desc_count; ++i){ lldesc_t* pd = &dma_desc[i]; pd->length = dma_desc_buf_size; pd->size = pd->length; pd->owner = 1; pd->sosf = 1; pd->buf = (uint8_t*) malloc(dma_desc_buf_size); if (pd->buf == NULL) { Serial.println("pd->buf NULL"); return ESP_ERR_NO_MEM; } pd->offset = 0; pd->empty = 0; pd->eof = 1; pd->qe.stqe_next = &dma_desc[(i + 1) % dma_desc_count]; } Serial.printf("System Free Heap Size = %d\r\n", esp_get_free_heap_size()); return ESP_OK; }
4つ分のdma_descを確保して、最後の行にあるように、次の構造体のポインタアドレスを、自身の構造体で初期化しています。
それをfor文で4回まわして、最後の4番目の初期化では最初のこの構造体の先頭アドレスを示すという特殊なものです。
自己参照構造体っていうものを知らないと、何やっているんだかサッパリ解らないと思います。
簡単に言うとこんな感じなんだと思います。
循環参照している感じですね。
DMAディスクプリタ構造体 dma_desc をこのように設定すれば、OV2640から出力されたパラレルのJPEGデータを、DMA で自動的にループして取り込んでくれるわけです。
これ考えた人、スゴイですよね。
ただ、先ほど言ったように、一度書き込んだデータ領域に新たに書き込むには、順番に読み出さなければいけない事を頭に入れておかねばなりません。
あくまで想像で書いているところがあるので、ここまで間違えていたら教えてください。
では、先に進みます。
DMAバッファサイズの謎
先に紹介した、DMAディスクプリタ構造体の初期化で、以下の所を注目して下さい。
pd->length = dma_desc_buf_size;
dma_desc_buf_size については、
uint16_t dma_desc_buf_size = out_camera_w * 2 * 4;
としています。
out_camera_w は、フレームサイズ(画角)の幅で、フレームサイズが 240 x 276 pixelの場合、
out_camera_w = 240
です。
そして、水平1列分のpixel(画素)はRGB565フォーマットの場合2バイトデータです。
そして、FIFOメモリは32bit単位で書き込まれるので、4バイト単位です。
つまり、水平1列分の容量は
dma_desc_buf_size = 240 * 2 * 4 = 1920 byte
ということになります。
しかし!
ちょっと待てよ!!!
RGBビットマップフォーマットならば、水平1列分を取るのは解りますが、今回はJPEG画像を扱うので、圧縮されているわけだから、このサイズは意味あるんですかね?
それに、JPEG画像は、被写体によってファイルサイズが刻々と変化するんです。
OV2640からJPEGデータサイズを送るようなレジスタ設定は一切していません。
そもそもそんなもん、無いです。
ここでサイズを指定する意味が分かりませんでした。
実はこれ、後の記事を読み進めていくとだんだん分かって来ると思います。
要するに、OV2640から不定サイズのJPEGデータが送られてきますが、先ほど述べたように1フレームのデータは必ずVSYNCがHIGHレベルの間に収められて送られてきます。
そして、ESP32がFIFOメモリバッファ0番へdma_desc_buf_size の長さまで書き込み終わったら、次のI2S割り込みが発生して、自動的に次のFIFOメモリバッファ1番へ書き込むという動作をするようです。
OV2640からはVSYNCがHIGHレベルの間にJPEG画像1フレームのまとまったデータをドーンと常時送ってきていて、ESP32のDMAで自動的にデータを分割してFIFOメモリへ格納しているものと考えられます。
それで、4つの領域で収まり切らなかったら、最初の0番へ自動的に書き込んでくるものと思われます。
だから、このサイズは自分で任意に決められるのだと思います。
大き目に設定すれば、I2S割り込み回数が少ないし、小さめに設定すればI2S割り込み回数が多くなるというわけです。
これについては、この後読み進めていくと分かって来ると思います。
PCLK clock divide (分周)について
先ほど説明したように、PCLK (Pixel Clock)の周波数設定は、D0~D7 ピンのパルス幅を決定する大事な役割があります。
PCLKのパルス幅やタイミング設定によって、マイコンが画像を正しく受信できるかどうかが決まります。
PCLKの設定は、OV2640へSCCBインターフェースでレジスタコマンドを送って設定します。
まず、OV2640のデータシートを見てみると、以下のレジスタが見つかりました。
Bank(0xFF) | レジスタアドレス | レジスタ名 |
---|---|---|
DSP(0x00) | 0xD3 | R_DVP_SP |
Sensor(0x01) | 0x32 | REG32 |
Bankというのは、以前のこちらの記事でも述べたように、レジスタ設定の前に0xFFコマンドを送り、0x00を送ればDSP設定、0x01を送ればSensor設定と切り替えるものです。
R_DVP_SP でPCLKの周波数を決定する
まず、データシートのレジスタアドレス 0xD3 (R_DVP_SP) の項目を見てみると、以下のように記述されています。
Bit[7]: Auto mode Bit[6:0]: DVP output speed control. DVP PCLK = sysclk (48)/[6:0] (YUV0); = sysclk (48)/(2*[6:0]) (RAW);
これ、私は長い間勘違いしていました。
周波数設定は48MHzで割ると思い込んでいました。
実は違って、システムクロックが48MHzの場合という、あくまで例という意味だったということがつい最近気付きました。
今回の場合は、XVCLKピンにESP32から20MHzを供給しているので、システムクロックは20MHzだと個人的に思っています。
でも、実際はロジックアナライザで測定してみると、40MHzで割れば辻褄が合いました。
これについては謎です。
ということで、PCLKを5MHzに設定したいとすると、
PCLK = 40MHz / 8 = 5MHz
となり、
Bit[6:0] = 8 = 0b00001000
と導き出せます。
こうすると、ロジックアナライザで解析したPCLKの実際の周波数と一致しました。
ロジックアナライザの波形を見てみると、
Bit[6:0] = 2
とした場合、
このように 40MHz の1/2 で、20MHz となっていることが分かります。
次に、Bit[6:0] = 8 とすると、
このように 5MHz となっていて、計算値と合いました。
そして、D0~D7のパラレルデータの波長(パルス幅)見てみると、PCLKの波長と一致して出力されていることが分かります。
このように、イメージセンサ OV2640 のメインクロック(XVCLK)の周波数を落とさずに、PCLKクロックを分周して周波数を下げてやれば、画像の出力データの周波数を下げてやることができるわけです。
つまり、OV2640自身の処理能力はそのまま維持して、受信側のマイコンの処理能力に合わせて画像データを出力することができるというわけです。
これを知った時には、よく考えられているなぁと思いましたね。
例えば、ブレッドボードで回路を組むとすると、回路のインピーダンスが悪いので、高速周波数だと画像を認識できない場合があります。
その場合、PCLKを分周すれば良いというわけです。
ただ、ブレッドボード上では実際のところ、回路の接触不良や外来ノイズが多く、どうやってもうまく行かない場合が多いですが。。。
REG32設定のPCLK分周設定は謎
次に、PCLKを分周できそうなレジスタアドレスは0x32 のレジスタ名 REG32 です。
ただ、これは正直良く解りませんでした。
まず、データシートには、
Common Control 32 Bit[7:6]: Pixel clock divide option 00: No effect on PCLK. 01: No effect on PCLK. 10: PCLK frequency divide by 2. 11: PCLK frequency divide by 4.
と書かれています。
他のビット[5:0]はフレームサイズ設定なのでここでは関係ありません。
ということは、先ほど述べたR_DVP_SP設定は何だったのでしょうか?
何はともあれ、REG32をSCCBで叩いてロジックアナライザ波形を見てみます。
PCLKを10MHzに統一して、まずは、
Bit[7:6] = 00
とした場合は、下図のようになりました。
PCLKの周波数は変わっていません。
おやおや?
この時、ブラウザに画像を表示させてみると、下図の様に真っ黒で何も映りませんでした。
では、
Bit[7:6] = 10 = 0x02
としてみます。
下図のようになりました。
おやおや?
PCLK周波数は変わらず10MHz ですが、XVCLKの波形と位置がズレています。
この時、ブラウザに画像を表示させてみると、下図の様に正常表示されました。
では、
Bit[7:6] = 11 = 0x03
としてみると、下図のようになりました。
同じく、PCLKの周波数は変わらず10MHzです。
でも、XVCLKとのエッジの立ち上がり位置が他と異なります。
そして、D0~D7の立ち上がり位置も他と異なります。
この時のブラウザの画像表示は下図のようになりました。
画像が左半分しか表示されず、しかも色味が変です。
右半分はノイズだらけです。
このことから、私の想像では、REG32のPCLK dividerは、クロックの立ち上がり位置設定なのかな?と思いました。
結局のところ、Bit[7:6]は0x02で設定しておけば安泰で、他は謎でした。
DMAによるI2S割り込みタイミングと、FIFOメモリ読み書きのタイミングをオシロで可視化してみた
正直言って、これから述べる方法が正しいのかは分かりません。
もしお分かりの方がいらっしゃったらコメント投稿等でご連絡いただけると助かります。
先にも述べましたが、DMA(Direct Memory Access)で自動的に4つのFIFOメモリ領域に書き込まれたJPEGデータを、書き込まれた順番に読み込まねばなりません。
今回の実験で問題だったのは、ESP32のWiFi送信処理とDMA読み込み動作をマルチタスクで同時に出来なかったことです。
私のプログラミングが悪いのかも知れませんし、Arduino core for the ESP32特有の問題なのかどうかは現段階では謎です。
そうすると、FIFOメモリからのJPEG画像1フレーム読み出しを完全に完了してから、WiFi送信するしかありません。
DMA書き込み速度と、CPUによるFIFOからの読み出し速度およびJPEGマーカー検索の速度、どちらが速いかにもよると思います。
本当はFreeRTOSのQUE待ち処理をすれば良いのでしょうが、私はFreeRTOSについては全く無知なので、地道にArduinoプログラミングするしかありません。
では、先ほど紹介したように、IRAM属性付きテスト用digitalWrite関数を i2s_isr関数内に以下のように配置します。
static void IRAM_ATTR i2s_isr(void* arg){ test_digitalWrite(4, HIGH); I2S0.int_clr.val = I2S0.int_raw.val; //vsync_isr直後のdesc_curカウントアップはスルーする if(isPassIncDescCur){ isPassIncDescCur = false; }else{ read_desc_cur = dma_desc_cur; dma_desc_cur = (dma_desc_cur + 1) % dma_desc_count; isI2Sisr = true; } test_digitalWrite(4, LOW); }
このようにi2s割り込み関数の入り口と出口にテスト用digitalWriteを入れて、GPIO #4のHIGHレベルとLOWレベルを切り替えます。
そして、オシロスコープで確認すると下図の様になりました。
バッチリ!!!
イイ感じにDMA (Direct Memory Access) によるI2S割り込み位置を可視化できました。
青い波形がテスト用digitalWrite関数による、GPIO #4 の波形です。
JPEG 240 x 176 pixel で画質Qs=10 の場合、VSYNCがHIGHレベルの間にI2S割り込みが計5回発生していることが分かります。
I2S割り込みが発生する時に、OV2640からパラレルで送られてきたJPEG画像が、FIFOメモリに順番にDMA(Direct Memory Access)で自動的に書き込まれていると思われます。
FIFO(First-in First-out)メモリの dma_desc は、先ほど述べたように4つの領域(配列0番~3番)を確保してあるので、その書き込み番号を記してあります。
dma_descは自己参照構造体なので、3番まで書き込んだら0番に自動的に戻ります。
ただし、先ほど述べたように、0番に戻ったとしても、その0番が読み出されていなければ、書き込むことができないようです。
FIFOメモリの書き出し順序だけに限った実験方法をいつかやってみたいとは思うのですが、なかなかアイデアが無くて、とりあえずそういうものと信じて想像するだけにしておきます。
間違えていること書いていたら教えてください。
ということで、FIFOメモリのその特性を考慮して、そこから画像データを取り出す順序を綿密に考えなければなりません。
いろいろ考えて行くと、1回でもデータを取りこぼしたら、永遠にデータを書き込めなくなってしまいそうな気がしてきます。
でも、有難いことにFIFOメモリをリセットしてしまえば、それ以降は最初の0番から書き込めるようになることが分かりました。
M5Cameraスケッチにある、i2s_conf_reset()関数でリセットします。
VSYNC信号割り込みが来た時に実行すれば良いわけです。
ただ、先のオシロスコープ波形をよく見てください。
VSYNC信号がHIGHレベルに立ち上がった後、直ぐにI2S割り込みが入っていて、時間が短すぎます。
そこでJPEG開始マーカーFFD8を取りこぼす可能性が大いにあります。
ならば、VSYNCはHIGHレベルからLOWレベルへ立ち下がった時(ネガティブエッジ)を検知して割り込むようにすれば良いわけです。
先ほど説明したように。以下の関数です。
gpio_set_intr_type((gpio_num_t)cam_pin_VSYNC, GPIO_INTR_NEGEDGE);
そうすれば、初回のI2S割り込みまで時間があるので、そこでFIFOメモリのリセットしてしまえば良いわけです。
では、FIFOメモリをリセットして、0番から書込みするようにしたら、その直後に0番から読み込めるのでしょうか?
実は読み込めました。
先ほどロジアナの波形で見たように、初回のI2S割り込みはD0~D7のデータ数バイト受信してから初回のI2S割り込みが発生していたので、直ぐに0番から読み込み可能なのだと思います。
読み込む速度より書き込む方が速ければOKだと思います。
ただ、そうならない場合が有り得るので、衝突が起きない様に、0番が書き込み終わるまで読み込みを飛ばすようにします。
そうすると、次のVSYNC割り込みが来た時にFIFOのリセットとバッティングしますよね。
そこで、下図の様に読み込み順やVSYNCによるFIFOメモリリセットをesp32-cameraライブラリを参考にしながら自己流で考えてみました。
dma_desc_cur はdma_desc配列のカーソル番号という意味で、要するに配列番号です。
I2S割り込みが発生すると、DMA (Direct Memory Access)でCPUを介さずに自動的にカーソル[0]番から書き込みます。
先ほど述べたように、1回目の読み込みは飛ばして、[0]番の書き込みだけが完了します。
その後は順番に読み込んでいきますが、5つ目の[0]番を読み込む前にVSYNC割り込みが発生してしまいます。
ここでFIFOメモリをリセットしてしまうと、JPEG終了マーカーFFD9までの最後のデータを消去してしまって、延々とJPEGデータの読み込みが完了しません。
そこで、この時、FIFOメモリはリセットはせずにスルーします。
そして、VSYNC割り込みをまたいで、5つ目のデータをゲットしてJPEG終了マーカーFFD9を検知したら、即WiFi送信します。
先ほど述べたように、ESP32ではなぜかDMA読み書きとWiFi送信がマルチタスクで同時にできないので、この間のフレームは無視します。
つまり、1フレーム分間引くことになるわけです。
となると、フレームレートは最高でも25fpsとなるわけです。
まぁ、25fps出れば充分ですけどね。
WiFi送信が終わったら、次のVSYNCでFIFOメモリをリセットして繰り返せば良いのではないかと思います。
ところで、ここである発見をしました。
なぜかあまり速度が出ず、いろいろ試行錯誤していたら、vsync_isr関数内のif文を1行削除しただけで、動作が正常に戻ったことがあったのです。
vsync_isr関数内はそれほど時間がかかる処理にはしていないと思っていたのですが、実際は違っていたようです。
おそらく、DMAとWiFiで高速処理しているとき、割り込み関数内のコードによるCPU処理で停滞する時間が長くなり、いろいろ支障が出てきたのではないかと想像しました。
ということで、割り込み関数内の処理は必要最低限に抑えて、出来るだけ早く脱出することが大事だということが解った次第です。
さて、今度は、画角と画質を最低にして、JPEGデータ量を極小にすると、オシロスコープ波形はどうなるかというと、こうなりました。
VSYNCがHIGHレベルの間にI2S割り込みが1回しか発生しない場面が出てきました。
こんな小さい画像を使う場面は殆どないと思いますが、今回試してみたら意外とハマりました。
下図を見てください。
このように、1フレーム分スルーしたら、次のI2S割り込み1回分でJPEG画像1フレーム読み取りが完了してしまうのです。
200 x 176 pixelでQs=10の最高画質の場合だと、画像サイズが5000~6000バイトくらいあったものが、96 x 96 pixel でQs=60の最低画質にすると、800~2000バイトまで圧縮されます。
データ量が少ない為、WiFi送信もVSYNCがHIGHの間に完了してしまうのです。
プログラミングを誤ると、全然通信してくれませんでした。
ちょっとしたプログラミングの改善で正常に動作するようになったりします。
ここはけっこう頭を使う所ですね。
最終的に約22~24fpsの速度を出すことができました。
以上より、イメージセンサ OV2640 のJPEG出力をESP32のDMA受信する勘所がようやく分かってきたような気がします。
RGB出力とは全然違った方式でかなり手間取りました。
というよりも、以前はまだDMAについて良く解っていなかったのです。
こうやってロジックアナライザで解析して、オシロスコープで割り込みを可視化して、DMAを図に書いてみると、だんだん分かって来るもんですね。
以上のことはあくまで自己流の解釈で、間違えていたらスイマセン。
今思えば、以前の記事で取り扱っていたビットマップ(BMP)画像が59200バイトのデータ量だったことを考えるとアホらしいですね。
そりゃぁ、フレームレート上げられないし、CPUも熱を持ちますよ。
データ通信するとき、とにかく圧縮しろと言われる訳が分かったような気がします。
今回は240 x 176 pixelまで画角を上げられた上に、5000バイトまで圧縮できたんですから、もはやビットマップ画像の転送には戻れなくなりました。
JPEG必須ですね。
ところで、この記事を書いている最中に、FIFOメモリの確保する領域は4つでなくても2つでもイケるんじゃね?
と思いました。
たぶん、いけるでしょうが、今後の課題とします。
ESP32 のWiFiデータ送信間隔をオシロで可視化してみた
フレームレートを上げたい場合、WiFi送信が一番のネックです。
ならば、ということで、先ほど紹介したテスト用digitalWrite関数でGPIO #4のHIGH、LOWを切り替えて、WiFi送信時間をオシロスコープで可視化してみました。
こんな感じです。
これはESP32のCPUデュアルコア、マルチタスクでDMA動作とWiFi送信のタスクを分けているので、VSYNCとWiFi送信の状況が同時に見られるわけです。
今回、WiFi送信状況を可視化することを初めてやってみました。
我ながらとっても新鮮なグラフでちょっと自慢ですね。
これを見て分かる通り、VSYNCを4つまたいで送信しているところもあれば、一瞬で終わっているところもあり、送信間隔がまちまちです。
WiFi TCPのトラフィック状況や、受信側の処理能力によるというわけですね。
こんなわけで、最初に紹介した動画のように、240 x 176 pixel の画角で最高画質の場合は10~15fps程度になってしまうのだと思います。
ただ、ここで一つの疑問が出てきます。
フレームサイズ(画角)が 96 x 96 pixel や 160 x 120 pixel の場合、22fpsの速度が出るわけですが、240 x 176 pixel でもJPEG画質を落として Qs=60 とかにすれば、ファイルサイズは充分小さくなります。
そうしたら、96 x 96 pixelの時と同じようにフレームレートが上がると予想できます。
しかし、実際はそうなりません。
なぜかというと、送信側 M5Camera のプログラムには、
httpd_resp_send_chunk
という関数がありますが、これはTCP通信なので、M5Stack側の受信が完了して、新たなデータ受け入れOK状態になるまで送信できないからです。
このことを頭に入れておくことは結構重要で、私はこれに気付くまでに随分時間がかかりました。
そのため、もっとフレームレートを上げられるはずだと思い込んで、ひたすらプログラムを書き換えて泥沼にハマり、時間を無駄に浪費してしまいました。
ということで、WiFiのフレームレートを上げるには、受信側のプログラミングを高速化することもセットで考えるということですね。
受信側のプログラミングについては後で紹介します。
さて、画角が 160 x 120 pixel の場合は、22~25fps 出るので、その場合はVSYNCがHIGHの間にWiFi送信が完了していると思います。
以上のように、WiFi送信の1フレーム送信に結構時間がかかるので、ESP32のデフォルト設定では25fpsが限界だと思います。
どなたかESP32のWiFi送信で最高画質で50fps実現できたら教えて欲しいですね。
DMA, FIFOメモリからの読み出しとWiFi通信はタスクを分けるべきか?
以上、いろいろ実験したことを考えると、DMAのFIFOメモリから読み出すタスクと、WiFi送信タスクが同時にできないのであれば、タスク分けする必要が無いのではないかと思ってしまいます。
実際、Arduino core ESP32 のサンプルスケッチ CameraWebServerのソースコードを見ると、タスク分けしていないように見受けられます。
マルチタスクにするならば、やはりPSRAMを使わないと効果が無いのではないかと想像します。
WiFi送信中にタスク0でFIFOメモリからPSRAM領域へ読み取り、WiFi送信完了したら即PSRAM領域から読み出すようにすれば、もしかしたらもっとフレームレート上げられるかもしれません。
これについては今後の課題とします。
では、次はJPEG開始マーカーと終了マーカーの検出方法を説明します。
コメント