メンバ関数をコールバック関数として渡せられない原因と解決策

メンバ関数をコールバック関数として渡せられない原因と解決策

2024-10/16

開発が進むと、センサーや通信機能をクラスとしてまとめ、部品化することがよくあります。

しかし Arduino 系の関数やライブラリの多くに、メンバ関数をコールバック関数として渡せられない(厳密にはコールバック関数からメンバにアクセスできない)という問題があり、これにかなり悩まされました。

この記事では、この問題の原因と解決策について説明します。

組み込みでのコールバック関数の用途

主にメインループとは別に、臨時に任意の関数を実行したいときに用います。例えば、データ受信時や、IO ピンの状態変化時などです。

Arduino では attachInterrupt 関数を使うことで、ピンの状態変化によって割り込みを行えます。グローバル関数であれば何の問題もなく呼び出せます。

void onChange()
{
    // 1 番ピンが変化するとここが呼ばれる
}

void setup()
{
    attachInterrupt(digitalPinToInterrupt(1), onChange, CHANGE);
}
void loop()
{
}

なぜメンバ関数はコールバック出来ないのか

コールバック関数(通常の静的関数)からインスタンスを参照できないためです。

例として、回転角を読むロータリーエンコーダーというセンサーのクラスを考えます。ロータリーエンコーダーからはパルス信号が出力されるので、割り込みでパルス信号を検出するようにします。

class Encoder
{
    int pinA;
    int pinB;
    int count;

public:
    Encoder(int pinA, int pinB)
        : pinA{ pinA }
        , pinB{ pinB }
        , count{}
    {}

    void begin()
    {
        attachInterrupt(digitalPinToInterrupt(pinA), onChange, CHANGE);  // ERROR
        attachInterrupt(digitalPinToInterrupt(pinB), onChange, CHANGE);  // ERROR
    }

    int getCount() const
    {
        return count;    // 実際は排他制御が必要
    }

private:
    void onChange()
    {
        this->count += ...
    }
};

次のようにインスタンスが複数存在し、仮にメンバ関数をコールバックできたとすると、どちらのインスタンスの onChange を呼び出せばよいのか判別できません。

static Encoder enc0{ 0, 1 };
static Encoder enc1{ 2, 3 };

this ポインタについて理解すれば少しわかりやすいかもしれません。

コンパイラは、メンバ関数呼び出しを、通常の関数呼び出しとして解釈します。この際、this という隠し引数が現れます。

先ほどのクラスは次のように解釈されます。(実際は名前修飾されます。あくまでイメージ)

struct Encoder
{
    int pinA;
    int pinB;
    int count;
};

void begin(Encoder* this)
{
    attachInterrupt(digitalPinToInterrupt(this->pinA), onChange, CHANGE);
    attachInterrupt(digitalPinToInterrupt(this->pinB), onChange, CHANGE);
}

void getCount(const Encoder* this)
{
    return this->count;
}

void onChange(Encoder* this)
{
    this->count += ...
}

呼び出し側はインスタンスのポインタを渡します。

static Encoder enc{ 0, 1 };

void setup()
{
    begin(&enc);
}

void loop()
{
    int count = getCount(&enc);
}

メンバ関数を呼び出すには、呼び出し側のインスタンスを指すポインタ (&enc) が必要なことが分かると思います。しかし attachInterrupt は関数ポインタのみ引数にとるため、インスタンスのポインタを渡す機能がありません。このような理由からメンバ関数はコールバック出来ません。

解決策

このようにメンバ関数をコールバック関数として渡すのは厳しいです。

そこで、通常の関数をコールバック関数とし、コールバック関数からメンバにアクセスする ことを目指します。そのためには、インスタンスを指すポインタを何とかしてコールバック関数に渡す必要があります。状況によって解決策が異なるため、いくつか紹介します。

前提知識

  • ラムダ式は関数オブジェクトで、関数内でも定義できます。

    int main()
    {
        auto f = [ /*キャプチャリスト*/ ]( /*引数*/ ){ /*処理*/ };
        f();
    }
    
  • キャプチャリストが空のラムダ式は、関数ポインタで指すことができます。

    attachInterrupt(..., [](){ /* ここが呼ばれる */ }, CHANGE);
    
  • 静的変数はキャプチャなしでラムダ式から参照できます。

    int main()
    {
        static int value;
        []() { value = 100; }
    }
    
  • static メンバ関数はコールバックできます。ただし、this ポインタがないため、メンバへのアクセスはできません。

    class Encoder
    {
        void begin()
        {
            attachInterrupt(..., onChange, CHANGE);
        }
    
        static void onChange()
        {
        }
    };
    

コールバック関数に void ポインタを渡せる場合 / 簡単

Arduino のボードによっては、コールバック関数に任意のポインタを渡せるような関数が存在します。this ポインタをコールバック関数に渡せるので、メンバへアクセスできます。

M5Stack : attachInterruptArg

Raspberry Pi Pico : attachInterruptParam

earlephilhower/arduino-pico ボード使用時

class Encoder
{
    int pinA;
    int pinB;
    int count;

public:
    Encoder(int pinA, int pinB)
        : pinA{ pinA }
        , pinB{ pinB }
        , count{}
    {}

    // attachInterruptArg の実装によるが、大抵の場合ピン番号とthisポインタは紐づいて保持される。
    // コピーされるとコピー元と自身で同じピン番号を持つことになり、どちらかのthisポインタが上書きされ使用不可になる。
    Encoder(const Encoder&) = delete;

    void begin()
    {
        attachInterruptArg(pinA, onChange, this, CHANGE);  // this ポインタを登録
        attachInterruptArg(pinB, onChange, this, CHANGE);
    }

private:

    static void onChange(void* param)
    {
        // 登録した this ポインタは param に渡されるので、キャストし復元
        Encoder* thisPtr = static_cast<Encoder*>(param);

        thisPtr->count += ...
    }
};
static Encoder enc0{ 0, 1 };
static Encoder enc1{ 2, 3 };
static Encoder enc2{ 4, 5 };

void setup()
{
    enc0.begin();
    enc1.begin();
    enc2.begin();
}

void loop()
{
    int count0 = enc0.getCount();
    int count1 = enc1.getCount();
    int count2 = enc2.getCount();
}

std::function を渡せられる場合 / 簡単

std::function はメンバ関数や、キャプチャ付きラムダ式等の関数を統合的に扱うことができるクラスです。

メンバ関数を渡すには std::bind 関数を用いる必要があり、コールバック関数に引数がある場合少々複雑になります。this ポインタをキャプチャしたラムダ式を渡すのが個人的には楽です。

class Encoder
{
    <>

    Encoder(const Encoder&) = delete;

    void begin()
    {
        attachInterrupt(pinA, [this]() { this->onChange(); }, CHANGE);
        attachInterrupt(pinB, [this]() { this->onChange(); }, CHANGE);
    }

    void onChange()
    {
        this->count += ...
    }
};

各インスタンスが固有の値を持つ場合 / 耐え

複数インスタンス化した際、各インスタンスごとに固有の値を持つ場合(今回だとピン番号)です。ピン番号をキーとして、インスタンスを指すポインタを得られる静的配列があれば、メンバへアクセスできます。

static Encoder enc0{ 0, 1 };  // < pinA = 0
static Encoder enc1{ 2, 3 };  // < pinA = 2
static Encoder enc2{ 4, 5 };  // < pinA = 4
外部割込みに使えるピンの本数はマイコンによって異なります。Arduino Nano は外部割込みピンが 2 本しかないため、複数のエンコーダーを扱えません

下記のようにピン数分の静的配列を作り、コールバック関数から参照します。

class Encoder
{
    int pinA;
    int pinB;
    int count;

public:
    Encoder(int pinA, int pinB)
        : pinA{ pinA }
        , pinB{ pinB }
        , count{}
    {}

    // コピーされると、コピー元と自身で同じピン番号を持つことになり、どちらか使用不可になる。
    Encoder(const Encoder&) = delete;

    void begin()
    {
        darkAttachInterrupt(pinA);
        darkAttachInterrupt(pinB);
    }
private:
    void onChange()
    {
        count += ...
    }

    void darkAttachInterrupt(int pin)
    {
        static Encoder* these[100];
        these[pin] = this;  // ピン番号から this ポインタを得られるようにセット

        switch (pin)
        {
        case 0: attachInterrupt(digitalPinToInterrupt(0), [](){ these[0]->onChange(); }, CHANGE); break;
        case 1: attachInterrupt(digitalPinToInterrupt(1), [](){ these[1]->onChange(); }, CHANGE); break;
        case 2: attachInterrupt(digitalPinToInterrupt(2), [](){ these[2]->onChange(); }, CHANGE); break;
        case 3: attachInterrupt(digitalPinToInterrupt(3), [](){ these[3]->onChange(); }, CHANGE); break;

        ...  // アアアアッ

        case 59: attachInterrupt(digitalPinToInterrupt(59), [](){ these[59]->onChange(); }, CHANGE); break;
        }

    }
};

インスタンスを一つしか作らない場合 / 耐え

例えば I2C スレーブの受信割り込みです。複数の I2C バスに接続する場合、インスタンスが 2 つ以上になり得ますが、そのような場面は考えにくいため諦めました。

静的変数にポインタを保持する点は変わりませんが、配列にする必要がないためシンプルになります。

#include <Wire.h>

class I2cSlaveReader
{
    TwoWire& wire;

public:
    I2cSlaveReader(TwoWire& wire)
        : wire(wire)
    {}

    // 複数インスタンス禁止
    I2cSlaveReader(const I2cSlaveReader&) = delete;

    void begin()
    {
        static I2cSlaveReader* self;
        self = this;
        wire.onReceive([](int n){
            self->onReceive(n);
        });
    }

private:
    void onReceive(int n)
    {
        // 受信割り込み
    }
};
static I2cSlaveReader reader{ Wire };

void setup()
{
    reader.begin();
}

インスタンスを複数作る場合 / 地獄

上記のクラスを発展させることで、複数インスタンスに対応できます。

クラス内の静的変数はクラス 1 つにつき 1 つ生成されます。そのため、インスタンスを複数生成すると、this ポインタが上書きされてしまい、複数のインスタンスを扱えません。

そこでテンプレートを用います。

template <int Unique>
class Hell
{
    static int value;
};
static Hell<0> hell0;
static Hell<1> hell1;
static Hell<2> hell2;

テンプレート引数に異なる値、型を入れてインスタンス化すると、コンパイラは次のように別のクラスとしてテンプレートのインスタンス化を行います。

class Hell_0
{
    static int value;
};
class Hell_1
{
    static int value;
};
class Hell_2
{
    static int value;
};
static Hell_0 hell0;
static Hell_1 hell1;
static Hell_2 hell2;

このように静的変数が複数作成されます。この仕様を悪用利用します。ユーザー側に固有値を指定させるのは酷なので、__COUNTER__ マクロを使います。

template <int Unique>
class HellImpl
{
public:
    HellImpl(const HellImpl&) = delete;

    void begin()
    {
        static HellImpl* self;
        self = this;
        hoge.onReceive([](){
            self->onReceive();
        });
    }

private:
    void onReceive()
    {
        // 受信割り込み
    }
};

#define Hell HellImpl<__COUNTER__>
static Hell hell0;
static Hell hell1;
static Hell hell2;

テンプレートを用いたため hell0hell1 インスタンスは型が異なります。するとインスタンスを関数等に渡すのが辛くなります。そこでインターフェースクラスを用意し、型を統合的に扱えるようにします。

class IHell
{
public:
    virtual void begin() = 0;
};
template <int Unique>
class HellImpl
    : public IHell
{
public:
    HellImpl(const HellImpl&) = delete;

    void begin() override
    {
        static HellImpl* self;
        self = this;
        hoge.onReceive([](){
            self->onReceive();
        });
    }

private:
    void onReceive()
    {
        // 受信割り込み
    }
};

#define Hell HellImpl<__COUNTER__>
static Hell hell0;
static Hell hell1;
static Hell hell2;

void invokeBegin(IHell& hell)  // 全て IHell& 型で参照できる
{
    hell.begin();
}

void setup()
{
    invokeBegin(hell0);
    invokeBegin(hell1);
    invokeBegin(hell2);
}

おわりに

ピン番号分配列を用意する手法は、Teensy のエンコーダーライブラリで使われています 1。作者の方は次のようなコメントを書かれています。

この巨大な関数は、Arduino の attachInterrupt 関数が、アタッチされた関数にポインタやその他のコンテキストを渡す方法をサポートしていないために生じた不運な結果である。

ユーザー側では本記事の様に悪あがきするしかないので、Arduino 側で改良してほしいです 🤔 この問題は GitHub で長く議論されていますが、対応されていません((+_+)) 2 3

かなり長くなってしまいましたが、最後までご覧頂き有難うございました。


  1. attachInterrupt のラッパー関数の実装

    https://github.com/PaulStoffregen/Encoder/blob/c083e9cbd6400f7e72a794c0d371a00a09d2a25d/Encoder.h#L387-L943 ↩︎

  2. issue - Enable use of attachInterrupt() in classes #85

    https://github.com/arduino/ArduinoCore-avr/issues/85 ↩︎

  3. Pull Request(Closed) - Support extra parameter on attachInterrupt() #58

    https://github.com/arduino/ArduinoCore-avr/pull/58 ↩︎