あみらぼ

電子工作がメインのDIYもの作り雑記

ESP32で240x320のLCDを毎フレ全画素書き換え!

さて、以前にESP32-WROVERで240x320のLCD60fpsでヌルヌル動かすという目標を立て、理論値で最大94fpsまで出せそうと書きましたが、案の定、いろいろ苦戦しました。

 

そもそも、240x320の60fpsって書いても、データの転送レートがいまいちピンとこないので、転送レートを計算してみます。ちなみに、今回使用するLCDはPC等で一般的なRGB8ビットずつと違い、R5ビットG6ビットB5ビットで1画素あたり16ビットカラーとなっております。LCDドライバに内蔵できるメモリ容量などの都合も関係しているのかとは思いますが、個人でマイコンから使用するLCDとしてはまぁ妥当でしょう。一応、R6ビットG6ビットB6ビットの18ビットカラーも設定により使用できるのですが、1画素18ビットはマイコン側でのメモリ管理が面倒そうですし、人間の輝度感覚からしてRedやBlueの諧調を細かくしても、あまり変化はなさそうなので、今回は見送ります。

で、計算すると1フレームの容量は240x320x16=1.2288Mbit(150KByte)です。

これを1秒間に60フレーム転送するので、1.2288x60=73.728Mbpsが、最低限必要な転送レートになります。ESP32が240Mhzで動作しているとはいえ、SPI通信でこの速度を出すのはちょっと厳しそうですね。もしESP側のSPIでこの速度を出せても、LCDモジュール側では早すぎて受け取れないので、SPI接続で60fpsはそもそも理論上からして不可能です。

ただ、今回は8ビット(もしくは16ビット)のパラレル転送を計画しているので、転送する回数としては1/8の9.216M回の転送を1秒間に行えればよい事になります。ESP32が240Mhzで動作するので、一回の転送を38.6cycle以下で行えればいいわけなので、まぁまぁ何とかなりそうな感じです。

 

で、とりあえずダメもとでdigitalWrite()の関数を使ってまずは画面を出すとこまでやってみました。digitalWrite()はいろいろなチェックや計算を経由するので遅いというのは承知の上なので、もしそれで60fpsが出ればラッキーという感じで。で、結果は案の定fps程度…。まぁこれはしょうがないですね。

と、いうわけでDirectPortAccess(DPA)の出番です。名前はなんか大げさですが、やる事は大した事はありません。digitalWrite()の関数を使うかわりに、各ポートのHIGHかLOWかの出力状態を制御しているレジスタに直接値を書き込んでしまうわけです。Atmega328PのArduinoでも、この方法を使って10倍以上は高速化できました。同じことをESP32でもやろうと思います。で、該当レジスタを探したところ、ESP32で最も高速にGPIOの出力を制御するには以下のレジスタを使うのが推奨のようでした。

f:id:amilabo:20200322175101j:plain

ESP32のDPAは情報が少ないようで、このレジスタを使う、という所にたどり着くのも少し苦労しましたが、レジスタ自体にも使い方にちょっとクセがある感じですね。

わかってしまえばどうという事は無いのですが、1つのレジスタで直接0か1かを指定して書き込む事はできないようです。(一応そういうレジスタもありますが、後述の理由により非推奨のようです)

まず、1を書き込みたいポート番号のビットを1にしてそれ以外を0にした32bitの値を作って、それをGPIO_OUT_W1TSに書き込みます。(TSの”S”は”Set"の意味ですね)

次に、0を書き込みたいポート番号のビットを1にしてそれ以外を0にした32bitの値を作って、それをGPIO_OUT_W1TCに書き込みます。(TCの"C"は"Clear"の意味ですね)

最終的に各ポートの出力を特定の状態にしたいのであれば、GPIO_OUT_W1TSを先に書くかGPIO_OUT_W1TCを先に書くかの順番はどちらでも構いません。

なんでこんなことになってるかというと、ESP32はシングルスレッドのみではない事が関係しているようです。GPIOの出力状態を0と1で直接指定するレジスタもありますがそのレジスタを読んで、必要な部分を書き換えてそれ以外の部分はそのままにして書き戻す、という事をすると、読み込んで~書き戻す、の間に他の誰かが同じレジスタを操作していると、それを破壊する事になりますし、逆もまたしかりです。いずれにしても、ReadModifyWriteは余計に時間がかかりそうですし、SPI-Flashがポートを使っている事を考えると、ReadModifyWriteするのはあまりに危険そうなので、ちょっと面倒でも上記の推奨のレジスタを使うしかなさそうです。(後々に調べた結果、この認識はちょっと間違いがありました…)

 

と、いうわけで、何とかこのレジスタを使う方法で実装をしてみましたが、予想以上に速度が出ません。digitalWrite()よりはもちろん早くなりましたが、60fpsどころか、30fpsにもとうてい及びません(絶望のあまり、正確なfpsをメモするのも忘れましたw)。原因は主に以下の3つかと思われます。

  • そもそもレジスタへの書き込みが遅い!
    Atmega328Pと比較して、特にPeripheralレジスタへの書き込みが遅いようで、通常のメモリWriteにかかる時間とかなり差があり一回のレジスタ書き込みにかなりのCycleを要しているようです。
  • GPIOの出力を変化させるのに2回のレジスタ書きが必要!
    上記の通り、推奨の方法では2種類のレジスタにそれぞれ書き込みを行う必要があります。Peripheralレジスタへの書き込みが遅い事と合わせると、1回で書き込みを済ませられない事も速度に十分影響してきます。
  • レジスタに書き込むデータを作るのに時間がかかる!
    Atmega328Pでは、1つのレジスタが8ビットで、かつポート毎にいくつかのレジスタに分かれていたので、書き込みに使うポートを上手いこと選べば、何も考えずに1バイトのデータを直接1つのレジスタに書くだけで済みました。ただ、ESP32ではレジスタが32ビットにまとまってしまっている上に、使えるポートの番号も飛び飛びなので、8ビットのデータを書き込むのに、各ビットを取り出してそれぞれを適切な場所にビットシフトして…、という計算が必要になります。これも、8ビット分となると、速度に十分影響してきます。

これらをどうにかしていかなければならないのですが、1つめはどうしようもなさそうなので、2つ目と3つ目を、まずは少しましになるよう最適化することにしました。

まず、8ビットデータの転送方法として、LCDモジュールのWRというピンがトリガになってるので、順序としては次のようになります。

  • WRピンをGPIO_OUT_W1TCでLOWにする
  • 8ビットデータの1に対応する部分をGPIO_OUT_W1TSで書き込む
  • 8ビットデータの0に対応する部分をGPIO_OUT_W1TCで書き込む
  • WRピンをGPIO_OUT_W1TSでHIGHにする

予想ではESP32が早すぎるので、要所にDelayやNOP等で待ちを入れないとLCDモジュール側でデータを受け取れないと思っていましたが上記の通り、レジスタへの書き込みが非常に遅いので、特に待ちを入れなくても動きました。(というか待ちが必要ないほどESP32が遅すぎる…)

で、LCDモジュール側でデータを受け取るタイミングはWRピンがHIGHになった瞬間なので、WRピンをLOWにするのは、いつでもよさそうという事で、WRピンをLOWにする処理もまとめて以下のように変更しました。

  • 8ビットデータの1に対応する部分をGPIO_OUT_W1TSで書き込む
  • 8ビットデータの0に対応する部分とWRピンに対応するビットをGPIO_OUT_W1TCで書き込む(必要なビットを0にすると同時にWRピンもLOWになる)
  • WRピンをGPIO_OUT_W1TSでHIGHにする

これで、レジスタの書き込みが4回必要だったのが3回で済むようになって、少し早くなりました。(ダメもとでWRをHIGHにする処理もまとめてレジスタの書き込みを2回にしてみましたが、やはりWRピンがHIGHになる瞬間にデータが準備されていないといけないようで、さすがにそれはダメでした)

 

次に、レジスタに書き込む32ビットの値を作るのに時間がかかる方の問題についてですが、1ビットずつ処理せず、まとめてごそっとシフト演算を済ませられないかと連番になってるポート番号が無いか探してみましたが、残念ながら8ビット連番になってるポート番号は無いようです。前半の0~15辺りは、間は挟むものの連番の部分が多いのでそこで処理を減らそうかとも考えましたが、ここで使うポートを決め打ちしてしまうと、のちのち使いたいポートがそことバッティングた時に面倒な事になりそうなので、連番を使った高速化は避ける事にしました。

そこで、あまりスマートとは言えないかもしれませんが、32ビット×256のLookup tableを事前に定数として仕込んでおくことにしました。何をしたかというと、ここでやるのは8ビットのデータを32ビットのデータに変換する事なので、パターンとしては0x00~0xFFの256種類に絞られる事となります。なので、uint32_t table[] ={.......}という感じで256種類全てのパターンで32ビットデータをどうすればよいか事前に計算して定数としておき、転送時にはそのLookup tableの1つを読み込むだけ、という方法をとりました。Lookup tableが4Byte×256=1KByteなので、かなりメモリの無駄遣いは発生しますが、ESP32のSRAMが512KBあるので何とか許容範囲かと思います。(Atmega328PだとSRAMが2KBなので、このLUTだけでSRAM全体の半分も使いますw)

加えて、LUTを使えば、8ビットで使用するポートは飛び飛びでもかまわず、後からでも自由に変えられるというメリットもあります。(さらに、後日書くかもですが、16ビットパラレル転送時には、別の素晴らしいメリットがある場合もあります)

 

これらの最適化で、SRAM上に用意した1フレーム分の画像データ(150KB)の全てを毎フレームLCDモジュールへ転送する処理で、何とか32fpsを達成する事が出来ました!

当初の目標の60fpsでヌルヌルはまだまだ及びませんが、30fpsを超えられた事は実用できるラインとしてかなり大きいです。

ただ、このままでは毎フレーム同じ絵を転送しているだけなので、絵を書き換える処理をしないと、何も転送していないのと変わりません。幸いなことに、ESP32はデュアルコアでSRAM512KBもあるので、150KBのバッファを2枚とって、片方のコアで1枚のバッファをLCDへ転送、もう片方のコアでもう一枚のバッファを作成、次のフレームではそれぞれのコアが使うバッファを入れ替え(フリップ)する、というダブルバッファの方式を採用すれば、何とかなりそうです。

 

と、思っていましたが、ESP32では150KBのバッファ2枚をSRAM上に取ることはできませんでした!少なくとも64KBはキャッシュとして使われている事や、ヒープとして取れる領域に制限があるなどいろいろ理由はあるかと思いますが、もし300KBを取れたとしても、SRAMがあまりにギチギチになってしまうので実用的ではなさそうです…。と、いうわけでこの先も困難が続くわけですが、長くなったので続きは次回

 

実は、この辺の事はしばらく前にやった事なのですが、なかなか記事に書く気が起きず時間が経ってしまいました…。というのも、この辺の話って特に興味のない人とっては、文字ばかりで写真も絵も無くてつまらないのです。もともと、ほぼ需要の無い話ばかりを書いてるブログなのですが、さらに誰にも興味の無いブログになりそうで…。この先も、同様の話が続く予感ですが、まぁ世の中の誰か数人くらいは見てくれるかも、という気持ちで続けていこうと思います!

 

ではでは。