Assembly (x86-64, AArch64, RISC-V) Module 2 – Bits, bytes and numbers
Floating-point numbers bit by bit
Decode IEEE 754 float and double into sign, exponent and fraction, see why 0.1 + 0.2 misses 0.3, and move the bits to an integer register on three ISAs.
What you will learn
- Decode an IEEE 754 binary32 and binary64 value into its sign, exponent and fraction
- Explain NaN, the infinities, subnormals and why 0.1 and 0.2 do not add to 0.3
- Distinguish a quiet NaN from a signalling one by its fraction bits
- Move floating-point bits into an integer register with movq, fmov or fmv.x.d
What you’ll learn
What you will learn
- Decode an IEEE 754 binary32 and binary64 value into its sign, exponent and fraction
- Explain NaN, the infinities, subnormals and why 0.1 and 0.2 do not add to 0.3
- Distinguish a quiet NaN from a signalling one by its fraction bits
- Move floating-point bits into an integer register with movq, fmov or fmv.x.d
On this page
A program stores 0.1 in eight bytes, and those bytes are 0x3FB999999999999A: not a tenth, but the nearest value a
double can hold, 0.1000000000000000055. That is why 0.1 + 0.2 prints 0.30000000000000004, and why a
floating-point number: A number stored as a sign, an exponent and a fraction, so one format covers a wide range by moving a binary point. binary32 (C float) and binary64 (C double) are the common sizes; the value is roughly 1.fraction times 2 to the exponent. number is really three fields packed into its bits, a sign, an
exponent and a fraction, laid out by the IEEE 754: The standard for floating-point arithmetic that x86-64, AArch64 and RISC-V all follow. It fixes the binary16, binary32, binary64 and binary128 formats, how each lays out a sign, exponent and fraction, and how results are rounded. standard that every one of the three ISAs
follows.
Below, you decode those fields for a 64-bit double and a 32-bit float, find where NaN, the infinities and the
subnormal numbers sit, work the 0.1 + 0.2 rounding out to the last bit, and move a value’s bits into an integer
register with one instruction on each ISA — movq on x86-64, fmov on AArch64 and fmv.x.d on RISC-V — so you can
read them.
Three fields: sign, exponent and fraction
IEEE 754 binary64, the C double, divides its 64 bits into three fields: 1 sign bit, then 11 exponent bits, then 52
fraction bits. The value is (-1)^sign × 1.fraction × 2^(exponent − 1023). The leading 1. is not stored, because a
normal number always has it; the exponent is held with a bias of 1023 added, so the stored field 1025 means an
exponent of 2. binary32, the C float, uses the same shape with smaller fields: 1 sign bit, 8 exponent bits with a
exponent bias: A constant added to the real exponent so the stored exponent field is a non-negative integer that still covers negative powers. binary64 adds 1023 and binary32 adds 127, so a stored field equal to the bias means an exponent of 0. of 127, and 23 fraction bits.
This program unpacks the bytes with struct and masks out each field. It decodes -6.25, which is exact, and 0.1,
which is not, in both formats:
"""Splits a double (binary64) and a float (binary32) into sign, exponent and fraction, then rebuilds each value."""
import struct
def fields(x, fmt, uint, exp_bits, frac_bits):
"""The bit pattern of x in format fmt, and its three fields."""
bits = struct.unpack(uint, struct.pack(fmt, x))[0]
sign = bits >> (exp_bits + frac_bits)
exponent = (bits >> frac_bits) & ((1 << exp_bits) - 1)
fraction = bits & ((1 << frac_bits) - 1)
return bits, sign, exponent, fraction
def show(name, x, fmt, uint, exp_bits, frac_bits, bias):
bits, s, e, f = fields(x, fmt, uint, exp_bits, frac_bits)
width = (1 + exp_bits + frac_bits) // 4
binary = f"{bits:0{1 + exp_bits + frac_bits}b}"
significand = 1 + f / 2**frac_bits
value = (-1) ** s * significand * 2.0 ** (e - bias)
print(f"{name} {x}: 0x{bits:0{width}X}")
print(f" bits {binary[0]} {binary[1:1 + exp_bits]} {binary[1 + exp_bits:]}")
print(f" sign {s}")
print(f" exponent field {e} - bias {bias} = {e - bias}")
print(f" fraction 0x{f:X}")
print(f" significand 1 + 0x{f:X} / 2**{frac_bits} = {significand!r}")
print(f" value {'-' if s else '+'}{significand!r} * 2**{e - bias} = {value!r}")
show("double", -6.25, "<d", "<Q", 11, 52, 1023)
show("double", 0.1, "<d", "<Q", 11, 52, 1023)
show("float", 0.1, "<f", "<I", 8, 23, 127) Output
double -6.25: 0xC019000000000000 bits 1 10000000001 1001000000000000000000000000000000000000000000000000 sign 1 exponent field 1025 - bias 1023 = 2 fraction 0x9000000000000 significand 1 + 0x9000000000000 / 2**52 = 1.5625 value -1.5625 * 2**2 = -6.25 double 0.1: 0x3FB999999999999A bits 0 01111111011 1001100110011001100110011001100110011001100110011010 sign 0 exponent field 1019 - bias 1023 = -4 fraction 0x999999999999A significand 1 + 0x999999999999A / 2**52 = 1.6 value +1.6 * 2**-4 = 0.1 float 0.1: 0x3DCCCCCD bits 0 01111011 10011001100110011001101 sign 0 exponent field 123 - bias 127 = -4 fraction 0x4CCCCD significand 1 + 0x4CCCCD / 2**23 = 1.600000023841858 value +1.600000023841858 * 2**-4 = 0.10000000149011612
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 decode.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
For -6.25 the sign is 1, the stored exponent is 1025 (so 2 after the bias), and the
significand: The fraction part of a floating-point number, the digits of its value apart from the exponent. For a normal number it is 1.fraction with a leading 1 that is implied and not stored; binary64 keeps 53 bits of it, binary32 keeps 24. 1 + 0x9000000000000 / 2^52 is 1.5625; −1.5625 × 2^2 = −6.25, exact. For
0.1 the double’s fraction is 0x999999999999A, a rounded repeating pattern, so the stored value is
0.1000000000000000055, not a tenth. The float stores even fewer bits, 0x3DCCCCCD, about 0.10000000149. A double
keeps 53 bits of significand (the hidden bit plus 52), nearly 16 significant decimal digits; a float keeps 24 bits,
about 7.
See any number's bits
The IEEE 754 Floating-Point Converter Type a number and read its sign, exponent and fraction in half, single, double and quad precision, with the exact stored value. shows the same three fields, and the exact decimal each bit pattern stands for, for a value of your own.
Step through the same decoding for -6.25, from its 64 stored bits back to its value:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The stored bits. The double holds 0xC019000000000000, which is 1100 0000 0001 1001 then 48 zeros in binary. Read it as three fields from the top down.
- The sign bit. The top bit is 1, so the number is negative. The sign is always the single highest bit, whatever the exponent and fraction hold.
- The exponent field. The next 11 bits are 100 0000 0001, which is 1025. Subtract the bias of 1023 to get the real exponent, 2. The bias lets one unsigned field cover both negative and positive exponents.
- The fraction field. The last 52 bits are 0x9000000000000. The significand is 1 plus that fraction over 2^52, which is 1.5625: the leading 1 is implied for a normal number and never stored.
- Rebuild the value. Put it together: -1 times 1.5625 times 2^2 is -6.25, the exact value decode.py prints. Every normal double is read this one way.
Key idea
A binary64 value is (-1)^sign × 1.fraction × 2^(exponent − 1023): a sign bit, an 11-bit biased exponent and a
52-bit fraction, with the leading 1 of the significand never stored.
Checkpoint
Check: The three fields of a double
Why 0.1 and 0.2 do not add to 0.3
One tenth is 0.000110011001100… in binary, a fraction that repeats for ever, so no finite field can hold it exactly.
The double nearest to 0.1 is a hair above it, and so is the one nearest to 0.2. Add those two rounded values and
the exact sum lands between two doubles; the processor must round again, and the nearest double to the sum is one step
above the double nearest to 0.3. The result, 0x3FD3333333333334, is 0.30000000000000004, which is why the
comparison 0.1 + 0.2 == 0.3 is false.
This program prints the stored value of each literal to its last decimal digit, then the exact sum before the final rounding:
"""Why 0.1 + 0.2 is not 0.3: each literal is rounded once, and the sum is rounded again."""
import math
import struct
from decimal import Decimal, getcontext
getcontext().prec = 80 # enough digits to add the exact stored values without rounding
def hex64(x):
return f"0x{struct.unpack('<Q', struct.pack('<d', x))[0]:016X}"
def steps(x):
"""A double between 0.25 and 0.5 as a whole number of steps of 2**-54: its 53-bit significand."""
return int(Decimal(x) * 2**54)
for text, x in [("0.1", 0.1), ("0.2", 0.2), ("0.3", 0.3)]:
print(f"{text} is stored as {hex64(x)}")
print(f" = {Decimal(x)}")
total = 0.1 + 0.2
print(f"0.1 + 0.2 is stored as {hex64(total)}")
print(f" = {Decimal(total)}")
print(f"repr {total!r}, equal to 0.3: {total == 0.3}")
exact = Decimal(0.1) + Decimal(0.2) # the true sum of the two stored values
below, above = 0.3, math.nextafter(0.3, 1.0)
print("exact sum of the stored values, before rounding:")
print(f" = {exact}")
print(f"halfway between {hex64(below)} and {hex64(above)}: {exact == (Decimal(below) + Decimal(above)) / 2}")
print(f"their significands: {steps(below)} (odd) and {steps(above)} (even)")
print(f"ties to even gives {hex64(total)}, one step ({math.ulp(0.3)}) above the double for 0.3") Output
0.1 is stored as 0x3FB999999999999A = 0.1000000000000000055511151231257827021181583404541015625 0.2 is stored as 0x3FC999999999999A = 0.200000000000000011102230246251565404236316680908203125 0.3 is stored as 0x3FD3333333333333 = 0.299999999999999988897769753748434595763683319091796875 0.1 + 0.2 is stored as 0x3FD3333333333334 = 0.3000000000000000444089209850062616169452667236328125 repr 0.30000000000000004, equal to 0.3: False exact sum of the stored values, before rounding: = 0.3000000000000000166533453693773481063544750213623046875 halfway between 0x3FD3333333333333 and 0x3FD3333333333334: True their significands: 5404319552844595 (odd) and 5404319552844596 (even) ties to even gives 0x3FD3333333333334, one step (5.551115123125783e-17) above the double for 0.3
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 point_three.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The exact sum of the two stored values is 0.30000000000000001665…, which sits exactly halfway between
0x3FD3333333333333 (the double for 0.3) and the next double up, 0x3FD3333333333334. A tie like this is broken by
the default rule, round to nearest, ties to even: of the two significands, 5404319552844595 and 5404319552844596, the
even one wins, so the sum rounds up and misses 0.3 by one Unit in the last place: the gap between a floating-point number and the next larger one of its type; it grows with the size of the number., about 5.6 × 10⁻¹⁷. Intel’s manual, Arm’s and the RISC-V spec all name round to nearest, ties to even as the default rounding (floating-point): Choosing the representable value nearest to a result that cannot be stored exactly. The default IEEE 754 mode is round to nearest, ties to even: the closest value wins, and an exact tie goes to the one whose last significand bit is 0. mode. Follow the two roundings one stage at a time:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Store each tenth. 0.1 becomes 0x3FB999999999999A and 0.2 becomes 0x3FC999999999999A. Neither is exact: a tenth repeats for ever in binary, so each is the nearest double, a hair too large.
- Add the stored values. Adding the two doubles gives 0.30000000000000001665…, the exact sum of what was really stored. This sum needs more than 52 fraction bits, so it cannot be a double as it stands.
- The sum lands on a tie. That exact sum sits exactly halfway between 0x3FD3333333333333 (the double for 0.3) and the next double up, 0x3FD3333333333334. The processor must pick one.
- Ties go to even. The default rule breaks a tie toward the even significand. Of 5404319552844595 and 5404319552844596, the even one is the larger, so the sum rounds up.
- One step past 0.3. The result is 0x3FD3333333333334, printed as 0.30000000000000004, one unit in the last place above the double for 0.3. So 0.1 + 0.2 == 0.3 is false.
Comparing floats with ==
if x == 0.3 fails for x = 0.1 + 0.2, because the stored sum is one step above the stored 0.3. Compare with a
tolerance instead: in Python, math.isclose(a, b); by hand, test that abs(a - b) is below a small bound you choose
for the size of the numbers. Never assume two routes to “the same” decimal give the same bits.
Checkpoint
Check: Rounding and 0.1 + 0.2
Zero, infinity, NaN and the subnormals
Two exponent fields are special. When the exponent field is all zeros the leading 1. is dropped and the number is
0.fraction × 2^(−1022): that gives two zeros (+0.0 and −0.0, differing only in the sign bit) and the
subnormal number: A floating-point value with a zero exponent field and a non-zero fraction. Its implied leading bit is 0 instead of 1 and its exponent stays at the smallest normal one, −1022 for binary64, so subnormals fill the gap between the smallest normal number and zero with gradually fewer significant bits. numbers, which fill the gap between the smallest normal number and zero. When
the exponent field is all ones, a zero fraction means an infinity (floating-point): An IEEE 754 value with the maximum exponent and a zero fraction, one for each sign. It is the result of overflow or of dividing a non-zero number by zero, and arithmetic with it follows defined rules, such as 1 divided by infinity being 0. (sign chooses which
end) and a non-zero fraction means a NaN: Not a Number: an IEEE 754 pattern with the maximum exponent and a non-zero fraction, the result of operations such as 0.0/0.0 or infinity minus infinity. A NaN is not equal to anything, including itself, so x != x detects one., “not a number”, the result of 0.0/0.0 or
inf − inf.
This program reads the bit pattern of each special value and shows what arithmetic does at the edges:
"""The special bit patterns of binary64: zeros, subnormals, infinities and NaNs, and what arithmetic does at the edges."""
import math
import struct
import sys
def bits(x):
return struct.unpack("<Q", struct.pack("<d", x))[0]
def from_bits(b):
return struct.unpack("<d", struct.pack("<Q", b))[0]
def show(name, x):
b = bits(x)
exponent = (b >> 52) & 0x7FF
fraction = b & ((1 << 52) - 1)
print(f"{name:<19} {b:016X} exp {exponent:>4} frac {'0' if fraction == 0 else 'not 0':<5} {x!r}")
inf = math.inf
show("+0.0", 0.0)
show("-0.0", -0.0)
show("smallest subnormal", from_bits(1))
show("largest subnormal", from_bits(0x000FFFFFFFFFFFFF))
show("smallest normal", sys.float_info.min)
show("1.0", 1.0)
show("largest finite", sys.float_info.max)
show("+infinity", inf)
show("-infinity", -inf)
show("inf - inf", inf - inf) # an invalid operation, computed by this machine's floating-point unit
show("quiet bit clear", from_bits(0x7FF0000000000001))
show("sign bit set", from_bits(0xFFF8000000000000))
print()
print("-0.0 == 0.0:", -0.0 == 0.0, "| copysign(1, -0.0):", math.copysign(1, -0.0))
nan = inf - inf
print("nan == nan:", nan == nan, "| nan != nan:", nan != nan, "| isnan:", math.isnan(nan))
print("largest finite * 2:", sys.float_info.max * 2, "| isinf:", math.isinf(sys.float_info.max * 2))
print("smallest normal / 2**52:", sys.float_info.min / 2**52, "| / 2**53:", sys.float_info.min / 2**53)
print("1 / inf:", 1 / inf, "| -1 / inf:", -1 / inf) Output
+0.0 0000000000000000 exp 0 frac 0 0.0 -0.0 8000000000000000 exp 0 frac 0 -0.0 smallest subnormal 0000000000000001 exp 0 frac not 0 5e-324 largest subnormal 000FFFFFFFFFFFFF exp 0 frac not 0 2.225073858507201e-308 smallest normal 0010000000000000 exp 1 frac 0 2.2250738585072014e-308 1.0 3FF0000000000000 exp 1023 frac 0 1.0 largest finite 7FEFFFFFFFFFFFFF exp 2046 frac not 0 1.7976931348623157e+308 +infinity 7FF0000000000000 exp 2047 frac 0 inf -infinity FFF0000000000000 exp 2047 frac 0 -inf inf - inf 7FF8000000000000 exp 2047 frac not 0 nan quiet bit clear 7FF0000000000001 exp 2047 frac not 0 nan sign bit set FFF8000000000000 exp 2047 frac not 0 nan -0.0 == 0.0: True | copysign(1, -0.0): -1.0 nan == nan: False | nan != nan: True | isnan: True largest finite * 2: inf | isinf: True smallest normal / 2**52: 5e-324 | / 2**53: 0.0 1 / inf: 0.0 | -1 / inf: -0.0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 special.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The smallest positive subnormal is 0x0000000000000001, about 5 × 10⁻³²⁴; the largest finite double is
0x7FEFFFFFFFFFFFFF, about 1.8 × 10³⁰⁸, and doubling it gives inf. A NaN has a quiet bit, the top fraction bit: a
quiet NaN (0x7FF8…) has it set and flows through arithmetic, while a signalling NaN (0x7FF0… with a non-zero rest)
has it clear and is meant to raise an exception. The run above returns 0x7FF8000000000000 for inf − inf; which
NaN an operation delivers is the processor’s choice, not the standard’s, so the exact bits
can differ between x86-64, AArch64 and RISC-V.
NaN is never equal to anything
nan == nan is false, and so is every other comparison with a NaN, which is how math.isnan(x) can be written as
x != x. A sort that assumes a total order can loop or drop values when a NaN slips in, so filter NaNs out before
you sort floating-point data.
Checkpoint
Check: Zeros, infinities, NaNs and subnormals
Moving the bits to an integer register
Each ISA keeps floating-point values in their own register file, apart from the integer registers: x86-64 in the SSE
registers xmm0–xmm15, AArch64 in the 128-bit vector registers v0–v31 (named d0–d31 for a 64-bit double and
s0–s31 for a 32-bit float), and RISC-V in f0–f31 through the F and D extensions. To inspect the bits with the
integer instructions you already know, you copy them across without converting the value. That is one instruction per
ISA: movq rax, xmm0 on x86-64, fmov x0, d0 on AArch64 and fmv.x.d a0, fa0 on RISC-V. Each moves 64 bits as they
are; none rounds or changes a single bit.
Step through the AArch64 copy, and see how it differs from a conversion:
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- Bits in d0. d0 is the 64-bit view of vector register v0, and it holds the bits of a double, say 0x3FB999999999999A for 0.1. The floating-point unit put them there.
- Copy them across. fmov x0, d0 copies all 64 bits into the integer register x0 without conversion. It assembles to the four bytes 9e660000. The value is unchanged; only the register file it sits in is different.
- Read the fields. Now the integer instructions can work on the pattern: lsr and and shift and mask out the sign, exponent and fraction, exactly as the decode program did in software.
- Converting is different. fcvtzs x0, d0 (bytes 9e780000) is not a copy: it converts the value to an integer, rounding toward zero, so 0.1 becomes 0. Reach for fmov when you want the bits, fcvtzs when you want the number.
The AArch64 program below is three such functions. A C caller passes a double in d0, and fmov x0, d0 returns its
bits in x0; fmov d0, x0 goes the other way; fcvtzs x0, d0, by contrast, converts, rounding the value toward
zero to an integer.
// bits_of.s: three AArch64 functions for macOS that a C program calls. Under AAPCS64 a double argument arrives in
// d0 (the low 64 bits of v0), an integer argument in x0, an integer result leaves in x0 and a double result in d0.
.text
.globl _bits_of, _from_bits, _to_integer
.p2align 2
_bits_of: // uint64_t bits_of(double x)
fmov x0, d0 // copy the 64 bits to an integer register, without conversion
ret
_from_bits: // double from_bits(uint64_t b)
fmov d0, x0 // the same copy, the other way
ret
_to_integer: // int64_t to_integer(double x)
fcvtzs x0, d0 // a conversion: the value, rounded toward zero
ret Assembled with a small C caller, linked and run on an Apple silicon Mac:
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session
# AArch64 on an Apple silicon Mac: assemble bits_of.s, link it with main.c, run it, then read the bytes of fmov.
session.run("""
clang -c -o bits_of.o asm/bits_of.s
clang -o bits asm/main.c bits_of.o
./bits; echo "exit=$?"
objdump -d --disassemble-symbols=_bits_of,_from_bits,_to_integer bits_of.o
""") Output
$ clang -c -o bits_of.o asm/bits_of.s
$ clang -o bits asm/main.c bits_of.o
$ ./bits; echo "exit=$?"
0.10000000000000001 bits 0x3FB999999999999A integer 0
-6.25 bits 0xC019000000000000 integer -6
0.30000000000000004 bits 0x3FD3333333333334 integer 0
from_bits(0x3FD3333333333333) = 0.29999999999999999
exit=0
$ objdump -d --disassemble-symbols=_bits_of,_from_bits,_to_integer bits_of.o
bits_of.o: file format mach-o arm64
Disassembly of section __TEXT,__text:
0000000000000000 <_bits_of>:
0: 9e660000 fmov x0, d0
4: d65f03c0 ret
0000000000000008 <_from_bits>:
8: 9e670000 fmov d0, x0
c: d65f03c0 ret
0000000000000010 <_to_integer>:
10: 9e780000 fcvtzs x0, d0
14: d65f03c0 ret
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 bits_session.py
The disassembly shows fmov x0, d0 as the four bytes 9e660000 and fcvtzs x0, d0 as 9e780000: different
instructions, one a copy and one a conversion. The run prints 0.1 as 0x3FB999999999999A with integer 0, and
0.1 + 0.2 as 0x3FD3333333333334, the pattern from the section above. The same idea on the other two ISAs is assembled
into Linux objects, which the session does not run; the assembler only encodes each instruction into its bytes:
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session
# Nothing here runs x86-64 or RISC-V code: LLVM assembles both files into Linux ELF objects and prints the bytes
# each instruction became. llvm-mc and llvm-objdump come from Homebrew's LLVM, which knows RISC-V.
session.run("""
clang --target=x86_64-linux-gnu -c -o bits_x86_64.o asm/bits_of_x86_64.s
objdump -d --x86-asm-syntax=intel bits_x86_64.o
llvm-mc --triple=riscv64 -mattr=+d -filetype=obj -o bits_riscv64.o asm/bits_of_riscv64.s
llvm-objdump -d bits_riscv64.o
""") Output
$ clang --target=x86_64-linux-gnu -c -o bits_x86_64.o asm/bits_of_x86_64.s
$ objdump -d --x86-asm-syntax=intel bits_x86_64.o
bits_x86_64.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <bits_of>:
0: 66 48 0f 7e c0 movq rax, xmm0
5: c3 ret
0000000000000006 <from_bits>:
6: 66 48 0f 6e c7 movq xmm0, rdi
b: c3 ret
$ llvm-mc --triple=riscv64 -mattr=+d -filetype=obj -o bits_riscv64.o asm/bits_of_riscv64.s
$ llvm-objdump -d bits_riscv64.o
bits_riscv64.o: file format elf64-littleriscv
Disassembly of section .text:
0000000000000000 <bits_of>:
0: e2050553 fmv.x.d a0, fa0
4: 00008067 ret
0000000000000008 <from_bits>:
8: f2050553 fmv.d.x fa0, a0
c: 00008067 ret
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 encodings_session.py
# bits_of_x86_64.s: the x86-64 Linux version, in Intel syntax for GNU as or LLVM. Under the System V ABI a double
# argument arrives in xmm0, the first integer argument in rdi, an integer result leaves in rax, a double in xmm0.
.intel_syntax noprefix
.text
.globl bits_of, from_bits
bits_of: # uint64_t bits_of(double x)
movq rax, xmm0 # copy the low 64 bits of xmm0 to rax, without conversion
ret
from_bits: # double from_bits(uint64_t b)
movq xmm0, rdi # the same copy, the other way
ret
.section .note.GNU-stack,"",@progbits # bits_of_riscv64.s: the RISC-V version for RV64 with the D extension, on Linux. Under the LP64D calling convention a
# double argument arrives in fa0 (f10), an integer argument in a0 (x10), an integer result leaves in a0, a double in fa0.
.text
.globl bits_of, from_bits
bits_of: # uint64_t bits_of(double x)
fmv.x.d a0, fa0 # copy the 64 bits of fa0 to a0, unchanged
ret
from_bits: # double from_bits(uint64_t b)
fmv.d.x fa0, a0 # the same copy, the other way
ret
.section .note.GNU-stack,"",@progbits On x86-64 the System V ABI hands a double to a function in xmm0 and returns an integer in rax, so movq rax, xmm0
(bytes 66 48 0f 7e c0) reads the bits; Intel’s manual lists this 66 REX.W 0F 7E /r form as “Move quadword from xmm
register to r/m64”. On RISC-V the LP64D convention passes a double in fa0 and returns in a0, so fmv.x.d a0, fa0
(bytes e2050553) reads them, and the RISC-V spec notes these moves “do not modify the bits being transferred”. The
move instruction only relabels the register file the bits live in.
Checkpoint
Check: Moving bits to an integer register
How wide is a long double?
The three fields scale up to a fourth IEEE format, binary128, with a 15-bit exponent and a 112-bit fraction, and down
to binary16, the half precision used in machine learning. The C type long double, though, is where the platforms
part ways, and it is an ABI choice, not an IEEE one. This program prints the width on the machine that runs it, then
asks three Linux compilers what they would use:
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session
# long double on the host (run), then what clang's own description of three Linux targets says about it (no run:
# -dM -E prints the macros the compiler predefines for a target). clang-23 is Homebrew's LLVM, which knows RISC-V.
session.run("""
clang -o ldsize c/ldsize.c
./ldsize; echo "exit=$?"
clang --target=aarch64-linux-gnu -dM -E -x c /dev/null | grep -E 'SIZEOF_LONG_DOUBLE|LDBL_MANT_DIG' | sort
clang --target=x86_64-linux-gnu -dM -E -x c /dev/null | grep -E 'SIZEOF_LONG_DOUBLE|LDBL_MANT_DIG' | sort
clang-23 --target=riscv64-linux-gnu -dM -E -x c /dev/null | grep -E 'SIZEOF_LONG_DOUBLE|LDBL_MANT_DIG' | sort
""") Output
$ clang -o ldsize c/ldsize.c $ ./ldsize; echo "exit=$?" double: 8 bytes, 53 significand bits long double: 8 bytes, 53 significand bits exit=0 $ clang --target=aarch64-linux-gnu -dM -E -x c /dev/null | grep -E 'SIZEOF_LONG_DOUBLE|LDBL_MANT_DIG' | sort #define __LDBL_MANT_DIG__ 113 #define __SIZEOF_LONG_DOUBLE__ 16 $ clang --target=x86_64-linux-gnu -dM -E -x c /dev/null | grep -E 'SIZEOF_LONG_DOUBLE|LDBL_MANT_DIG' | sort #define __LDBL_MANT_DIG__ 64 #define __SIZEOF_LONG_DOUBLE__ 16 $ clang-23 --target=riscv64-linux-gnu -dM -E -x c /dev/null | grep -E 'SIZEOF_LONG_DOUBLE|LDBL_MANT_DIG' | sort #define __LDBL_MANT_DIG__ 113 #define __SIZEOF_LONG_DOUBLE__ 16
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ldsize_session.py
On macOS on Apple silicon a long double is 8 bytes with 53 significand bits: Apple’s ARM64 document records that it
is “identical to the double type”. The three Linux ABIs all make it 16 bytes, but store different things inside:
AArch64 and RISC-V hold true binary128 (113 significand bits), as AAPCS64 and the RISC-V psABI require, while x86-64
holds the old x87 80-bit extended format (64 significand bits), whose 10 significant bytes sit in 16 bytes of storage
with 6 bytes of padding, as the x86-64 psABI sets out. The table gathers what the run and the ABI documents say:
| Target | sizeof(long double) |
Significand bits | Format |
|---|---|---|---|
| macOS AArch64 (the run above) | 8 | 53 | same as double |
| Linux AArch64 | 16 | 113 | IEEE binary128 |
| Linux RISC-V (RV64) | 16 | 113 | IEEE binary128 |
| Linux x86-64 | 16 | 64 | x87 80-bit extended |
Key idea
double and float are the same bit layout everywhere, but long double is an ABI decision: 8 bytes on an Apple
silicon Mac, 16 bytes on the Linux ABIs, holding binary128 on AArch64 and RISC-V but the 80-bit x87 format on
x86-64.
Checkpoint
Check: How wide a long double is
Practise: is a bit pattern a NaN?
Write is_nan_bits, which decides whether a 64-bit pattern is a NaN using only integer operations, the way an
assembly routine would with fmov and a mask: a pattern is a NaN exactly when its exponent field is all ones and its
fraction is non-zero. The sample tests cover a quiet NaN, a signalling NaN, both infinities, zero and ordinary
numbers:
Exercise · Easy · Python
Tell a NaN from its bits, with integer operations
Write is_nan_bits(bits) in is_nan_bits.py.
bits is a whole number from 0 to 2⁶⁴ − 1: the raw bit pattern of an IEEE 754 binary64 value, as fmov x0, d0 would leave it in an integer register. Return True when the pattern is a NaN and False otherwise, using only integer operations (shifts, masks and comparisons) — never turn the pattern back into a float.
A binary64 pattern is a NaN exactly when both of these hold:
- its 11 exponent bits (bits 52 to 62) are all ones, that is, the exponent field equals
0x7FF; - its 52 fraction bits (bits 0 to 51) are not all zero.
An all-ones exponent with a zero fraction is an infinity, not a NaN, so it must return False. The sign bit (bit 63) does not matter: a negative NaN is still a NaN. All the tests are public: they cover a quiet NaN, a signalling NaN, both infinities, the zeros, a subnormal, ordinary numbers and the largest finite double.
Starter code · is_nan_bits.py
def is_nan_bits(bits):
"""True when the 64-bit binary64 pattern `bits` is a NaN, using integer operations only.
bits: a whole number from 0 to 2**64 - 1 (the raw bits of a double).
"""
raise NotImplementedError("write is_nan_bits") The sample tests · test_is_nan_bits.py
from is_nan_bits import is_nan_bits
def test_quiet_nan():
"""a quiet NaN: exponent all ones, top fraction bit set"""
assert is_nan_bits(0x7FF8000000000000) is True
def test_signalling_nan():
"""a signalling NaN: exponent all ones, a low fraction bit set, quiet bit clear"""
assert is_nan_bits(0x7FF0000000000001) is True
def test_negative_nan():
"""the sign bit does not matter: a negative NaN is still a NaN"""
assert is_nan_bits(0xFFF8000000000000) is True
def test_positive_infinity():
"""exponent all ones but fraction zero is +infinity, not a NaN"""
assert is_nan_bits(0x7FF0000000000000) is False
def test_negative_infinity():
"""exponent all ones, fraction zero, sign set: -infinity, not a NaN"""
assert is_nan_bits(0xFFF0000000000000) is False
def test_zero():
"""+0.0 is all zero bits"""
assert is_nan_bits(0x0000000000000000) is False
def test_negative_zero():
"""-0.0 has only the sign bit set"""
assert is_nan_bits(0x8000000000000000) is False
def test_one():
"""1.0 = 0x3FF0000000000000, an ordinary number"""
assert is_nan_bits(0x3FF0000000000000) is False
def test_subnormal():
"""the smallest positive subnormal, exponent zero and a single fraction bit"""
assert is_nan_bits(0x0000000000000001) is False
def test_largest_finite():
"""the largest finite double: exponent 0x7FE (not all ones), fraction all ones"""
assert is_nan_bits(0x7FEFFFFFFFFFFFFF) is False A hint
Pull the exponent field out with a shift and a mask: exponent = (bits >> 52) & 0x7FF. Pull the fraction out with fraction = bits & ((1 << 52) - 1). The pattern is a NaN when the exponent equals 0x7FF and the fraction is not zero. Return that single boolean; do not build a float.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
The questions below cover the whole lesson, weakest idea first and harder as your answers come out right:
Practice that adapts to you
Practise this lesson
11 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
How is a double laid out in its 64 bits, and how do you read the value back?
A binary64 double is one sign bit, then 11 exponent bits, then 52 fraction bits. A normal value is
(-1)^sign × 1.fraction × 2^(exponent − 1023): the significand has a leading 1 that is implied and not stored, and the
exponent is held with a bias of 1023 added, so a stored field of 1025 means an exponent of 2. Two exponent fields are
special: all zeros gives the zeros and the subnormal numbers (no leading 1, exponent fixed at −1022), and all ones
gives the infinities (zero fraction) and the NaNs (non-zero fraction). To read a value back you take the bytes as a
64-bit integer, shift and mask out the three fields, undo the bias, and put the leading 1 back for a normal number. A
float is the same shape with an 8-bit exponent biased by 127 and a 23-bit fraction. The sign is always the top bit, so
a negative number differs from its magnitude in one bit.
Two harder questions follow: one sorts doubles by reading them as integers, the other chases an error that grows over a long running total. Think each through before you open it:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
You want to sort a large array of doubles by reinterpreting each as an unsigned 64-bit integer and sorting those integers (for a radix sort, or a byte-ordered database key). A plain reinterpret-cast does not sort correctly. What transform fixes it, and what happens to negatives, the two zeros and NaNs?
-
Mid to senior
A dashboard sums millions of small positive readings into one double, and the total drifts away from a slower exact sum. Why does a long running total lose accuracy, and what are two ways to make it more accurate without changing the stored data type?
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What are the three fields of a binary64 double, and how is its value read back?
A sign bit, an 11-bit exponent biased by 1023 and a 52-bit fraction. A normal value is (-1)^sign x 1.fraction x 2^(exponent - 1023), with the leading 1 implied and never stored.
-
How does binary32, the C float, differ from binary64?
Same shape, smaller fields: an 8-bit exponent biased by 127 and a 23-bit fraction after the sign bit, so 24 bits of significand instead of 53.
-
Why is 0.1 + 0.2 not equal to 0.3 in doubles?
None of the three values is exact in binary, so each is rounded to the nearest double when stored. The sum comes out as 0x3FD3333333333334, which is one ulp higher than the stored 0.3. Compare floating-point values with a tolerance.
-
Which exponent patterns give zeros, subnormals, infinities and NaNs?
An all-zero exponent gives the zeros (fraction 0) and the subnormals (fraction non-zero). An all-one exponent gives the infinities (fraction 0) and the NaNs (fraction non-zero). A quiet NaN has the top fraction bit set; a signalling NaN has it clear.
-
How can code detect a NaN without looking at its bits?
Test x != x. A NaN compares unequal to everything, itself included, while every other value equals itself.
-
How do you copy the raw 64 bits of a double into an integer register on each ISA?
movq rax, xmm0 on x86-64, fmov x0, d0 on AArch64 and fmv.x.d a0, fa0 on RISC-V. They copy the bits unchanged; fcvtzs and its kin convert the value to an integer instead.
-
How wide is long double?
It is an ABI choice. On an Apple silicon Mac it is 8 bytes, the same as double. The Linux ABIs give it 16 bytes, holding binary128 on AArch64 and RISC-V and the old x87 80-bit extended format on x86-64.
Key takeaways
- A binary64 double is a sign bit, an 11-bit exponent biased by 1023 and a 52-bit fraction, read as
(-1)^sign × 1.fraction × 2^(exponent − 1023); binary32 is the same shape with an 8-bit exponent biased by 127 and a 23-bit fraction. 0.1,0.2and0.3each round to the nearest double when stored, so0.1 + 0.2is0x3FD3333333333334, one unit in the last place above the double for0.3; compare floats with a tolerance, never==.- An all-zero exponent gives the zeros and subnormals; an all-one exponent gives the infinities (zero fraction) and the NaNs (non-zero fraction), and a quiet NaN has the top fraction bit set while a signalling NaN has it clear.
movq rax, xmm0,fmov x0, d0andfmv.x.d a0, fa0copy 64 bits from a floating-point register to an integer one without converting, so you can inspect them;fcvtzsand its kin convert the value instead.doubleandfloathave the same layout everywhere, butlong doubleis an ABI choice: 8 bytes on an Apple silicon Mac, and 16 bytes on the Linux ABIs holding binary128 on AArch64 and RISC-V but the 80-bit x87 format on x86-64.
Terms in this lesson
- exponent bias
- A constant added to the real exponent so the stored exponent field is a non-negative integer that still covers negative powers. binary64 adds 1023 and binary32 adds 127, so a stored field equal to the bias means an exponent of 0.
- floating-point number
- A number stored as a sign, an exponent and a fraction, so one format covers a wide range by moving a binary point. binary32 (C float) and binary64 (C double) are the common sizes; the value is roughly 1.fraction times 2 to the exponent.
- IEEE 754
- The standard for floating-point arithmetic that x86-64, AArch64 and RISC-V all follow. It fixes the binary16, binary32, binary64 and binary128 formats, how each lays out a sign, exponent and fraction, and how results are rounded.
- infinity (floating-point)
- An IEEE 754 value with the maximum exponent and a zero fraction, one for each sign. It is the result of overflow or of dividing a non-zero number by zero, and arithmetic with it follows defined rules, such as 1 divided by infinity being 0.
- NaN
- Not a Number: an IEEE 754 pattern with the maximum exponent and a non-zero fraction, the result of operations such as 0.0/0.0 or infinity minus infinity. A NaN is not equal to anything, including itself, so x != x detects one.
- rounding (floating-point)
- Choosing the representable value nearest to a result that cannot be stored exactly. The default IEEE 754 mode is round to nearest, ties to even: the closest value wins, and an exact tie goes to the one whose last significand bit is 0.
- significand
- The fraction part of a floating-point number, the digits of its value apart from the exponent. For a normal number it is 1.fraction with a leading 1 that is implied and not stored; binary64 keeps 53 bits of it, binary32 keeps 24.
- subnormal number
- A floating-point value with a zero exponent field and a non-zero fraction. Its implied leading bit is 0 instead of 1 and its exponent stays at the smallest normal one, −1022 for binary64, so subnormals fill the gap between the smallest normal number and zero with gradually fewer significant bits.
References
- IEEE Standard for Floating-Point Arithmetic (IEEE 754-2019) (IEEE Standards Association)
- Intel 64 and IA-32 Architectures Software Developer's Manual, Volume 1: Basic Architecture (Intel Corporation)
- Intel 64 and IA-32 Architectures Software Developer's Manual, Volume 2B: Instruction Set Reference (MOVD/MOVQ) (Intel Corporation)
- Learn the architecture: A64 Instruction Set Architecture Guide (Arm Limited)
- Arm A-profile A64 Instruction Set Architecture: FMOV (general) (Arm Limited)
- Writing ARM64 code for Apple platforms (Apple Developer)
- Procedure Call Standard for the Arm 64-bit Architecture (AAPCS64) (Arm Limited)
- RISC-V Instruction Set Manual: "F" and "D" standard extensions (ratified) (RISC-V International)
- RISC-V calling convention (riscv-elf-psabi-doc) (RISC-V International)
- System V Application Binary Interface: AMD64 Architecture Processor Supplement (x86-64 psABI) (x86-64 psABI maintainers)
- struct: Interpret bytes as packed binary data (Python Software Foundation)
- Floating-Point Arithmetic, Issues and Limitations (Python tutorial) (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress