XC8攻略記4

XC8の最大の弱点が分かりました。それはswitch文です。

【注意点8】switchは最大の鬼門

すでに結論を述べていますが、XC8はswitch文が最大の鬼門だと思います。

switch文はいろいろメリットがあります。C言語に慣れている人には、条件分岐が分かりやすく、コードを読解しやすくなります。また、不具合があることをコンパイラが検知してくれるので、不具合防止にも役に立ちます。

しかし、XC8の場合、生成されるアセンブルリストの効率があまりにも悪いのです。

switch文の一般常識

一番の問題は一般常識と真逆であることです。

C言語を解説する書籍やページを、私はそれなりに見てきた自信がありますが、その多くで次のように解説されています。

  • switch文は、複数のif文を使って書き換えることはできるが、それはしないほうが良い。if文に置き換えてしまうと、読解にしにくくなり、不具合を作りこみやすくなる。
  • たいていのCコンパイラはswitch文に対して効率的なアセンブルリストを生成できるようになっている。高速化したつもりでif文に置き換えてもあまり効果が無いことも多く、Cコンパイラの最適化にまかせた方が良い。

例えば、次のようなswitch文に対して、たいていのコンパイラ(もちろんXC8も)エラーやワーニングを出力します。

    switch (S1)
    {
    case S1_GREEN:
        /* 処理1:詳細は省略 */
        break;
    case S1_YELLOW:
    case S1_GREEN:   // <- ここでS1_GREENが再登場。なにかミスがある。
        /* 処理2:詳細は省略 */
        break;
    }

if文を使って同様のミスをしたとします。

    if (S1 == S1_GREEN)
    {
        /* 処理1:詳細は省略 */
    }
    else if (S1 == S1_YELLOW || S1 == S1_GREEN) // <- ここでS1_GREENが再登場
    {
        /* 処理2:詳細は省略 */
    }

これに対してコンパイラはワーニングもエラーも出さないので、ミスに気づくことができません。switch文は不具合防止にとても有効ですし、この点はXC8を使う上でも変わりありません。

switch文はswitch条件(上のコードならS1)の値に応じて、複数の分岐先にジャンプする命令です。このジャンプ先を選択する手法としてテーブル方式でジャンプすると効率が良い場合があります。Cコンパイラの最適化により、ジャンプ先を決定する効率の良いアルゴリズムを選択し、適切なアセンブルリストを(たいていのコンパイラは)作ってくれます。

ところがXC8のフリー版ではこの最適化が働かないようです。そのためif文で書いた方が効率がよくなります。

「ifよりswitchの方が効率が良い」がまったく成り立たないのです。一般常識と反してしまうので、この点は大きな罠と言ってよいでしょう。

実際の例

実際にCのソースコードとそれに対するアセンブルリストを見ていきましょう。まず、簡単な状態遷移を行うswicth文を作ってみました。

// 信号機の状態
typedef enum
{
    S1_GREEN,  // 青信号
    S1_YELLOW, // 黄信号
    S1_RED,    // 赤信号
    S1_ERROR,  // エラー/電源OFF時
} S1_Type;

static S1_Type S1 = S1_ERROR;
static uint8_t S1_Timer = 0;

#define G_TIME 160 // 青信号の時間:2分40秒
#define Y_TIME 20  // 黄信号の時間:20秒
#define R_TIME 180 // 赤信号の時間:3分

#define ChangeGreen()                    \
    {                                    \
        S1 = S1_GREEN;                   \
        LATC = (LATC & 0xF8) | (1 << 2); \
        S1_Timer = 0;                    \
    }

#define ChangeYellow()                   \
    {                                    \
        S1 = S1_YELLOW;                  \
        LATC = (LATC & 0xF8) | (1 << 1); \
        S1_Timer = 0;                    \
    }

#define ChangeRed()                      \
    {                                    \
        S1 = S1_RED;                     \
        LATC = (LATC & 0xF8) | (1 << 0); \
        S1_Timer = 0;                    \
    }

void S1_Action(void)
{
    switch (S1)
    {
    case S1_GREEN: 
        if (S1_Timer == G_TIME)
        {
            ChangeYellow();
        }
        break;
    case S1_YELLOW:
        if (S1_Timer == Y_TIME)
        {
            ChangeRed();
        }
        break;
    case S1_RED:
        if (S1_Timer == R_TIME)
        {
            ChangeGreen();
        }
        break;
    // case S1_ERROR:
    default:
            ChangeRed();
        break;
    }
}

void S1_Action2(void)
{
    if (S1 == S1_GREEN)
    {
        if (S1_Timer == G_TIME)
        {
            ChangeYellow();
        }
    }
    else if (S1 == S1_YELLOW)
    {
        if (S1_Timer == Y_TIME)
        {
            ChangeRed();
        }
    }
    else if (S1 == S1_RED)
    {
        if (S1_Timer == R_TIME)
        {
            ChangeGreen();
        }
    }
    else
    {
        ChangeRed();
    }
}

信号機のミニチュア模型を動かすためのプログラムだと考えてください。

S1は状態変数で最初はERRORで初期されています。

これが最初にERROR→REDに変化します。そしてS1_Timerが180になるとGREENになります。S1_Timerはどこか別の場所(タイマなど)で1秒ごとにカウントアップされていると考えてください。

そして、GREENになった160秒後にYELLOWになり、さらに20秒後にRED、さらに180秒後にGREENと360秒周期でぐるぐると変わっていきます。

RC2、RC1、RC0がそれぞれ緑黄赤の信号(おそらくLED)を点灯させていて、それらの出力を変える処理も行っています。

この処理をS1_Action()はswicth文で記載し、S1_Action2()はif文で記載しています。

このソースをコンパイルすると、S1_Action()に対しては57命令のアセンブルリストが生成され、S1_Action2()に対しては46命令のアセンブルリストが生成されます。命令数ではif文の方が効率が良いです。命令数が多いほど処理時間がかかるので、処理速度もif文の方が速そうです。(ただし、条件分岐によるジャンプ命令があるので単純比較は禁物です。)

switch文のアセンブルリスト

つぎにswitch文に対して生成されるアセンブルリストを見てみましょう。

40:            void S1_Action(void)
41:            {
42:                switch (S1)
07B5  2FDA     GOTO 0x7DA
43:                {
 (途中省略)
07DA  087D     MOVF 0x17D, W
07DB  00F0     MOVWF 0x170
07DC  01F1     CLRF 0x171
07DD  0871     MOVF 0x171, W
07DE  3A00     XORLW 0x0
07DF  1903     BTFSC STATUS, 0x2
07E0  2FE2     GOTO 0x7E2
07E1  2FC7     GOTO 0x7C7
07E2  0870     MOVF 0x170, W
07E3  3A00     XORLW 0x0
07E4  1903     BTFSC STATUS, 0x2
07E5  2FB6     GOTO 0x7B6
07E6  3A01     XORLW 0x1
07E7  1903     BTFSC STATUS, 0x2
07E8  2FC3     GOTO 0x7C3
07E9  3A03     XORLW 0x3
07EA  1903     BTFSC STATUS, 0x2
07EB  2FD0     GOTO 0x7D0
07EC  2FC7     GOTO 0x7C7
67:            }
07ED  0008     RETURN

(途中省略)としている部分は条件分岐先(case 文)に対するCソースのアセンブルリストです。そこまで載せると長くなるので今回は省略しました。

07DA~07ECのところは、どのcase文にジャンプするのかを判定するアセンブリリストです。ちょっとなにやっているのかわかりませんね。

そこで今回は、"dist\default\production\{PROJECT}.production.lst"を見ることにします。(注意:{PROJECT}の部分は、プロジェクト名によって変化します。)

  1620     07DA                     l1192:
  1621     07DA  087D               	movf	_S1,w
  1622     07DB  00F0               	movwf	??_S1_Action
  1623     07DC  01F1               	clrf	??_S1_Action+1
  1624                           
  1625                           ; Switch on 2 bytes has been partitioned into a top level switch of size 1, and 1 sub-sw
      +                          itches
  1626                           ; Switch size 1, requested type "simple"
  1627                           ; Number of cases is 1, Range of values is 0 to 0
  1628                           ; switch strategies available:
  1629                           ; Name         Instructions Cycles
  1630                           ; simple_byte            4     3 (average)
  1631                           ; direct_byte            8     6 (fixed)
  1632                           ; jumptable            260     6 (fixed)
  1633                           ;	Chosen strategy is simple_byte
  1634     07DD  0871               	movf	??_S1_Action+1,w
  1635     07DE  3A00               	xorlw	0	; case 0
  1636     07DF  1903               	skipnz
  1637     07E0  2FE2               	goto	l1458
  1638     07E1  2FC7               	goto	l1174
  1639     07E2                     l1458:
  1640                           
  1641                           ; Switch size 1, requested type "simple"
  1642                           ; Number of cases is 3, Range of values is 0 to 2
  1643                           ; switch strategies available:
  1644                           ; Name         Instructions Cycles
  1645                           ; simple_byte           10     6 (average)
  1646                           ; direct_byte           12     6 (fixed)
  1647                           ; jumptable            260     6 (fixed)
  1648                           ;	Chosen strategy is simple_byte
  1649     07E2  0870               	movf	??_S1_Action,w
  1650     07E3  3A00               	xorlw	0	; case 0
  1651     07E4  1903               	skipnz
  1652     07E5  2FB6               	goto	l1164
  1653     07E6  3A01               	xorlw	1	; case 1
  1654     07E7  1903               	skipnz
  1655     07E8  2FC3               	goto	l1172
  1656     07E9  3A03               	xorlw	3	; case 2
  1657     07EA  1903               	skipnz
  1658     07EB  2FD0               	goto	l1178
  1659     07EC  2FC7               	goto	l1174
  1660     07ED  0008               	return
  1661     07EE                     __end_of_S1_Action:

こっちの方が分かりやすいと思います。

"1649"で始まる行以降は、??_S1_Actionを0, 1, 2と比較し、一致するときにジャンプしています。これは結局のところ、S1_GREEN, S1_YELLOW, S1_RED と順番に比較し一致すれば対応するcase 文に跳んでいるのと同じです。

??_S1_ActionにS1の値が入っていれば、if ( S1 == S1_GREEN ) と同じように比較していることになり、if文のときとさほど変わりありません。

しかし、それより前の行のところ(色を付けています。)が謎の処理です。

1620 l1192:
1621 movf	_S1,w             ; WREGに グローバル変数S1の値をセット。
1622 movwf	??_S1_Action      ; ??_S1_Action に WREGの値をセット。
1623 clrf	??_S1_Action+1    ; ??_S1_Action+1 を0クリア

1634 movf	??_S1_Action+1,w  ; WREGに ??_S1_Action+1 の値をセット
1635 xorlw	0	; case 0  ; 定数0とXOR
1636 skipnz                       ; XORの結果が0でないなら、次の命令をスキップ
1637 goto	l1458             ; ラベル l1458にジャンプ
1638 goto	l1174             ; ラベル l1174にジャンプ

"??_S1_Action"はCのソースには出てこない作業変数です。関数S1_Action()の計算処理をするために、一時的にWREGの値を保持しておくためのメモリにコンパイラが自動的に名前を付けています。ここでは2byteサイズのメモリを使っています。uint16_t XXX;にアクセスするときに_XXXと_XXX+1とするのと同じように、??_S1_Actionと??_S1_Action+1になっています。

??_S1_Actionを uint16_t tmpとして考えると、最初の3行は tmp = S1; と代入しているのと同じです。

そして、後ろの5行は、tmpの上位1byteを0と比較し、一致すればl1458にジャンプし、一致しなければl1174にジャンプしています。 しかし、直前で0クリアしているので必ずl1458にジャンプします。

l1458は"1469”で始まる行につながっていて、tmpの下位1byteをS1_GREENなどと比較してcase文にジャンプしているのです。

C言語風に書くと、こうなります。

uint16_t tmp = S1;
switch ( tmpの上位1byte ){
  case 0:
    switch ( tmpの下位1byte ){
    case G_GREEN: 
      /* 以下書略 */
    }
    break;
  default:
    break;
}

なぜか16bitに拡張し、不要な条件分岐を増やしているのです。これがif文を使ったときよりもswitch文のときの方が命令数が多くなる原因です。

そしてこの不要な条件分岐はS1がどんな値であっても必ず通ることになるので、処理時間は確実に増えます。

アセンブルリストをよく見ると、下のようなコメントがあります。XC8はswicth文の条件式に1byte変数を記載していても、必ず2byteとして扱うと考えた方が良さそうです。

1625 ; Switch on 2 bytes has been partitioned into a top level switch of size 1, and 1 sub-switches

まとめ

XC8のPRO版はアセンブルリストレベルでの最適化がされると明言されていますので、PRO版だとここは最適化される可能性が高いですが、フリー版ではこのような無駄は残ってしまいます。

結論として「処理速度が重要なときは、switch文を使ってはならない」と言ってよいでしょう。

また注意してほしいことは、不具合防止や可読性など、メンテナンス面ではswitchはとても有効なことです。基本的にはswitchは使う方が良く、処理速度が求められる時にswitchを使わないようにする…使い分けが重要です。

条件分岐を高速にする方法

速度を重視してswitchを使わないことを選んだ時、単純なif文のコードで満足するのは考えどころです。他に方法はないでしょうか?

もちろん、いくつかの方法があります。

比較順を変更する

今回の例として使っているプログラムは、信号機をイメージしていて、緑黄赤の信号をそれぞれ160秒、20秒、180秒の時間点灯させるプログラムになっています。

したがって状態変数S1は、S1_REDになっている確率が最も多く、S1_GREEN、S1_YELLOWと続きます。この確率に合わせ、if文でチェックする順番を変更します。

void S1_Action2A(void)
{
    if (S1 == S1_RED)
    {
        if (S1_Timer == R_TIME)
        {
            ChangeGreen();
        }
    }
    else if (S1 == S1_GREEN)
    {
        if (S1_Timer == G_TIME)
        {
            ChangeYellow();
        }
    }
    else if (S1 == S1_YELLOW)
    {
        if (S1_Timer == Y_TIME)
        {
            ChangeRed();
        }
    }
    else
    {
        ChangeRed();
    }
}

S1_Action2A()に対しては46命令のアセンブルリストが生成されます。S1_Action2()と同じですが、確率論的にifチェックの回数が少なくなるので、こちらの方が高速になります。

ビットチェックによる分岐

XC8は、というか、PICはビットチェックによる分岐命令("BTFSS"と"BTFSC")があります。ソースコードは読みにくくなりますが、これを使うと高速に分岐できるはずです。

void S1_Action3(void)
{
    if (!(S1 & (1 << 1)))
    {
        if (!(S1 & (1 << 0)))
        {
            // S1 == 00 => S1_GREEN
            if (S1_Timer == G_TIME)
            {
                ChangeYellow();
            }
        }
        else
        {
            // S1 == 01 => S1_YELLOW
            if (S1_Timer == Y_TIME)
            {
                ChangeRed();
            }
        }
    }
    else
    {
        if (!(S1 & (1 << 0)))
        {
            // S1 == 10 => S1_RED
            if (S1_Timer == R_TIME)
            {
                ChangeGreen();
            }
        }
        else
        {
            // S1 == 11 => S1_ERROR
            ChangeRed();
        }
    }
}

enum型は値を指定しなければ0から順にカウントアップする整数値が割り当てられます。そのためS1は常に0~3の整数値です。ということは、0bit目と1bit目をチェックすればすべてのケースをチェックできます。

このS1_Action3()に対しては42命令のアセンブルリストが生成されます。S1_Action2()よりも少なくなりました。

またこの方式は全てのケースの分岐時間に均等なのも特徴です。swicth文を使う場合もif文を使う場合も最初に(G_GREENと)比較するケースにジャンプする方が早くなり、最後に(G_REDと)比較するケースにジャンプするのは遅くなります。

ビットチェックの場合は、どのケースでも必ず2回チェックするので差がありません。

テーブル方式による分岐

無理やりテーブル方式におるジャンプで分岐をすることもできます。

しかし、それにはインラインアセンブラを使わなくてなりません。C言語だけでは実現できないので「XC8攻略記」としては邪道ではありますが、紹介だけしておきましょう。

void S1_Action4(void)
{
    asm("MOVF _S1,w");
    asm("BRW");
    asm("GOTO S1_Action4_GREEN");
    asm("GOTO S1_Action4_YELLOW");
    asm("GOTO S1_Action4_RED");
    // asm("GOTO S1_Action4_ERROR");

    asm("S1_Action4_ERROR:");
    ChangeRed();
    asm("RETURN");

    asm("S1_Action4_RED:");
    if (S1_Timer == R_TIME)
    {
        ChangeGreen();
    }
    asm("RETURN");

    asm("S1_Action4_YELLOW:");
    if (S1_Timer == Y_TIME)
    {
        ChangeRed();
    }
    asm("RETURN");

    asm("S1_Action4_GREEN:");
    if (S1_Timer == G_TIME)
    {
        ChangeYellow();
    }
}

このケースは例を示すだけにとどまり、説明はしません。説明がないと何をしているのかわからない人がこれを使うのは危険です。

このS1_Action4()に対しては52命令のアセンブルリストが生成されます。XC8には同じ処理をまとめる最適化があるのですが、それが使えなくなるので命令数が増えてしまいます。

switch+テーブル方式

先に例示したテーブル方式のよる分岐は一見すると分岐しているように見えずメンテナンスに問題があります。そこで、多少メンテナンスしやすくした場合を以下に示します。

void S1_Action4A(void)
{
    asm("MOVF _S1,w");
    asm("BRW");
    asm("GOTO S1_Action4A_GREEN");
    asm("GOTO S1_Action4A_YELLOW");
    asm("GOTO S1_Action4A_RED");
    asm("GOTO S1_Action4A_ERROR");
    switch (S1)
    {
    case S1_GREEN:
        asm("S1_Action4A_GREEN:");
        if (S1_Timer == G_TIME)
        {
            ChangeYellow();
        }
        break;
    case S1_YELLOW:
        asm("S1_Action4A_YELLOW:");
        if (S1_Timer == Y_TIME)
        {
            ChangeRed();
        }
        break;
    case S1_RED:
        asm("S1_Action4A_RED:");
        if (S1_Timer == R_TIME)
        {
            ChangeGreen();
        }
        break;
    // case S1_ERROR:
    default:
        asm("S1_Action4A_ERROR:");
        ChangeRed();
        break;
    }
}

これも詳細説明は省きますが、本来ならC言語のswitch(S1)が個々のcase文に処理を分岐させるところを、その直前のインラインアセンブラで分岐させるようにしています。

この方式は元々のC言語のソースに少し追加するだけなので、メンテナンス性は良くなります。switch文はソースコードを読みやすくなる利点がありましたが、その利点が減っていません。

またS1_Action()のソースと比べたときに変更点が少ないことに気づくでしょう。switch文で書かれた既存コードを他の書き方に変更するのは修正箇所が多く手間がかかりますが、今回の方式だとそれを減らすことができます。

ただし、命令数は少し多くなってしまい、このS1_Action4A()に対しては64命令のアセンブルリストが生成されます。

これは、XC8はインラインアセンブラの意味を無視してアセンブルするためです。

S1_Action4A()はインラインアセンブラを無視すれば、S1_Action()と同じです。そのためXC8はS1_Action()をコンパイルしたときに同様のアセンブルリストに、インラインアセンブラを後付けしたようなアセンブルリストを作成します。

インラインアセンブラ部分で各case文にジャンプしているので、Cソースのswitch(S1)の部分に対するアセンブラはまったく通過することのない無駄な部分になってしまうのですが、XC8はそのようなことを認識することができないので、switch(S1)部分のアセンブルリストも作ってしまいます。

各処理方法の比較

今回は条件分岐によるジャンプが発生するので、命令数だけで処理時間を比較することは難しいです。ここまでに示した関数を、MPLABのシミュレーションで動かしてみたときの処理サイクル数を計測してみました。

下のような条件で10000回実行しました。

void status_sw(void)
{
    uint16_t i;
    S1_Timer = 0;
    for (i = 10000; i > 0; i--)
    {
        S1_Timer++;
        S1_Action();
    }
    S1_Timer = 0;
}

S1_Timer = 0 となっているところにBreakPointをセットして、MPLABのstopwatch機能でサイクル数を計測しています。

関数名命令数サイクル数説明
S1_Action57382630switch文で分岐
S1_Action246277658if文(単純比較)で分岐
S1_Action2A46266650if文(比較順の改良版)で分岐
S1_Action342246298ビットチェックを使って分岐
S1_Action452250714インラインアセンブラを使ったテーブル方式で分岐
S1_Action4A64250778switch文+インラインアセンブラを使ったテーブル方式
条件分岐のソースコードの性能比較

やはり、swicth文を使うケースが最も遅いです。

すでに説明したように、switch文とif文(単純比較)はアルゴリズムとしては同じで、switch文に無駄な命令が生成されていました。その無駄が約10.5サイクル分あるということになります。

最速はビットチェックです。これはビットチェックでの比較が命令1つ(変数に対し直接ビットチェックする)で行われるのにたいし、その他の比較は命令2~3つ(例えば、変数に対しXORWF命令やSUBWF命令で比較を行い、その結果が入ったSTATUSをビットチェックする)になるためです。

if文(単純比較)もそれほど遅くは有りませんが、これは分岐が4つしかなく、if文の比較回数が1~3回で済んでいるためだと考えられます。その証拠にif文の比較回数が減る改良版のほうが、少し高速になっています。もし分岐が4倍の16になれば、分岐にかかる処理時間も4倍になるでしょう。

またテーブル方式があまり速くないのも同様にenum値が4種類しかないからだと考えられます。今回は結果が良かったビットチェックでもenum値が増えれば比較回数が増えるので遅くなることが予想されます。テーブル方式はenum値が増えてもスピードは変わりません。

理論的には、今回のような分岐の仕方で分岐がN個あるなら、分岐にかかる時間は、if文(単純比較)はOrder(N)、ビットチェックはOrder(log N)、テーブル方式はOrder(1)になります。

コメント