Episode 26 <ベアメタルシリーズ> Raspberry Pi PicoでI2C通信を使ってみた

ブログ

皆さんこんにちは、グレキチです。

いやー、あっという間に、今年も年末になってしまいました。もうすっかり冬なんでしょうが、体が慣れてきたのか、最近はもうあまり寒いとは感じなくなってきました。
えっ、冬はまだまだこれからが本番だって?😆
みなさん、体調を崩さないよう、引き続きしっかり管理していきましょう‼️

前回の投稿からちょっだけ間が空いてしまいましたが、今回紹介するI2C通信の実装対応でなかなかに苦戦していたため、ブログを更新出来ませんでした💦I2Cってこんなに使い難いものなんでしょうか?
今回たまたま使用したデバイスのクセなのか、ベアメタル特有の難しさなのか、それともまだ私がヒヨッコだからなのか🥲どうか分かりませんが、とりあえずI2C通信に成功したデバイスの内容について紹介していきます。

I2Cについて

I2Cは“Inter-Integrated Circuit”の略で、通信方式の1つです。マイコン関連の通信方式については、前回のブログで大まかな分類などを紹介しましたので、気になる方は確認してみて下さい。

そこでの内容に沿って区分を説明すると、今回のI2Cは、シリアル、クロック同期、半二重、シングルエンド、ということになります。これらをまとめて簡潔に言うと、1本のデータ信号線を使って、クロック信号に同期させて1バイト毎に送受信を行うもの。但し、送受信できるといっても、送信中は受信出来ないし、その逆も然りで、さらにノイズを拾いやすいので気をつけてね、ということです。これだけでは分かりづらいと思いますので、もうちょっとだけ詳しく説明します。

I2Cは、マイクロコントローラやプロセッサなどのコントローラデバイスと、データコンバータやその他の周辺機器などのターゲットデバイスを接続するための低速通信プロトコルで、フィリップスセミコンダクタ社(現NXPセミコンダクタ社)によって1982年に開発されたようです。
I2Cで通信線として必要なのは、データ入出力に使うSDAとクロック信号用のSCLの2本です。コントローラデバイスは、シリアルデータラインを介して送信される固有のI2Cアドレスを通じて、任意のターゲットデバイスとクロック信号を同期させて通信を行います。I2Cは、この2本の通信線だけでバス上の複数のデバイスに接続できるため、デバイスメーカーにとっては実装が簡単で経済的なので、広く普及してきた経緯があるとのことです。実際は電源とGNDも必要なので、配線数としては全4本となります。
また、デジタル信号の出力には、通常HighとLowに各々スイッチ(トランジスタ)がある構造となっていて、それはプッシュプルと呼ばれる方法になっていますが、I2Cでは、High側スイッチが無いオープンドレイン(※オープンコレクタも同意)という方法を取ります。(下図参照)

出典:NXP Communityリンク

High側スイッチが無い状態で電源OFFにしていると電圧が不安定なので、プルアップ抵抗を設けて電源側と接続し、初期状態を確実にHighに設定するようにします。この方法は、Low側のスイッチがONになったら、Lowが出力されるようになります。そのため、I2C通信の回路にはプルアップ抵抗が必須となります。多くのモジュールでは、プルアップ抵抗を内蔵しているようですが、内蔵されていない場合は、使用するマイコン側に実装されていればそれで設定するか、外付けでプルアップ抵抗を準備する必要があります。
それと、最大クロック周波数によって通信速度を区分けするモードがI2Cには規定されています。一般的には、100kHz以下で動作するスタンダードモード、400kHzを上限とするスピードモード、それと1MHz以下で動作するスピードモードプラス、またその上の3.4MHzを上限とするハイスピードモードという4つのモードが使えるようです。それ以外にもう一つ特殊用途向けに、ウルトラファーストモードというものも規定されているようで、そのモードはクロック上限が5MHzまでとなっているようです。なお、ラズパイPicoの場合は、第3段階のスピードモードプラスまでが使えるとのことです。

以上、重要なポイントに絞った概要になります。
もっと詳しく知りたい方は、こちらのサイトが参考になると思います。(さすが、開発元!)

使用した通信モジュール

今回ラズパイPicoとのI2C通信で使用したデバイスは、SENSIRION社の高精度温湿度センサSHT31-DISを搭載したセンサモジュール(下図)で、仕様は下表の通りです。
画像の一番上の四角い所がセンサ部で、サイズは3mm×3mmとかなり小さいです。

AE-SHT31(メーカー:秋月電子通商)
出典:AE-SHT31マニュアル
項目仕様
電源電圧DC 2.4 〜 5.5 V
消費電流800 μA(測定時)
最大クロック周波数1 MHz
温度測定レンジ– 40 〜 125 ℃
湿度測定レンジ0 〜 100 %
実装タイプスルーホール
パッケージSIP5
基板サイズ(横幅 × 高さ × 厚さ)14 × 12 × 1.2 mm
プルアップ抵抗各10kΩ搭載

こちらの製品は非常にコンパクトなサイズで出来ており、何か小型の複合電子デバイスを作成する際などに重宝しそうな感じを受けました。なお、自身で端子ピンのはんだ付けが必要ですが、ランド面が広いため簡単に出来ました👍

さて、このモジュールのコントロールに関してですが、データ書き込みや読み出しには次のような制約が設けられていました。
まず書き込みについてですが、下記の通り最初に対象モジュールの(スレーブ)アドレスを入力したのち、引き続いてコマンドの上位8bitを入力し、続けて下位8bitを入力するという方法を取るようです。

これはI2Cではごく一般的な書き込み方法のようで、アドレスとコマンドの入力順序とタイミングを間違わなければ、基本的にはエラーになることは無さそうです。なお、このモジュールのスレーブアドレスは、先ほどのセンサーモジュール図の所にも記載がありましたが、デフォルトでは0x45に設定されているようでした。

続いて読み出しですが、下記のような出力形態になっていました。書き込みに比べてちょっと複雑です🧐

最初の処理は書き込み時と同じでコマンドを書き込み、その後、読み出し処理が行われるようになっているようです。このモジュールでは温度と湿度が同時に計測できるため、読み出しデータも温度の後に湿度と連続で吐き出される仕組みです。各データ16bit長のデータが出力された後、データ整合性確認用のチェックサム(1バイト)が各々のデータの最後にくっついて出力されるため、3バイト + 3バイトでトータルで6バイトのデータが一気に出力されるようでした。このデータの受け取り方はプログラムに反映しないといけないので、しっかりと把握しておく必要がありました。
その他に関しては、このモジュール特有の初期化方法、コマンドコードなど必要なものを、マニュアルで確認して設定しました。

回路図および配線図

それでは次に、回路図等の説明を行います。
今回、I2C通信に使う信号線のSDAとSCLには、それぞれGPIO4とGPIO5を使っています。(ラズパイPicoのデフォルトI2C設定ポート)
また、温湿度センサーの測定結果の確認には、UART通信を使ってPCでモニターすることにしました。そのため、前回のブログで紹介した自作のPico Probeも使っています。それに伴い回路のVSSとGNDは、TargetボードとPico Probeで各々共有させます。なお、UART関連の結線については、前回の構成と同じです。
それで、実際の回路は下図のように計画しました。

また、配線図は下図の通りです。上側がPico Probeになります。

そして、実際に組んだボードは下図の通りです。
温湿度センサーが浮いてますが、そこはご愛嬌ということで😚

プログラム内容

プログラムについては、I2Cの初期設定に関して少々ややこしかったので、そのあたりを中心に説明したいと思います。

ラズパイPicoでのI2C通信を題材にしたプログラムをウェブで検索すると、大多数の方々はMicroPythonを使ってコーディングしたものをアップされていて、ベアメタルでコードを作成しているものはなかなか見つけられませんでした😭 私も皆さんに習って、MicroPythonを使って一度実装してみましたが、なるほど、とても簡単に複数のモジュールとI2C通信を行うことができました。
しかし、私はどうしてもベアメタルでやりたかったので、それならばということで、MicroPythonの元コードを参考にしてはどうか?という結論に至り、MicroPythonのI2C関連プログラムを分析することにしました。そこから芋づる式に調べていくと、最終的にはpico-sdkのプログラムに行き着きました。pico-sdkはC言語で作成されていたので、これは存分に活用できると思い、I2C関連のコード内容をベアメタル用に書き換えることに挑戦しました。pico-sdkコードの中で温湿度センサーを実行するのに、最低限必要そうな内容に絞り込みながら、少しずつ記述していって、繰り返しデバッグを行い、何とか形にすることが出来ました。pico-sdk以外の参考になりそうなコードも横目で見つつでしたが、絶対に必要だったのはラズパイPicoのマニュアルです。レジスタのアドレス情報や各レジスタの使い方などが記載されているので、Mustで読み込まないとダメでしたね😵

そうしてようやく出来上がった内容ですが、I2C初期設定のコードは下記の通りです。

static void i2c_init(void)
{
    PUT32(I2C0_IC_ENABLE_RW, 0);  // I2C0初期化
    while (GET32(I2C0_IC_ENABLE_RW) & 1);

    // FIFO trigger
    PUT32(I2C0_IC_TX_TL_RW, 0);
    PUT32(I2C0_IC_RX_TL_RW, 0);

    // DMA off
    PUT32(I2C0_IC_DMA_CR_RW, 0);

    // interrupt clear
    GET32(I2C0_IC_CLR_INTR_RW);

    // I2C0をマスターモード、 高速モード、7bitアドレス送信 に設定
    PUT32(
        I2C0_IC_CON_RW,
        1 << 0 |  // MASTER_MODE -> enabled
        1 << 1 |  // SPEED -> standard
        0 << 3 |  // IC_10BITADDR_SLAVE -> Slave 7bits addressing mode
        0 << 4 |  // IC_10BITADDR_MASTER -> Master 7bits addressing mode
        1 << 5 |  // IC_RESTART_EN -> enable
        1 << 6 |  // IC_SLAVE_DISABLE -> slave_disabled
        0 << 7 |  // STOP_DET_IFADDRESSED -> disbaled
        1 << 8 |  // TX_EMPTY_CTRL -> enabled
        0 << 9    // RX_FIFO_FULL_HLD_CTRL -> disabled
    );    
    // I2C0 標準モードのSCLクロックのHigh期間カウント
    PUT32(I2C0_IC_SS_SCL_HCNT_RW, 500); 
    // I2C0 標準モードのSCLクロックのLow期間カウント
    PUT32(I2C0_IC_SS_SCL_LCNT_RW, 741);
    // I2C0 スパイク抑制ロジックによってフィルタリングされる最長スパイクの持続時間
    PUT32(I2C0_IC_FS_SPKLEN_RW, 4);
    // I2C0 SDAホールド持続時間
    PUT32(I2C0_IC_SDA_HOLD_RW, 75); // 最低値は0x1

    // IO_BANK0 setting
    PUT32(IO_BANK0_GPIO4_CTRL_RW, 3);  // I2C0-SDA
    PUT32(IO_BANK0_GPIO5_CTRL_RW, 3);  // I2C0-SCL

    // Input有効化
    PUT32(PADS_BANK0_GPIO4_SET, 1 << 6);  // IE -> enable
    PUT32(PADS_BANK0_GPIO5_SET, 1 << 6);  // IE -> enable

    // I2C0を有効化
    PUT32(I2C0_IC_ENABLE_RW, 1);
    DELAY(5000);  // 40μs待ち
}

この中で特に大事なのが、I2C0_IC_CON_RWレジスタとI2C周波数関連レジスタの設定値のところです。

まず、I2C0_IC_CON_RWレジスタですが、これはI2Cコントロールレジスタです。その名の通り、各種機能の有効化/無効化をこのレジスタで行います。このレジスタで設定できるのは、0bitから10bitまでで、上のコードでは、明示的に指示が必要だと考えたものだけを設定しています。個々の項目についてはPicoマニュアルを読めばわかることなので、ここでは敢えて説明しませんが、このレジスタは必ず設定しないといけないものになります。なお、設定していないビットの項目についてはデフォルト設定となっています。

続いて、I2C周波数関連についてです。ここが今回のプログラムの最難関では無いかと思います。なぜかと言うと、計算式を使っていい具合の値を決定しないといけないからです。正直に言うと、ここについてはラズパイPicoのデータシートを見ただけではわからなかったので、ChatGPTに相談しました。(なんかちょっと悔しいと感じるのは、私だけでしょうか?😅)その結果、ラズパイPicoで採用しているI2Cは、Synopsys社が設計したDesignWare_apb_i2c(v2.01) IP(設計資産)の構成に基づいているとのことなので、Synopsys社が作成したマニュアルを見ないとわからないようでした。そのマニュアルを横目に見つつ計算を行いました。
まず最初の計算としては、I2C0_IC_SS_SCL_HCNT_RWI2C0_IC_SS_SCL_LCNT_RWの設定値を求める方法をみていきます。(※以降の説明では、I2C0_IC_SS_SCL_HCNT_RWとI2C0_IC_SS_SCL_LCNT_RWを省略して、単にHCNT、LCNTと表現しています)
そのためにまずは、SCLクロックの実行High/Low時間の算出が必要でした。計算式は以下の通りです。

tHIGH  =  ( HCNT + 8 )  /  ic_clk
tLOW   =  ( LCNT + 1 )  /  ic_clk
※ ic_clkはシステムクロックのことで、今回は125MHz

また、I2CがStandard(SS)モード(100kHzの場合)では、下記が規格値としてあるとのことでした。

SCL周期 >= 10.0 μs
tHIGH  >=  4.0 μs
tLOW   >=  4.7 μs 

かつ

tHIGH + tLOW = SCL周期(10μs)

以上をふまえて、tHIGHからHCNTを、tLOWからLCNTを暫定的に計算で求めると、以下のようになります。

// tHIGHからHCNTを求める
tHIGH >= 4.0 μs

(HCNT + 8) / 125e6 >= 4.0e-6
HCNT + 8 >= 500

∴ HCNT >= 492


// tLOWからLCNTを求める
tLOW >= 4.7 μs

(LCNT + 1) / 125e6 >= 4.7e-6
LCNT + 1 >= 587.5

∴ LCNT >= 586.5 ≒ 587

次に、周期条件からHCNTとLCNTの関係性を導き出します。周期条件としては、tHIGHとtLOWを足したものはSCL周期(10μs)になるというものでした。なので、下記の式が成り立ちます。

((HCNT + 8 ) + (LCNT + 1 )) / 125e6 = 10e-6
HCNT + LCNT + 9 = 1250
HCNT + LCNT = 1241

この成立式に合致するように、最終的にHCNTとLCNTを調整の上決定します。
HCNTとLCNTの最小値はすでに計算した通りで、それらの合計が1241になれば良いので、例えば下記の通りに決めることが出来ます。

HCNT = 500
LCNT = 741

ここでポイントとなるのは、SCLとSDAの立ち上がりはプルアップに依存しているので、LCNTを長めにとった方が回路的に安全ということのようです。
そして、決定した上記の値で規格値に合致しているか、最終確認を行います。

tHIGH = (500 + 8) / 125e6 = 4.064 μs > 4.0 μs  -> OK

tLOW  = (741 + 1) / 125e6 = 5.936 μs > 4.7 μs  -> OK

tHIGH + tLOW = 4.064 + 5.936 = 10 μs  -> OK

ということで、規格値を満足できているので問題なさそうです😃

さて次に、I2C0_IC_FS_SPKLEN_RWの設定値の求め方です。ラズパイPicoには、スイッチ開閉時のスパイクノイズを抑制する機能があって、それを判断するための最長スパイクの持続時間をこのレジスタを用いて設定します。スパイクノイズは必ず発生するため、このレジスタの有効最低値は1と規定されています。仮に0とレジスタに指示しても、強制的に内部で1に変更されるようです。また、Picoマニュアルによれば、SSモードとFSモードの場合、最大スパイク長は50nsと規定されており、そのより小さい値はスパイクと判断して抑制してよいとのことです。逆に言うと、50nsを超える信号は正しい信号の可能性がある(※あくまで可能性)ということみたいです。以上の情報をもとに、設定値を求めていきます。
仮にこのレジスタ値をSPKLENと表現すると、スパイク持続時間の計算式は以下のようになっています。

スパイク持続時間 = ( SPKLEN + 1 ) * 1クロック(時間)

※システムクロック周波数は125MHzのため、1クロックは 8ns

ということなので、 50ns以下にするSPKLENの最大値は5 ((5 + 1) * 8 = 48ns)となります。しかしここでちょっと注意点があって、規定されている50nsは正しい信号の可能性が高いというだけで、完全に正しいとは言えないようでした。なので、もう少し余裕をみた値に設定した方がよく、その場合設定値は4((4 + 1) * 8 = 40)ということになります。あー難しい😓

それでは最後に、I2C0_IC_SDA_HOLD_RW設定値の求め方です。これはSDAホールド時間長レジスタと呼ばれるもので、送信時と受信時のSDAホールド時間をこのレジスタ1つで設定します。0〜15bitが送信用、16〜23bitが受信用に振り分けられています。なので、送信および受信のどちらのSDAホールド時間を設定しているのか、bit位置を間違えないように注意しないといけないですね。但し、今回はラズパイPicoをマスターに設定しているため、受信のSDAホールド時間はスレーブ側に依存するので、そういった場合には、受信側のSDAホールド時間は特に設定しなくて良いようでした☝️(デフォルト設定値は0になっていました)
ということで、ここでは送信時のSDAホールド時間のみを考えます。

それで実際のSDAホールド時間の求め方ですが、これも少々ややこしい内容になってます😣
求めたいのはSDAホールド時間(以下、tHD;DAT)なんですが、これは、マスターモードの際には設定不要なSDAセットアップ時間(以下、tSU;DAT)の値と関係しているようです。I2Cの規格では、SSモード(100kHz)の場合、tHD;DAT >= 0ns、tSU;DAT >= 250nsとなっています。さらにこれらは先に求めたtLOW(今回の計算結果は約5.9μs)とも関係しているとのことでした。諸々含めた情報から、一連の関係では、以下の2つの式が成立しているようです。

tHD;DAT = I2C0_IC_SDA_HOLD_RW / ic_clk (式1)

tHD;DAT + tSU;DAT ≤ tLOW (式2)

これらの2式からtHD;DATを求めていきます。まずはtHD;DATを仮決めします。

tHD;DAT + 250 ns <= 5900 ns
∴ tHD;DAT <= 5650 ns

ということで、tHD;DATは5650ns以下であれば良いことになります。
しかしこれだけだと、レンジが広すぎて決められませんよね?🤔
ここでもう一つ追加情報です。今回のDesignWare_apb_i2cでは、実測ベースの経験則として、tHD;DATは400〜800nsで設定するのが最も安定しているとの情報がありました。
それならば、例えば真ん中を取って、tHD;DAT=600nsとして以降の計算を進めると、式1から以下の計算が出来ます。

600 ns = I2C0_IC_SDA_HOLD_RW / 125e6
I2C0_IC_SDA_HOLD_RW = 600e-9 * 125e6
∴ I2C0_IC_SDA_HOLD_RW = 75

ということで、めでたく決定出来ました。後は実際にコードを実行してみて、細かな調整を行なって、最終決定という流れになるかと思います。
いやー、理解するのにも説明するのにも、非常に骨が折れる内容でした😮‍💨

ということで、このセクションで言いたかったことは以上になります。
全体コードは、いつものようにこちらのGithubに掲載していますので、気になった方は確認してみて下さいね。

実行結果

それでは実行結果です。
回路図のところでお伝えした通り、温湿度の測定結果はUART通信を使ってPCでモニターしました。モニタリングには、前回の記事でも紹介したminicomを使った簡単な方法で行いました。以下が、測定時の端末画面です。

リアルタイムで、うまく計測できているのがわかると思います☺️見た目が分かりやすいように、文字列はお好みで追加しました。温度と湿度が徐々に上がっているのは、センサーがきちんと機能しているのを確かめたかったために、センサー周辺を手で覆って強制的に変化を促したからです。
実装するまでにかなり時間は掛かったものの、いざ実行してみると何ともあっさりしています。ですが、配線を最小限にして、はんだ付けを行い、デザインした筐体などで基板を囲ったりしてあげると、立派な温湿度計の出来上がりですね。(いや売りませんけど😆)

まとめ

そういうことで、今回はI2C通信の実装にトライしてみました。
温湿度センサーモジュール自体を今回初めて扱いましたが、AE-SHT31はバイナリで計測結果を見るとリアルタイムでの反応も良く、計測エラーもほとんどなかったので、なかなか良いモジュールなんじゃないかと思いました。

それと冒頭にちょっと伏線を張っておきましたが、実は今回のI2Cトライにあたっては、当初もう一つ、I2C通信できる小型LCDモジュールも実装する予定だったんです。温湿度計の計測結果をそのLCDに表示させようと思ってました。しかし、LCDは何度やっても通信エラーが出て初期化がうまく出来ず、ディスプレイへの文字表示が成功しなかったので、今回紹介するのを断念しました😭同じ回路を使って、MicroPythonを使ったコードで実行させたら問題なく起動出来たので、プルアップ抵抗の設定がうまくいっていないとかのハード的な問題ではないのだろうと推測したので、ベアメタル特有の何かなんだと思っています。(処理スピードが早すぎるか?🤔)
この経験があったので、I2Cはモジュール毎の癖が強いのかなと思ってみたり、通信が安定しないのがちょっと腑に落ちない感じがしました。配線が少なくできるというのがI2Cの推しポイントのようですが、それを反故にしても、取り扱うモジュールに左右され難い、もっと安定して使える通信方式を、出来れば私は選びたいなと思いました。歴戦の組み込み開発者の方々はどう思っているのか、面識が出来たら一度感想を伺ってみたいところです🧐

実装が上手くいかなかった小型LCDモジュールの起動トライは、またいつか時間があったらリベンジしようかなと思っています。

それでは、以上になります🖐️