前回は条件分岐を行うためのEFLAGSレジスタと条件分岐命令について説明しました。
しかし、
今回は、
if分岐の限界
たとえば:
変数 "
x
" に格納された値が0, 1, 2, 3, およびそれ以外の場合に、それぞれ異なる処理を行いたい。
という状況で、
if(x == 0){ .... }
else if(x == 1){ .... }
else if(x == 2){ .... }
else if(x == 3){ .... }
else{ .... }
上記の例は、
- 最悪の場合なら、
常にN回の条件判定 - 値が均等に分散している場合でも、
平均してN/ 2回の条件判定
となりますx
" の傾向次第ですが)
このような場合、
switch 構文を使用することで、
switch(x){
case 0:
....; break;
case 1:
....; break;
case 2:
....; break;
case 3:
....; break;
default:
....; break;
}
switch構文での実装の場合、x
の判定に要するコスト
- ※)
- 詳細は後述しますが、
caseで列挙される値次第では、 必ずしもこの前提は成立しません。
switch の動作原理
アセンブラでswitch構文を実現するには、
判定対象の値x
」)を列挙されたcaseごとに比較していたのでは、
switch構文の振る舞いをもう少し細かく見てみると、
- 対象値がMINなら、
" case MIN
" 位置へ - 対象値がMIN + 1なら、
" case MIN + 1
" 位置へ - 対象値がMIN + 2なら、
" case MIN + 2
" 位置へ - ....(省略)....
- 対象値がMAX - 1なら、
" case MAX - 1
" 位置へ - 対象値がMAXなら、
" case MAX
" 位置へ - それ以外なら、
" default
" 位置へ
というのがswitch構文に期待される処理です。
対象値がMIN~MAXの間の処理は、
これなら:
- 値
x
がMIN ≦ x ≦ MAX
か否かを判定 - 成立していないなら、
default
位置へ制御遷移 - 値
x
に応じて、対応する位置へ制御遷移
という手順に単純化できます。
「値に応じて、
対象値がMIN~MAXの間で連続していない場合に、配列参照で上手く機能するのか?
という疑問が出てくるかも知れません。しかし:
case
で列挙されていない値の場合はdefault
位置へ制御遷移
で良いわけですから、case
列挙されていない値に対応する要素を、default
を指すものに設定してやれば良いのです。
switchの実現
それでは、
アセンブラでの実装の元となる処理は、
switch(x){
case 2:
a = 1; break;
case 5:
a = 2; break;
case 8:
a = 3; break;
default:
a = 4; break;
}
switchブロック部分の実装
まずはswitch構文のブロック部分
switch_case_2:
movl $1, a
jmp switch_eob
switch_case_5:
movl $2, a
jmp switch_eob
switch_case_8:
movl $3, a
jmp switch_eob
switch_default:
movl $4, a
jmp switch_eob
switch_eob: # end of block
.global end_of_program
end_of_program:
int3 # プログラム実行の一時停止
nop
break
に相当する処理が、
制御遷移先配列の作成
次は
何やら堅苦しい表現をしてはいますが、
addr_table:
.long switch_case_2 # 2(MIN + 0)
.long switch_default # 3(MIN + 1)
.long switch_default # 4(MIN + 2)
.long switch_case_5 # 5(MIN + 3)
.long switch_default # 6(MIN + 4)
.long switch_default # 7(MIN + 5)
.long switch_case_8 # 8(MIN + 6)
見ての通り、
元ソースのcase
で列挙されている値は、default
に相当するアドレスを設定しているのがミソです。
対応するcase位置への制御遷移
ここまで準備ができたなら、
.text
.align 4
.global entry_point
entry_point:
int3 # プログラム実行の一時停止
movl x, %eax
# 値域判定
cmpl $2, %eax
jl switch_default # "x < 2" なら default
cmpl $8, %eax
jg switch_default # "8 < x" なら default
# 制御遷移先配列による制御遷移
leal addr_table, %ebx
subl $2, eax # 最小値の 2 を減算
jmp *(%ebx, %eax, 4)
switch実現の肝になるのは、
これまでの本連載における実装例では、
- ベース値はレジスタ
ebx
の値(= addr_
)table - レジスタ
eax
(=変数 "x" - 2) によるインデックス付き - インデックスの倍数は4 =32ビットアドレスの格納サイズ
- 間接アドレッシング=メモリに格納されているアドレスを使用
以上のことから、
- 「
addr_
の位置に格納されたアドレス」table + (変数 "x" - 2) * 4
となります。
後は変数xおよびaの格納領域を確保する必要がありますので、
.data
.align 4
.global x
x:
.long 0
.global a
a:
.long 0
最終的なサンプルソースは、
- リスト 7
- リスト 5
- リスト 6
- リスト 4
それでは実際に実行してみましょう。
(gdb) run .... Program received signal SIGTRAP, Trace/breakpoint trap. 0x00401001 in entry_ point () (gdb) disassemble entry_ point Dump of assembler code for function entry_point: 0x00401000 <entry_ point+0>: int3 0x00401001 <entry_ point+1>: mov 0x402000,%eax 0x00401006 <entry_ point+6>: cmp $0x2,%eax 0x00401009 <entry_ point+9>: jl 0x401040 <switch_ default> 0x0040100b <entry_ point+11>: cmp $0x8,%eax 0x0040100e <entry_ point+14>: jg 0x401040 <switch_ default> 0x00401010 <entry_ point+16>: lea 0x402008,%ebx 0x00401016 <entry_ point+22>: sub $0x2,%eax 0x00401019 <entry_ point+25>: jmp *(%ebx,%eax,4) End of assembler dump. (gdb) set var x=5 ※ 変数 x を 5 に設定 (gdb) stepi ... "jmp *(%ebx,%eax,4)" まで進める ... 0x00401019 in entry_ point () (gdb) info register ebx eax ebx 0x402008 4202504 eax 0x3 3 ※ レジスタ値の確認 (gdb) stepi 0x00401028 in switch_ case_ 5 () ※ "case 5" 相当の位置に制御遷移 (gdb) x/ 1x 0x402008+3*4 0x402014 <addr_table+12>: 0x00401028 ※ "%ebx + %eax * 4" の位置に "case 5" 相当の アドレスが格納されていることを確認 (gdb)
値に応じて想定した位置に制御遷移していることがわかります。
低レイヤーから見たswitch
switch相当の機能実現に関する説明は以上で終わりですが、
データ量を減らすには
先述した switch実現例では、
しかし、
- ※)
- ひょっとしたら、
8ビット=1バイトでも十分かもしれません。
たとえば、
leal addr_table, %ebx
subl $2, %eax
movl $0, %ecx
# 16 ビット幅の転送なので、転送先レジスタ指定は
# 32 ビット幅の ecx ではなく、16 ビット幅の cx。
# インデックスの倍数も 4 ではなく 2。
movw (%ebx, %eax, 2), %cx
# アドレス値としての switch_jmp を加算するので
# 即値を示す $ を使用
addl $switch_jmp, %ecx
switch_jmp:
# ecx の値が指すアドレスへ制御遷移
jmp *%ecx
上記実装での制御遷移先アドレスの算出では、switch_
のアドレスを加算しています。そのため制御遷移先配列に格納する値はswitch_
からの相対的な値、switch_
との差分だけを格納すれば良いことになりますから、
addr_table:
.word switch_case_2 - switch_jmp
.word switch_default - switch_jmp
.word switch_default - switch_jmp
.word switch_case_5 - switch_jmp
.word switch_default - switch_jmp
.word switch_default - switch_jmp
.word switch_case_8 - switch_jmp
実行される命令数の増加=性能低下をある程度許容してでも、
制御遷移先は「既知」
初心者がswitch構文を使用する場合の代表的な間違いに:
変数を使ったcase列挙によるコンパイルエラー
というものがあるのではないでしょうか。
プログラミングに慣れるに従い:
switch構文のcaseには、
変数を指定してはならない
というルールを理解するわけですが、
コンパイル時点で、
制御遷移先配列を格納=値の範囲を確定する必要があるため、 実行時にならないと値が確定できない変数値は、 caseでの列挙には使用できない
という理屈で理解できるようになっていることでしょう。
switchのコスト
先述したswitchの実現例を見て:
case
列挙の下限値~上限値の範囲判定が必要なので、if/ elseを使用したものよりもswitchは余計な処理をしているのでは?
と思われるかも知れません。
必要な条件判定が2つ程度であるなら、
また、
さらに上限値と下限値の開きが大きい場合、
このようなケースでCコンパイラが生成するアセンブラコードは
- case で列挙されている値を大小で2つのグループに分割
- 中間値で大小判定
- どちらかのグループで一致が検出されるまで、
分割~判定の手順を繰り返す - 一致するcase値が判明したなら、
該当する処理に制御遷移 - 一致するcase値が無いことが判明した場合は default 処理に制御遷移
といった二分探索
コンパイラの最適化が上手くハマれば、
以上のように、
しかし、