CANの受信は、ライブラリが8バイトを返すところまでやってくれます。詰まるのはその先です。返ってきた8バイトを自分のコードでどう持ち、どこから車速やギア段を取り出すのか。Arduinoを2台つないで、実際に動かしながら書いていきます。

この記事から学べる事

  • 受信フレームを入れる構造体の作り方
  • 8バイトに詰まった信号をBITマスクとBITシフトで取り出せる
  • バイト順の取り違えと受信の取りこぼしの回避方法について

機材 Arduino 2台+MCP2515 2速度 500kbpsデータ 8バイト検証 gcc+実機


だいき
現役の車載組み込みソフトエンジニア
車載マイコンのソフトを業務で書いています。CANは用語の多さで足止めされがちですが、アプリのコードから触るのはごく一部です。

この記事が扱うCANの範囲

1フレームのうち、コードに出てくるのは3つだけです。

使用するのはMCP2515というCANコントローラと、TJA1050というトランシーバが載ったモジュールです。フレームの形を決めているのはコントローラ側で、扱えるのはクラシカルCAN。1フレームのデータは0〜8バイト、IDは標準の11ビットと拡張の29ビットのどちらも使えます(MCP2515データシート)。トランシーバのほうは、その信号を実際の2本の線へ出す役です。

データ部が64バイトまで伸びるCAN FDには、この2つのICは対応していないため本記事では対象外としています。
CAN FDへ広げるときの設計は、記事の終わりで扱います。

バスを流れる1フレームは、こう並んでいます。

1フレームSOF1ID11RTR1制御2DLC4データ0〜64CRC16ACK2EOF7

自分のコードに出てくるコントローラが自動でやる
幅はビット数のおおよその比率。データ部の単位はビット(0〜8バイト)。

色の付いた3つだけが、これから書くコードに出てきます。

  • ID:そのフレームの名前です。宛先ではありません。CANは全ノードへ流し、受け取る側が自分に必要なIDだけ拾います。この記事では 0x123 を1つだけ流します
  • DLC:データが何バイト入っているか。CANはフレームごとに長さが変わるので、受信側にはこれが無いと何バイト読めばいいか分かりません
  • データ:中身です。ここに何を詰めるかはCANの規格の外で、プロジェクトごとの取り決めになります。この記事の主題は、まさにこの取り決めをコードにする話です

残る6つ(SOF・RTR・制御・CRC・ACK・EOF)は、コントローラが送信時に埋め、受信時に検査して捨てます。変数としてすらコードに出てきません。フレームが壊れていればコントローラが弾くので、あなたのコードへ届いた時点で中身は検査済みです。用語の一覧を全て覚えるのは大変なのでまずは色付きのところだけでも内容を覚えてください。

今回無視する6つのデータがそれぞれ何をしているかはCAN通信のフレーム構造とプロトコル|どこまでがCANで、どこからが違う規格かに書いています。車の中でCANが何をしているかは車載組込みソフトとは?仕事内容を現役エンジニアが解説で説明しています。

まず動かす|Arduino 2台でCAN通信する

バスに8バイトのデータを流します。データの中身はまだ見ません。

事前準備:2台を1本のバスでつなぐ

CANは独立したノードどうしをつなぐための規格です。そのためここでもArduinoを2台使い、それぞれにMCP2515モジュールを1枚ずつつなぎます。片方が送信ノード、もう片方が受信ノードです。

配線図。Uno R4とUno R3にMCP2515モジュールを1枚ずつ7本でつなぎ、2台の間をCANH・CANL・GNDの3本で結ぶ
図:2台とも同じ7本でモジュールにつなぎ、2台の間をH・L・GNDの3本で結ぶ

配線は2台ともまったく同じ7本です。チップセレクトを分ける必要も、線を分岐させる必要もないので、ブレッドボードは要りません。

Arduino側 モジュール側 役割
5V VCC 電源。3.3Vではないことに注意
GND GND 0V
D13 SCK SPIクロック
D12 SO モジュールからArduinoへ
D11 SI Arduinoからモジュールへ
D10 CS このモジュールを選ぶ
D2 INT 受信を知らせる

そして2台の間に3本渡します。モジュールのネジ端子台どうしで H と H、L と L。それに加えてGNDどうしも1本つなぎます。

この3本目が何をしている線なのかは、図にすると一目で分かります。

GNDをつながないと2台の0Vの高さがそろわずHとLがズレて見えるが、GNDを1本つなぐと同じ0Vを共有して差だけが残ることを並べた図
図:GNDが無いと2台の0Vがそろわない。1本渡すと同じ0Vを共有し、HとLの差だけが残る

CANはHとLの差で伝える信号です。ところが受信側は自分の0Vを基準にして測るので、2台の0Vがずれていると、差そのものが読めなくなります。2台を別々のUSBから給電している以上、この基準は自分でそろえるしかありません。車載ではボディアース全体がこの1本の役目をしていて、机の上ではそこが繋がっていないわけです。

厄介なのは、つながなくても動いてしまうことがある点。2台を同じPCのUSBに挿していると、PCの側でGNDが繋がるため、3本目を省いても通信できてしまいます。ただしズレがどれだけ出るかは給電のしかた次第で、ACアダプタやモバイルバッテリーに替えた途端に通らなくなる。最初からGNDを1本渡しておくのが確実です。

配線を終えると、こうなります。

実際に組んだところ。上がUno R4、下がUno R3で、それぞれにMCP2515モジュールをつなぎ、2枚のネジ端子台の間を赤と青の線で、2台のArduinoのGNDピンどうしを白い線で結んでいる
写真:上がUno R4(送信)、下がUno R3(受信)。モジュールどうしを結ぶ赤がH、青がL。左側でArduinoどうしを結んでいる白が3本目のGND

通信速度は500kbpsにしました。車載の高速CANでよく使われる値です。終端抵抗はモジュール基板に120Ωが載っているので、2枚を1対1でつなぐとちょうどバスの両端になります。

水晶が8MHzか16MHzかを先に見る
MCP2515モジュールには水晶が8MHzの版と16MHzの版が流通しています。基板に載っている水晶の刻印を読んで、ライブラリの初期化に同じ値を指定してください。ここを取り違えるとビットレートが計算どおりになりません。配線が正しくても1フレームも通らないので、原因が配線側にあると思い込んで長く探すことになります。

何を流すのかを先に決める

コードに入る前に、このバスに何を流すのかを決めておきます。CANフレームの「データ」に該当する箇所を定義します。

この記事では車速・ギア段・警告灯の3つを1本のフレームに詰めて、100ミリ秒ごとに流し続けることにしました。車の中では「関係する信号をまとめて1本のフレームに入れ、決まった周期で流し続ける」のが普通の形なので、それを最小の形で真似ています。値は車速62.0km/h・ギア段2・警告灯ONで固定です。

この3つを選んだのは、ビットの詰め方が3通りとも違うからです。車速は12ビットあってバイトをまたぎ、ギア段は2ビットで1バイトの中に収まり、警告灯は1ビットだけ。ビット幅と置き場所の型を3通り押さえておけば、たいていの信号はこの組み合わせで取り出せます。

スケッチは2つに分かれます。送信側は100ミリ秒ごとに1フレーム送るだけ、受信側は割り込みピンを見て受け取り、メインループでシリアルへ出します。ライブラリはcoryjfowler版のMCP_CANを使いました。初期化はどちらも同じ形です。

ここから先は説明のために必要なところだけを抜き出して載せます。2つのスケッチの全文は、最後のコピーして動かせるサンプルプログラムに置いてあります。先に手を動かしたい場合は、そちらを貼ってから戻ってきてください。

can_rx.ino ── 設定と初期化

#define MCP_CLOCK   MCP_8MHZ
#define CAN_SPEED   CAN_500KBPS   /* 車載の高速CANでよく使う値 */

#define PIN_CS      10   /* SPIのチップセレクト。このモジュールを選ぶ線 */
#define PIN_INT     2    /* MCP2515が「受信した」と教えてくる線(受信でLOWになる) */

MCP_CAN CAN(PIN_CS);     /* CSピンを教えてインスタンスを作る */
can_rx.ino ── 初期化(setup)

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

    /* 第1引数 MCP_ANY は「受け取るIDを絞らない」指定。
     * MCP2515は特定IDだけ通すフィルタを持つが、まずは全部受ける。 */
    if (CAN.begin(MCP_ANY, CAN_SPEED, MCP_CLOCK) != CAN_OK) {
        Serial.println(F("初期化に失敗しました。配線と水晶の設定を確認してください"));
        /* 重要: 初期化に失敗したまま先へ進ませない。
         *       通信できない状態で本処理を動かしても、
         *       原因の分からない誤動作になるだけ。 */
        while (1) {
            ;
        }
    }

    /* 重要: MCP2515は電源投入直後、設定変更モードに入っている。
     *       ここで通常モードへ移さないとバスに参加せず、
     *       初期化は成功しているのに1フレームも届かない。 */
    CAN.setMode(MCP_NORMAL);

    /* INTピンは入力。フレームが届くとMCP2515がここをLOWへ落とす */
    pinMode(PIN_INT, INPUT);

    Serial.println(F("受信ノード: CAN 500kbps で開始しました"));
    Serial.println();
}

初期化でやっているのは4つだけです。1行ずつ見ます。

この行 何をしているか 忘れる・間違えるとどうなるか
Serial.begin(115200) シリアルの速度を決める モニタ側を9200のままにすると文字化けする
CAN.begin(MCP_ANY, CAN_SPEED, MCP_CLOCK) 第1引数は受け取るIDを絞らない指定、第2引数がビットレート、第3引数がモジュールの水晶 第3引数が実物と違うと1フレームも通らない(配線が正しくても)
CAN.setMode(MCP_NORMAL) 設定変更モードから通常モードへ移す 初期化は成功しているのに1フレームも届かない
pinMode(PIN_INT, INPUT) 受信を知らせるINTピンを入力にする 受信の合図を読めず、SPIを空振りで叩き続ける

補足を3つ。

MCP_ANY は「フィルタをかけない」という指定です。MCP2515は特定のIDだけ通すフィルタを持っていますが、まずは全部受けます。

while (1) で止めているのは、初期化に失敗したまま先へ進ませないためです。通信できない状態で本処理を動かしても、原因の分からない誤動作になるだけです。

setMode は見落としやすい行です。MCP2515は電源投入直後、設定変更モードに入っています。ここで移さないとバスに参加しません。

受け取った値を、フレームの図と突き合わせる

ここがフレームの構造とコードが繋がる場所です。受信の本体はこれだけです。

can_rx.ino ── 受信(loop)

void loop()
{
    can_frame_t f;

    /* --- 受け取る側:届いていたら読み出してキューへ積むだけ --- */
    if (digitalRead(PIN_INT) == LOW) {   /* LOW = 未読のフレームがある */
        unsigned long rx_id = 0;                   /* ← フレームのID   が入る */
        byte          len   = 0;                   /* ← フレームのDLC  が入る */
        byte          buf[CAN_PAYLOAD_MAX] = {0};  /* ← フレームのデータが入る */

        /* readMsgBufが返すのはこの3つだけ。
         * SOF・RTR・制御・CRC・ACK・EOF はコントローラが検査して捨てた後で、
         * 変数としてすら出てこない。 */
        if (CAN.readMsgBuf(&rx_id, &len, buf) == CAN_OK) {
            memset(&f, 0, sizeof(f));   /* 前回の値が残らないよう先に0で埋める */

            /* このライブラリは拡張フレームのとき、返すIDの最上位ビット
             * (0x80000000) を立てて知らせてくる。IDは最大29bitなので
             * 上の3bitが空いているのを目印に使っている。
             * この判定を飛ばすと、拡張フレームのIDが 0x80000123 のように
             * 見えて、どのIDとも一致しなくなる。 */
            f.is_extended = (rx_id & 0x80000000UL) != 0;   /* 目印だけを見る */
            f.id          = rx_id & 0x1FFFFFFFUL;          /* 1が29個並んだマスク */

            /* DLCは受け取った値をそのまま信じない。
             * 配列の大きさを超えていたら丸める(バッファあふれの防止)。 */
            f.dlc = (len > CAN_PAYLOAD_MAX) ? CAN_PAYLOAD_MAX : len;
            memcpy(f.data, buf, f.dlc);

            can_queue_push(&g_rx_queue, &f);
        }
    }

    /* --- 処理する側:たまっているぶんをまとめて捌く --- */
    while (can_queue_pop(&g_rx_queue, &f)) {
        print_frame(&f);
    }
}

このうち肝になるのは readMsgBuf() の1行です。ここでフレームがC言語の変数に変わります。

readMsgBuf() が返してくるのは3つです。そして、その3つはさきほどの図で色を付けた3つと、そのまま1対1で対応します

フレームの中の場所 受け取る変数 中身
ID(11ビット) rx_id そのフレームの名前。今回は 0x123
DLC(4ビット) len データが何バイトか。今回は 8
データ(0〜8バイト) buf[] 中身そのもの。今回は 26 CA 00 …
SOF・RTR・制御・CRC・ACK・EOF 受け取らない コントローラが検査して捨てた後

つまりあなたのコードから見たCANのフレームは、この3つがすべてです。フレームの図で色が付いていなかった部分は、変数として現れることすらありません。

1つだけ、このライブラリ固有の作法があります。rx_idunsigned long(32ビット)で返ってきますが、拡張フォーマットのフレームだったときは最上位ビット(bit31)を1にして知らせてきます。IDそのものは最大29ビットなので、上の3ビットが空いているのを目印に使っているわけです。

そのため受け取り側では、rx_id & 0x80000000UL でその目印だけを見て拡張かどうかを判定し、rx_id & 0x1FFFFFFFUL で下位29ビットだけを残してIDの実体を取り出します。0x1FFFFFFF は2進で1が29個並んだ値で、これが「29ビットぶんのマスク」です。この判定を飛ばすと、拡張フレームが来たときにIDが 0x80000123 のような値に見えて、どのIDとも一致しなくなります。

受信の流れを、あらためて順に整理します。

  1. INTピンを見るフレームが届くとMCP2515がこのピンをLOWへ落とします。落ちていなければ何もしません。SPIを空振りで叩かずに済みます。
  2. 読み出すreadMsgBuf() がID・DLC・データの3つを返します。ここまでがライブラリの仕事です。
  3. 構造体へ詰め替えるバラバラの3つを can_frame_t という1つの型にまとめます(受信したCANフレームをC言語の構造体で持つで作ります)。このとき拡張フラグを外し、DLCを配列の大きさで丸めます。
  4. キューへ積むキューは届いたフレームを一時的にためておく入れ物です(受信を取りこぼさない入れ物で作ります)。ここではまだ何も表示しません。表示は時間がかかるので、受信の場では積むだけにします。
  5. たまったぶんをまとめて処理するwhile で空になるまで取り出し、シリアルへ出します。受け取る場所と処理する場所を分けたのがこの形です。

受信側のシリアルモニタを115200bpsで開くと、フレームが流れてきます。この記事では出力を3段にしました。生の8バイト、構造体に入れた後、取り出した値の順です。これから説明する変換が、そのまま上から下へ並んでいます。

シリアルモニタの出力。RAWに生の8バイト、FRAMEにIDとDLC、VALUEに車速62.0km/hとギア段2が並んでいる
図:受信したフレームを3段で出力したところ。RAWが生の8バイト、FRAMEが構造体、VALUEが取り出した値

受信したCANフレームをC言語の構造体で持つ

届いた8バイトを、置き場所に入れます。

受信APIから返ってくるのは、IDと長さとバイト配列がばらばらの変数です。これを持ち回ると、関数の引数が増えて渡し忘れが起きます。CANのフレームデータは、ID、DLC、データをまとめて1つの構造体にする。実務でもこの形です。

can_rx.ino ── フレームを入れる構造体

#define CAN_PAYLOAD_MAX 8   /* クラシカルCANのデータ部は最大8バイト */

/* 受信した1フレームの置き場所。
 * ID・DLC・データはバラバラの変数で返ってくるが、
 * 持ち回ると引数が増えて渡し忘れるので、1つにまとめる。 */
typedef struct {
    /* IDは標準フォーマットなら11bit、拡張フォーマットなら29bit。
     * 後から拡張が来ても型を変えずに済むよう、最初から32bitで持つ。 */
    uint32_t id;
    bool     is_extended;            /* 拡張フォーマットで届いたら true */
    uint8_t  dlc;                    /* データが何バイト入っているか(0〜8) */
    uint8_t  data[CAN_PAYLOAD_MAX];  /* 中身。長さを決め打ちで確保する */
} can_frame_t;
.ino では型定義を「最初の関数より前」に置く
Arduino IDEはコンパイル前に、最初の関数定義の直前へ全関数のプロトタイプを自動で差し込みます。そのため typedef がそれより後ろにあると、型が見つからず
error: variable or field '...' declared voiderror: '...' was not declared in this scope で落ちます。構造体の定義は、必ず最初の関数より前にまとめて置いてください。ファイルを分けないなら避けて通れない、.ino 特有の落とし穴です。

この構造体に、さきほどの3つの変数がどう入るかを図にしました。

バスを流れるフレームの9フィールドのうちID・DLC・データの3つがreadMsgBufの3変数になり、さらにマスクとコピーでcan_frame_tのid・is_extended・dlc・dataの4メンバへ入る流れを示した図
図:フレームの3フィールド → 受信APIの3変数 → 構造体の4メンバ。rx_id だけが2つに分かれる

入り口が3つなのに、構造体のメンバは4つあります。増えているのは is_extended で、これはrx_id の上位ビットに隠れていた情報を、独立したメンバとして取り出したものです。ライブラリの都合で1つの変数に混ざっているものを、自分のコードでは意味ごとに分けて持つ。構造体を挟む価値がいちばん出るところです。

決め方で迷いやすいのが id の型です。標準フォーマットなら11ビットなので uint16_t で足ります。ただ実務のバスでは標準と拡張が混ざります。あとから拡張フレームが来て型を変えると、その構造体を使っている箇所を全部直すはめになる。最初から uint32_t で持ち、どちらだったかを is_extended に残しておくほうが安全です。

data を配列にしているのは、8バイトの中身が用途ごとに違うからです。通信データの受信バッファは基本的に配列で持ちます。フレームの構造体とペイロードの配列は、いつもこの形でセットになります。

構造体の書き方そのものから固めたいときはC言語 構造体の練習問題7問|メンバ・typedefまで固めるへ。配列側はC言語 配列の練習問題7問|添字・2次元配列まで固めるです。先に手を動かしてから戻ってきてください。

8バイトから信号を取り出す

置き場所の8バイトから、意味のある値を取り出します。

data[0] から data[7] までを手に入れても、それはまだ数値の並びでしかありません。1つのフレームには複数の信号がビット単位で詰め込まれています。バイトの区切りとも一致しません。

どのビットに何が入っているか

車速・ギア段・警告灯の3つを、8バイトのどこに置いたかを決めた表がこれです。この表が、送る側と受け取る側の唯一の約束になります。

8バイトのうちdata[0]の8ビット全部とdata[1]の上位4ビットが車速、bit3〜2がギア段、bit1が警告灯、bit0が予約で、data[2]以降は未使用であることを色分けで示した図
図:8バイトのどこに、どの信号が入っているか。数字は実際に届いた data[0] = 0x26data[1] = 0xCA の中身

車速はByte 0の8ビット全部と、Byte 1の上位4ビットをつないで12ビットです。生の値に0.1を掛けるとkm/hになります。分解能を粗くしてビット数を節約するのは、8バイトしかないフレームでは普通の設計です。

予約ビットは、いまは使っていないというだけで勝手に使ってよいものではありません。あとから別の信号が入る場所として空けてあります。

マスクとシフトで切り出す

取り出しに使うのは、マスクとシフトだけです。欲しいビットを右端まで下ろして、いらない上位ビットをマスクで落とす。手順としてはこの2つしかありません。

can_rx.ino ── 8バイトから値を取り出す

#define SPEED_RESOLUTION 0.1f

/* 車速(12bit)を組み立てる。バイトをまたぐ信号はこの形になる。
 *   上位側(data[0])を左シフトして下に空きを作り、
 *   下位側(data[1])を右シフトして下端まで下ろし、| で重ねる。
 *
 * 重要: (uint16_t) のキャストを外すと壊れる。
 *       data[0]は8bit変数なので、そのまま4bit左へずらすと上位4bitが消える。
 *       先に16bitへ広げてからずらすこと。 */
static uint16_t can_get_speed_raw(const can_frame_t *f)
{
    return (uint16_t)(((uint16_t)f->data[0] << 4) | (f->data[1] >> 4));
}

/* 生の値に分解能を掛けて、はじめて物理値(km/h)になる */
static float can_get_speed_kmh(const can_frame_t *f)
{
    return (float)can_get_speed_raw(f) * SPEED_RESOLUTION;
}

/* ギア段(Byte1のbit3..2)。
 *   >> 2   欲しいビットの一番下(bit2)の位置ぶん右へずらして端に寄せる
 *   & 0x03 残したい2bitぶんの1でマスクし、上位を落とす */
static uint8_t can_get_gear(const can_frame_t *f)
{
    return (uint8_t)((f->data[1] >> 2) & 0x03);
}

/* 警告灯(Byte1のbit1)。1bitなので >> 1 して & 0x01 */
static bool can_get_warning(const can_frame_t *f)
{
    return ((f->data[1] >> 1) & 0x01) != 0;
}

ただし、どのビットをどれだけ動かすかは信号ごとに違います。実際に届いた data[0] = 0x26data[1] = 0xCA で、3つの信号すべてを絵にしました。

車速はシフトして重ね、ギア段は右へ2つずらしてマスク、警告灯は右へ1つずらしてマスクする手順を、ティール・琥珀・黒の3色のパネルに分けて並べた図
図:どのビットを、どう動かして、どの値にするか。3つの信号それぞれの取り出し方

図の3つを、順に言葉にします。

ずらす数は「欲しいビットの一番下の位置」です。ギア段の下端はbit2なので >> 2、警告灯の下端はbit1なので >> 1ここを間違えると、隣の信号を読んでしまいます。

マスクの値は「残したいビット数ぶんの1」です。ギア段は2ビットなので 0x030b11)、警告灯は1ビットなので 0x01。3ビットなら 0x07、4ビットなら 0x0F になります。右へ寄せただけでは左側に上位のビットが残っているので、これで落とします。

車速だけは2つのバイトにまたがるので、もう一手要ります。data[0] を左へ4つ動かして下に空きを作り、data[1] を右へ4つ動かして下端まで下ろし、| で重ねる。図の左の列がその3手です。ビット数が変わっても、ずらす量が変わるだけで形は同じです。

1点だけ注意。(uint16_t) のキャストを外すと壊れます。data[0] は8ビットの変数なので、そのまま4ビット左へずらすと上位4ビットがはみ出して消える。先に16ビットへ広げてからずらす必要があります。

そしてこの12ビットは「生の値」であって、まだkm/hではありません。620という数字に 0.1 を掛けて初めて62.0km/hになります。この0.1が分解能で、送る側と受け取る側で同じ値を約束しておくものです。

値を変えて試す

シリアルモニタの VALUE 行に出ていた speed=62.0 km/h gear=2 warn=ON は、いま追った計算の結果そのものです。

手を動かして確かめるなら、送信側の tx[1] を書き換えてもう一度書き込んでみてください。受信側は1行も変えなくて構いません。

tx[1] 2進で 受信側の表示 何が変わったか
0xCA(元) 1100 1010 speed=62.0 gear=2 warn=ON (基準)
0xCE 1100 1110 speed=62.0 gear=3 warn=ON bit3〜2 を 11 にした
0xC8 1100 1000 speed=62.0 gear=2 warn=OFF bit1 を0にした
0xC2 1100 0010 speed=62.0 gear=0 warn=ON bit3〜2 を 00 にした

車速はどれも62.0のままです。上位4ビット(1100)を触っていないからで、信号がビット単位で独立していることが目で見えます。車速のほうを変えるなら tx[0] を触ります(0x130x6A で31.0km/h)。

この4通りは、記事と同じ切り出し関数をPC上のgcc 13.2.0でコンパイルして実行し、表の値になることを確認しています。

ビット演算そのものの練習はC言語 ビット演算の練習問題7問|マスク・シフトを図解で固めるにまとめてあります。

エンディアン(バイトオーダー)を取り違えるとどうなるか

車速のように2バイトにまたがる信号を見ると、uint16_t にまとめて読みたくなります。ところが素直にキャストすると壊れます。

やってはいけない書き方

uint16_t v;
memcpy(&v, &f->data[0], sizeof(v));   /* ポインタのキャストでも同じ */
uint16_t speed = v >> 4;               /* 期待は 620 */

同じデータで両方を実行して比べました。正しく取り出すと620、そのままキャストすると3234になります。値が化けるだけでエラーにはならないので、走らせるまで気づきません。

原因はバイトの並び順です。CANのフレームは上位バイトが先に来る並びで設計されることが多く、一方でx86やAVRのメモリは下位バイトが先に置かれます。data[0]data[1] をそのまま16ビットとして読むと、上下が入れ替わります。

この並びには名前が付いていて、上位が先ならMotorola形式、下位が先ならIntel形式と呼びます。信号の割り当て表をもらって実装するときは、どちらの形式かを最初に確かめてください。ここを読み違えると、バイトをまたぐ信号だけが化けます。バイトをまたぐ信号は、面倒でもシフトとマスクで組み立ててください。キャストは環境が変わった瞬間に意味が変わります。

現場でここは危ないと見ている場所
ここで注意したいのが、エンディアンの取り違えとバッファサイズのあふれです。どちらもコンパイルは通り、テストでもたまたま通ってしまうことがあります。それでいて出る症状は「値がおかしい」「たまに取りこぼす」で、原因にたどり着くまでが長い。レビューで必ず見る2か所です。

受信を取りこぼさない入れ物

8バイトが届く速さに、処理が追いつかないときの備えです。

CANのフレームは、こちらの都合とは関係なく届きます。受信は割り込みで受けて、実際の処理はメインループでやる形が基本です。そのため受け取る瞬間と処理する瞬間はずれます

このずれを吸収するのが受信バッファです。履歴が必要なデータは固定長の配列で持ち、いっぱいになったら古いものから上書きするリングバッファにします。

can_rx.ino ── リングバッファ

#define CAN_RX_QUEUE_LEN 8   /* 何件ためられるか。RAMと相談して決める */

typedef struct {
    can_frame_t buf[CAN_RX_QUEUE_LEN];
    uint8_t     head;      /* 次に「書く」位置 */
    uint8_t     tail;      /* 次に「読む」位置 */
    uint8_t     count;     /* いま入っている数。headとtailだけでは空と満杯を区別できない */
    uint16_t    dropped;   /* あふれて捨てた数。これが無いと取りこぼしに気づけない */
} can_rx_queue_t;
can_rx.ino ── リングバッファの操作

static can_rx_queue_t g_rx_queue;   /* 実体。static初期化で全メンバ0になる */

/* 1件書き込む。満杯なら「いちばん古いものを捨てて」新しいものを入れる。
 * 車速のように最新値だけが意味を持つ信号ではこれでよい。
 * 1件も落とせない用途なら、捨てずに失敗を返す作りにする。 */
static void can_queue_push(can_rx_queue_t *q, const can_frame_t *f)
{
    if (q->count == CAN_RX_QUEUE_LEN) {
        /* 満杯。読む位置を1つ進めて、いちばん古い1件を切り捨てる */
        q->tail = (uint8_t)((q->tail + 1) % CAN_RX_QUEUE_LEN);
        q->count--;
        q->dropped++;   /* 捨てた事実は必ず残す */
    }
    q->buf[q->head] = *f;
    /* 端(7)まで来たら (7+1) % 8 = 0 で先頭へ戻る。これが「リング」の由来 */
    q->head = (uint8_t)((q->head + 1) % CAN_RX_QUEUE_LEN);
    q->count++;
}

/* 1件取り出す。空なら false を返す(呼び側の while の終了条件になる) */
static bool can_queue_pop(can_rx_queue_t *q, can_frame_t *out)
{
    if (q->count == 0) {
        return false;
    }
    *out = q->buf[q->tail];
    q->tail = (uint8_t)((q->tail + 1) % CAN_RX_QUEUE_LEN);
    q->count--;
    return true;
}

動きを追うと単純です。書く位置(head)と読む位置(tail)を別々に持ち、それぞれ独立に前へ進みます。

長さ8のリングバッファが回るところ

添字            0   1   2   3   4   5   6   7
最初          [   ][   ][   ][   ][   ][   ][   ][   ]
               ↑head ↑tail                        count=0

3件届いた      [ A ][ B ][ C ][   ][   ][   ][   ][   ]
                ↑tail        ↑head                count=3

1件処理した    [ - ][ B ][ C ][   ][   ][   ][   ][   ]
                     ↑tail   ↑head                count=2

端まで来たら   head=7 → (7+1) % 8 → head=0  ← 先頭へ戻る

端まで来たら % CAN_RX_QUEUE_LEN で0へ戻ります。これが「リング」と呼ばれる理由で、配列の最後と最初が繋がっているものとして扱います。確保し直さないので、あとから置き場所を増やす必要がありません。

count を別に持っているのは、headtail だけでは「空」と「満杯」が区別できないからです。どちらの状態でも2つの位置が重なります。数を数えておけば、0 なら空、8 なら満杯とはっきりします。

満杯のときの振る舞いは設計で決めるところです。このコードではいちばん古いものを捨てて新しいものを入れています(tail を1つ進めてから書く)。車速のように最新値だけが意味を持つ信号ではこれが正解です。逆に、1件も落とせないログのような用途なら、捨てずに「入らなかった」と返す作りにします。どちらが正しいかはデータの性質で決まります。

そのうえで入れておきたいのが dropped です。あふれて捨てた回数を数えるだけの変数ですが、これがないと取りこぼしが起きていること自体に気づけません。デバッグのときにこの値を見て、0以外ならバッファの長さか処理の重さを疑います。捨てる設計にするなら、捨てた事実は必ず残してください。

CAN FDに移るとき、この構造体はどう伸ばすか

8バイトが64バイトになったら、この設計はどうなるか。
この章だけ実機で確認していません
今回使ったMCP2515とTJA1050はCAN FDに対応していないため、この節の内容は仕様と型定義の確認までです。実機で動かした結果ではありません。

CAN FDになると、データ部が最大64バイトまで伸びます。ここで問題になるのがDLCの意味が変わることです。DLCは4ビットのままなので、9から15までの値に飛び飛びの長さを割り当てています。

DLCの値 実際のデータ長
0〜8 そのまま0〜8バイト
9 12バイト
10 16バイト
11 20バイト
12 24バイト
13 32バイト
14 48バイト
15 64バイト

出典はMicrochip「CAN FDコントローラモジュール」DS20005678B_JP 表1-1。同じ表には、クラシカルCANならDLCが9〜15でも長さは8バイトとも書かれています。

つまり dlc をそのまま長さとして使っているコードは、ここで壊れる。DLCと実際のバイト数は別物になるので、変換表を1つ挟む必要があります。

構造体側の変更は、思ったより小さく済みます。

CAN FDまで見た構造体

#define CAN_PAYLOAD_MAX 64        /* 8 から広げる */

typedef struct {
    uint32_t id;
    bool     is_extended;
    bool     is_fd;                  /* FDフレームなら true */
    bool     brs;                    /* データ部だけ高速にするか */
    uint8_t  dlc;                    /* 0〜15。長さそのものではない */
    uint8_t  len;                    /* DLCから引いた実際のバイト数 */
    uint8_t  data[CAN_PAYLOAD_MAX];
} can_frame_t;

最初から iduint32_t にして、長さを dlc という名前で持っておいたおかげで、足すのはフラグ2つと len だけで済みます。逆に uint16_t iduint8_t data[8] で作っていたら、この構造体を使っている箇所をすべて見直すことになります。

いま8バイトしか扱わないとしても、IDは32ビットで持ち、長さとバイト数を分けて考えておく。それだけで、あとで移るときの手間がだいぶ変わります。

コピーして動かせるサンプルプログラム

ここまでの部品を1つの .ino にまとめたものです。

ここまで見てきたものを、そのままArduino IDEへ貼って動く形で置いておきます。ファイルは分けません。can_rx.inocan_tx.ino の2つを、それぞれのボードへ書き込むだけです。

まず受信側。この記事で説明した構造体・切り出し関数・リングバッファが、すべてこの1ファイルに入っています。長いので折りたたんであります。枠の下の「全文を表示」で開き、右上の「コピー」でまるごと持っていけます。

can_rx.ino(受信側の全文)

/*
 * can_rx.ino ---- CAN通信 受信側のサンプルプログラム(Arduino Uno + MCP2515)
 * ============================================================
 * 受信した1フレームが「C言語の値」になるまでを、3段階そのままの順で出す。
 *
 *   RAW   : 受信した生の8バイト(まだ意味を持たない数値の並び)
 *   FRAME : 構造体に入れた後(ID / 拡張フォーマットか / DLC)
 *   VALUE : マスクとシフトで取り出した物理値(車速・ギア段・警告灯)
 *
 * 配線(このボードには MCP2515モジュールを1枚だけつなぐ)
 *   VCC -> 5V      GND -> GND      ※VCCは3.3Vではない。TJA1050は5V動作
 *   SCK -> D13     SO  -> D12      SI -> D11    ※SIとSOの取り違えに注意
 *   CS  -> D10     INT -> D2
 *   CANH/CANL -> もう1台のモジュールの CANH/CANL へ(ネジ端子台)
 *   重要: 2台のArduinoの GND どうしも1本つなぐ。
 *         CANは差動信号だが、両ノードの0Vがずれていると差が読めない。
 *
 * シリアルモニタは 115200 baud で開く(9600のままだと文字化けする)。
 *
 * 動作確認済み: 2026-08-14 / UNO R4 送信・UNO R3 受信 / MCP2515 8MHz / 500kbps
 */

#include <SPI.h>        /* MCP2515とはSPIで話す。ライブラリが内部で使う */
#include <mcp_can.h>    /* coryjfowler / MCP_CAN_lib(IDEのライブラリマネージャで mcp_can) */

/* ============================================================
 * 1. 環境に合わせて変えるのはここだけ
 * ============================================================ */

/* 重要: モジュール基板に載っている水晶(銀色の部品)の刻印と必ず合わせる。
 *       8.000 なら MCP_8MHZ、16.000 なら MCP_16MHZ。
 *       ここが実物と違うとビットレートの計算がずれ、
 *       配線が完璧でも1フレームも通らない。最も多い詰まりどころ。 */
#define MCP_CLOCK   MCP_8MHZ
#define CAN_SPEED   CAN_500KBPS   /* 車載の高速CANでよく使う値 */

#define PIN_CS      10   /* SPIのチップセレクト。このモジュールを選ぶ線 */
#define PIN_INT     2    /* MCP2515が「受信した」と教えてくる線(受信でLOWになる) */

MCP_CAN CAN(PIN_CS);     /* CSピンを教えてインスタンスを作る */

/* ============================================================
 * 2. 型の定義
 *
 * 重要: .ino では構造体の typedef を「最初の関数より前」に置く。
 *       Arduino IDEはコンパイル前に、最初の関数定義の直前へ
 *       全関数のプロトタイプを自動で差し込む。型がそれより後ろにあると
 *         error: variable or field '...' declared void
 *         error: '...' was not declared in this scope
 *       で落ちる。ファイルを分けない .ino 特有の落とし穴。
 * ============================================================ */

#define CAN_PAYLOAD_MAX 8   /* クラシカルCANのデータ部は最大8バイト */

/* 受信した1フレームの置き場所。
 * ID・DLC・データはバラバラの変数で返ってくるが、
 * 持ち回ると引数が増えて渡し忘れるので、1つにまとめる。 */
typedef struct {
    /* IDは標準フォーマットなら11bit、拡張フォーマットなら29bit。
     * 後から拡張が来ても型を変えずに済むよう、最初から32bitで持つ。 */
    uint32_t id;
    bool     is_extended;            /* 拡張フォーマットで届いたら true */
    uint8_t  dlc;                    /* データが何バイト入っているか(0〜8) */
    uint8_t  data[CAN_PAYLOAD_MAX];  /* 中身。長さを決め打ちで確保する */
} can_frame_t;

/* 受信をためるリングバッファ。
 * フレームはこちらの都合と関係なく届くので、
 * 「受け取る瞬間」と「処理する瞬間」のずれをここで吸収する。 */
#define CAN_RX_QUEUE_LEN 8   /* 何件ためられるか。RAMと相談して決める */

typedef struct {
    can_frame_t buf[CAN_RX_QUEUE_LEN];
    uint8_t     head;      /* 次に「書く」位置 */
    uint8_t     tail;      /* 次に「読む」位置 */
    uint8_t     count;     /* いま入っている数。headとtailだけでは空と満杯を区別できない */
    uint16_t    dropped;   /* あふれて捨てた数。これが無いと取りこぼしに気づけない */
} can_rx_queue_t;

/* ============================================================
 * 3. 8バイトの中の信号レイアウト(このプロジェクトの取り決め)
 *
 * ここに何を詰めるかはCANの規格の外。送る側と受け取る側で
 * 同じ表を共有していないと、値は永遠に一致しない。
 *
 *   Byte0 bit7..0 : 車速の上位8bit
 *   Byte1 bit7..4 : 車速の下位4bit   ← 合計12bit・0.1km/h刻み
 *   Byte1 bit3..2 : ギア段(0〜3)
 *   Byte1 bit1    : 警告灯(0=OFF / 1=ON)
 *   Byte1 bit0    : 予約(使っていないが、勝手に使わない)
 * ============================================================ */

/* 分解能。生の値1カウントが何km/hにあたるか。
 * 12bitで0〜409.5km/hを表せる。分解能を粗くしてビットを節約するのは
 * 8バイトしかないフレームでは普通の設計。 */
#define SPEED_RESOLUTION 0.1f

/* 車速(12bit)を組み立てる。バイトをまたぐ信号はこの形になる。
 *   上位側(data[0])を左シフトして下に空きを作り、
 *   下位側(data[1])を右シフトして下端まで下ろし、| で重ねる。
 *
 * 重要: (uint16_t) のキャストを外すと壊れる。
 *       data[0]は8bit変数なので、そのまま4bit左へずらすと上位4bitが消える。
 *       先に16bitへ広げてからずらすこと。 */
static uint16_t can_get_speed_raw(const can_frame_t *f)
{
    return (uint16_t)(((uint16_t)f->data[0] << 4) | (f->data[1] >> 4));
}

/* 生の値に分解能を掛けて、はじめて物理値(km/h)になる */
static float can_get_speed_kmh(const can_frame_t *f)
{
    return (float)can_get_speed_raw(f) * SPEED_RESOLUTION;
}

/* ギア段(Byte1のbit3..2)。
 *   >> 2   欲しいビットの一番下(bit2)の位置ぶん右へずらして端に寄せる
 *   & 0x03 残したい2bitぶんの1でマスクし、上位を落とす */
static uint8_t can_get_gear(const can_frame_t *f)
{
    return (uint8_t)((f->data[1] >> 2) & 0x03);
}

/* 警告灯(Byte1のbit1)。1bitなので >> 1 して & 0x01 */
static bool can_get_warning(const can_frame_t *f)
{
    return ((f->data[1] >> 1) & 0x01) != 0;
}

/* ============================================================
 * 4. リングバッファの操作
 * ============================================================ */

static can_rx_queue_t g_rx_queue;   /* 実体。static初期化で全メンバ0になる */

/* 1件書き込む。満杯なら「いちばん古いものを捨てて」新しいものを入れる。
 * 車速のように最新値だけが意味を持つ信号ではこれでよい。
 * 1件も落とせない用途なら、捨てずに失敗を返す作りにする。 */
static void can_queue_push(can_rx_queue_t *q, const can_frame_t *f)
{
    if (q->count == CAN_RX_QUEUE_LEN) {
        /* 満杯。読む位置を1つ進めて、いちばん古い1件を切り捨てる */
        q->tail = (uint8_t)((q->tail + 1) % CAN_RX_QUEUE_LEN);
        q->count--;
        q->dropped++;   /* 捨てた事実は必ず残す */
    }
    q->buf[q->head] = *f;
    /* 端(7)まで来たら (7+1) % 8 = 0 で先頭へ戻る。これが「リング」の由来 */
    q->head = (uint8_t)((q->head + 1) % CAN_RX_QUEUE_LEN);
    q->count++;
}

/* 1件取り出す。空なら false を返す(呼び側の while の終了条件になる) */
static bool can_queue_pop(can_rx_queue_t *q, can_frame_t *out)
{
    if (q->count == 0) {
        return false;
    }
    *out = q->buf[q->tail];
    q->tail = (uint8_t)((q->tail + 1) % CAN_RX_QUEUE_LEN);
    q->count--;
    return true;
}

/* ============================================================
 * 5. 3段ダンプ(変換の途中経過をそのまま見せる)
 * ============================================================ */

static void print_frame(const can_frame_t *f)
{
    char line[64];   /* 固定長。sprintf先のサイズは必ず自分で決める */

    /* RAW: 届いた生のバイト列。まだ意味を持たない数値の並び */
    Serial.print(F("RAW   : "));   /* F() で文字列をRAMでなくFlashへ置く */
    for (uint8_t i = 0; i < f->dlc; i++) {
        snprintf(line, sizeof(line), "%02X ", f->data[i]);
        Serial.print(line);
    }
    Serial.println();

    /* FRAME: 構造体に入れた後。ここまでがライブラリから受け取った情報 */
    snprintf(line, sizeof(line), "FRAME : id=0x%03lX ext=%d dlc=%u",
             (unsigned long)f->id, (int)f->is_extended, f->dlc);
    Serial.println(line);

    /* VALUE: マスクとシフトで取り出した物理値。ここが最終目的地 */
    Serial.print(F("VALUE : speed="));
    Serial.print(can_get_speed_kmh(f), 1);   /* 第2引数は小数点以下の桁数 */
    Serial.print(F(" km/h  gear="));
    Serial.print(can_get_gear(f));
    Serial.print(F("  warn="));
    Serial.println(can_get_warning(f) ? F("ON") : F("OFF"));

    Serial.println();
}

/* ============================================================
 * 6. 初期化
 * ============================================================ */

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

    /* 第1引数 MCP_ANY は「受け取るIDを絞らない」指定。
     * MCP2515は特定IDだけ通すフィルタを持つが、まずは全部受ける。 */
    if (CAN.begin(MCP_ANY, CAN_SPEED, MCP_CLOCK) != CAN_OK) {
        Serial.println(F("初期化に失敗しました。配線と水晶の設定を確認してください"));
        /* 重要: 初期化に失敗したまま先へ進ませない。
         *       通信できない状態で本処理を動かしても、
         *       原因の分からない誤動作になるだけ。 */
        while (1) {
            ;
        }
    }

    /* 重要: MCP2515は電源投入直後、設定変更モードに入っている。
     *       ここで通常モードへ移さないとバスに参加せず、
     *       初期化は成功しているのに1フレームも届かない。 */
    CAN.setMode(MCP_NORMAL);

    /* INTピンは入力。フレームが届くとMCP2515がここをLOWへ落とす */
    pinMode(PIN_INT, INPUT);

    Serial.println(F("受信ノード: CAN 500kbps で開始しました"));
    Serial.println();
}

/* ============================================================
 * 7. メインループ
 *
 * 「受け取る」と「処理する」を分ける。
 * 受信のところで時間のかかるSerial出力をやると、
 * その間に届いた次のフレームを取りこぼす。
 * ============================================================ */

void loop()
{
    can_frame_t f;

    /* --- 受け取る側:届いていたら読み出してキューへ積むだけ --- */
    if (digitalRead(PIN_INT) == LOW) {   /* LOW = 未読のフレームがある */
        unsigned long rx_id = 0;                   /* ← フレームのID   が入る */
        byte          len   = 0;                   /* ← フレームのDLC  が入る */
        byte          buf[CAN_PAYLOAD_MAX] = {0};  /* ← フレームのデータが入る */

        /* readMsgBufが返すのはこの3つだけ。
         * SOF・RTR・制御・CRC・ACK・EOF はコントローラが検査して捨てた後で、
         * 変数としてすら出てこない。 */
        if (CAN.readMsgBuf(&rx_id, &len, buf) == CAN_OK) {
            memset(&f, 0, sizeof(f));   /* 前回の値が残らないよう先に0で埋める */

            /* このライブラリは拡張フレームのとき、返すIDの最上位ビット
             * (0x80000000) を立てて知らせてくる。IDは最大29bitなので
             * 上の3bitが空いているのを目印に使っている。
             * この判定を飛ばすと、拡張フレームのIDが 0x80000123 のように
             * 見えて、どのIDとも一致しなくなる。 */
            f.is_extended = (rx_id & 0x80000000UL) != 0;   /* 目印だけを見る */
            f.id          = rx_id & 0x1FFFFFFFUL;          /* 1が29個並んだマスク */

            /* DLCは受け取った値をそのまま信じない。
             * 配列の大きさを超えていたら丸める(バッファあふれの防止)。 */
            f.dlc = (len > CAN_PAYLOAD_MAX) ? CAN_PAYLOAD_MAX : len;
            memcpy(f.data, buf, f.dlc);

            can_queue_push(&g_rx_queue, &f);
        }
    }

    /* --- 処理する側:たまっているぶんをまとめて捌く --- */
    while (can_queue_pop(&g_rx_queue, &f)) {
        print_frame(&f);
    }
}

送信側は短いです。100ミリ秒ごとに 0x123 を1本送るだけで、受信側のコードは送信側が何であっても変わりません

can_tx.ino(送信側の全文)

/*
 * can_tx.ino ---- CAN通信 送信側のサンプルプログラム(Arduino Uno + MCP2515)
 * ============================================================
 * 100ミリ秒ごとに、ID 0x123 のフレームを1本送るだけ。
 * 中身は「車速 62.0km/h、ギア段 2、警告灯 ON」を8バイトに詰めたもの。
 *
 * 配線(このボードには MCP2515モジュールを1枚だけつなぐ)
 *   VCC -> 5V      GND -> GND      ※VCCは3.3Vではない
 *   SCK -> D13     SO  -> D12      SI -> D11
 *   CS  -> D10     INT -> D2(送信だけなのでこのスケッチでは使わない)
 *   CANH/CANL -> もう1台のモジュールの CANH/CANL へ
 *   重要: 2台のArduinoの GND どうしも1本つなぐ
 *
 * 重要: モジュール上の水晶が 8MHz か 16MHz かで MCP_CLOCK を変える。
 *       間違えるとビットレートがずれて1フレームも通らない。
 *
 * 動作確認済み: 2026-08-14 / UNO R4 送信・UNO R3 受信 / MCP2515 8MHz / 500kbps
 */

#include <SPI.h>
#include <mcp_can.h>

/* --- 環境に合わせて変えるのはここだけ --- */
#define MCP_CLOCK   MCP_8MHZ      /* 水晶が16MHzなら MCP_16MHZ */
#define CAN_SPEED   CAN_500KBPS

#define PIN_CS      10
#define SEND_ID     0x123         /* このフレームの名前。宛先ではない */
#define SEND_CYCLE  100           /* 送信周期[ミリ秒]。周期送信は車載の基本形 */

MCP_CAN CAN(PIN_CS);

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

    if (CAN.begin(MCP_ANY, CAN_SPEED, MCP_CLOCK) != CAN_OK) {
        Serial.println(F("初期化に失敗しました。配線と水晶の設定を確認してください"));
        while (1) {
            ;
        }
    }

    /* 電源投入直後は設定変更モード。通常モードへ移さないとバスに出ない */
    CAN.setMode(MCP_NORMAL);

    Serial.println(F("送信ノード: CAN 500kbps で開始しました"));
}

void loop()
{
    static uint32_t last_send = 0;   /* 前回送った時刻。static なので値が残る */

    /* delay() で待たない。待っている間ほかの処理が止まるため、
     * 経過時間を見て「まだなら何もせず戻る」形にする */
    if (millis() - last_send < SEND_CYCLE) {
        return;
    }
    last_send = millis();

    /* 8バイトの中身(受信側と同じ表を共有していないと値が一致しない)
     *   Byte0        : 車速の上位8bit
     *   Byte1 bit7..4: 車速の下位4bit   合計12bit・0.1km/h刻み
     *   Byte1 bit3..2: ギア段
     *   Byte1 bit1   : 警告灯
     *   Byte1 bit0   : 予約(0のまま)
     *
     * 値からバイトを組み立てると、受信側の逆の手順になる。
     *   車速 62.0km/h -> 620 (0x26C) -> 上位8bit=0x26 / 下位4bit=0xC
     *   ギア段 2  -> bit3..2 へ入れるので 2 << 2 = 0x08
     *   警告灯 ON -> bit1 へ入れるので   1 << 1 = 0x02
     *   Byte1 = 0xC0 | 0x08 | 0x02 = 0xCA
     */
    uint8_t tx[8] = {0};   /* 使わないバイトは0で埋める */
    tx[0] = 0x26;
    tx[1] = 0xCA;

    /* 第2引数の 0 は「標準フォーマット(11bitのID)で送る」の意味。
     * 拡張フォーマット(29bit)で送るなら 1 にする。 */
    if (CAN.sendMsgBuf(SEND_ID, 0, sizeof(tx), tx) != CAN_OK) {
        /* CANは受け手がACKを返さないと送信が完了しない。
         * ここが出続けるときは受信側が動いていない可能性が高い。 */
        Serial.println(F("送信できませんでした。相手が応答しているか確認してください"));
    }
}

送信側のコメントに、62.0km/h・ギア段2・警告灯ONという値から 0x260xCA を組み立てる手順を書いてあります。受信側でやったマスクとシフトの、ちょうど逆の操作です。

書き込む前にボードの選択を確認してください。Uno R3なら「Arduino Uno」、Uno R4なら「Arduino UNO R4 …」です。ここが実物と違うと、書き込み時に stk500_getsync() … not in sync が並びます。

次に読む

受信した8バイトを構造体で受け止め、マスクとシフトで値にする。あふれる前提でリングバッファに積む。ここまでが、CANのデータをコードで扱うときの最小のセットです。

コードの部品ごとに手を動かして固めたいときは、この3本が対応しています。
STRUCTC言語 構造体の練習問題7問|メンバ・typedefまで固める
ARRAYC言語 配列の練習問題7問|添字・2次元配列まで固める
BITWISEC言語 ビット演算の練習問題7問|マスク・シフトを図解で固める
文法を順番に固めたい場合は、C言語 基礎ドリル|組込みに必要な8テーマの練習問題まとめが8テーマの入口です。組み込みでC言語が選ばれている理由から知りたいときは組込みでC言語が使われる理由とは?未経験が学ぶべき5つのポイントをどうぞ。