制御フロー命令
制御フローは、どの命令を次に実行するかを定める流れです。クラッシュ前にチェックがあったか、ループの終了条件が何か、どこへ間接呼び出しするかを調べる際に役立ちます。以下は共通サンプルのMSVC /O2 /Ob0ビルドをCDBで確認した出力です。
比較命令とフラグ
x86・x64のcmp a,bはa - bに相当する条件フラグを設定しますが、差そのものは保存しません。test a,bはビット積に基づいてフラグを設定します。
int guarded_read(const int *p) { return p ? *p : -1; }
0:000:AMD64> uf sample!guarded_read
sample!guarded_read:
00007ff6`81521190 4885c9 test rcx,rcx
00007ff6`81521193 7403 je sample!guarded_read+0x8 (00007ff6`81521198)
sample!guarded_read+0x5:
00007ff6`81521195 8b01 mov eax,dword ptr [rcx]
00007ff6`81521197 c3 ret
sample!guarded_read+0x8:
00007ff6`81521198 b8ffffffff mov eax,0FFFFFFFFh
00007ff6`8152119d c3 ret
rcxが0ならZFが1になり、jeで-1を返す経路へ分岐します。0でなければポインターから読み取ります。ただし、このチェックはNULL以外のポインターの有効性を保証しません。jeとjzは同じ条件の別名です。test eax,4なら、値全体の0判定ではなく、ビット2が立っているかを確認できます。
ARM64のcmp w0,w1は、結果を捨ててNZCVだけを更新するsubsの別名です。tst w0,w1もビット積の結果を捨ててフラグを設定します。
フラグを読むときは、直前の行だけでなく、どの命令がそのフラグを最後に更新したかを追います。x86・x64のmov・lea、ARM64の通常のadd・subなどは条件フラグを更新しません。
符号付き比較と符号なし比較
以下はcmp a,bの後で、aとbを比較する場合の対応です。
| 条件 | x86・x64 | ARM64 |
|---|---|---|
| 等しい | je / jz | b.eq |
| 等しくない | jne / jnz | b.ne |
| 符号なしで小さい | jb(CF = 1) | b.lo(C = 0) |
| 符号なしで以下 | jbe(CF = 1 または ZF = 1) | b.ls(C = 0 または Z = 1) |
| 符号なしで大きい | ja(CF = 0 かつ ZF = 0) | b.hi(C = 1 かつ Z = 0) |
| 符号なしで以上 | jae(CF = 0) | b.hs(C = 1) |
| 符号付きで小さい | jl(SF ≠ OF) | b.lt(N ≠ V) |
| 符号付きで以下 | jle(ZF = 1 または SF ≠ OF) | b.le(Z = 1 または N ≠ V) |
| 符号付きで大きい | jg(ZF = 0 かつ SF = OF) | b.gt(Z = 0 かつ N = V) |
| 符号付きで以上 | jge(SF = OF) | b.ge(N = V) |
ARM64のb.loとb.cc、b.hsとb.csはそれぞれ同じ条件です。減算におけるCFとCの意味が逆になるため、x86のCFの読み方をそのままARM64へ持ち込まないことが重要です。
たとえば32ビットの0xffffffffは符号付きなら-1、符号なしなら4294967295です。これを1と比較すると、jlでは「小さい」、jbでは「小さくない」と判定されます。添字の範囲チェックが期待どおりか調べる際に、分岐命令から符号の扱いを確認できます。
同じ違いは、分岐ではなく0か1の結果を作る命令にも現れます。
int signed_less(int a, int b) { return a < b; }
int unsigned_less(unsigned int a, unsigned int b) { return a < b; }
0:000:AMD64> uf sample!signed_less
sample!signed_less:
00007ff6`815213b0 33c0 xor eax,eax
00007ff6`815213b2 3bca cmp ecx,edx
00007ff6`815213b4 0f9cc0 setl al
00007ff6`815213b7 c3 ret
0:000:AMD64> uf sample!unsigned_less
sample!unsigned_less:
00007ff6`815213e0 33c0 xor eax,eax
00007ff6`815213e2 3bca cmp ecx,edx
00007ff6`815213e4 0f92c0 setb al
00007ff6`815213e7 c3 ret
0:000> uf sample!signed_less
sample!signed_less:
00007ff6`7bef4420 6b01001f cmp w0,w1
00007ff6`7bef4424 1a9fa7e0 csetlt w0
00007ff6`7bef4428 d65f03c0 ret
0:000> uf sample!unsigned_less
sample!unsigned_less:
00007ff6`7bef4460 6b01001f cmp w0,w1
00007ff6`7bef4464 1a9f27e0 csetlo w0
00007ff6`7bef4468 d65f03c0 ret
比較自体は同じでも、x64はsetlとsetb、ARM64はcsetltとcsetloで条件が異なります。このCDBは条件を命令名に連結して表示しています。たとえばcsetlt w0は、他のツールでのcset w0,ltに相当します。
ARM64でフラグを使わない分岐
0:000> uf sample!guarded_read
sample!guarded_read:
00007ff6`7bef4160 b4000060 cbz x0,sample!guarded_read+0xc (00007ff6`7bef416c)
sample!guarded_read+0x4:
00007ff6`7bef4164 b9400000 ldr w0,[x0]
00007ff6`7bef4168 d65f03c0 ret
sample!guarded_read+0xc:
00007ff6`7bef416c 12800000 mov w0,#-1
00007ff6`7bef4170 d65f03c0 ret
同じguarded_readですが、ARM64ではcbzだけでNULL判定と分岐を行っています。cbz・cbnzはレジスターが0かどうか、tbz・tbnzは指定ビットが0か1かを判定します。これらはNZCVを参照・更新しません。w0の0判定とx0の0判定も区別します。
if文を読む
次の条件付きの値選択をCDBで確認します。
int positive_or_zero(int value) { return value > 0 ? value : 0; }
0:000:AMD64> uf sample!positive_or_zero
sample!positive_or_zero:
00007ff6`81521340 33c0 xor eax,eax
00007ff6`81521342 85c9 test ecx,ecx
00007ff6`81521344 0f4fc1 cmovg eax,ecx
00007ff6`81521347 c3 ret
0:000> uf sample!positive_or_zero
sample!positive_or_zero:
00007ff6`7bef43b0 7100001f cmp w0,#0
00007ff6`7bef43b4 1a9fc000 cselgt w0,w0,wzr
00007ff6`7bef43b8 d65f03c0 ret
このビルドでは、x64のcmovgとARM64のcselgtで、分岐せず値を選んでいます。ソースにifや条件演算子があっても、必ず条件ジャンプがあるとは限りません。
ループを読む
次は、配列コピーのソースから生成されたループです。x64はuf出力のスカラーループ部分だけを抜粋しています。入口の件数チェックやmemcpyへ渡す別経路は省略しています。
0:000:AMD64> uf sample!copy_ints
sample!copy_ints+0x43:
00007ff6`815210b3 8b0c82 mov ecx,dword ptr [rdx+rax*4]
00007ff6`815210b6 890c83 mov dword ptr [rbx+rax*4],ecx
00007ff6`815210b9 ffc0 inc eax
00007ff6`815210bb 413bc2 cmp eax,r10d
00007ff6`815210be 72f3 jb sample!copy_ints+0x43 (00007ff6`815210b3)
0:000> uf sample!copy_ints
sample!copy_ints:
00007ff6`7bef4080 52800008 mov w8,#0
00007ff6`7bef4084 340000c2 cbz w2,sample!copy_ints+0x1c (00007ff6`7bef409c)
sample!copy_ints+0x8:
00007ff6`7bef4088 b8685829 ldr w9,[x1,w8 uxtw #2]
00007ff6`7bef408c b8285809 str w9,[x0,w8 uxtw #2]
00007ff6`7bef4090 11000508 add w8,w8,#1
00007ff6`7bef4094 6b02011f cmp w8,w2
00007ff6`7bef4098 54ffff83 blo sample!copy_ints+0x8 (00007ff6`7bef4088)
sample!copy_ints+0x1c:
00007ff6`7bef409c d65f03c0 ret
x64のeax、ARM64のw8が添字で、終了条件はそれぞれr10d、w2の件数との符号なし比較です。入口にはcount = 0で本体を実行しないためのチェックがあります。実際には添字ではなく終端ポインターと比較することもあるため、終了条件だけでなく、ポインターやカウンターが何単位で更新されるかも確認します。
無条件分岐・間接分岐・末尾呼び出し
| 目的 | x86・x64 | ARM64 |
|---|---|---|
| 指定先へ移動 | jmp label | b label |
| レジスターの値を移動先とする | jmp rax(x64) | br x16 |
| レジスターの値を呼び出し先とする | call rax(x64) | blr x16 |
| 呼び出し元へ戻る | ret | ret(通常x30を使用) |
switch文がジャンプテーブルになる場合や、関数ポインター・仮想関数を使用する場合は、実行時に読み取った値が分岐先になります。ターゲットを調べるときは、その値の取得元と、実行可能なモジュールに属するかを確認します。
関数末尾のjmpやbは、スタックを復元した後に別の関数へ移る末尾呼び出しかもしれません。この場合、通常のcall・blと異なり新しい戻りアドレスを作らないため、コールスタックに中間の関数が現れないことがあります。
ダンプで分岐を判断できる範囲
条件分岐の直前で停止していれば、レジスターやフラグから次の分岐先を判断できます。一方、既に分岐した後で、別の演算や関数呼び出しにより値が変わっていれば、過去の判定結果を現在の値だけで確定できません。
ループ中のハングも、1回のダンプだけでは単に処理中なのか、進捗がないのか判断しにくい場合があります。時間を空けた複数のダンプ、ライブデバッグ、Time Travel Debuggingなどが有効です。
参考資料
- Intel 64 and IA-32 Architectures Software Developer's Manuals — CMP、TEST、Jcc、CMOVcc
- Arm A64 Instruction Set Architecture — B.cond、CBZ、TBZ、CSEL
- Time Travel Debugging overview | Microsoft Learn