🙀

PicoのPIOコードをGeminiにMicroPythonへ移植させてみたら...

に公開

以前からGoogleのGeminiは、Raspberry Pi Picoシリーズ(RP2040/RP2350)のPIOを少し苦手にしてるようだと思っていましたが、ちょっとした機会があってGeminiを使ってCで書いたPIO使用ライブラリをMicroPythonに移植させてみたところ、なんとなくPIOを苦手にしている理由がわかったような気がするので、少しまとめてみようと思います。Geminiしか使っていないので、AI全般に言えることかどうかはわからないですが、PIOに引っかかることは、AIコーディングの弱点を象徴する現象の一つかなとも思えるので、何かの参考になるかもしれません。

お題はPicoシリーズ用のTM1637 PIO Driver

Geminiに命じてMicroPythonに移植させてみたのは、こちらのRaspberry Pi Picoシリーズ向けTM1637 PIO Driverです。AmazonのブラックフライデーセールでTM1637を使用した4桁の7セグメントLEDディスプレイモジュールを安く買えたので、手慰みにライブラリを書いてみたという感じ。ついでに、この書籍のおまけコンテンツにできればいいかなあと。

TM1637は、I{^2}Cモドキの2線式シリアルでホスト(MCU)とやり取りを行うICです。プロトコルはI{^2}Cによく似ていて、Start Conditionでトランザクションを開始し、Stop Conditionで終える点や、1バイトごとにACKを挟む点はI{^2}Cと同じです。I{^2}Cとの違いは、次の2点です。

  • アドレスがない。バスに1つのTM1637しか接続できません
  • LSb First。送受信するビット順がI{^2}Cの逆で最下位ビットから送受信します

データシートを眺めた感じ、I{^2}Cコントローラを適当に騙してTM1637とやり取りできそうに思えたので試してみましたが、うまくいかなかったので、PIOにやらせることにしました。I{^2}Cモドキのプロトコルは、PIOに振るのに最適ですからね。

具体的なコードはtm1637out.pioを見てもらえばいいかと思いますが、PIOのFIFOにプッシュした値の下位8bitをI{^2}Cモドキのインタフェースに送出します。

SDA(TM1637のピン名はDIO)とCLKは連続したGPIOに接続する仕様としました。2つのGPIOを内部プルアップしておいて、Stop Conditionでバスを解放したら両ピンを入力に切り替え、高インピーダンスとすることでHighに遷移させて解放する仕様のためです。set命令のpindirsで、SDAとCLKの入出力を制御するため、隣り合ったGPIOピンである必要があるというわけですね。

FIFOにプッシュする値の第9bitがオンならば、Stop Conditionに移行してバスを解放します。第9bitをオフにしておけば、Stop Conditionを挟まずに連続したデータをTM1637に送信できます。下位8bitをデータとして、上位ビットにバスを制御するフラグを持たせる、このアイデアは、RP2040/RP2350のI{^2}Cコントローラの仕様からいただいています。

また、ACKをチェックして、NACKならばirq命令でステートマシン番号のIRQフラグをオンにしています。ただ、ライブラリ本体側のCコードでは、IRQフラグを監視していません。

というのも、テストしてみた限りNACKが観測できない一方、仮に割り込みでIRQフラグを厳重にチェックするとなると、かなり複雑になってしまいます。テストで観測できない程度の事象への対応としては、あまりに効率が悪く複雑すぎるので、バッサリ削除しました。必要ならば、IRQフラグを適宜チェックするという形で十分です(多分)。

というわけで、このPIOコードで次の図のようにきれいにTM1637とのI{^2}Cモドキのやり取りができています。

TM1637初期化部のロジアナ
初期化の冒頭をロジアナで測定

Start/Stop Conditionともにほぼ完璧じゃないかなと思います(わかんないけど)。TX FIFO JOINでFIFOを連結して8エントリとしているので、TM1637の最大である6桁表示でもCPUをストールさせずにデータを送出できるでしょう。PIOを使う最大の利点が、それですよね。

MicroPythonに移植しよう

このPIOを使ったライブラリを、MicroPythonで利用できるようにしてほしいというリクエストがありました。MicroPythonでGPIOをドライブしてI{^2}Cモドキを処理するのはゾッとしない……そもそも遅いMicroPythonでI{^2}Cモドキのために時間を使う無駄を考えると気が遠くなりそう……ので、MicroPythonに移植するのはいいアイデアかなと。

移植といっても、PIOコード部分をMicroPython記法に変えて、あとはちょちょいとクラスを書けばいいだけなんで、ものの1時間もあれば終わりそう。だけどちょっと作業としてはつまらなそうな感じ。

基本的な設計としては、TM1637クラスを書いて、生成されたインスタンスとステートマシン番号を関連付ければいいかなと。RP2040でステートマシンは最大8なので、8を超えるインスタンスが生成されようとしたらエラーにすれば良さげ。

Pythonにはあまり詳しくない(そもそも何事にも対して詳しい訳では無い)ので、Geminiに相談。

:Gemini君さ、Pythonで生成されるインスタンス数を制限するいい方法ってある?

Gemini君:「__new__()を使うといいでしょう! Pythonにはインスタンスを生成する前に__new__()を呼ぶメカニズムがあるので、そこで禁止すればインスタンスの生成を完全に抑止できリソースの消費を抑えられます」

:ほうほう、__new__()なんてあるんだ勉強になるなー。それって(サブセットである)MicroPythonでも利用できるメカニズムなの?

Gemini君:「問題ないです!」

:じゃあステートマシン番号とインスタンスを関連付けて、インスタンス数が8を超えたら__new__()で例外をスローすれば良さそうだね。

Gemini君:「完璧ですね!」

:その方向で「これ」をMicroPythonに移植してくれない?(ここにある4つのファイルを貼る)

Gemini君:(数秒後)「できました!」

という感じのやり取りで、1行のコードも書かずに、Gemini君がMicroPythonに移植してくれたのだけれど……

ここが駄目だよGemini君

案の定というかなんというか、Gemini君が提出してくれたコードは、まともには動きません。クラスの主要部分は、おおむね問題ないかなという感じではあるけれど、問題はPIOコードの部分。動いているPIOコードで、ロジアナでタイミングもチェックして問題ないことを確認しているとGeminiに念押ししているにも関わらず、どいうわけかPIOコードをイジってしまうんですね。

まず第一に、Gemini君は一見すると無駄そうに見える命令を削ろうとする傾向がある。ただ、言うまでもなくPIOコードはTM1637とのやりとりのタイミングを制御しているので、無駄そうに見える命令も削ればタイミングが変わり、場合によっては新たに検討する必要がある事項が出てくる可能性があります。したがって動くコードから命令は削ってほしくないんだけれど、Gemini君はコードの効率性を優先し、タイミングは考慮しないようです。このことはPIOのコーディングにとって致命的とも言える傾向かもしれない。

また、PIOコードのロジックを正しく読み取れていないと思わせるミスが2箇所ほどありました。下は動くように修正したコードですが、Gemini君がすっ飛ばしたりミスしていた主要な部分を示します。

    @rp2.asm_pio(
        out_init=(rp2.PIO.IN_HIGH),  # 1.Gemini君はここの初期化子の指定が間違っていた
        set_init=(rp2.PIO.IN_HIGH),
        sideset_init=(rp2.PIO.OUT_HIGH),
        out_shiftdir=rp2.PIO.SHIFT_RIGHT,
        autopull=False,
        fifo_join=rp2.PIO.JOIN_TX
    )
    def _tm1637_pio():
        wrap_target()
        # --- Start Condition ---
        pull(block)
        set(pindirs, 0b00)          # SDA/SCL input
        nop()                   [7]
        set(pindirs, 0b01)          # SDA output/SCL input
        set(pins, 0)                # SDA is Low
        nop()                   [7]
        set(pindirs, 0b11)             # SDA/SCL output
        set(x, 7)               .side(0)    # CLK is Low
        jmp("output")
        
        label("byte_loop")
        set(x, 7)       .side(0)    # CLK is Low
        pull(block)         # 2.Gemini君はこの命令を削ってしまっていた
        
        label("output")
        nop()                   [3]
        out(pins, 1)            [4] # LSB output
        nop()                   .side(1) # CLK is High
        nop()                   [7]
        nop()                   .side(0)    # 3.この命令も削ってしまっていた
        jmp(x_dec, "output")
        
        # --- ACK Check ---
        set(pindirs, 0b10)      [7] # SDA input/SCL output
        nop()                   .side(1) # SCL = High
        jmp(pin, "nack")            # SDA High (NACK) なら分岐
        jmp("ack")
        
        label("nack")
        irq(rel(0))                 # NACK通知
        
        label("ack")
        nop()                   [6]
        nop()                   .side(0) # CLK is Low (ACK cycle end)
        set(pindirs, 0b11)      [7] # SDA/SCL Output
        out(x, 1)                   # 9bit目を読み取って判定
        jmp(not_x, "byte_loop")     # 0なら次のバイトへ
        
        # --- Stop Condition ---
        set(pins, 0)            [7] # SDA is Low
        nop()                   .side(1) # SCL is High
        nop()                   [7]
        set(pindirs, 0b00)      [7] # SDA/SCL Input (STOP)
        wrap()

まず1.の初期化子ですが、Gemini君はset_init=out_init=を入れていなかったため、SDAピンのALTがPIOに切り替わりません。もっとも、@rp2.asm_pio()のパラメータは私も調整に戸惑ったところなので、Gemini君だけが駄目というわけじゃなく、私も駄目ではあったのですけどね。この指定をきっちりやっておかないと、SDA/SCLを入力に切り替えたときにプルアップ=Highが正しく機能しない、ということがわかるまでに少し時間がかかりました。

2.の部分は、Stop Conditionを挟まないときに戻って来るループの先頭で、続きのデータをpullしないとならないですが、Gemini君はなぜかpullを削ってしまっていました。Start Conditionの前にpullがあるため、このpullは無駄もしくはダブりと判断したのかもしれません。

もっとも、Gemini君はCのコードに対して

ご提示いただいたC SDKのソースコードを拝見しました。非常に丁寧に作り込まれたライブラリですね! 特に、out x, 1 を使って継続か終了(STOP)かを判定する仕組みや、ACKチェックのロジックが明確です。

などとお世辞を言っているので、この部分のロジックが正しく読めてないのは妙ですが、先に述べた通り効率を優先するというGemini君の特性からpullを無駄もしくはダブりと判断した可能性は高いと思います。

3.は致命的で、CLKをLowに落とす命令をすっ飛ばしていました。なぜオリジナルのPIOコードに入っている命令を削除したかはわかりませんが、Gemini君はTM1637のI^2Cモドキのタイミングを、PIOコードに落とし込むことができていない、あるいは苦手なのかもしれません。信号のタイミングは物理世界の出来事で、物理世界のタイミングをPIOコードで表現するのは、LLMにとって難しい作業なのかなとも推測します(少し話を広げすぎ?)。

このように、PIOコードの思わぬ箇所を微妙に削ったり変えたりしていたので、1行1行チェックし修正していくのに結構手間がかかりました。数秒で書いてくれたのは良かったけれど、かかった時間をトータルすると自分で移植してもたいして変わらなかったかもしれない。まあ、__new__()を使ったロジックは、私は知らなかったので功罪半ばといったところですかね。

というわけで、修正の後に下の図のようにMicroPythonバージョンもしっかり動作するようになりました。

MicroPython版の初期化部分
MicroPython版をロジアナで測定

さすがMicroPythonで、データセットアップに時間がかかりPIOの駆動が間欠的に見えますね。でもPIOにデータ転送処理を移すことで表示の負荷がだいぶ軽くできるでしょう。

Geminiしか使っていないので、AI全般に言えるかはわかりませんが、Gemini君にPIOのコードを相談するときには十分な注意が必要だろうと思います。Gemini Code AssistantでもPIOのロジックがちゃんと読めていないのかなという経験を過去にしています。また、Gemini 3になってからはわかりませんが、以前はGemini君にPIOのコードをスクラッチで書かせると、ありもしない算術演算命令を使用たり(incとか)結構めちゃくちゃでした。AI全般に言えることですが、滅茶苦茶なくせにわかったようなことをいうので初心者は特に要注意でしょう。

Discussion