yoshiyuki's blog

Arduino/Teensy/Raspberry pi pico を利用した I2C, SPI 通信アプリを紹介します

Raspberry Pi Pico / SDK の I2C 関数を使ってみる

Raspberry Pi Pico を自作アプリで操作できる I2C ツールにするファームウェアを作成したのですが、この記事ではそれを作るまでに苦労した話を紹介します。

pico-sdk の I2C 関数

I2C ツールを作るには当然、I2C 関数が必要です。I2C 関数のような基本的なものは、これも当然、 Raspberry pi pico の開発環境である pico-sdk に含まれているのでそれを使ってみます。

pico-sdk に含まれる関数は公式ページの Documentation のところにある Raspberry Pi Pico C/C++ SDK という pdf にまとめられていて、I2C 関数に関しては hardware_i2c の節に解説があります。Write/Read の関数はいくつかあるのですが、ここでは以下の関数を使ってみます。

static int i2c_write_timeout_us (i2c_inst_t *i2c, uint8_t addr, const uint8_t *src, size_t len, bool nostop, uint timeout_us)
static int i2c_read_timeout_us (i2c_inst_t *i2c, uint8_t addr, uint8_t *dst, size_t len, bool nostop, uint timeout_us)

これらは Timeout を持っているのが特徴です。以前に Arduino で I2C ツールを作ろうとした際に Timeout が無くて苦労したので私にとっては Timeout は必須です。

pico-sdk の I2C関数を使ってみる

pico-sdk の関数を使ってコードを書いてみました。

プロジェクトフォルダ: pico-test

pico-test
 |-- pico_sdk.import.cmake
 |-- CMakeLists.txt
  -- try_03
      |-- try_03.c
       -- CMakeLists.txt

pico-test\CMakeLists.txt

cmake_minimum_required(VERSION 3.12)

# Pull in SDK (must be before project)
include(pico_sdk_import.cmake)

project(pico-test)
set(CMAKE_C_STANDARD 11)
set(CMAKE_CXX_STANDARD 17)

# Initialize the SDK
pico_sdk_init()

# Add subdirectory
add_subdirectory(try_03)

pico-test\try_03\try_03.c

#include <stdio.h>
#include "pico/stdlib.h"
#include "class/cdc/cdc_device.h"
#include "hardware/i2c.h"

#define SDA_PIN 20  // GP20 = Pin.26 = SDA
#define SCL_PIN 21  // GP21 = Pin.27 = SCL

int main() {
    const uint LED_PIN = PICO_DEFAULT_LED_PIN;

    uint8_t cntDAT;
    uint8_t getDAT[1];
    uint8_t inDAT[256];
    uint8_t outDAT[16];

    uint8_t byteADR;
    uint8_t byteWRITE[16];
    uint8_t byteREAD[16];

    int i2c_result;

    stdio_init_all();

    gpio_init(LED_PIN);
    gpio_set_dir(LED_PIN, GPIO_OUT);

    i2c_init(i2c0, 100000); // use i2c0 module, SCL = 100kHz
    gpio_set_function(SDA_PIN, GPIO_FUNC_I2C);  // set function of SDA_PIN=GP20 I2C
    gpio_set_function(SCL_PIN, GPIO_FUNC_I2C);  // set function of SCL_PIN=GP21 I2C
    gpio_set_pulls(SDA_PIN, true, false);   // enable internal pull-up of SDA_PIN=GP20
    gpio_set_pulls(SCL_PIN, true, false);   // enable internal pull-up of SCL_PIN=GP21

    while (true) {
        cntDAT = 0;

        while(tud_cdc_available() > 0){
            tud_cdc_read(getDAT, 1);
            inDAT[cntDAT] = getDAT[0];
            cntDAT++;
            busy_wait_us_32(10000);
        }

        if(cntDAT > 0){
            byteADR = 0x24;
            byteWRITE[0] = 0x00;
            byteWRITE[1] = inDAT[0] - '0';

            if((byteWRITE[1] > 0) && (byteWRITE[1] < 10)){
                i2c_result = i2c_write_timeout_us(i2c0, byteADR, byteWRITE, 2, false, 600);

                if(i2c_result == PICO_ERROR_GENERIC){
                    outDAT[0] = 'F';
                }
                else if(i2c_result == PICO_ERROR_TIMEOUT){
                    outDAT[0] = 'T';
                }
                else{
                    outDAT[0] = i2c_result + '0';
                }


            }
            else{
                i2c_result = i2c_write_timeout_us(i2c0, byteADR, byteWRITE, 1, false, 400);

                busy_wait_us_32(1000);

                if((i2c_result != PICO_ERROR_GENERIC) && (i2c_result != PICO_ERROR_TIMEOUT)){
                    i2c_result = i2c_read_timeout_us(i2c0, byteADR, byteREAD, 1, false, 400);
                }

                if(i2c_result == PICO_ERROR_GENERIC){
                    outDAT[0] = 'F';
                }
                else if(i2c_result == PICO_ERROR_TIMEOUT){
                    outDAT[0] = 'T';
                }
                else{
                    outDAT[0] = byteREAD[0] + '0';
                }
            }
            
            outDAT[1] = 0x0a;
            tud_cdc_write(outDAT, 2);
            tud_cdc_write_flush();            
        }
    }
}

pico-test\try_03\CMakeLists.txt

add_executable(try_03
    try_03.c
)

# Add pico_stdlib library which aggregates commonly used features
target_link_libraries(try_03 pico_stdlib hardware_i2c)

# enable usb output, disable uart output
pico_enable_stdio_usb(try_03 1)
pico_enable_stdio_uart(try_03 0)

# create map/bin/hex file etc.
pico_add_extra_outputs(try_03)

コードの流れですが、Raspberry pi pico を Arduiono IDE のシリアルモニタと接続することを想定して、シリアルモニタから 1 から 9 の数字を送ると Raspberry pi pico は受け取った数字を I2C で Slave device に書き込み、それ以外の文字を送ると Raspberry pi pico は Slave device のレジスタ値を読み出します。また、書き込み/読み出しを行った結果をシリアルモニタに返します。

USB serial 通信関係の関数については別の記事で解説しています。

I2C 関数について順に説明します。
まず、I2C 関数は i2c.h に含まれるので 、ヘッダファイルの include と CMakeLists.txt の target_link_libraries への追加が必要です。

    i2c_init(i2c0, 100000); // use i2c0 module, SCL = 100kHz

I2C HW block を有効にします。Raspberry pi pico は i2c0 と i2c1 の2個の I2C HW block を持っていて、ここでは i2c0 の方を有効にしています。ちなみに i2c0 と i2c1 とでは SCL/SDA として使用できる端子が異なる以外は特に違いはありません。
同時に i2c0 の伝送周波数を 100000 Hz = 100 kHz に設定しています。

#define SDA_PIN 20  // GP20 = Pin.26 = SDA
#define SCL_PIN 21  // GP21 = Pin.27 = SCL
    gpio_set_function(SDA_PIN, GPIO_FUNC_I2C);  // set function of SDA_PIN=GP20 I2C
    gpio_set_function(SCL_PIN, GPIO_FUNC_I2C);  // set function of SCL_PIN=GP21 I2C
    gpio_set_pulls(SDA_PIN, true, false);   // enable internal pull-up of SDA_PIN=GP20
    gpio_set_pulls(SCL_PIN, true, false);   // enable internal pull-up of SCL_PIN=GP21

これらの記述では SCL/SDA 端子を設定しています。
i2c_init で HW block として i2c0 を選択したので、SCL/SDA として使用できる端子は下の Raspberry pi pico の端子図のうち I2C0 SCL, I2C0 SDA がタグ付けされている端子となり、ここではそのうち Pin.27 = GP21 を SCL, Pin.26 = GP20 を SDA として使おうとしています。
gpio_set_function では指定した端子にどの機能を割り当てるかを設定しています。Raspberry pi pico の各端子は、1つの端子が複数の機能を持っていて、そのうちのどの機能を使うかを選べるようになっています。上の記述では GP20, GP21 の機能として I2C を選択しています。前述の通り GP20, GP21 の I2C としての機能は i2c0 の SDA, SCL で固定されているため、この関数では I2C と選択するだけで良く、i2c0, I2c1, SDA, SCL を個別に選択する必要はありません (選ぶこともできません)。
gpio_set_pulls では端子の内蔵 Pull-up/Pull-down を設定しています。対象端子に対して 2番目の引数で Pull-up, 3番目の引数で Pull-down の有効/無効を設定します。I2C の SCL/SDA には Pull-up が必要で、この後の実験環境には外部に Pull-up が無いため、この記述では内蔵 Pull-up を有効にしていますが、外部の Pull-up を使用する場合は 2番目の引数を false にすることで内蔵 Pull-up を無効にできます。

f:id:ysin1128:20210902143536p:plain

            byteADR = 0x24;
            byteWRITE[0] = 0x00;
            byteWRITE[1] = inDAT[0] - '0';
                i2c_result = i2c_write_timeout_us(i2c0, byteADR, byteWRITE, 2, false, 600);

これらの記述で I2C Write を実行しています。

主役は i2c_write_timeout_us です。
i2c0 は I2C HW の指定です。
byteADR は書き込みを行う相手の Slave address (7 bit) の指定で、ここでは後の実験の通信相手である 0x24 を指定しています。
byteWRITE は書き込むデータを格納した配列のポインタの指定です。ここでは書き込むデータの格納用に byteWRITE[16] を用意しているので、そのポインタである byteWRITE を引数に置いています。
2 は、byteWRITE のうち実際に I2C 通信で書き込むデータの長さを指定しています。byteWRITE[16] は 16-byte の配列として用意されていますが、実際に書き込むのは最初の 2-byte、つまり byteWRITE[0] と byteWRITE[1] だけとなります。ここでは後の実験用に byteWRITE[0] に 0、byteWRITE[1] に Serial通信から受け取った数字 (Ascii) を数値に変換したものを代入しています。
false は、この関数による I2C 通信が終わった後に Stop condition を生成して SCL/SDA を離すよう指示しています。ここを true にすると通信が終わった後も SCL/SDA を保持して、他のデバイスが通信を始められないようにします。他のデバイスに割り込ませずに連続して別の通信を行いたい場合は true に設定します。
600 は Timeout の時間の指定で、600 us を意味します。ここでは I2C の伝送周波数を 100 kHz に設定していて、1-byte の伝送時間をだいたい 100 kHz x 8 bit + alpha = 100 us として、Slave address + 2-byte のデータを伝送することから 100 us x 3 = 300 us が必要な通信時間として、その倍の 600 us を Timeout の時間としています。
戻り値は伝送したデータ数、または、Error コードとなります。書き込みに成功した場合、書き込むデータ量は 2-byte なので 2 という値が返ってきます。NACK の場合は PICO_ERROR_GENERIC、 Timeout になった場合は PICO_ERROR_TIMEOUT が返ってきます。

                i2c_result = i2c_write_timeout_us(i2c0, byteADR, byteWRITE, 1, false, 400);

                busy_wait_us_32(1000);

                if((i2c_result != PICO_ERROR_GENERIC) && (i2c_result != PICO_ERROR_TIMEOUT)){
                    i2c_result = i2c_read_timeout_us(i2c0, byteADR, byteREAD, 1, false, 400);
                }

これらの記述では I2C Read を実行しています。

最初の i2c_write_timeout_us では、続く Read で読み出すレジスタのアドレスを指定しています。各引数の意味は先の解説と同じなので省略します。

i2c_write_timeout_us の戻り値が Error コードでなかった場合は i2c_read_timeout_us を実行します。
i2c_read_timeout_us の引数のうち、i2c0, byteARD, false, 400 の意味は i2c_write_timeout_us と同じです。
byteREAD は読み出したデータを格納する配列のポインタの指定です。ここでは読み出したデータの格納用に byteREAD[16] を用意しているので、そのポインタである byteREAD を引数に置いています。
1 は読み出すデータ量の指定です。ここでは I2C 通信で 1-byte だけ読み出して、その値を byteREAD[0] に格納します。例としてここで 2 を指定すると読み出すデータ量は 2-byte になり、読み出したデータは byteREAD[0] と byteREAD[1] に格納されます。
戻り値は読み出したデータ数、または、Error コードとなります。Error コードの意味は i2c_write_timeout_us と同じです。

i2c_write_timeout_us と i2c_read_timeout_us の間の 1000 us の Wait はおまじないです。私の実験では、この Wait が 0 だと i2c_read_timeout_us が必ず PICO_ERROR_GENERIC を返してきました。100 us でも同じく駄目で、1000 us でやっと i2c_read_timeout_us が Error コード以外を返すようになったのでそれをそのまま残しています。実際にどれだけの Wait があれば十分なのか、または、他に何か要因があるのかは分かりません。
i2c_write_timeout_us と i2c_read_timeout_us の間には i2c_write_timeout_us で指示した I2C 通信が完了するのを待つための Wait が必要です。i2c_write_timeout_us 関数は I2C HW block に I2C 通信を指示した後、それが終わるのを待たずに関数を終了させます。そのため、関数が終わった時点では I2C 通信はまさにまだ実行中で、そこに Wait 無しで i2c_read_timeout_us 関数を開始してしまうと、この関数は最初に I2C HW block をリセットしてしまうので、それで通信がおかしくなってエラーになっていたようです。 Wait の適正時間は厳密には 1-2 byte 分の通信時間 + 処理時間ですが、余裕をもって 4-byte 分くらいにしておけば大丈夫だと思われます。(コードは原因が分かる前に書いたので 1000 us = 約10 byte分になっていますが)

動作確認

Build して出来た try_03.uf2 を Raspberry pi pico に書き込んで動作確認です。通信相手の Slave device には Arduino に以下の記事の Sketch を書き込んだものを使用しました。なお、Arduino の内蔵 Pull-up 電圧 は 5 V で Raspberry pi pico の動作電圧 3.3 V より大きいので内蔵 Pull-up 無効の方を使用します。

ysin1128.hatenablog.com

Raspberry pi pico と Arduino は下図のように接続します。また、Raspberry pi pico、Arduino ともに PC に接続します。 (Arduino を PC に接続するのは電源のためなので、接続先は PC で無くても構いません)

f:id:ysin1128:20210902175145p:plain

PC に接続後、Arduino IDERaspberry pi pico の COM番号を指定してシリアルモニタを立ち上げ、"1", "a", "2", "a", "5", "a" の順で1文字ずつ、文字を送った結果が以下になります。

f:id:ysin1128:20210902175340p:plain

"1", "2", "5" を送ると Raspberry pi pico からは "2" が返ってきてシリアルモニタに表示されました。"2" は I2C Write で書き込んだデータの数が 2-byte であることを示していて、これは記述で指定した通りの数です。また、Slave device となった Arduino は Register address = 0x00 に値が書き込まれると書き込まれた値の数だけ LED を点滅させるようになっているので、この LED の点滅によってシリアルモニタから送った数字が狙い通り Slave device の Register address = 0x00 に書き込まれていることを確認できました。

また、"a" に対して返ってきたのは I2C Read で読み出した Slave device の Register address = 0x00 の値なので、"1" を書き込んだ直後の "a" に対しては "1", "2" を書き込んだ直後の "a" に対しては "2"、というように、狙った Register の値が、その変更も含めて読み出せていることが確認できました。

ここまでで正常時の動作は確認できたので、次は異常時の動作を確認します。Slave device を外して Raspberry pi pico のみを PC と接続した状態で シリアルモニタから "1", "a" を1文字ずつ送った結果が以下になります。

f:id:ysin1128:20210902180643p:plain

Slave device が無いので NACK と判定され、I2C Write/Read の関数の戻り値は PICO_ERROR_GENERIC になり、その場合、シリアルモニタに戻ってくるのは "F" (Fail) の文字となるはずで、"a" に対しては狙い通り "F" が返ってきているのですが、 "1" に対しては何故か正常時と同じ "2" が返ってきています。I2C Read の関数はちゃんと NACK を判定してくれていますが、 I2C Write の関数は NACK でも構わずにデータを送信してその結果を返してきているようです。pico-sdk の関数をそのまま使っているだけなので関数のバグの可能性が考えられます。

もう一つ、異常時の動作の確認として、また Raspberry pi pico だけを PC と接続した状態で SCL/SDA をそれぞれ GND に短絡した状態でシリアルモニタから "1", "a" を1文字ずつ送った結果が以下になります。

f:id:ysin1128:20210902182020p:plain

SCL/SDA が GND に短絡していると Raspberry pi pico は他の誰かが I2C 通信中と判断して通信が終わるのを待つのですが、強制短絡しているだけなので待っても終わるはずは無く、その間に Timeout が来るので I2C Write/Read 関数は PICO_ERROR_TIMEOUT を返して通信を諦めます。その場合、シリアルモニタに戻ってくるのは "T" (Timeout) の文字となります。"1" に対しても "a" に対しても狙い通り "T" が返ってきているので、Timeout 処理が正常になされていることが確認できました。

以上のように、NACK の判定に少し疑問が残りましたが、 pico-sdk の I2C 関数の動作確認は概ね成功です。

NACK の判定がうまくいかなかった理由は次の記事で詳しく調べています。
ysin1128.hatenablog.com