算術演算とビット操作
サイズ計算の誤り、配列の添字、フラグ判定などを調べるときは、命令名に加えて演算幅と符号の解釈を確認します。以下の出力は共通サンプルをMSVCの/O2 /Ob0でビルドし、CDBで取得しています。レジスターの幅は汎用レジスター、分岐条件は制御フロー命令で扱います。命令の比較表は、一般的な形式の説明です。
加算と減算
| 目的 | x86・x64 | ARM64 |
|---|---|---|
| 加算 | add eax,edx | add w0,w1,w2 |
| 減算 | sub eax,edx | sub w0,w1,w2 |
| フラグを更新する加算 | add eax,edx | adds w0,w1,w2 |
| フラグを更新する減算 | sub eax,edx | subs w0,w1,w2 |
x86・x64のadd eax,edxはeaxを読み書きします。ARM64のadd w0,w1,w2はw1 + w2をw0へ書き込みます。ARM64では通常のaddとsubは条件フラグを変更せず、addsとsubsが変更します。
x86・x64のincとdecは1を加減しますが、add・subと異なりCFを変更しません。直前に見える命令が、調べたいフラグを更新した命令とは限らない点に注意します。
桁あふれと符号
32ビットの加算結果は、下位32ビットだけがレジスターに残ります。同じ結果でも、符号付きと符号なしでは桁あふれの判定が異なります。
| 32ビットの加算 | 保存される結果 | 符号なしの桁あふれ | 符号付きの桁あふれ |
|---|---|---|---|
0xffffffff + 1 | 0x00000000 | あり | なし(-1 + 1) |
0x7fffffff + 1 | 0x80000000 | なし | あり |
x86・x64では、符号なしの桁あふれをCF、符号付きの桁あふれをOFで確認します。ARM64のaddsでは、それぞれC、Vに対応します。ただし、減算ではC/CFの意味が次のように異なります。
- x86・x64のCFが1:ボロー(borrow、桁借り)が発生した
- ARM64のCが1:ボローが発生しなかった
この違いは符号なし比較の分岐条件にも現れます。命令レベルの結果が折り返すことと、C/C++の符号付き整数オーバーフローが未定義動作であることも別です。最適化済みコードでは、未定義動作にならないという前提で比較や分岐が除去される場合があります。
サイズ計算の例
次は、要素数を4倍する32ビット演算です。同じソースをx64・ARM64で確認します。
unsigned int scaled_bytes(unsigned int count) { return count * 4u; }
0:000:AMD64> uf sample!scaled_bytes
sample!scaled_bytes:
00007ff6`81521390 8d048d00000000 lea eax,[rcx*4]
00007ff6`81521397 c3 ret
0:000> uf sample!scaled_bytes
sample!scaled_bytes:
00007ff6`7bef4400 531e7400 lsl w0,w0,#2
00007ff6`7bef4404 d65f03c0 ret
このビルドのx64ではshlではなくleaが選ばれましたが、書き込み先は32ビットのeaxです。countが0x40000000なら、下位32ビットの結果は0になります。後からraxを64ビットのサイズとして使っても、既に失った上位ビットは戻りません。確保サイズと実際の書き込み量が食い違う障害では、最終的なポインター幅だけでなく、途中の演算が32ビットか64ビットかを追うとよいでしょう。
ARM64でもlsl w0,w1,#2なら32ビット、lsl x0,x1,#2なら64ビットの演算です。
乗算
| 命令例 | 読み方 |
|---|---|
x86・x64:imul eax,ecx,0Ch | ecxを10進数の12倍した下位32ビットをeaxへ格納 |
x86・x64:mul ecx | eaxとecxを符号なし乗算し、64ビットの結果をedx:eaxへ格納 |
ARM64:mul w0,w1,w2 | 32ビット乗算の下位32ビットをw0へ格納 |
ARM64:umull x0,w1,w2 | 32ビット符号なし乗算の64ビット結果をx0へ格納 |
Intel構文の数値表記はツールによって異なります。WinDbgの0ChやARM64の#12も同じ12を表します。
配列や構造体のアドレス計算では、明示的な乗算の代わりにシフトやleaが使われることがあります。lea eax,[ecx+ecx*2]は3倍の計算で、メモリーの読み取りは行いません。
除算
x86・x64のdivは符号なし、idivは符号付きの除算です。32ビットのidiv ecxはeaxだけではなく、edx:eaxを被除数とし、商をeax、余りをedxへ格納します。
int divide_values(int dividend, int divisor) { return dividend / divisor; }
0:000:AMD64> uf sample!divide_values
sample!divide_values:
00007ff6`81521100 448bc2 mov r8d,edx
00007ff6`81521103 8bc1 mov eax,ecx
00007ff6`81521105 99 cdq
00007ff6`81521106 41f7f8 idiv eax,r8d
00007ff6`81521109 c3 ret
このCDBは除算をidiv eax,r8dと表示しています。被除数は暗黙のedx:eax、除数はr8dです。元の除数がedxに渡されるため、cdqで上書きする前にr8dへ退避しています。
cdqはeaxの符号をedxへ拡張します。x64で64ビット除算する場合は、cqoでraxをrdx:raxへ符号拡張し、idiv rcxなどを使用します。除数が0の場合だけでなく、商が格納先に収まらない場合にも除算例外が発生します。
同じ関数のARM64出力では、除数0のチェックも生成されました。
0:000> uf sample!divide_values
sample!divide_values:
00007ff6`7bef40b0 35000041 cbnz w1,sample!divide_values+0x8 (00007ff6`7bef40b8)
sample!divide_values+0x4:
00007ff6`7bef40b4 d43e0080 brk #0xF004
sample!divide_values+0x8:
00007ff6`7bef40b8 1ac10c00 sdiv w0,w0,w1
00007ff6`7bef40bc d65f03c0 ret
ARM64はudiv・sdivで商を求めます。たとえばsdiv w0,w1,w2はw1 / w2の商をw0へ格納します。余りは必要に応じて乗算・減算で別に求めます。ARM64の整数除算命令は除数0でもハードウェアの除算例外を発生させず、商を0にします。ただし、C/C++の0除算が有効になるわけではありません。言語仕様上は未定義動作であり、チェックコードの有無も含めて確認します。
今回のcbnzとbrkはコンパイラーが追加したチェックで、sdiv自体の動作とは別です。命令セットの仕様と、実際に生成されたコードの例外処理を区別するとよいでしょう。
ビット操作とシフト
| 操作 | x86・x64 | ARM64 |
|---|---|---|
| ビット積 | and eax,edx | and w0,w1,w2 |
| ビット和 | or eax,edx | orr w0,w1,w2 |
| 排他的論理和 | xor eax,edx | eor w0,w1,w2 |
| 左シフト | shl eax,2 | lsl w0,w1,#2 |
| 論理右シフト | shr eax,2 | lsr w0,w1,#2 |
| 算術右シフト | sar eax,2 | asr w0,w1,#2 |
論理右シフトは上位を0で埋め、算術右シフトは符号ビットで埋めます。負数の算術右シフトを、符号付き除算と同じと考えると誤読します。たとえば-3を1ビット算術右シフトした結果は-2ですが、-3を2で符号付き除算した商は0方向に丸めた-1です。
xor eax,eaxは0を格納する典型例で、条件フラグも変わります。ARM64で0を作るmov w0,wzrはNZCVを変更しません。値を0にするという目的が同じでも、後続の条件分岐への影響は異なります。
ビット操作の実測例
unsigned int mask_flags(unsigned int value, unsigned int mask)
{
return (value & mask) | 0x10u;
}
int arithmetic_half(int value) { return value >> 1; }
0:000:AMD64> uf sample!mask_flags
sample!mask_flags:
00007ff6`81521330 23ca and ecx,edx
00007ff6`81521332 83c910 or ecx,10h
00007ff6`81521335 8bc1 mov eax,ecx
00007ff6`81521337 c3 ret
0:000:AMD64> uf sample!arithmetic_half
sample!arithmetic_half:
00007ff6`81521030 d1f9 sar ecx,1
00007ff6`81521032 8bc1 mov eax,ecx
00007ff6`81521034 c3 ret
0:000> uf sample!mask_flags
sample!mask_flags:
00007ff6`7bef43a0 0a010008 and w8,w0,w1
00007ff6`7bef43a4 321c0100 orr w0,w8,#0x10
00007ff6`7bef43a8 d65f03c0 ret
0:000> uf sample!arithmetic_half
sample!arithmetic_half:
00007ff6`7bef4030 13017c00 asr w0,w0,#1
00007ff6`7bef4034 d65f03c0 ret
このMSVCビルドでは、符号付き値の右シフトにSAR・ASRが使われています。-3の入力なら結果は-2で、符号付き除算-3 / 2の-1とは異なります。
障害調査で確認する順序
- 演算対象が32ビットか64ビットかを確認する
- 途中で切り捨て、符号拡張、ゼロ拡張がないか確認する
- 符号付き・符号なしのどちらとして判定されるか、後続の分岐を見る
- 確保サイズ、添字、コピー長が同じ単位で計算されているか確認する
- ダンプだけで入力値を復元できなければ、再現環境のブレークポイントなどで演算前の値を確認する
参考資料
- Intel 64 and IA-32 Architectures Software Developer's Manuals — ADD、SUB、IMUL、IDIV、シフト命令
- Arm A64 Instruction Set Architecture — ADDS、SUBS、SDIV、UDIV、シフト命令
- 未定義動作