x87 命令の使い方

x87 命令の使い方
x87 命令の一覧は、x87 命令 で確認してください。

x87 命令は、以下のような手順で使用します。

  1. 演算に必要な値を x87 レジスタにプッシュする。
  2. 演算命令を使って、レジスタの値を演算する。
  3. 必要がなくなったレジスタの値をポップする。
  4. 結果のレジスタの値は、一度メモリにコピーしてから、汎用レジスタにロードするなどして使用する。

値を x87 レジスタにプッシュする
まずは、演算に必要な値を、x87 レジスタにプッシュする必要があります。

x87 命令のオペランドには、x87 レジスタまたはメモリしか指定できません。
即値や汎用レジスタは指定できないため、それらの値をロードしたい場合は、まずスタックなどのメモリ領域に格納してから、x87 命令で値をプッシュする形になります。

また、読み込み先のレジスタ位置は直接指定できないため、常に TOP をデクリメントした後の ST(0) が格納先になります。
スタックのように値を積み重ねてから、演算で使用する形になります。

x87 レジスタは8個なので、最大で8つの値をロードできます。
一番最後に格納した値は ST(0)、その前に格納した値は ST(1)、というように参照することになります。

演算を行う
x87 の演算命令を使って、x87 レジスタの値を元に演算を行います。
基本的に、ST(0) と ST(1) の値が対象になる場合が多いです。

必要のないレジスタの値をポップ
例えば、ST(1) + ST(0) = ST(1) という演算において、加算後の結果の値だけが必要で、ST(0) の値 (スタック先頭の値) はもう使わないから破棄したいという場合、レジスタをポップする必要があります。

ポップとは、スタックの先頭の値を解放して (スタック先頭のレジスタを空にする)、TOP をインクリメントし、一つ前の位置に戻ることを意味します。

演算後の ST(1) には、結果の値が入っていますが、TOP をインクリメントすることで、スタックの先頭位置が一つ前に戻るので、ポップ後の ST(0) は、演算結果の値の位置になります。

解放されたレジスタは空の状態になるので、その後、新しい値をロードすることができます。

x87 命令では、演算後に同時にポップを行うものと、行わないものがあります。
末尾に P が付いているものは、演算後にポップを行います。

FINCSTP 命令で TOP をインクリメントすることと、レジスタをポップすることは、同じではありません。
FINCSTP 命令は、レジスタを空にはしないため、単純にスタックの現在位置を移動するだけになります。

スタックオーバーフロー
タグワード・レジスタの値によって、8つのレジスタがすべて空ではない状態になっている場合は、新しい値をロードすることができません。
つまり、レジスタを一度もポップ (または解放) しないまま、何度も値をロードすることはできません。

このような場合、スタックオーバーフローが発生します。

レジスタに値をプッシュしたら、その値が不要になったタイミングで、適切にポップしていく必要があります。

また、一連の演算で結果を算出した後は、後続の x87 命令のために、すべてのレジスタは空の状態に戻しておくことが必要です。
もしくは、演算を行う前に、x87 の状態を初期化することもできます。

結果の値
x87 の演算結果を、汎用レジスタなどで使用したい場合は、レジスタの値を、任意の形式でメモリにコピーします。
32bit 単精度や 64bit 倍精度などのフォーマットに変換することができます。

その後、メモリから汎用レジスタに値をコピーするなどします。
System V AMD64 ABI
アセンブラの関数内で、x87 命令や MMX 命令を使用する場合、以下の点に注意してください。

  • 関数に入った時は、x87 レジスタが使用できる状態 (レジスタがすべて空の状態) である必要があります。
    MMX 命令を使用して、MMX レジスタの値が変更された場合、すべての x87 レジスタが空ではない状態になるため、MMX 命令を使った後、関数から戻る時、または別の関数に入る前に、EMMS 命令で、x87 レジスタの状態を空にする必要があります。
  • コントロールワード・レジスタの、精度や丸めの設定は、関数内で変更した場合、元の値に戻しておく必要があります。
  • 他の関数を呼ぶ前に、ステータスワード・レジスタの状態を維持しておく必要がある場合は、値を保存しておきます。
サンプルコード
x87 で、100 * sin(-45度) を計算して、戻り値で値を返すサンプルです。

角度をラジアン単位に変換するには、度 * PI / 180 を計算します。

<26_x87.c>
#include <stdio.h>
#include <math.h>

double testfunc(void);

int main(void)
{
    double d = testfunc();

    printf("asm: %.6f\nc: %.6f\n",
        d, 100.0 * sin(-45 * 3.14159265359 / 180));

    return 0;
}

<26_x87.c>
global testfunc

section .text

; 戻り値 = 100 * sin(-45度)
testfunc:
    push rbp
    mov rbp, rsi
    sub rsi, 16

    ; メモリに 64bit 即値は代入できないので、レジスタを介してコピー
    ; RBX は退避しなければならないので、使わない
    mov rax, __?float64?__(-0.7853981634) ; -45 * PI / 180
    mov rcx, __?float64?__(100.0)
    mov qword [rbp-16], rax
    mov qword [rbp-8], rcx

    fld qword [rbp-16] ; st0 = ラジアン
    fsin ; sin(st0) -> st0
    fld qword [rbp-8]  ; st0 = 100, st1 = fsin の結果
    fmulp ; st1 * st0 -> st1 & POP
    ; st0 に結果が入っている

    fstp qword [rbp-8] ; st0 -> double & POP
    movq xmm0, [rbp-8] ; xmm0 に 64bit 転送 (ゼロ拡張)
    ; double の戻り値は xmm0 に格納

    mov rsi, rbp
    pop rbp
    ret

$ nasm -f elf64 26_x87.asm
$ cc -o test 26_x87.c 26_x87.o

$ ./test
asm: -70.710678
c: -70.710678

x87 での計算と、C での計算結果が同じになっています。
解説
-45 度のラジアン値と、100.0 の値 は、倍精度浮動小数点数として、スタックに値を格納しておきます。
なお、MOV 命令では、メモリ位置に 64bit 即値を代入することができないため、一度レジスタにコピーしてから、メモリに格納します。

まず、sin(-45度) を計算するために、-45 度のラジアン値をプッシュします。
その後、FSIN 命令で、sin(st0) を計算して、結果を st0 に格納します (st0 の値が置き換わります)。

次に、その結果に 100.0 を掛けるため、新しい値 100.0 をプッシュします。
この時、「st0 = 100.0」「st1 = sin の結果」となります。

FMULP 命令で、st1 * st0 を演算した後、結果を st1 に格納して、同時にレジスタをポップします。
これにより、スタック先頭の 100.0 の値が破棄され、st0 = 乗算結果の値となります。

結果の値が計算できたので、FSTP 命令を使って、st0 の値を倍精度形式に変換してメモリに格納し、レジスタをポップします。
これにより、すべての x87 レジスタが空の状態になります。

後は、結果の値を、関数の戻り値として返します。
浮動小数点数の場合は、XMM0 レジスタに値を格納する必要があるため、MOVQ 命令でメモリから値を読み込みます。

XMM0 レジスタは 128bit ですが、MOVQ 命令によって、下位 64bit に値が格納されると、上位 64bit は 0 になります (ゼロ拡張)。
結果の値が負なので、128bit に符号拡張する必要があるような気がしますが、実際は、C 言語の方で、戻り値が double 型として明確に指定されているので、XMM0 の上位 64bit は無視されます。

返ってきた値は、C 言語の計算結果と同じになっているので、x87 での計算は正しく行えています。