Raspberry Pi PicoとArduino IDEでCAN通信

Featured image of the post

目次

備考

📄Arrow icon of a page linkRaspberry Pi PicoとArduino IDEでロボマスモーターを回す

以前この記事を書きました。旧題は「Raspberry Pi PicoとArduino IDEでCAN通信」つまりこの記事だったものです。

CAN通信と言いながらロボマスモーターを回すだけだったので、嘘ではないんですが不親切だったと思います。

今回は丁寧に書き直しました。

はじめに

ロボコン(高専および学ロボ)といったらCAN通信というのがロボコン部現役およびOBに囲まれて2年間過ごした印象です。

CANを使うことができればロボマスモーターを使うことができる、というのがまず大きなメリットとしてありますが、ロボマスモーターに限らずともCANは便利なので、ロボコン部に囲まれているうちに自分の手札にできて良かったと思っています。

RP2040でCAN通信して何かをする基板の自作は3つめになり、RP2040でロボマスとかDAMIAOとかRobStrideとかRollerCANとか回すライブラリを作っては遊んでいました。

CANが使える(I can CAN.)だけで趣味の工作でもできることや使えるアクチュエータが増えますし、手札はあればあるだけ良いものです。

こういうものは厳密に安全に扱うほかに、雑でもいいから手軽に使える手段もあることに価値があるという思想があるので、パッと読んだらすぐ動くくらいの手軽さでCAN通信を(趣味工作界隈の)市井に広めたいです。

CANのプロトコルについては下の記事がわかりやすかったです。

構成

MCU:Raspberry Pi Pico(RP2040)

CANコントローラー

PIOで代用するので使用しません。個人的にはCANコントローラーを使うほうが複雑で難しくて面倒だったのでPIOでやってます。

CANトランシーバ:SN65HVD230

SN65HVD230を使います。3.3V電源で動作するのが良いですね。

あとJLCPCBのPreferred (Extended) Partsになっているため、実質Basic Parts扱いなのが嬉しいです。(Extended Partsだと部品追加1種ごとに3ドルの手数料が掛かる)

1つあたりも安いので、マイコン搭載CAN接続モタドラをPCBAするのにもじゃんじゃん使えます。

Image in a image block

今回はAliExpressで買ったモジュールを使いました。

MCP2515+TJA1050のCAN Busモジュールと比べると小さくて良いですね。

Image in a image block

120Ωの終端抵抗が実装されています。

10kΩの抵抗はMode select pinをGNDにプルダウンしているものです。これでSlope Control Modeになるらしい。

Slope Control Mode

GND直結で選べるHigh-Speed Modeは出力の立ち上がりに制限を設けず、Slope Control Modeは立ち上がりを制御する。

10kΩだと約15V/μsのスルーレートになる。

急激な立ち上がりだと電波干渉や高調波の影響があるのでそれを低減するためのものらしい。

あって動くのならこのままで良さそう。

ボードマネージャー

Earle Philhower氏によるRaspberry Pi Pico/RP2040用のArduino coreであるArduino-Picoを使用します。

Arduino IDEの設定で追加のボードマネージャーのURLとして

https://github.com/earlephilhower/arduino-pico/releases/download/global/package_rp2040_index.json

を入れると表示されるようになります。

導入方法は下記ドキュメント参照。

ライブラリ

Arduino IDEからインストール可能です。

Image in a image block

RP2040はPIOが2個(ステートマシンが8個)で、RP2350はPIOが3個(ステートマシンが12個)あります。

RP2040でデュアルCANができました。RP2350ではトリプルCANができそうですが試してません。

接続

Raspberry Pi Pico CANトランシーバ
GP0 CTX
GP1 CRX
3V3 3V3
GND GND

これでCANH(CAN High)はCANH同士、CANL(CAN Low)はCANL同士で繋げばCAN通信の接続が完成します。

モジュール同士の通信であればそれぞれに終端抵抗があるので問題ないです。

CTX, CRXのピンは連続してなくてもいいです。重複するピンは流石に無理ですが。

Image in a image block

これはロボマスモーターを動かしたときの接続です。

詰まるポイント

RP2040系でCANを使ったときに、詰まった点は1つです。

PIOでCANを再現しているので、他のことにPIOを使っていて、PIOが重複するとクラッシュします。

例えば、PicoEncoder とCAN通信とを両立させるときに、シリアルすら受け付けなくなって毎回強制書き込みモードをするしかなくなる、みたいなことがありました。

PIO番号の重複を避けるため、CANじゃなくてCAN1を使うことで解決しました。

PIOはソフトウェアシリアルとかNeoPixelとかエンコーダとかI2Cとかで便利に使えるので、併用するときは気をつけたほうが良いと思います。

デモ1:送信のみ、受信のみ

CANの初期化がboolで返ってくるので成否判定を入れています。まずこれで通信ができるかを試しましょう。動かなかったらオシロスコープでCAN H, CAN Lの確認と、配線を疑います。

RP2040-Zeroを実装した基板で試したNeoPixelの色発光付きバージョン

送信

#include <RP2040PIO_CAN.h>
#include <Adafruit_NeoPixel.h>

// NeoPixelの指定
#define NUM_LEDS 1  // NeoPixelの数
#define LED_PIN 16  // NeoPixel信号接続端子 RP2040-Zero用
Adafruit_NeoPixel strip(NUM_LEDS, LED_PIN, NEO_GRB + NEO_KHZ800);

const uint32_t CAN_TX_PIN = 0;  // CANトランシーバ TXピン 連続してなくても良い
const uint32_t CAN_RX_PIN = 1;  // CANトランシーバ RXピン

void setup() {
  strip.begin();                                  // NeoPixel初期化
  strip.setPixelColor(0, strip.Color(32, 0, 0));  // 赤色を指定
  strip.show();                                   // 点灯

  Serial.begin(115200);
  while (!Serial && millis() < 3000);  // シリアルが初期化されるのを待つ(3秒まで)

  delay(100);

  CAN.setRX(CAN_RX_PIN);
  CAN.setTX(CAN_TX_PIN);
  if (!CAN.begin(CanBitRate::BR_1000k)) {
    Serial.println("CAN bus initialization failed!");
    while (1) {
      strip.setPixelColor(0, strip.Color(32, 0, 0));  // 赤点滅
      strip.show();
      delay(500);
      strip.setPixelColor(0, strip.Color(0, 0, 0));  // 消灯
      strip.show();
      delay(500);
    }
  }
  Serial.println("CAN bus initialized.");

  strip.setPixelColor(0, strip.Color(0, 32, 0));  // 緑色を指定
  strip.show();                                   // 点灯
}

void loop() {
  CanMsg msg;  // 8バイトのCANメッセージ構造体を作成(0埋めされている)

  // CANメッセージの内容を設定
  msg.id = CanStandardId(0x01);  // 送信IDを0x01に設定
  msg.data_length = 8;           // データ長を8に設定(8bit x 8 = 8bytes)
  msg.data[0] = 0x00;            // メッセージ1つは8bit
  msg.data[1] = 0x01;
  msg.data[2] = 0x02;
  msg.data[3] = 0x03;
  msg.data[4] = 0x04;
  msg.data[5] = 0x05;
  msg.data[6] = 0x06;
  msg.data[7] = 0x07;

  if (CAN.write(msg)) {
    Serial.print("CAN message sent!");
    Serial.print(" ID: 0x");
    char id_str[5];                                // 標準IDは最大11ビットなので、3桁の16進数 + null terminatorで十分
    sprintf(id_str, "%03X", msg.getStandardId());  // 16進数のゼロ埋めのためにsprintfを使用
    Serial.print(id_str);
    Serial.print(" Data: ");
    for (int i = 0; i < msg.data_length; i++) {
      char data_str[3];  // データ1バイトは最大0xFFなので、2桁の16進数 + null terminatorで十分
      sprintf(data_str, "%02X", msg.data[i]);
      Serial.print("0x");
      Serial.print(data_str);
      Serial.print(" ");
    }
    Serial.println();
  } else {
    Serial.println("Failed to send CAN message!");
  }

  static bool last_led_blue = false;  // 送信ごとにLEDの色を切り替える
  if (last_led_blue) {
    strip.setPixelColor(0, strip.Color(0, 32, 0));  // 緑色を指定
  } else {
    strip.setPixelColor(0, strip.Color(0, 0, 32));  // 青色を指定
  }
  last_led_blue = !last_led_blue;
  strip.show();

  delay(1000);
}

受信

#include <RP2040PIO_CAN.h>
#include <Adafruit_NeoPixel.h>

// NeoPixelの指定
#define NUM_LEDS 1  // NeoPixelの数
#define LED_PIN 16  // NeoPixel信号接続端子 RP2040-Zero用
Adafruit_NeoPixel strip(NUM_LEDS, LED_PIN, NEO_GRB + NEO_KHZ800);

const uint32_t CAN_TX_PIN = 0;  // CANトランシーバ TXピン 連続してなくても良い
const uint32_t CAN_RX_PIN = 1;  // CANトランシーバ RXピン

void setup() {
  strip.begin();                                  // NeoPixel初期化
  strip.setPixelColor(0, strip.Color(32, 0, 0));  // 赤色を指定
  strip.show();                                   // 点灯

  Serial.begin(115200);
  while (!Serial && millis() < 3000);  // シリアルが初期化されるのを待つ(3秒まで)

  delay(100);

  CAN.setRX(CAN_RX_PIN);
  CAN.setTX(CAN_TX_PIN);
  if (!CAN.begin(CanBitRate::BR_1000k)) {
    Serial.println("CAN bus initialization failed!");
    while (1) {
      strip.setPixelColor(0, strip.Color(32, 0, 0));  // 赤点滅
      strip.show();
      delay(500);
      strip.setPixelColor(0, strip.Color(0, 0, 0));  // 消灯
      strip.show();
      delay(500);
    }
  }
  Serial.println("CAN bus initialized.");

  strip.setPixelColor(0, strip.Color(0, 32, 0));  // 緑色を指定
  strip.show();                                   // 点灯
}

void loop() {
  while (CAN.available()) {
    CanMsg msg = CAN.read();  // 受信したCANメッセージを構造体に格納
    Serial.print("CAN message received!");

    Serial.print(" ID: 0x");
    char id_str[5];                                // 標準IDは最大11ビットなので、3桁の16進数 + null terminatorで十分
    sprintf(id_str, "%03X", msg.getStandardId());  // 16進数のゼロ埋めのためにsprintfを使用
    Serial.print(id_str);
    Serial.print(" Data: ");
    for (int i = 0; i < msg.data_length; i++) {
      char data_str[3];  // データ1バイトは最大0xFFなので、2桁の16進数 + null terminatorで十分
      sprintf(data_str, "%02X", msg.data[i]);
      Serial.print("0x");
      Serial.print(data_str);
      Serial.print(" ");
    }
    Serial.println();

    static bool last_led_blue = false;  // 受信ごとにLEDの色を切り替える
    if (last_led_blue) {
      strip.setPixelColor(0, strip.Color(0, 32, 0));  // 緑色を指定
    } else {
      strip.setPixelColor(0, strip.Color(0, 0, 32));  // 青色を指定
    }
    last_led_blue = !last_led_blue;
    strip.show();
  }
}

送信のみ

#include <RP2040PIO_CAN.h>

const uint32_t CAN_TX_PIN = 0;  // CANトランシーバ TXピン 連続してなくても良い
const uint32_t CAN_RX_PIN = 1;  // CANトランシーバ RXピン

void setup() {
  Serial.begin(115200);
  while (!Serial && millis() < 3000);  // シリアルが初期化されるのを待つ(3秒まで)

  delay(100);

  CAN.setRX(CAN_RX_PIN);
  CAN.setTX(CAN_TX_PIN);
  if (!CAN.begin(CanBitRate::BR_1000k)) {
    Serial.println("CAN bus initialization failed!");
    while (1);
  }
  Serial.println("CAN bus initialized.");
}

void loop() {
  CanMsg msg;  // 8バイトのCANメッセージ構造体を作成(0埋めされている)

  // CANメッセージの内容を設定
  msg.id = CanStandardId(0x01);  // 送信IDを0x01に設定
  msg.data_length = 8;           // データ長を8に設定(8bit x 8 = 8bytes)
  msg.data[0] = 0x00;            // メッセージ1つは8bit
  msg.data[1] = 0x01;
  msg.data[2] = 0x02;
  msg.data[3] = 0x03;
  msg.data[4] = 0x04;
  msg.data[5] = 0x05;
  msg.data[6] = 0x06;
  msg.data[7] = 0x07;

  if (CAN.write(msg)) {
    Serial.print("CAN message sent!");
    Serial.print(" ID: 0x");
    char id_str[5];                                // 標準IDは最大11ビットなので、3桁の16進数 + null terminatorで十分
    sprintf(id_str, "%03X", msg.getStandardId());  // 16進数のゼロ埋めのためにsprintfを使用
    Serial.print(id_str);
    Serial.print(" Data: ");
    for (int i = 0; i < msg.data_length; i++) {
      char data_str[3];  // データ1バイトは最大0xFFなので、2桁の16進数 + null terminatorで十分
      sprintf(data_str, "%02X", msg.data[i]);
      Serial.print("0x");
      Serial.print(data_str);
      Serial.print(" ");
    }
    Serial.println();
  } else {
    Serial.println("Failed to send CAN message!");
  }
  delay(1000);
}

受信のみ

#include <RP2040PIO_CAN.h>

const uint32_t CAN_TX_PIN = 0;  // CANトランシーバ TXピン 連続してなくても良い
const uint32_t CAN_RX_PIN = 1;  // CANトランシーバ RXピン

void setup() {
  Serial.begin(115200);
  while (!Serial && millis() < 3000);  // シリアルが初期化されるのを待つ(3秒まで)

  delay(100);

  CAN.setRX(CAN_RX_PIN);
  CAN.setTX(CAN_TX_PIN);
  if (!CAN.begin(CanBitRate::BR_1000k)) {
    Serial.println("CAN bus initialization failed!");
    while (1);
  }
  Serial.println("CAN bus initialized.");
}

void loop() {
  while (CAN.available()) {
    CanMsg msg = CAN.read();  // 受信したCANメッセージを構造体に格納
    Serial.print("CAN message received!");

    Serial.print(" ID: 0x");
    char id_str[5];                                // 標準IDは最大11ビットなので、3桁の16進数 + null terminatorで十分
    sprintf(id_str, "%03X", msg.getStandardId());  // 16進数のゼロ埋めのためにsprintfを使用
    Serial.print(id_str);
    Serial.print(" Data: ");
    for (int i = 0; i < msg.data_length; i++) {
      char data_str[3];  // データ1バイトは最大0xFFなので、2桁の16進数 + null terminatorで十分
      sprintf(data_str, "%02X", msg.data[i]);
      Serial.print("0x");
      Serial.print(data_str);
      Serial.print(" ");
    }
    Serial.println();
  }
}

デモ2:8bit以上の大きなデータを送る

さっきのデモ1で動けば動くはずなので、コードを削いで最小限にしました。

(クラシック)CANのメッセージは最大で8bitが8つです。8bitは0x00~0xFFで0~255なので、これだけで情報を送るには小さすぎます。

255よりも大きなデータを送るには、データを分割して複数のメッセージの枠を使って送信し、受信後に結合、という手順をとります。

今回はmillis()で32bitで表される時刻を4分割して8bit4枠で送信します。

リトルエンディアンです。

Image in a image block

送信(リトルエンディアン)

#include <RP2040PIO_CAN.h>

const uint32_t CAN_TX_PIN = 0;
const uint32_t CAN_RX_PIN = 1;

void setup() {
  Serial.begin(115200);

  CAN.setRX(CAN_RX_PIN);
  CAN.setTX(CAN_TX_PIN);
  CAN.begin(CanBitRate::BR_1000k);
}

void loop() {
  uint32_t now = millis();  // 32bit

  CanMsg msg;
  msg.id = CanStandardId(0x01);
  msg.data_length = 8;
  // 32bitを8bit x 4に分割して、リトルエンディアンで格納
  msg.data[0] = now >> 24;
  msg.data[1] = now >> 16;
  msg.data[2] = now >> 8;
  msg.data[3] = now;
  // 残りは0で埋まっているのでそのまま
  CAN.write(msg);

  Serial.println("Sent CAN message with timestamp: " + String(now) + " ms");
  delay(100);
}

受信(リトルエンディアン

#include <RP2040PIO_CAN.h>

const uint32_t CAN_TX_PIN = 0;
const uint32_t CAN_RX_PIN = 1;

void setup() {
  Serial.begin(115200);

  CAN.setRX(CAN_RX_PIN);
  CAN.setTX(CAN_TX_PIN);
  CAN.begin(CanBitRate::BR_1000k);
}

void loop() {
  while (CAN.available()) {
    CanMsg msg = CAN.read();

    Serial.print(" ID: 0x");
    char id_str[5];
    sprintf(id_str, "%03X", msg.getStandardId());
    Serial.print(id_str);
    Serial.print(" Data: ");
    for (int i = 0; i < msg.data_length; i++) {
      char data_str[3];
      sprintf(data_str, "%02X", msg.data[i]);
      Serial.print("0x");
      Serial.print(data_str);
      Serial.print(" ");
    }

    Serial.print("Received little-endian value: ");
    uint32_t value = static_cast<uint32_t>(msg.data[0] << 24)
                     | static_cast<uint32_t>(msg.data[1] << 16)
                     | static_cast<uint32_t>(msg.data[2] << 8)
                     | static_cast<uint32_t>(msg.data[3]);
    Serial.print(value);

    Serial.println();
  }
}

デモ3:IDのフィルタと送受信

一つのマイコンで送信と受信の両方を行うことは多いはずです。ロボマスモーターをフィードバック読んで使うのだってそうです。

CANバス上に流れるメッセージを全て読み込み、自分が出したものは弾いて読まない、というのをします。コードは共通ですが、書き込むときに冒頭のIDを適当な数字に書き換えて別の数字にしてください。

2つのマイコンが、互いに自分の時刻を送り続けます。リセットやUSBの抜き差しをすると、時刻がリセットされるのが分かると思います。

Image in a image block

送受信(リトルエンディアン)

#include <RP2040PIO_CAN.h>

#define DEVICE_ID 0x01  // 自分のIDは読まずに破棄するので、書き込むボードごとに番号を分ける

const uint32_t CAN_TX_PIN = 0;
const uint32_t CAN_RX_PIN = 1;

void setup() {
  Serial.begin(115200);

  CAN.setRX(CAN_RX_PIN);
  CAN.setTX(CAN_TX_PIN);
  CAN.begin(CanBitRate::BR_1000k);
}

void loop() {
  static uint32_t received_value = 0;

  // 受信処理
  while (CAN.available()) {  // 受信できるメッセージがあれば全部読む
    CanMsg rx_msg = CAN.read();

    if (rx_msg.getStandardId() != DEVICE_ID) {  // 自分以外のメッセージを読む
      received_value = static_cast<uint32_t>(rx_msg.data[0] << 24)
                       | static_cast<uint32_t>(rx_msg.data[1] << 16)
                       | static_cast<uint32_t>(rx_msg.data[2] << 8)
                       | static_cast<uint32_t>(rx_msg.data[3]);
    }
  }

  uint32_t now = millis();

  // 送信処理
  CanMsg tx_msg;
  tx_msg.id = CanStandardId(DEVICE_ID);
  tx_msg.data_length = 8;
  tx_msg.data[0] = now >> 24;
  tx_msg.data[1] = now >> 16;
  tx_msg.data[2] = now >> 8;
  tx_msg.data[3] = now;
  CAN.write(tx_msg);

  // 情報の表示
  Serial.print("Sent value: ");
  Serial.print(now);
  Serial.print(" Last received value: ");
  Serial.println(received_value);

  delay(100);
}