はじめに
「関数ポインタ」はCの中でも難解なトピックとして敬遠されがちです。しかし組み込みの現場では、RTOSのタスク登録やコールバック処理など、関数ポインタが自然に使われている場面が多くあります。
仕組みを一度理解してしまえば、難しくはありません。今回は基本的な使い方から、関数ベクタ(関数ポインタの配列)、そしてTOPPERS/JSPでの実際の使用例まで順を追って見ていきます。
関数ポインタの基本
Cでは変数がメモリ上のアドレスを指すように、関数もメモリ上のアドレス(先頭番地)を持っています。そのアドレスを格納できるのが関数ポインタです。
int func(int x) {
/* ... */
return x * 2;
}
void proc() {
int (*pf)(int); /* 関数ポインタの宣言 */
int y; /* C89では宣言はブロック先頭にまとめる */
pf = func; /* 関数の先頭アドレスを代入(関数名がアドレス) */
y = pf(3); /* 関数ポインタ経由で呼び出し */
}
宣言の int (*pf)(int) は少し見慣れない書き方ですが、分解すると読めます。
-
(*pf)―pfはポインタ - 左側の
int― 戻り値の型 - 右側の
(int)― 引数の型
「int を受け取って int を返す関数へのポインタ」という意味です。
typedefで読みやすくする
関数ポインタの宣言は記述が長くなりがちなので、typedef で型に名前を付けると読みやすくなります。
typedef int (*IFUNC)(int); /* 関数ポインタの型宣言 */
IFUNC pf; /* 関数ポインタの変数宣言 */
pf = func; /* アドレス代入 */
int y = pf(3); /* 呼び出し */
TOPPERS/JSPでも同じパターンが使われています。
typedef void (*FP)(); /* プログラムの起動番地(ポインタ)*/
FP という型名で「戻り値なしの関数へのポインタ」を定義しています。() はC言語では「引数なし」ではなく「引数の型を指定しない(チェックしない)」という意味です。引数なしを明示するには void (*FP)(void) と書きます。TOPPERS/JSPでは多様なシグネチャの関数を統一型で扱うために意図的に () を使っています。
関数ベクタ ― 関数ポインタの配列
関数ポインタを配列にしたものを関数ベクタと呼びます。インデックスで呼び出す関数を動的に切り替えられるのが特徴です。
typedef void (*FPTR)(int);
void func0(int x) { /* ... */ }
void func1(int x) { /* ... */ }
void func2(int x) { /* ... */ }
FPTR fvector[3] = {func0, func1, func2}; /* 関数ベクタ */
void proc(int n, int x) {
fvector[n](x); /* n番目の関数を呼び出し */
}
switch 文で分岐するよりもコードがすっきりし、関数の追加・削除も配列の変更だけで済みます。状態に応じて処理を切り替えるステートマシンや、イベントハンドラの登録などでよく使われるパターンです。
RTOSでの使用例 ― TOPPERS/JSPのタスク登録
関数ポインタが最も実感しやすい使われ方の一つが、RTOSのタスク登録です。TOPPERS/JSPでは、タスクとして実行する関数を関数ポインタで登録します。
まずタスク初期化ブロックの構造体定義を見てみます。
/* jsp/kernel/task.h より */
typedef struct task_initialization_block {
ATR tskatr; /* タスク属性 */
VP_INT exinf; /* タスクの拡張情報 */
FP task; /* タスクの起動番地 */
/* ... */
} TINIB;
FP task がタスク関数へのポインタです。タスクとして実行したい関数の先頭アドレスをここに登録します。
実際の登録はこう書かれています。
/* application/timer1/kernel_cfg.c より */
const TINIB _kernel_tinib_table[TNUM_TSKID] = {
{0x00u | 0x02u, (VP_INT)(0), (FP)(entry_task), /* ... */},
{0, (VP_INT)((VP_INT)0), (FP)(timer_task), /* ... */},
/* ... */
};
(FP)(entry_task) や (FP)(timer_task) が関数ポインタへのキャストです。entry_task や timer_task という関数名がそのままアドレスとして渡されています。
RTOSはこのテーブルを参照して、スケジューリングに従い各タスク関数を呼び出します。関数ポインタがなければ、このような柔軟なタスク管理の仕組みは実現できません。
実務での注意点・よくある落とし穴
型が一致しない関数を代入する
関数ポインタの型(引数・戻り値)と、代入する関数のシグネチャが一致していないと未定義動作になります。typedef で型を明示しておくと、コンパイラが型不一致を検出してくれます。
typedef void (*FPTR)(int);
void func_ok(int x) { /* ... */ } /* OK */
void func_ng(void) { /* ... */ } /* 引数が違う ― コンパイラが警告 */
FPTR pf = func_ng; /* 型不一致 */
NULLチェックを忘れる
関数ポインタが NULL のまま呼び出すと、クラッシュします。特に初期化前や条件によって登録されないケースがある場合は、呼び出し前にNULLチェックを入れる習慣をつけてください。
if (pf != NULL) {
pf(x);
}
デバッガで追いにくくなる
関数ポインタ経由の呼び出しは、静的解析やデバッガでの追跡がやや難しくなります。呼び出し先が動的に決まるため、コードを読むだけでは処理の流れを把握しにくい場合があります。関数ベクタを使う場合は、どの関数が登録されうるかをコメントや設計ドキュメントで明示しておくと保守性が上がります。
まとめ
関数ポインタは難解に見えますが、使われるパターンは限られています。
- 関数ポインタは関数の先頭アドレスを格納する変数
-
typedefで型に名前を付けると宣言が読みやすくなる - 関数ベクタ(配列)を使うとインデックスで呼び出す関数を切り替えられる
- RTOSのタスク登録など、組み込みの現場では自然に使われている
以上でシリーズ全5回が完結です。今回扱ったテクニックは単独で使うよりも組み合わせて使うことで真価を発揮します。typedef で型を縛り、volatile でレジスタを守り、マクロで抽象化し、ヘッダで設計を整理し、ビット操作でハードウェアを制御する。これらが組み合わさったコードが、組み込みCの現場で動いています。