Floating point and the x87 stack

Floating point and the x87 stack

The bits in a register or memory have meaning only because an instruction or a program gives them one. The bit pattern 0x05 can be the integer 5, for example, while another program could use an integer as a fixed-point value with an implied fractional position. This lesson covers the IEEE 754 binary floating-point formats built into x86 and the x87 instructions that operate on them.

Binary32 and binary64 in memory

The names binary32 and binary64 describe 32-bit and 64-bit IEEE 754 memory formats. They are also commonly called single precision and double precision. NASM can emit them with a decimal point in a dd or dq declaration:

single_value:   dd 1.5      ; four-byte binary32
double_value:   dq 1.5      ; eight-byte binary64

Each format divides its bits into three fields:

formattotal bitssignexponentstored fraction
binary32321 bit8 bits23 bits
binary64641 bit11 bits52 bits

For binary64, the sign bit selects positive or negative. The 11-bit exponent records a power of two, using a bias of 1023 for normal values. The 52 stored fraction bits describe the significant binary digits after the point. A normal, nonzero value has an implicit leading 1, so that leading bit does not need a place in the encoding.

The exponent field also identifies special cases:

exponent fieldfraction fieldmeaning
neither all zeroes nor all onesanynormal finite value; implicit leading 1
all zeroesall zeroespositive or negative zero
all zeroesnonzerosubnormal value; no implicit leading 1
all onesall zeroespositive or negative infinity
all onesnonzeroNaN, “not a number”

Subnormal values let the format represent very small magnitudes close to zero. Infinities and NaNs are results that floating-point instructions can carry through later calculations. Operations that produce them can also raise x87 exception conditions; when an exception is masked, the condition is recorded and execution continues with the corresponding floating-point result.

Encoding 3.75 as binary64

Take 3.75 apart one step at a time.

Write it in binary. The integer 3 is 11. The fraction 0.75 is one half plus one quarter, so it is .11. Together they are 11.11.

Normalize it. Move the point until one digit remains before it. One move changes 11.11 to 1.111, so the exponent is 1:

11.11 = 1.111 x 2^1

Form the fraction field. This is a normal nonzero number, so the leading 1 is implicit. Store the digits after the point, 111, then pad the 52-bit fraction field with zeroes.

Bias the exponent. Add the binary64 bias of 1023. The stored exponent is 1 + 1023 = 1024, or 10000000000 in 11 bits.

Lay out the fields. The number is positive, so its sign bit is zero:

 0   10000000000   1110000000000000000000000000000000000000000000000000
 ^        ^                                  ^
 sign   exponent                      stored fraction
 1 bit   11 bits                           52 bits

Grouping the same 64 bits into fours gives hexadecimal:

0100 0000 0000 1110 0000 0000 ... 0000
  4    0    0    E    0    0       0

The binary64 encoding of exactly 3.75 is therefore 0x400E000000000000.

The reverse process decodes 0x3FF0000000000000. Its exponent field is 0x3FF, or 1023, so the unbiased exponent is zero. Its fraction field is zero. Restoring the implicit leading bit gives 1.0 x 2^0, which is exactly 1.0.

Values such as decimal 0.1 behave differently. Its binary fraction repeats:

0.00011001100110011...

A binary64 value has only 52 stored fraction bits, so it cannot hold that infinite expansion. It holds the nearest representable value under the current rounding rule. This is why calculations with decimal fractions can differ slightly from their exact decimal answers. Values such as 3.75, 0.5, and 0.25 end after a finite number of binary places and can be represented exactly.

Eight physical registers, one logical stack

x87 has eight physical floating-point registers. A rotating top pointer exposes them as a logical stack. This lesson writes the logical names as st(0) through st(7):

  • st(0) is the current top;
  • st(1) is the value immediately below it;
  • st(7) is the last available logical entry.

A push moves the logical top, so every live value receives a new logical name. If st(0) held 1.5, then pushing 2.25 makes 2.25 the new st(0) and renames 1.5 as st(1). A pop removes the top and renames the old st(1) as the new st(0).

NASM spells these register names st0, st1, and so on in instruction operands. Thus the prose name st(1) appears as st1 in faddp st1, st0.

Each physical register has a tag that says whether its logical entry is empty. Popping marks an entry empty; it need not erase the old bits. A ninth live value, or an instruction that reads an empty entry, raises the x87 invalid-operation condition for a stack fault. This condition is normally masked and recorded in x87 status, but unmasking it can make it trap. The practical rule is simple: every load pushes, and a popping arithmetic or store instruction must eventually remove that value.

A small x87 instruction set

These forms are enough for the examples on this page:

instructionvalue and stack effect
fld dword [x]push the binary32 value stored in four bytes at x
fld qword [x]push the binary64 value stored in eight bytes at x
fld1push the exactly representable value 1.0
fild word [n]read a signed 16-bit integer, convert it, and push it
fild dword [n]read a signed 32-bit integer, convert it, and push it
fild qword [n]read a signed 64-bit integer, convert it, and push it
faddp st1put old st(1) + st(0) in old st(1), then pop
fsubp st1put old st(1) - st(0) in old st(1), then pop
fmulp st1put old st(1) * st(0) in old st(1), then pop
fdivp st1put old st(1) / st(0) in old st(1), then pop
fstp dword [x]round st(0) to binary32, store four bytes, then pop
fstp qword [x]round st(0) to binary64, store eight bytes, then pop
fistp word [n]round to a signed 16-bit integer, store two bytes, then pop
fistp dword [n]round to a signed 32-bit integer, store four bytes, then pop
fistp qword [n]round to a signed 64-bit integer, store eight bytes, then pop

The default x87 rounding mode is nearest, with a value exactly halfway between two candidates going to the candidate whose low bit is even. Other modes exist in the x87 control word. An integer result must also fit the signed destination width; an out-of-range conversion raises the invalid-operation condition.

Load, add, and store

This program adds 1.5 and 2.25. The comment after every x87 instruction shows the complete live logical stack, from the top downward:

The second fld changes the logical names without moving 1.5 between physical registers. Before the addition, st(0) is 2.25 and st(1) is 1.5. faddp st1 writes 3.75 into the old st(1), pops the old top, and exposes the result as the new st(0). The store writes the binary64 bits to memory and empties the stack.

Subtraction and division have an order

For subtraction and division, the old st(1) is the left operand and the old st(0) is the right operand. This sequence calculates (10.0 - 4.0) / 2.0:

    fld qword [ten]         ; stack: [10.0]
    fld qword [four]        ; stack: [4.0, 10.0]
    fsubp st1               ; old st(1) - old st(0): stack [6.0]
    fld qword [two]         ; stack: [2.0, 6.0]
    fdivp st1               ; old st(1) / old st(0): stack [3.0]
    fstp qword [answer]     ; stack: []

After fsubp, the result first occupies the old st(1) physical register. The pop then makes it the new st(0). fdivp follows the same rule: it computes old st(1) / st(0), places the result in old st(1), and pops.

Integer loads and stores use the same push/pop discipline. If n is a signed dword containing 7, this balanced sequence stores the signed dword 4:

    fild dword [n]          ; stack: [7.0]
    fld1                    ; stack: [1.0, 7.0]
    faddp st1, st0          ; stack: [8.0]
    fld qword [half]        ; stack: [0.5, 8.0]
    fmulp st1, st0          ; stack: [4.0]
    fistp dword [iout]      ; stack: []; signed 32-bit result is 4

Comparing and using integer flags

An ordinary integer conditional jump needs flags in rflags. The x87 instruction fucomip st0, st1 compares st(0) with st(1), writes ZF, PF, and CF, and pops st(0). Its four possible results are:

relation of old st(0) to old st(1)ZFPFCF
greater000
less001
equal100
unordered111

Unordered means that at least one operand is a NaN. A quiet NaN is a NaN encoding intended to flow through ordinary calculations without signaling the invalid-operation exception in this comparison. fucomip reports a quiet NaN as unordered. Check PF first with jp; the unordered row also has CF=1 and ZF=1, so checking jb or je first would misclassify it.

Here left is 1.0 and right is 2.0. The remaining fstp st0 discards the other operand. It does not change the integer flags, so the jumps still read the result from fucomip:

    fld qword [right]       ; stack: [2.0]
    fld qword [left]        ; stack: [1.0, 2.0]
    fucomip st0, st1        ; compare 1.0 with 2.0; stack: [2.0]
    fstp st0                ; discard remaining value; stack: []; flags unchanged

    jp unordered_path       ; PF=1 must be tested first
    jb less_path            ; CF=1
    je equal_path           ; ZF=1
    jmp greater_path        ; ZF=PF=CF=0

A NaN comparison is therefore not simply “false.” It produces the distinct unordered flag pattern, which the program must handle if NaNs are possible.

Precision and exceptions

On hardware, each physical x87 register uses the 80-bit extended floating-point format. Arithmetic still rounds according to the x87 control word, which selects a rounding direction and an arithmetic precision. Storing to binary32 or binary64 memory rounds the value to that target format as well.

The playground represents x87 stack values as binary64 rather than as hardware's 80-bit register format. Intermediate results can therefore differ from physical x87 execution. This matters most when a calculation depends on its final few bits; the examples and exercises here use values whose expected answers are exactly representable.

The control word also masks or unmasks exception classes. Masked conditions such as invalid operations, division by zero, overflow, underflow, and inexact results are recorded in x87 status while execution continues with a defined floating-point response. Unmasking an exception permits a trap. Control-word programming is outside this first x87 lesson.

Your turn

Calculate (10.0 - 4.0) / 2.0 with the explicitly ordered popping forms. Store the binary64 result at out, then copy its eight bits to r8. Every input and the result is exactly representable.

Show solution

Now compare four pairs: less, equal, greater, and unordered. Write byte -1, 0, 1, or 2 to the corresponding position in results. Load the final four bytes into r8d. For every pair, load the right operand first and the left operand second, use fucomip st1, discard the one remaining x87 value, and test jp before the ordered branches.

Show solution