組み込み開発のプログラムを読んでいると、関数を呼び出しているわけではないのに、関数名を別の関数へ渡しているコードを見かけることがあります。
たとえばArduinoの割り込みで使うattachInterrupt()です。
attachInterrupt(
digitalPinToInterrupt(SW_PIN),
onSwitch,
FALLING
);
ここではonSwitch()を呼び出しているのではなく、onSwitchという関数そのものを登録しています。
この仕組みを理解するために必要になるのが、関数ポインタとコールバックです。
この記事では、「関数にもアドレスがある」というところから始めて、関数ポインタの書き方、コールバックの仕組み、Arduinoの割り込みやライブラリで使われる理由まで解説します。
関数ポインタとは?
関数ポインタとは、その名前の通り関数を指すポインタです。
以前の記事では、通常のポインタについて次のように説明しました。
int value = 100;
int *p = &value;
pには、変数valueが存在するメモリアドレスが格納されています。
実行可能なプログラムでは関数のコードもメモリ空間上の位置を持っています。その関数を指すためのポインタが関数ポインタです。
通常のポインタ 変数 ↓ アドレス ↓ ポインタ 関数ポインタ 関数 ↓ 関数のアドレス ↓ 関数ポインタ
通常のポインタについては「ポインタとは?アドレス・&・*の意味を初心者向けに解説」も参照してください。
まず普通の関数を見てみよう
次の単純な関数を考えます。
void ledOn()
{
digitalWrite(0, HIGH);
}
通常は、
ledOn();
と書けば関数が実行されます。
ここで重要なのが、次の2つの違いです。
ledOn ledOn()
ledOn()は関数を呼び出す記述です。
一方、関数ポインタへ代入する場面などで使うledOnは、関数を呼び出すのではなく関数を指す値として扱われます。
関数ポインタを作ってみよう
ledOn()を指す関数ポインタは、次のように宣言できます。
void (*func)();
最初は非常に読みにくい書き方ですが、分解すると次のようになります。
void (*func)() ↑ ↑ ↑ 戻り値 名前 引数
つまり、
「引数なし、戻り値voidの関数を指すポインタfunc」
という意味です。
関数を関数ポインタへ代入する
先ほどのledOn()を代入してみましょう。
void (*func)();
func = ledOn;
これでfuncがledOnを指すようになります。
そして、
func();
と書くことで、funcが指しているledOn()を呼び出せます。
関数ポインタの便利なところ
「普通にledOn()を呼べばいいのでは?」と思うかもしれません。
関数ポインタの大きな特徴は、実行する関数を後から切り替えられることです。
void ledOn()
{
digitalWrite(0, HIGH);
}
void ledOff()
{
digitalWrite(0, LOW);
}
void (*func)();
func = ledOn;
func();
func = ledOff;
func();
同じfunc()という呼び出しでも、関数ポインタがどの関数を指しているかによって実行される処理が変わります。
引数のある関数ポインタ
関数ポインタでは、指す関数の戻り値と引数の型を合わせる必要があります。
たとえば次の関数を考えます。
int add(int a, int b)
{
return a + b;
}
この関数を指す関数ポインタは、
int (*func)(int, int);
となります。
func = add;
int result = func(10, 20);
resultには30が入ります。
関数ポインタは型が重要
次の2つは異なる型の関数です。
void functionA();
int functionB(int value);
したがって、それぞれに対応する関数ポインタも異なります。
void (*pA)();
int (*pB)(int);
戻り値や引数の型が一致する関数を登録する必要があります。
typedefを使うと読みやすくなる
関数ポインタの宣言は複雑になりやすいため、typedefを使って型に名前を付けることがあります。
typedef void (*Callback)();
これで、
Callback func;
と書けるようになります。
つまりCallbackという名前を「引数なし・戻り値voidの関数を指すポインタ型」として使えます。
コールバックとは?
ここからが関数ポインタの代表的な用途であるコールバック(Callback)です。
コールバックとは、簡単に言えば、
「ある処理やイベントが発生したときに呼び出してほしい関数を、あらかじめ別の処理へ渡しておく仕組み」
です。
自分のプログラム
↓
関数を登録する
↓
ライブラリ / OS / ドライバ
↓
待機
↓
イベント発生
↓
登録されていた関数を呼び出す
簡単なコールバックを作ってみよう
まずは自分で簡単な仕組みを作ってみます。
void execute(void (*callback)())
{
callback();
}
void hello()
{
Serial.println("Hello!");
}
void setup()
{
Serial.begin(115200);
execute(hello);
}
void loop()
{
}
execute()は関数ポインタを引数として受け取っています。
呼び出す側では、
execute(hello);
としてhelloを渡します。
そしてexecute()内部の、
callback();
によって、登録されたhello()が実行されます。
ArduinoのattachInterrupt()もコールバック
Arduinoで非常に分かりやすい例がattachInterrupt()です。
attachInterrupt(
digitalPinToInterrupt(SW_PIN),
onSwitch,
FALLING
);
ここで、
onSwitch
を渡しています。
onSwitch()ではありません。
つまり、ここでonSwitchを実行しているのではなく、「割り込みが発生したらこの関数を呼んでください」と登録しているわけです。
割り込みが発生するとどうなる?
大まかな流れは次のようになります。
attachInterrupt()
↓
onSwitchを登録
↓
通常のプログラムを実行
↓
GPIOの状態が変化
↓
割り込み発生
↓
登録されていたonSwitch()を実行
自分からonSwitch()を呼び出していないのに、イベントが発生すると後から呼ばれるため「Callback(呼び戻し)」と呼ばれます。
割り込みの仕組みについては「割り込みとは?マイコンがイベントをすぐ処理できる仕組みを初心者向けに解説」も参照してください。
KUMITATE-C3で試してみよう
KUMITATE-C3では、SW1がGPIO21へ接続されています。
SW1を押したときにコールバック関数を呼び出してみましょう。
const int SW_PIN = 21;
volatile bool switchPressed = false;
void IRAM_ATTR onSwitch()
{
switchPressed = true;
}
void setup()
{
Serial.begin(115200);
pinMode(SW_PIN, INPUT_PULLUP);
attachInterrupt(
digitalPinToInterrupt(SW_PIN),
onSwitch,
FALLING
);
}
void loop()
{
if (switchPressed) {
switchPressed = false;
Serial.println("SW1 pressed");
}
}
attachInterrupt()へonSwitchを渡しておくことで、SW1による割り込みが発生したときにonSwitch()が呼び出されます。
前回学習したvolatileもここで登場しています。
「constとvolatileとは?組み込み開発での意味と使い方を初心者向けに解説」と合わせて読むと、割り込み処理のコードがかなり読みやすくなります。
なぜ割り込み処理の中で全部やらないの?
先ほどのコードでは、割り込み関数onSwitch()の中ではフラグを変更するだけにしています。
void IRAM_ATTR onSwitch()
{
switchPressed = true;
}
一般に割り込みハンドラでは、長時間かかる処理や、割り込みコンテキストで安全に使用できないAPIの呼び出しを避け、必要最小限の処理にすることが重要です。
そこで、
割り込み ↓ フラグを立てる ↓ 通常処理でフラグを確認 ↓ 本来の処理を行う
という設計がよく使われます。
ライブラリでもコールバックがよく使われる
コールバックは割り込みだけの仕組みではありません。
通信ライブラリやドライバなどでも頻繁に使われます。
たとえば概念的に、次のようなAPIがあったとします。
void onReceive(uint8_t *data, size_t length)
{
// 受信したデータを処理
}
driver.setReceiveCallback(onReceive);
この場合は「データを受信したらonReceive()を呼び出してください」という意味です。
自分のコード ↓ onReceiveを登録 ↓ ライブラリ ↓ 通信データを受信 ↓ onReceive()を呼び出す
コールバックが使われる場面
組み込み開発では、さまざまな場所でコールバックが登場します。
- GPIO割り込み
- タイマー
- UARTなどの通信処理
- Wi-FiやBluetoothのイベント
- データ受信通知
- ボタンイベント
- センサーのイベント通知
- RTOSやドライバのイベント処理
- エラー発生時の通知
関数ポインタの配列
関数ポインタは配列にすることもできます。
void taskA()
{
Serial.println("A");
}
void taskB()
{
Serial.println("B");
}
typedef void (*Handler)();
Handler handlers[] = {
taskA,
taskB
};
すると、
handlers[0]();
handlers[1]();
のように、配列から実行する関数を選べます。
コマンド番号や状態によって処理を切り替えるようなプログラムにも利用できます。
状態によって処理を切り替える
関数ポインタを使うと、「現在どの処理を実行するか」を関数として保持することもできます。
void normalMode()
{
// 通常処理
}
void lowPowerMode()
{
// 省電力処理
}
void (*currentMode)();
void setup()
{
currentMode = normalMode;
}
void loop()
{
currentMode();
}
モード変更時に、
currentMode = lowPowerMode;
とすれば、以降のcurrentMode()では省電力処理が実行されます。
C++ではstd::functionやラムダ式もある
C++では、単純な関数ポインタ以外にも、関数のように呼び出せる処理を扱う仕組みがあります。
- ラムダ式
std::function- 関数オブジェクト
これらを利用すると、より柔軟なコールバックを作れる場合があります。
ただし組み込み開発では、実行環境やライブラリ、メモリ使用量などの条件によって、単純な関数ポインタが使われることも多くあります。
普通のメンバ関数はそのまま関数ポインタにできないことがある
C++のクラスを使い始めると、コールバックで少し難しい問題が出てきます。
通常の非staticメンバ関数には、呼び出し対象のオブジェクトを示すthisが関係するため、通常のC形式の関数ポインタを要求するAPIへそのまま渡せない場合があります。
class Button {
public:
void onPress()
{
// 処理
}
};
このonPressは、単純な
void (*)()
とは異なる種類のメンバ関数ポインタになります。
ライブラリによってはstaticメンバ関数、ユーザーデータ用ポインタ、ラムダ式などを利用して橋渡しします。
関数ポインタを使うときの注意点
関数ポインタを使用するときは、いくつか注意点があります。
- 戻り値と引数の型を合わせる
- 無効な関数ポインタを呼び出さない
- NULLやnullptrの可能性がある場合は確認する
- コールバックに渡すデータの寿命に注意する
- 割り込みコールバックでは実行可能な処理に制約がある場合がある
- C++の非staticメンバ関数は通常の関数ポインタとは異なる
nullptrを確認してから呼び出す
C++では、関数が登録されているか分からない場合、nullptrを使って確認できます。
void (*callback)() = nullptr;
if (callback != nullptr) {
callback();
}
何も指していない関数ポインタを呼び出すと正常に動作しないため、登録が任意の設計ではこのような確認が重要です。
関数ポインタとコールバックの違い
最後に、この2つを区別しておきましょう。
| 用語 | 意味 |
|---|---|
| 関数ポインタ | 関数を指すポインタ |
| コールバック | 後から呼び出してもらう関数を登録する設計・仕組み |
つまり、関数ポインタは仕組みを実現するための技術で、コールバックはその代表的な使い方と考えると分かりやすいでしょう。
これまで学んだ内容とのつながり
関数ポインタまで理解すると、これまでの内容が実際の組み込みプログラムへかなりつながってきます。
ポインタ ↓ 関数にもアドレスがある ↓ 関数ポインタ ↓ 関数を別の処理へ渡す ↓ コールバック ↓ 割り込み / タイマー / 通信 / ドライバ
そして、割り込みコールバックでは、
GPIO ↓ 割り込み ↓ コールバック関数 ↓ volatileなフラグ ↓ メイン処理
という形で、これまで学んだGPIO・割り込み・volatileも一緒に登場します。
まとめ
関数ポインタとコールバックは、組み込み開発で非常によく使われる仕組みです。
- 関数ポインタは関数を指すポインタ
- 関数を関数ポインタへ代入して後から呼び出せる
- 関数ポインタでは戻り値と引数の型を合わせる必要がある
- typedefを使うと複雑な関数ポインタ型を読みやすくできる
- コールバックは後から呼び出してほしい関数を登録する仕組み
- ArduinoのattachInterrupt()もコールバックを利用する代表例
- 通信・タイマー・ドライバなどでもコールバックがよく使われる
- 関数ポインタの配列を使えば処理を柔軟に切り替えられる
- C++の非staticメンバ関数は通常の関数ポインタとは異なる
関数ポインタの記述は最初は難しく見えますが、まずは「関数を変数のように保持して、後から呼び出せる」と理解すると分かりやすくなります。
さらにコールバックを理解すると、ArduinoやESP-IDFなどのライブラリで見かける「なぜ関数名を引数として渡しているのか」が分かるようになり、ライブラリ内部のコードも読みやすくなってきます。

