2の補数による負数表現
下記は,負数を含むプログラムの例だ。
; 100 + (-12) を計算して終了コードとして出力
section .text
global _start
_start:
mov eax, -12
mov ebx, 100
add ebx, eax
mov eax, 1
int 0x80 ; exit
実行してみよう。
$ nasm minus.s
$ ld minus.o
$ ./a.out
$ echo $?
88
確かに,100 + (−12) の計算結果が出力されている。
−12 という値は機械語プログラム中ではどのように表されているのだろうか?
$ objdump -M intel -d a.out
-- 中略 --
08049000 <_start>:
8049000: b8 f4 ff ff ff mov eax,0xfffffff4
8049005: bb 64 00 00 00 mov ebx,0x64
804900a: 01 c3 add ebx,eax
804900c: b8 01 00 00 00 mov eax,0x1
8049011: cd 80 int 0x80
負数 −12 は 0xfffffff4 で表されていることがわかる。
現在のほとんどのコンピュータは,負の整数 −x を,x の2の補数 (two's complement) で表す。 0xfffffff4 は,32桁の2進数 0x0000000c (= 12) の2の補数だ。 N桁の2進数 x の2の補数は,2N − x と定義される。
(角度と類似していると考えれば少しわかりやすいかも知れない。角度を度数で表す場合,−20°は340°と等しい。−x 度は 360 − x 度と等しい。N桁の2進数で数を表す場合,「1周」に当たるのが2N というわけだ。)
正負のある(つまり符号付きの)整数を扱う場合,Nビットで表せるすべてのビットパターンのうち半分を負数のために使う(下表)。 最上位ビットが1であれば負数,最上位ビットが0であれば正数または0を表す。 つまり,最上位ビットが正負の符号を表しており,このビットを符号ビット (sign bit) と言う。
2の補数以外にも負数を表す方法はあるが,2の補数を使った場合の特徴は以下の2点だ。
- 加減算 (ADD/SUB) の際,符号付き数か符号なし数かを区別しなくてよい。 符号付き数のための特別な加減算命令は必要ない。
- 乗除算 (MUL/DIV) については,符号付き数か符号なし数かを区別する必要がある。 MUL/DIV命令は符号なし数同士の乗除算命令だ。 それとは別に,符号付き数同士の乗除算を行う IMUL/IDIV 命令 (integer multiply/divide) が用意されている。
例: 8ビットの数 0xfe と 0xf6 の積を,MUL 命令で計算した場合,IMUL 命令で計算した場合,それぞれ出力させてみよう。
section .text
global _start
extern print_eax_hex
_start:
mov al, 0xfe
mov ah, 0xf6
mul ah ; ax = al * ah
call print_eax_hex
mov al, 0xfe
mov ah, 0xf6
imul ah ; ax = al * ah
call print_eax_hex
mov eax, 1
mov ebx, 0
int 0x80 ; exit
$ nasm imul.s
$ ld imul.o ../chap8/print_eax_hex.o
$ ./a.out
0000f414
00000014
0xfe, 0xf6 は,符号なし数と解釈すれば 254 と 246,それらの積は 62484 (= 0xf414) だ。 符号付き数と解釈すれば −2 と −10,それらの積は 20 (= 0x14) だ。
ここまでの説明からもわかると思うが,CPUは,レジスタや主記憶内のビット列が符号付き数であるか符号なし数であるか関知しない。 MUL/DIV命令を実行すると,オペランドを符号なし数と解釈した場合の乗除算結果が得られる。 IMUL/IDIV命令を実行すると,オペランドを符号付き数と解釈した場合の乗除算結果が得られる。 どちらの命令を使うべきかはプログラム作成者(またはコンパイラ)が選ばなければならない。
補足: 符号付き数の大小比較
問: EAX = 0xfffffffe かつ EBX = 0x0000000a のとき,EAX と EBX はどちらが大きいか?
符号付き数と解釈すれば,EAX = −2 かつ EBX = 10 なので,EAX < EBX。 符号なし数と解釈すれば,EAX = 4,294,967,294 かつ EBX = 10 なので,EAX > EBX。
i386の場合,「符号付き数用」及び「符号なし数用」の条件付きジャンプ命令がある。 JG/JL (jump if greater/less) は「符号付き数用」, JA/JB (jump if above/below) は「符号なし数用」だ。
mov eax, -2 ; = 0xfffffffe
mov ebx, 10 ; = 0x0000000a
cmp eax, ebx
jg L0 ; jump if -2 > 10 (No!)
cmp eax, ebx
ja L1 ; jump if 4294967294 > 10 (Yes!)
CMP命令を実行すると OF, SF, ZF, AF, PF, CF の各フラグが変化する。 JG/JL はこのうち OF, SF, ZF の値に従ってジャンプするかどうか決める。 JA/JB は ZF, CF の値に従ってジャンプするかどうか決める。
- JG ... jump if ZF=0 && SF=OF
- JL ... jump if SF≠OF
- JA ... jump if CF=0 && ZF=0
- JB ... jump if CF=1
- 参照: フラグレジスタ(第3章 繰り返しと条件分岐 (i386))
- OF (overflow flag) は,正数同士の加算結果が負数になったり負数同士の加算が正数になったりしたときに 1 になる。後述の「桁あふれ」の項も参照。
補足: 正負の判定
あるレジスタの値が負数かどうか調べる方法はいくつかある。これまでの説明からわかる簡単な方法は,0 との大小比較をすることだろう。
cmp eax, 0 ; 0と比較
jl minus ; 0より小 (つまり負)
ほかに,符号フラグ (sign flag; SF) を使う方法もある。 SFは,演算命令を実行した結果が負数(最上位ビットが1)だったとき 1 になる。JS / JNS 命令を使うことで,SFの値によって分岐することができる。 レジスタの値が負数かどうか調べる場合は,レジスタの値自身が演算結果になるような命令を実行した後,JS または JNS を実行すればよい。 下の JS を使った記述例は,上の JL を使った例と全く同じ結果になる。
cmp eax, 0
js minus ; eaxは負
この例では,cmp eax, 0
を「EAXの値自身が演算結果になる演算」として使っている。「EAXの値自身が演算結果になる演算」は他にも多数ある(下記)。下記の演算命令のいずれも,JS / JNS と組み合わせて正負の判定に使える。(と言ってもCMP・TEST以外の命令をわざわざ使う必要性はたぶんない。test eax, eax
は,cmp eax, 0
より命令長が短いという利点はある。)
; 以下はいずれもeaxの値自身が演算結果になる, かつeaxは変化しない
cmp eax, 0 ; 0を引く
test eax, eax ; 自分自身とand
sub eax, 0 ; 0を引く
add eax, 0 ; 0を足す
and eax, eax ; 自分自身とand
or eax, eax ; 自分自身とor
(同じことが,レジスタの値が 0 かどうか調べる場合にも言える。上記の演算命令のいずれも,JZ / JNZ と組み合わせて0かどうかの判定に使える。)
補足: 符号反転
問: 符号付き数において,正負を反転する(つまり −1 倍する)にはどうすればよいか?
「IMUL命令で −1 を掛ける」のも一つの方法だが,ちょっと大げさだし,IMUL命令のないCPUではどうしたらよいかわからない。 もっと簡単な方法があるのではないだろうか?
NOT命令を適用して得られる数,つまりすべてのビットを反転した数を,1の補数 と言う。
問: Nビットの2進数 x の1の補数を ~x で表す。x + (~x) はいくらか?
x の1の補数を ~x,x の2の補数を −x で表すとする。定義より,x + (−x) = 2N だ。一方,x + (~x) も,x に関わらず一定の値 c になる。これらから,−x = (~x) + 2N − c が言える。つまり,~x に 2N − c を加えると −x が得られるということだ。
1の補数と2の補数の間の関係を利用すれば,NOT命令と加算命令を使って符号を反転させる(x から −x を求める)ことができる。 上述の8ビットの場合の「2の補数の表」を見て考えよう。 0x00 (= 0) の1の補数は 0xff (= −1),0x01 (= 1) の1の補数は 0xfe (= −2),0x7e (= 126) の1の補数は 0x81 (= −127),0x7f (= 127) の1の補数は 0x80 (= −128)。 つまり,0 をビット反転すると −1,1 をビット反転すると −2,…,127 をビット反転すると −128。 逆に,−1 をビット反転すると 0,−2 をビット反転すると 1,…,−128 をビット反転すると 127。 ビット反転した値に何か加えると符号反転した数になることがわかっただろうか?
参考: 桁あふれ
「正数同士を加算したのに結果が負数になる」「負数同士を加算したのに結果が正数になる」などの現象を桁あふれ (overflow) と言う。 例えば,8ビットの整数 0x60 と 0x70 の和を計算すると結果は 0xd0 になるが,これを符号付き数同士の加算と解釈すると,「96 と 112 の和が −48」となる。 これは,96 と 112 の本来の和 208 が8ビット符号付き整数で表せる範囲を超えたために起こった現象だ。
桁あふれが発生したときはフラグレジスタ中の OF (overflow flag) が 1 になる。 桁上がりを表す CF (carry flag) とは異なる(0x60 + 0x70 の計算では,桁上がりは発生しないが桁あふれは発生する)。
参考: 符号拡張
「8ビットの数を32ビットに変換する」「32ビットの数を64ビットに変換する」などの際,増加した上位の桁を符号ビットと同じ値で埋めることを,符号拡張 (sign extension) と言う。 例えば 0x90 を32ビットに変換する場合,0x90 の符号ビットは 1 なので,増加した上位24ビットをすべて 1 にして 0xffffff90 に変換する。 このようにすると,そのビット列が表す整数を変えずにビット幅を変更できる(0x90, 0xffffff90 はどちらも −112 を表す)。