Closed4

Raspberry Pi Pico (RP2040) のI2Cマスタ/スレーブ検証

七瀬七瀬

SPI に引き続き、RP2040 で I2C のマスタとスレーブの通信について検証した。

前提条件

  • ボード: Raspberry Pi Pico
  • フレームワーク: arduino-pico v3.6.2 (earlephilhower版Arduinoライブラリ)

arduino-pico の I2C 実装

今回は回路中に 1kΩ のプルアップ抵抗を追加で入れたが、arduino-pico では該当のピンにプルアップが指定される。本来ならば追加のプルアップ抵抗は必要ないが、I2Cのクロック周波数を上げて検証するために今回は挿入した。なおデータシート[1]によると、RP2040 内部のプルアップ抵抗は 50~80kΩ であり、1kΩ の抵抗を併用すると合計で 980.4~987.7Ω 程度ということになる。

サンプルプログラムと結線

今回もサンプルプログラム TalkingToMyself が公式で用意されているためそれを実行する。

結線についてはサンプルプログラムの冒頭に示されているとおり、以下のように行った。プルアップ抵抗は少し低めの1kΩ、ダンピング抵抗として 33Ω を接続した。

マスタ側 スレーブ側
I2C0 SDA / GP0 I2C1 SDA / GP2
I2C0 SCL / GP1 I2C1 SCL / GP3

回路図で示すと以下のようになる。丸印の位置にオシロスコープのプローブを接続した。

サンプルプログラムの動作

通信は1秒ごとに発生し、マスタからスレーブへ(書き込み)、スレーブからマスタへ(読み込み)交互に通信が行われる。

マスタからスレーブへ(書き込み)

スレーブからマスタへ(読み込み)

マスタからスレーブへの通信の拡大

R/Wビットで一瞬立ち上がっているが、動作に影響はない。おそらくスレーブ側(I2C1 SDA)が L レベルに切り替わるまでに少々時間があるためと思われる(仕様上問題はない)。ACKビットの直後のエッジですぐにデータの送信が始まっている。

スレーブからマスタへの通信の拡大

こちらはACKビットの直後にSCLが止まり、少し経ってから再開する。

脚注
  1. RP2040 Datasheet (Release 2.2), page 619, 5.5.3.4. IO Electrical Characteristics, Table 624. Digital IO characteristics より ↩︎

七瀬七瀬

クロック周波数(ボーレート)

arduino-pico で I2C のクロック周波数を指定しない場合は 100kHz となる。実際にサンプルプログラムを 1kΩ のプルアップ抵抗で動かすと若干低いおよそ 95.4kHz になった。1kΩ となるとクロックストレッチが発生するほどではないはずだが、他の周波数でも指定された速度よりもやや低い周波数になった。

周波数は以下のように Wire.setClock 関数を記述して変更できる。また、この関数は Wire.begin の後に記述しても効果がある。スレーブの Wire1 にもクロック周波数を指定する必要はないはずだが、1MHz以上ではスレーブ側も設定しないとうまく動作しなかった。

  Wire.setSDA(0);
  Wire.setSCL(1);
  Wire.setClock(1'000'000);
  Wire.begin();
  Wire1.setSDA(2);
  Wire1.setSCL(3);
  Wire1.setClock(1'000'000);
  Wire1.begin(0x30);

なお、setClock 関数で呼び出される pico-sdk 側は i2c_set_baudrate 関数である。この関数は実際に設定された周波数を返すが、Wire.setClock 関数は何も返さない(void)。

以下にクロック周波数と実際の周波数、通信の可否を表でまとめる。

指定周波数 実際の周波数 誤差 通信可否
5.10 MHz N/A N/A
5.00 MHz 3.40 MHz -32.0%
3.40 MHz 2.56 MHz -24.7%
2.00 MHz 1.64 MHz -18.0%
1.00 MHz 893 kHz -13.7%
400 kHz 371 kHz -7.25%
100 kHz 95.4 kHz -4.60%

3.4MHz は I2C の規格上は最大のクロック周波数だが、それには 5MHz を指定しなければならない。クロック周波数が上がると誤差も大きくなっていく。ではプルアップ抵抗の値を下げれば良いというわけでもなく、たとえば 5.0MHz を指定してプルアップ抵抗を 470Ω にすると数回の通信の後に通信不能になってしまう。

以下に 5.00 MHz と 5.10 MHz を指定したときの信号を載せる。

5.00 MHz を指定したとき

5.10 MHz を指定したとき(通信不能)

最初の2クロック分が出て SDA、SCL ともに停止してしまう。復帰はされず、読み書きどちらも同じ状態となる。

よく観察すると 5.00 MHz の指定を超えると SCL の立ち上がりに SDA の出力が間に合わず、後続のクロックとほぼ同時に出力されている。SDA のプルアップ抵抗だけたとえば 200Ω 程度にすると通信が再開できる。

七瀬七瀬

データの送受信

サンプルプログラム TalkingToMyself ではマスタからスレーブに文字列を送り、スレーブからもマスタへ文字列を送っている。もちろんこれだけでも送受信を確認できているが、もっと単純化した送受信を考える。

スレーブを256バイトのメモリデバイスとみなし、アドレス(8bit)とデータ(8bit)を送って書き込み、アドレスを送って読み込むことができるようにする。具体的には以下のような動作をする。

  • R/WフラグがWのとき(書き込み)
    • マスタはアドレスとデータを送る
    • スレーブは指定されたアドレスにデータを格納する
  • R/WフラグがRのとき(読み込み)
    • マスタはアドレスを送る
    • スレーブは指定されたアドレスを記憶し、データ要求を待つ
    • 続けてマスタは1バイトのデータを要求する
    • スレーブは指定されたアドレスのデータをマスタに送る
    • マスタは送られてきたデータを読み取る
// To run, connect GPIO0 to GPIO2, GPIO1 to GPIO3 on a single Pico

#include <Wire.h>

void recv(int len);
void req();

void setup() {
  Serial.begin(9600);
  delay(5000);
  Wire.setSDA(0);
  Wire.setSCL(1);
  Wire.setClock(100'000);
  Wire.begin();
  Wire1.setSDA(2);
  Wire1.setSCL(3);
  Wire1.setClock(100'000);
  Wire1.begin(0x30);
  Wire1.onReceive(recv);
  Wire1.onRequest(req);
}

bool writeI2C(uint8_t address, uint8_t value) {
  Wire.beginTransmission(0x30);
  Wire.write(address);
  Wire.write(value);
  return Wire.endTransmission() == 0;
}

bool readI2C(uint8_t address, uint8_t *value) {
  Wire.beginTransmission(0x30);
  Wire.write(address);

  if (Wire.endTransmission() != 0) {
    return false;
  }

  if (Wire.requestFrom(0x30, 1) == 0) {
    return false;
  }

  uint32_t return_value = Wire.read();

  if (return_value == -1) {
    return false;
  }

  *value = (uint8_t)return_value;
  return true;
}

void test(uint8_t address, uint8_t value, bool *write, bool *read) {
  uint8_t return_value;

  *write = false;
  *read  = false;

  if (!writeI2C(address, value)) {
    return;
  }

  *write = true;

  if (!readI2C(address, &return_value)) {
    return;
  }

  *read = value == return_value;
}

void loop() {
  static uint8_t address     = 0;
  static uint32_t c          = 0;
  static uint32_t write_acum = 0, read_acum = 0;
  bool write_suc, read_suc;
  uint8_t value = (uint8_t)rp2040.hwrand32();

  test(address, value, &write_suc, &read_suc);
  c++;
  address++;

  if (write_suc)
    write_acum++;

  if (read_suc)
    read_acum++;

  Serial.printf("#%d, 0x%02x: 0x%02x, error w/r: %d/%d\n",
                c, address, value, c - write_acum, c - read_acum);
  delay(1000);
}

uint8_t data[256];
volatile bool reading;
uint8_t reading_address;

void recv(int len) {
  if (len == 1) {  // read
    reading         = true;
    reading_address = (uint8_t)Wire1.read();
  } else if (len == 2) {  // write
    uint8_t address = (uint8_t)Wire1.read();
    data[address]   = (uint8_t)Wire1.read();
  }
}

void req() {
  if (reading) {
    reading = false;
    Wire1.write(data[reading_address]);
  } else {
    Wire1.write(0);
  }
}

実際の通信

上記のプログラムを実行すると、1秒ごとに以下のような通信が発生する。

書き込みは1回の通信、続く読み込みでは2回の通信が発生する。

Read部を拡大すると最後はNACKがマスタからスレーブに送信されている。これはマスタで Wire.requestFrom 関数にて1バイトを指定しているため、次のデータを送ってはならないことを通知しているものであり、正常な動作である[1]

3.4MHz での動作

正常に通信できている。データ書き込みで19μs、読み込みで21μsかかっている。
データ書き込みのアドレスとデータの送信で10μsほどかかっているのはクロックストレッチが発生しているからである。すなわち、スレーブ側の準備ができていないためにSCLをlowにしてマスタからの後続のデータ送信を止めている状態である。

上記のプログラムではコア0でマスタとスレーブの両方の処理を行っている。たとえばスレーブだけコア1にするには以下のように setup1 を記述するだけでよい。

void setup() {
  Serial.begin(9600);
  delay(5000);
  Wire.setSDA(0);
  Wire.setSCL(1);
  Wire.setClock(5'000'000);
  Wire.begin();
}

void setup1() {
  delay(3000);
  Wire1.setSDA(2);
  Wire1.setSCL(3);
  Wire1.setClock(5'000'000);
  Wire1.begin(0x30);
  Wire1.onReceive(recv);
  Wire1.onRequest(req);
}

これにより、クロックストレッチが短くなることを観察できる。

ばらつきはあるものの、上に載せた波形ではクロックストレッチは 1.6μs まで短くなり、結果、送信全体は 10.4μs で完了している。

arduino-pico の場合、I2Cスレーブがクロックストレッチを発生させるのは以下の場合である。

  • 書き込みでマスタから 2バイト 以上を受信するとき
  • 読み込みのとき

すなわち、書き込み時でもマスタから 1バイト だけ送られてきたときはクロックストレッチを発生させない。また、スレーブ側の受信ハンドラ(プログラム中では recv 関数)が呼ばれるのは書き込みの通信が終わってからである。

ストップビットを送らずに通信続行

書き込み時はアドレスの指定→データの要求と2回の通信が行われている。この通信ごとにストップビットが送られているため I2C バスは都度解放される。今回は I2C バス上にマスタは1つだけ存在するので考慮の必要がないが、仮にこの通信の間に別のマスタがアドレスの指定を行うと取り出すアドレスを誤ってしまう。

そこで endTransmission 関数の引数に false を指定することで、ストップビットを送らずに通信を続けることができる。requestFrom 関数はデフォルトでストップビットを送るようになっているが、ここでは true(ストップビット送信)を明示する。

bool readI2C(uint8_t address, uint8_t *value) {
  Wire.beginTransmission(0x30);
  Wire.write(address);

  if (Wire.endTransmission(false) != 0) {
    return false;
  }

  if (Wire.requestFrom(0x30, 1, true) == 0) {
    return false;
  }

実際の通信は以下のようになる。

変化したのは2回目の書き込みの後、SCL が low、SDA が high になったままになったところである。これによりアドレス指定→データの要求がひとつづきの通信に変わる。この状態で仮に他のマスタが存在して通信を開始しようとしても、I2C バスが解放された状態(SCL = high, SDA = high)ではないため失敗する。

脚注
  1. NXP UM10204 I2C バス仕様およびユーザーマニュアル 10ページ, 3.1.6 アクノリッジ (ACK) とノッ ト ・アクノリッジ (NACK)より ↩︎

七瀬七瀬

(余談)Wire.available 関数を使う意味はあるのか

結論: arduino-pico においては、ない

Wire.available 関数 について以下のように説明されている。

This function returns the number of bytes available for retrieval with read(). This function should be called on a controller device after a call to requestFrom() or on a peripheral inside the onReceive() handler. available() inherits from the Stream utility class.

(参考訳) この関数は read() で取得可能なバイト数を返します。この関数は requestFrom() を呼び出した後にコントローラデバイスで呼び出すか、 ペリフェラルデバイスにおいて onReceive() (で設定した)ハンドラの内部で呼び出す必要があります。available() は Stream ユーティリティ・クラスを継承しています。

すなわち、マスタの requestFrom 関数の直後か、スレーブの受信ハンドラ関数の中において、read 関数を最大で何回呼べるのかを取得できるのがこの available 関数である。

上記のプログラムであえてこの関数を使うものとすると、以下のように記述できる。

master
bool readI2C(uint8_t address, uint8_t *value) {
  ...
  if (Wire.requestFrom(0x30, 1) == 0) {
    return false;
  }

  while (!Wire.available()) {};

  uint32_t return_value = Wire.read();
  ...
}
slave
void recv(int len) {
  if (Wire.available() == 1) {  // read
    reading         = true;
    reading_address = (uint8_t)Wire1.read();
  } else if (Wire.available() == 2) {  // write
    uint8_t address = (uint8_t)Wire1.read();
    data[address]   = (uint8_t)Wire1.read();
  }
}

しかしプログラム中で available 関数を使っていないのは、使う必要がない ためである。

まず、requestFrom 関数はスレーブ側に引数で指定した quantity 分のバイト数のデータを要求し、スレーブから返されたバイト数 を返り値としている。タイムアウトが発生すれば 0 が返る。すなわち、requestFrom 関数と available 関数の返り値は同一である。

Arduino公式のドキュメントでは言及がないが、少なくとも arduino-pico では requestFrom 関数はスレーブからの転送が終わるまでブロッキングされる。なぜならば内部で pico-sdk の i2c_read_blocking_until 関数が使われ、転送が終わるかタイムアウトしない限り制御が返らないためである。つまり requestFrom 関数から 0 が返される場合は何らかの理由で転送に失敗したときのみである。

よって、requestFrom 関数の直後に available 関数を使う必要はなく、以下のように記述すればよい。read 関数を呼び出す前に転送は確実に終わっている。

master
bool readI2C(uint8_t address, uint8_t *value) {
  ...
  if (Wire.requestFrom(0x30, 1) == 0) {
    return false;
  }

  uint32_t return_value = Wire.read();
  ...
}

一方のスレーブ側の受信ハンドラ関数の引数にはマスタ側から渡されたデータの長さ len が渡される。当然これも available 関数の返り値と同値であるので、わざわざ available 関数を呼び出す必要がない。

slave
void recv(int len) {
  if (len == 1) {  // read
    reading         = true;
    reading_address = (uint8_t)Wire1.read();
  } else if (len == 2) {  // write
    uint8_t address = (uint8_t)Wire1.read();
    data[address]   = (uint8_t)Wire1.read();
  }
}

ではなぜこんな関数があるのか

available 関数の説明に

available() inherits from the Stream utility class.

(参考訳) available() は Stream ユーティリティ・クラスを継承しています。

とあるように、Stream クラス を継承することによって同一のインタフェースを提供するためである。requestFrom 関数も onReceive 関数も Wire 独自の関数であり、これらは Stream クラスのインタフェースではない。あくまで Stream クラスのサブクラスのインスタンス的用法の一環として available 関数は使われるのであって、そうでない場合で available 関数を単独で使う意味は、少なくとも arduino-pico を使う上では存在しない。仮に呼び出しても無害であるが、同じ数値を2度計算しているため意味のない動作となる。

「arduino-pico を使う上では」と書いたのは他のプラットフォームやボードでは事情が異なる可能性があるからだ。ノンブロッキングな requestFrom 関数を実装しているかもしれないし、onReceive 関数で設定した受信ハンドラ関数は受信が完全に終わる前に呼び出されるかもしれない。

しかしたとえば Arduino UNO R3 に代表される AVR系Arduino ではそのような実装にはなっていない[1][2]。古い Arduino のスケッチはたくさん存在するが、requestFrom 関数の返り値を無視して available 関数が使われているものも見られる[3]

脚注
  1. Wire.requestFrom 関数 はブロッキング実装になっている。 ↩︎

  2. I2Cの受信ハンドラ は受信が完了したときに呼び出されている。つまり受信途中では決して呼び出されない ↩︎

  3. たとえば ここここここ ↩︎

このスクラップは2023/12/13にクローズされました