XC8攻略記5

前回はswitch文に関連する話を書きました。そのときに記載を省いたことが1つありました。それを書いてみたいと思います。

関数テーブルによる処理分岐

条件分岐を高速に行う方法の1つとして「テーブル方式による分岐」があります。enum値が0から始まる整数値であることを利用し、PICのBRW命令を使う事で、前回はインラインアセンブラを利用して「テーブル方式の分岐」を行いました。

ところで、0から始まる整数値を利用するなら、関数ポインタを格納した配列を使っても同じようなことができます。(以降は、これを関数テーブルと呼ぶことにします)。

実際のコード例を以下に示します。

void S1_Action_GREEN(void) {
    if (S1_Timer == G_TIME) {
        ChangeYellow();
    }
}

void S1_Action_YELLOW(void) {
    if (S1_Timer == Y_TIME) {
        ChangeRed();
    }
}

void S1_Action_RED(void) {
    if (S1_Timer == R_TIME) {
        ChangeGreen();
    }
}

void S1_Action_ERROR(void) {
    ChangeRed();
}

static void (* Actions[])() = {
    S1_Action_GREEN,
    S1_Action_YELLOW,
    S1_Action_RED,
    S1_Action_ERROR,
};

void S1_Action5(void) {
    Actions[S1]();
}

変数S1やChangeYellow()などは前回のコードと同じなのでそちらを参照してください。

前回のコードのS1_Action()を関数テーブル方式に置き換えたのがS1_Action5()です。

計測結果

このS1_Action5()の処理時間(サイクル数)を計測すると次のようになりました。

関数名命令数サイクル数説明
S1_Action571320724関数ポインタ配列を使ったテーブル方式
S1_Action57382630単純なswitch文
S1_Action4A64250788switch文+インラインアセンブラを使ったテーブル方式

サイクル数ですが、単純なswitch文よりは小さく(早く)なっているので高速化されています。純粋にC言語だけでテーブル方式の分岐でき高速化できている点は、悪くないでしょう。

しかし命令数は大幅に多くなってしまいます。S1_Action5()自体の命令数は10ですが、条件分岐後の処理はすべてS1_Action_GREEN()のように関数にするため、全体としての命令数が増加します。

また、配列Actionsを初期化するための命令も存在するので、それも命令数が増える要因です。それらを合わせると次のようになります。

命令数の内訳命令数
S1_Action5()10
S1_Action_GREEN()13
S1_Action_YELLOW()13
S1_Action_RED()12
S1_Action_ERROR()9
配列Actionsを初期化する処理24
合計71

またswitch文+インラインアセンブラ方式と比較すると、命令数とサイクル数の両方が多くなってしまいます。

つまり命令数が増えるわりには、余り高速化できていません。これは2つの要因が考えられます。それは次で説明します。

関数テーブルの問題点

S1_Action5()のアセンブルリスト

処理速度が遅い要因を説明するために、S1_Action5()のアセンブルリストを説明します。

S1_Action5()に対するアセンブルリストを以下に示します。

223:           void S1_Action5(void) {
224:               Actions[S1]();
0565  357D     LSLF 0x17D, W
0566  3E29     ADDLW 0x29
0567  0086     MOVWF FSR1L
0568  0187     CLRF FSR1H
0569  3F41     MOVIW 1[FSR1]
056A  008A     MOVWF PCLATH
056B  3F40     MOVIW 0[FSR1]
056C  000A     CALLW
056D  3185     MOVLP 0x5
225:           }
056E  0008     RETURN

まず前提として、配列Actions[]がメモリの0x29番地のメモリから始まる8byteの情報として存在しています。関数ポインタは2byteの情報であり、関数が4つ(配列の要素数が4)なので、2×4=8byteになります。

そのActions[i]にアクセスする場合、(0x29+2*i)と(0x29+2*i+1)を参照する必要があります。

アセンブルリストの最初の2行は、この0x29+2*iを計算していることになります。

LSLF 0x17D, W    0x17D番地のメモリの値を左シフトした値をWregにセット
ADDLW 0x29       Wregに0x29を加算
(補足)
0x17D番地のメモリは変数 S1 の値を保持している。そのため、上の2つの命令で、
Wreg = S1 * 2 + 0x29 を計算している。

次の2行は、FSR1をセットしています。FSR1は間接アドレッシングでメモリを参照するためのレジスタです。

MOVWF FSR1L     直前で計算した S1 * 2 + 0x29をFSR1Lにセット
CLRF FSR1H      FSR1Hを0クリア

ここまでの処理で、FSR1による間接アドレッシングで配列Actions[S1]にアクセスすることができるようになりました。

次の5行は、Actions[S1]の関数ポインタを読み取って、その関数をコールする処理をしています。

MOVIW 1[FSR1] 間接アドレッシングでActions[S1]の上位byteの値をWregにセット
MOVWF PCLATH   Wregの値をPCLATHにセット
MOVIW 0[FSR1] 間接アドレッシングでActions[S1]の下位byteの値をWregにセット
CALLW          PCLATHとWregの値を使って、関数をコール。(Actions[S1]の関数をコール)
MOVLP 0x5      PCLATHを5にする。(補足:3つ前の命令で書き換えたので、ここで戻している。)

サイクル増加の理由1

処理サイクルが増える理由の1つ目は、関数ポインタを使っていることにつきます。

関数ポインタを使う場合、以下の処理が必ず必要になります。

  1. 関数ポインタの格納されている変数にアクセスし、PCLATHとWregに値をセットする。
  2. CALLW命令を実行する。

これらのために最低でも4個の命令が必要です。

switch文+インラインアセンブラを使ったテーブル方式では分岐先へのジャンプは、PICのGOTO命令1つで行います。インラインアセンブラで以下のように書いていたのを思い出して下さい。

asm("GOTO S1_Action4A_0");

分岐のためのジャンプ1回当たりの命令数で、関数ポインタ配列によるテーブル方式は不利です。

サイクル増加の理由2

理由の2つ目は、配列にアクセスすることです。

関数ポインタ配列にアクセスするために番地を計算し、FSR1レジスタをセットする処理を行っています。そのために合計4命令を使っています。

それに対して、switch文+インラインアセンブラを使ったテーブル方式では分岐先へのジャンプは、

asm("MOVF _S1,w");
asm("BRW");

の2つの命令で終わっています。

この点でも命令数に差が出るため、関数ポインタ配列によるテーブル方式は不利です。

その他の問題点

その他の問題点として、関数テーブル方式は「気軽にコーディングできない」というものが挙げられます。

S1_Action_ERROR()の例を見ればわかるように、1行しかない(マクロ関数なので実際は数行ですが)コードでも関数化しなければ関数ポインタ配列にすることができません。これはコーディングの手間がかかりやすくなります。

さらに関数ポインタそものがかなり難易度が高いです。(そもそもC言語初心者の第一の関門がポインタですし…。)C言語に不慣れな人にとって関数ポインタ配列のソースコードは非常にわかりにくいことも問題です。

下の関数ポインタ宣言を、Cの解説本やWEBページを見ずにすらすらと書くことができる人は、かなりの上級者だと思います。私はまったく思い出せず、何度も誤ったソースコードを書いてしまいました。最終的にネット検索で解説ページを探しだし、ようやく書くことができました。

static void (* Actions[])() = {
S1_Action_GREEN,
S1_Action_YELLOW,
S1_Action_RED,
S1_Action_ERROR,
};

【注意点9】関数テーブルは非効率

結論として、条件分岐のために関数テーブルを使うのはXC8では止めた方がよいでしょう。

コーディングに手間がかかり、難解であり、プログラムサイズと使用メモリが増える…といったデメリットの割に、得られるメリットが少なすぎると私は判断します。

関数ポインタを使うなら…

実は関数ポインタ自体は上手に利用することで今回例題としているケースでは改善が得られます。

実際のコードを以下に示します。

void S1_Action6_GREEN(void);
void S1_Action6_YELLOW(void);
void S1_Action6_RED(void);
void S1_Action6_ERROR(void);

static void (*S1_Action6)(void) = S1_Action6_ERROR;

void S1_Action6_GREEN(void)
{
    if (S1_Timer == G_TIME)
    {
        S1_Action6 = S1_Action6_YELLOW;
        ChangeYellow();
    }
}

void S1_Action6_YELLOW(void)
{
    if (S1_Timer == Y_TIME)
    {
        S1_Action6 = S1_Action6_RED;
        ChangeRed();
    }
}

void S1_Action6_RED(void)
{
    if (S1_Timer == R_TIME)
    {
        S1_Action6 = S1_Action6_GREEN;
        ChangeGreen();
    }
}

void S1_Action6_ERROR(void)
{
    S1_Action6 = S1_Action6_RED;
    ChangeRed();
}

前回のコードのS1_Action()の代わりに関数ポインタS1_Action6()に置き換えています。

ChangeGreen()などでS1が変化するタイミングで関数ポインタ自体を変えてしまうことで、S1→関数ポインタを探す→その関数ポインタをコール」だった処理が「関数ポインタをコール」に変わるので、switch文のような分岐処理がなくなります。

このコードを使った場合のサイクル数などの計測結果は次のようになります。

関数名命令数サイクル数説明
S1_Action668250917分岐先を関数ポインタで管理する方式
S1_Action57382630単純なswitch文
S1_Action4A64250788switch文+インラインアセンブラを使ったテーブル方式

インラインアセンブラを使う方式とほぼ同じといってよいほど、処理サイクルが少なくなっています。そしてこれは完全にC言語だけで実現できています。

また、この例では状態変数S1が不要になってしまうので、それをセットする処理(ChangeGreen()などのマクロの中でS1を変更する処理を取り除く)を省くことでもっと改善できます。

各関数の命令数は次のようになりました。

命令数の内訳命令数
S1_Action6_GREEN()17
S1_Action6_YELLOW()17
S1_Action6_RED()16
S1_Action6_ERROR()13
関数ポインタの影響5
合計68

各関数は関数ポインタのセットが増える分、命令が増えています。

また、S1_Action6は関数ポインタなので、通常関数をコールするときよりも命令が増えます。その分を「関数ポインタの影響」のところに計上しています。

上に書いたように関数ポインタを使うことで命令が多くなるので、その分処理サイクルも増えます。

しかしそのような処理サイクルの増加以上に、分岐のためにswitchやif文を判定する処理が不要になる効果が大きくなるので、全体としては処理サイクルが減ります。

コメント