I2Cクラスを追加(RPiベアメタルMicroPython)

mp-i2c-rpi.jpg

前回の実装を元に、ベアメタルRaspberry Pi版MicroPythonにI2Cクラスを追加してみました。

writeとreadを載せるだけなら簡単だったのですが、MicroPython用の既存のI2Cデバイスドライバが動作するようにしようとすると、結構苦労しました。

上の写真は、MicroPythonに同梱されているOLED(SSD1306)のドライバを今回作成したI2Cクラスの上で動作させたものです。
何とか動きましたが、ドライバを2行ほど修正が必要でした。(注:これは私が誤解していた仕様に基づくもので、現在はI2Cクラスの実装を修正したため使用できなくなっています。)

以下、かなり細かいメモです(主に自分用です・・・)。

●既存のI2CクラスのAPI

まずは他のportsでのI2Cクラスを調べてみたのですが、結構APIに違いがあります。
I2Cデバイスのデバイスドライバを書くときには、APIに依存した書き方になりますので、APIの違いは結構課題ではあると思います。

MicroPythonには2種類のI2C実装があります。1つはハードウェアのI2Cコントローラを呼び出すもので、もう1つはソフトウェアでGPIOを制御してI2C信号を生成するもの(いわゆるbit-banging)です。

前者はSTM32用のpyb.I2C、後者はESP8266用やESP32用のmachine.I2Cなどが相当します。
それぞれ以下のリンクにドキュメントがあります。

micropython/pyb.I2C.rst at master · micropython/micropython

micropython/machine.I2C.rst at master · micropython/micropython

この両者は、若干APIが異なっています。

このほか、MicroPythonの派生形であるAdafruitのCircuitPythonのI2C実装もあります。これはCircuitPythonの全ハードウェアで共通のAPIです。

I2C — Two wire serial protocol — Adafruit CircuitPython 0.0.0 documentation

●I2C APIの階層

APIには低レイヤ・中レイヤ・高レイヤの3つのレイヤがあります。
ソフトウェア実装のAPIは、以下のように綺麗に整理されています。

・低レイヤ:バスの状態遷移やバス上での1バイト単位の送受信などのPrimitive I2C operations
・中レイヤ:スレーブアドレスを指定してread/writeするStandard bus operations
・高レイヤ:デバイスのレジスタを読み書きするMemory operations

ハードウェアのI2Cコントローラでは、低レイヤのAPIは(ハードウェア依存ですが)コントローラ内部で処理され、中レイヤ以上のAPIが提供されています。
CircuitPythonでは、中レイヤに絞ってAPIが提供されています。

このほか、I2Cの初期化のお作法に若干の違いがあります。

●各APIの違い

HW I2C、SW I2C、CircuitPythonをレイヤごとに比較してみると、以下のようになります。

◎初期化等
MicroPython HW I2C:
pyb.I2C(bus, …)
init(mode, *, addr=0x12, baudrate=400000, gencall=False, dma=False)
deinit()
scan()

MicroPython SW I2C:
machine.I2C(freq=400000)
init(scl, sda, *, freq=400000)
deinit()
scan()

CircuitPython I2C:
busio.I2C(scl, sda, *, frequency=400000)
deinit()
scan()
try_lock()
unlock()

◎低レイヤ
MicroPython HW I2C:
無し

MicroPython SW I2C:
start()
stop()
readinto(buf, nack=True)
write(buf)

CircuitPython I2C:
無し

◎中レイヤ
MicroPython HW I2C:
is_ready(addr)
recv(recv, addr=0x00, *, timeout=5000)
send(send, addr=0x00, *, timeout=5000)

MicroPython SW I2C:
readfrom(addr, nbytes, stop=True)
readfrom_into(addr, buf, stop=True)
writeto(addr, buf, stop=True)

CircuitPython I2C:
readfrom_into(address, buffer, *, start=0, end=len(buffer))
writeto(address, buffer, *, start=0, end=len(buffer), stop=True)、

◎高レイヤ
MicroPython HW I2C:
mem_read(data, addr, memaddr, *, timeout=5000, addr_size=8)
mem_write(data, addr, memaddr, *, timeout=5000, addr_size=8)

MicroPython SW I2C:
readfrom_mem(addr, memaddr, nbytes, *, addrsize=8)
readfrom_mem_into(addr, memaddr, buf, *, addrsize=8)
writeto_mem(addr, memaddr, buf, *, addrsize=8)

CircuitPython I2C:
無し

●低レイヤAPIの解説

I2Cはスレーブデバイスをアドレスで指定しますが、低レイヤAPIにはアドレスの概念がありません。
アドレスも「I2C BUS上で送受信する8bitのデータ」の一種という位置づけです。
実際「Start Condition後の最初の1バイトの先頭7bitがスレーブアドレス、続く1bitがR/Wを表す」という取り決めがあるだけで、アドレスに特別な形式は必要ありません。

start()とstop()は、前回ちょっと書きましたが、「SCL=HのときSDA=H→L」「SCLがL→HとなったあとでSDAがL→H」という信号を生成します。

原理的には、全てのI2Cのやり取りは低レイヤのAPIだけで実現できます。

●中レイヤAPIの解説

中レイヤのAPIは直感的にも分かりやすい、readとwriteです。しかし、中レイヤのSW I2CとCircuitPythonのI2CはAPIに細かい差分がありますので、内容を調査しました。

・startとend

CircuitPythonは、バッファの特定部分を使ったRead/Writeを実現するために、startとendの指定ができます。
buf[start:end]のようにしてバッファのスライスを作るとメモリを余計に消費しますし、元のバッファへの書き込みができません。

MicroPythonには(Python互換の機能として)、memoryviewという、特定のメモリ領域への参照(ポインタ)を作るオブジェクトがあり、これを使えばバッファを複製せずにスライス相当のオブジェクトをメソッドへ渡すことができます。
その代わり、memoryviewオブジェクト自体のメモリ消費(16バイト)があります。

・readfrom

CircuitPythonにはreadfromメソッドがありません。
readfromは、readしたデータを保存するためのメモリをメソッド内で新規に確保します(メモリ消費がある)。
一方、readfrom_intoメソッドはデータを保存するバッファを呼び出し側が与えます。
readfromは便利ですが、無くても良いメソッドです。

CircuitPythonはメモリの利用についてはかなりストイックなポリシーのようです。ターゲットデバイスが、RAMが32KBの製品群だからかもしれません。

・readfrom_intoメソッドのstopパラメータ

CircuitPythonではreadfrom_intoメソッドのstopパラメータがありません。stopパラメータは、送受信完了後にI2Cバスをstop conditionへ移行する(再開時は再度スレーブを指定する必要あり)か否かを指定します。

CircuitPythonはデバイスへの書き込みには指定できますが、読み込みでは指定できません。従って、読み込み後は常にstop conditionへ移行することになります。

また、MicroPythonでは低レイヤAPI導入後、中レイヤAPIから一時stopパラメータが削除されたようです。
理由は「低レイヤAPIを使えば実装可能だから」ということですが、中レイヤAPIが機能不足になるのはどうかということで、結局復活しています。

esp8266: I2C.writeto no longer accepts stop=False · Issue #2073 · micropython/micropython

RFC: refine definitions of I2C methods by dpgeorge · Pull Request #2640 · micropython/micropython

●高レイヤのAPIの解説とcombined format

I2Cデバイスの読み書きでは、メモリやセンサなどで「デバイスのレジスタを読み書きする」というモデルがよく用いられます。
このモデルでは、マスタからスレーブへ「書き込みたいレジスタのアドレス」と「書き込むデータ」を連続して書き込んだり、「読み出したいレジスタのアドレス」を書き込んだあと、スレーブからデータを読み出すといった操作を行います。

高レイヤのAPIはこのモデルに特化したAPIで、スレーブデバイスとアドレスを指定して読み書きを行います。
原理的には、中レイヤのAPIを使って書くことができますが、デバイスによっては、読み出しに「combined format」が必要になる場合があります。

combined formatとは、読み・書きを連続して行う通信で、UM10204には以下のように記載されています。

Combined format (see Figure 13). During a change of direction within a transfer, the START condition and the slave address are both repeated, but with the R/W bit reversed. If a master-receiver sends a repeated START condition, it sends a not-acknowledge (A) just before the repeated START condition.

i2c-combined-format.png

combined formatでは、STOPへの状態遷移を省略し、直前の送受信が終わった段階ですぐSTART状態へ遷移します。これをrepeated STARTと呼んでいます。

combined formatがよく用いられるパターンは

START→ 読み出したいレジスタを指定(書き込み)→ 指定したレジスタの値を読み取る(読み込み)→ STOP

というものです。
この場合は、stopパラメータの指定はwriteにだけ必要で、readには無くても困らないことになります。
しかし、規格上はread→writeのcombined formatも許されます。その場合は、readの最後(repeated START直前)の受信バイトに対して、マスターはNAKを返さなければなりません。マスターが受信する場合のNAKは、受信失敗というよりも「受信中断」というフロー制御的な意味合いがあるようです。

●Raspberry Pi向けのAPI設計

上記およびRaspberry PiのI2Cコントローラの仕様を考慮して、Raspberry PiのI2Cクラスは以下のようなAPIにしました。(実際には、いろいろ紆余曲折がありましたが)

◎初期化等
machine.I2C(bus)
init(freq=400000)
deinit()
scan()

◎中レイヤ
readfrom(addr, nbytes)
readfrom_into(addr, buf)
writeto(addr, buf, stop=True)

APIは中レイヤのみとしました。

マニュアルを見ながら、低レイヤの実装が可能かどうか考えてみたのですが、無理そうでした。
SW I2Cの低レイヤのAPIはアドレスとデータを区別しませんが、Raspberry Piのコントローラはスレーブアドレスと通常の送信データとは扱いが異なります。

具体的には、転送開始と、スレーブアドレスのバスへの送出がセットになっており、分離できません。
また、通信開始時点で指定したバイト数の送信が完了することと、Stop conditionへの遷移がセットになっています。
Start Condition、Stop conditionへの遷移はどちらも明示的に単独で実行することができないようです。

高レイヤは中レイヤの機能を用いて実装可能ですが、私自身のハードウェアにない機能の実装はなるべく後回しという方針に則り、見送ることにしました。

●「stop」パラメータの必要性

Raspberry Pi用のAPIはMicroPythonのSW I2Cの中レイヤの仕様を基本としました。
これが既存のI2Cデバイスドライバを一番動作させやすいと考えたためです。
しかしRaspberry PiのI2Cコントローラでは、stopパラメータの実装が困難です。
これは、Stop conditionへの遷移を単独で実行する方法が見当たらなかったためです。

そのため、実装を見送ろうかとも考えたのですが、stopパラメータを実装しないと、以下のような不都合があることが判りました。

(1)combined formatの使用

上でも書いたcombined formatは、writeのあとStopを経ずにrepeated STARTを経てreadへ移行します。stop=Falseでwriteを実行し、続いてreadを実行することで、combined formatが実現できます。
stopパラメータが使えないと、writeとreadの間にStop conditionが入ってしまいます。大抵のデバイスはこれでも問題ないようですが、デバイスによってはcombined formatでないと不具合が出ることがあるそうです。

repeated STARTが実装できるかどうか調べてみたのですが、ペリフェラルマニュアルからは明示的には読み取れませんでした。
しかし、以下のRaspberry Pi Forumの議論によると、

・writeを開始(制御レジスタのSTビットをON)してから、writeが完了する前に、DLENレジスタに読み出したいデータ長をセット
・CレジスタのR/WビットをREAD(1)に、STビットをONにセット

とすることでrepeated STARTを実現できるそうです。

i2c repeated start transactions – Page 2 – Raspberry Pi Forums

以下のLinuxのドライバを見ても、そんな感じの処理をしているようです。

linux/i2c-bcm2708.c at rpi-3.12.y · raspberrypi/linux

(2)メモリを無駄にせずにbytearrayの先頭にデータを挿入する

これはMicroPython用のSSD1306ドライバを見ていて分かったのですが、大きなbytearrayデータの先頭に1バイトのデータを付加して送りたい場合があります。

SSD1306では、「コマンド(1バイト)+データ(最大1Kバイト)」という形でフレームバッファのデータを送信します。
そのため、デバイスドライバにデータが渡され、デバイスドライバはSSD1306にコマンド+データを送ります。
しかし、データはbytearrayなので先頭にデータを追加することはできません。
連結結果を新たなbytearrayとして生成することは可能ですが、メモリを新たに消費することになります。

上記のドライバでは、この部分の実装は以下のようになっていました。


低レイヤのAPIを使って、

START→ スレーブアドレス指定(Write)→ Data/Cmdモード選択(0x40)→ フレームバッファ用データ→ STOP

というように一続きのI2C転送処理を行っています。Raspberry Piではこのような低レイヤの処理はできません。

しかし、中レイヤのAPIでstopパラメータを使えば

とすることで同等の処理が行えるはずです。(実際に他のportsで実験したわけではありませんが。)(2018/8/11追記:これは誤りでした。上記のやり方でも、2行目のwritetoで最初にスレーブアドレスが送られますので、フレームバッファ用データだけを送ることはできません。writetoではなく低レイヤAPIのwrite(buf)が実装されていればデータだけを送ることができますが、その場合はwriteの後にstop()をコールしてストップビットを送る必要があります。)

ちなみに、Adafruitによる古いドライバでは、フレームバッファ自体を1バイト多くして、先頭にコマンド用の領域を確保することでコマンドとデータを毎回連結することを回避しています。

●Raspberry Piでのstop=Falseなwriteの実装

stopパラメータをどうすればRaspberry PiのI2Cコントローラで実装できるか、ロジアナを頼りにいろいろ試行錯誤してみた結果、以下のような方法に辿りつきました。
とりあえずwriteだけ実装しています。

(1)自動で生成されるStopの回避

通常の使い方では、FIFOにデータを入れ、そのデータ長をDLENに入れます。その結果、DLENにセットされたバイト数分のデータがFIFOから読み出され、送信された後に自動でSTOPが挿入されます。
そこで、DLENを送信するデータ長よりも大きく(実際には最大値0xffff)設定します。
これで、STOPが自動で送信されることはありません。

(2)強制的なSTOP

stop=falseの後でstop=Trueのwriteを実行する場合は、最後に強制的にSTOP Conditionへ移行する必要があります。
マニュアルをよく読むと、「FIFOをクリアすると通信が中断される(Note that clearing the FIFO during a transfer will result in the transfer being aborted.)」とあります。
これを逆手に取って、STOPしたい場合にはFIFOをクリアしてやります。

クリアするタイミングは、送られるべきデータが全てI2C Bus上へ送出された直後でなければなりません。
それより早くクリアすると、データを送り終える前にSTOP状態に遷移してしまいます。

これは「FIFOが空になった瞬間にFIFOをクリアする」ことでうまくいきました。
「FIFOが空になった瞬間=I2C Busへ最後のデータが出始めた瞬間」であり、このタイミングで通信を中断することで、データ送信後にSTOP状態へ遷移させることができます。

かなり無理やりな実装ですので、Zero以外のRaspberry Piや今後新しく出てくるRaspberry Piで動作しない可能性はあります。
なお、writetoのstopパラメータはオプションで、デフォルト値はTrueです。単発のwritetoでstop=Trueの場合は、通常通りSTOPが自動生成されるようにしてあります。

以下の図は、今回作成したI2Cクラスでstop=Trueの場合とstop=Falseの場合の信号です。
いずれも、1バイトwrite→1バイトreadです。writeからreadへ移行する部分を拡大しています。

repeatedStart.png

STOP condition、START conditionは、SCL=HighのときにSDAの信号が変化することで生成されます。
図上はstop=Trueの場合で、STOP conditionが生成された後、START conditionが生成されていることがわかります。
図下はstop=Falseの場合です。START conditionは生成されていますが、STOP conditionは生成されていません。

これは分かりにくいので、STARTの直前でSDAがL→Hと変化したタイミングの部分を拡大したのが次の図です。

repeatedStart2.png

SDAが変化しているのはSCLがLのときなので、STOP conditionは生成されていないことが分かります。

●終わりに

I2Cクラスの実装、簡単に済むかと思ったのですが、意外や難航しました。
基本的にはシンプルなプロトコルなのですが、応用的な使い方を今回いろいろ知ることができました。

今回の実装はまだテストが不十分だと思いますので、今後、いろいろなデバイスとちゃんと通信できるかどうか試してみるつもりです。

コメント