I distinctly remember the first time I tried to add two decimal numbers in Assembly using ordinary integer instructions and got complete garbage back. That’s when I learned the hard truth: ADD doesn’t know what a fraction is. Floating-point arithmetic in Assembly requires an entirely separate execution unit, its own register set, its own instruction mnemonics, and its own mental model. Let’s unpack how it actually works, from the IEEE 754 standard underneath it all to real x86 and ARM code.
Why Integers Can’t Just “Do” Floating-Point
Integer registers and ALUs are built to represent whole numbers using two’s complement encoding. Floating-point numbers, by contrast, use the IEEE 754 standard, which represents a number as three separate fields:
| Field | Single Precision (32-bit) | Double Precision (64-bit) |
|---|---|---|
| Sign | 1 bit | 1 bit |
| Exponent | 8 bits | 11 bits |
| Mantissa (fraction) | 23 bits | 52 bits |
The value is reconstructed as: (-1)^sign × 1.mantissa × 2^(exponent - bias). This bit layout means you can’t just “add” two floating-point numbers with an integer ADD instruction — the exponents need to be aligned, the mantissas added with proper rounding, and the result renormalized. That entire process requires dedicated floating-point hardware: the x87 FPU historically, and SSE/AVX or ARM’s NEON/FPU today.
A Brief History: The x87 FPU
Early x86 processors (starting with the 8087 coprocessor) introduced a completely separate floating-point unit with its own stack-based register model — eight 80-bit extended-precision registers, ST(0) through ST(7), organized as a circular stack rather than directly addressable registers like EAX/EBX.
section .data
a dq 3.5
b dq 2.25
section .text
global _start
_start:
fld qword [a] ; push a onto the FPU stack -> ST(0) = 3.5
fld qword [b] ; push b -> ST(0) = 2.25, ST(1) = 3.5
faddp st1, st0 ; ST(1) = ST(1) + ST(0), pop -> ST(0) = 5.75
fstp qword [result] ; pop ST(0) into memory
The x87 stack model made sense for its era but is awkward to work with — you can’t randomly address “the third register,” you have to think in terms of push/pop operations, and it’s genuinely tricky to reason about which value sits where after a sequence of operations.
The Modern Standard: SSE/SSE2 and XMM Registers
Since the introduction of SSE2 (standard on all x86-64 CPUs), floating-point arithmetic moved to a much more sane model: 16 dedicated XMM registers (XMM0–XMM15), each 128 bits wide, directly addressable like general-purpose registers, and capable of holding either a single scalar float/double or multiple values for SIMD (vectorized) operations.
section .data
a dq 3.5
b dq 2.25
result dq 0.0
section .text
global _start
_start:
movsd xmm0, [a] ; load a (double precision) into xmm0
movsd xmm1, [b] ; load b into xmm1
addsd xmm0, xmm1 ; xmm0 = xmm0 + xmm1 (scalar double add)
movsd [result], xmm0 ; store result back to memory
Note the instruction naming convention: movsd = “move scalar double,” addsd = “add scalar double.” The single-precision equivalents drop the “d”: movss, addss. This naming pattern is consistent across the whole SSE instruction family (mulsd, subsd, divsd, sqrtsd, and so on).
x87 vs SSE2: A Direct Comparison
| Aspect | x87 FPU | SSE2/XMM |
|---|---|---|
| Register model | 8-register stack (ST0-ST7) | 16 directly addressable registers (XMM0-XMM15) |
| Precision | 80-bit extended internally | 32-bit single or 64-bit double, IEEE-754 compliant |
| Programming difficulty | Awkward stack-based push/pop model | Straightforward register-to-register operations |
| SIMD/vectorization support | None | Yes — can process multiple floats/doubles per instruction |
| Modern compiler default | Rarely used except for 80-bit long double | Default for all float/double arithmetic |
| Status today | Legacy, still present for compatibility | Standard for all modern floating-point code |
ARM Floating-Point: VFP and NEON
ARM handles floating-point through its own dedicated Floating-Point Unit (VFP, “Vector Floating Point”) and the NEON SIMD extension, using 32 or 64 registers depending on the ARM revision (D0–D31 for 64-bit “double” registers on AArch64, or S0–S31 for 32-bit single-precision views of the same physical register file).
.data
a: .double 3.5
b: .double 2.25
result: .double 0.0
.text
.global _start
_start:
ldr x0, =a
ldr x1, =b
ldr d0, [x0] // load a into d0 (64-bit double register)
ldr d1, [x1] // load b into d1
fadd d0, d0, d1 // d0 = d0 + d1
ldr x2, =result
str d0, [x2] // store result back to memory
ARM’s floating-point instruction naming is more direct: fadd, fsub, fmul, fdiv, fsqrt all operate on the D/S register set, with the assembler and register naming making single vs double precision explicit through register choice (Sn = single, Dn = double) rather than through an instruction suffix like x86’s ss/sd.
Internal Working: How a Floating-Point Add Actually Happens
flowchart TD
A[Load two IEEE-754 operands] --> B[Extract sign, exponent, mantissa from each]
B --> C{Are exponents equal?}
C -- No --> D[Shift smaller mantissa right to align exponents]
C -- Yes --> E[Mantissas already aligned]
D --> F[Add or subtract aligned mantissas]
E --> F
F --> G[Normalize result: adjust exponent and mantissa]
G --> H[Round result per current rounding mode]
H --> I[Check for overflow, underflow, NaN, infinity]
I --> J[Store final IEEE-754 result]
This entire pipeline is implemented in silicon inside the FPU/SSE execution units, executing in a handful of cycles — but understanding these steps explains real-world floating-point quirks like why 0.1 + 0.2 doesn’t exactly equal 0.3 in floating-point representation (rounding during mantissa alignment and renormalization introduces tiny representation errors).
Floating-Point Comparisons and the FLAGS Register
Comparing floating-point values requires special instructions because ordinary integer comparison flags don’t handle NaN (Not-a-Number) correctly. On x86, ucomisd/comisd compare two doubles and set the standard ZF, PF, and CF flags in a specific pattern that then gets checked with jump instructions like jp (jump if parity, indicating an “unordered” NaN result):
ucomisd xmm0, xmm1
jp nan_case ; jump if either operand was NaN
je equal_case ; jump if equal
jb less_case ; jump if xmm0 < xmm1
ARM’s fcmp instruction sets the standard NZCV condition flags directly, letting you use ordinary conditional branches (b.eq, b.lt, b.vs for “overflow/unordered”) after the comparison:
fcmp d0, d1
b.eq equal_case
b.mi less_case // "minus" - set when d0 < d1
b.vs nan_case // overflow flag set indicates unordered (NaN) result
Special Values: Infinity, NaN, and Denormals
IEEE 754 reserves specific bit patterns for special cases, and Assembly-level floating-point code needs to handle these deliberately:
| Value | Exponent bits | Mantissa bits | Meaning |
|---|---|---|---|
| Zero | All 0 | All 0 | +0.0 or -0.0 (sign bit determines which) |
| Denormal | All 0 | Non-zero | Extremely small values near zero, reduced precision |
| Normal | Neither all 0 nor all 1 | Any | Ordinary representable number |
| Infinity | All 1 | All 0 | +∞ or -∞ |
| NaN | All 1 | Non-zero | Not a Number — result of invalid operations like 0/0 |
Both x86’s MXCSR register and ARM’s FPCR register let you configure how the FPU handles these cases — for example, whether denormal numbers are flushed to zero for performance, or whether exceptions are raised on overflow/invalid operations.
Practical Use Cases
- Scientific and engineering computation: physics simulations, signal processing, anywhere fractional precision genuinely matters.
- Graphics and game engines: vector math, transformations, and lighting calculations are almost entirely floating-point, often vectorized with SSE/AVX or NEON for performance.
- Financial software (with caution): floating-point is often deliberately avoided here in favor of fixed-point or decimal arithmetic, precisely because of the rounding behavior described above — this is itself an important practical lesson.
- Audio DSP: real-time audio processing relies heavily on SIMD floating-point instructions for performance on both x86 and ARM.
Performance Considerations
- Vectorization: SSE/AVX and NEON can process 2, 4, or 8 floating-point values per instruction using packed operations (
addpsfor “packed single” on x86, or NEON’sfadd v0.4s, v1.4s, v2.4sfor four 32-bit floats simultaneously on ARM) — a massive throughput gain over scalar operations. - x87 legacy code is genuinely slower on modern CPUs than equivalent SSE2 code and should be avoided in new development; virtually every modern compiler defaults to SSE2 for floating-point on x86-64 for exactly this reason.
- Denormal numbers can silently tank performance on some microarchitectures because they require special, slower handling paths in the FPU — flushing denormals to zero (via the
FTZ/DAZbits in MXCSR) is a common optimization in performance-critical audio and graphics code.
Debugging Floating-Point Code
In GDB, inspecting XMM registers requires slightly different commands than general-purpose registers:
info registers xmm0 # shows raw bits of xmm0
print $xmm0.v2_double[0] # interpret xmm0 as two doubles, show the first
p/x $xmm0 # view as raw hex, useful for spotting NaN/Inf patterns
On ARM targets, similarly:
info registers d0
print $d0
Common Mistakes
- Using integer
ADD/SUBon floating-point bit patterns — this doesn’t work, ever; the bit layout is fundamentally different from two’s complement integers. - Comparing floats for exact equality without accounting for rounding error, leading to comparisons that “should” be true failing unexpectedly.
- Mixing single and double precision instructions accidentally (e.g.,
movsswhen you meantmovsd), silently corrupting values or losing precision. - Ignoring NaN propagation — forgetting that any operation involving NaN produces NaN, and not checking for it, especially after a divide-by-zero.
Best Practices
- Always use SSE2 (or AVX where available) rather than legacy x87 instructions for new x86 code.
- Explicitly test for NaN/Infinity after operations where invalid input is plausible (division, square roots, logarithms).
- Prefer vectorized (packed) instructions when processing arrays of floating-point data for significant performance gains.
- Be deliberate about single vs double precision — mixing them without intention is a common, hard-to-spot bug source.
Converting Between Integers and Floating-Point
A very common real-world need is converting between integer and floating-point representations, and this is another place where dedicated instructions are mandatory — you cannot simply reinterpret an integer’s bits as a float and expect a meaningful result, since the two encodings mean completely different things for the same bit pattern.
On x86-64 with SSE2:
section .data
int_val dd 42
float_result dq 0.0
section .text
global _start
_start:
cvtsi2sd xmm0, dword [int_val] ; convert signed int32 -> double
movsd [float_result], xmm0
cvttsd2si eax, xmm0 ; convert double -> int32, truncating toward zero
cvtsi2sd performs the actual re-encoding — shifting the integer’s value into the correct sign/exponent/mantissa layout — rather than just copying bits. The tt in cvttsd2si specifically means “truncating,” as opposed to the rounding-mode-dependent cvtsd2si, which is a subtle but important distinction if you need exact, predictable rounding behavior.
On ARM:
scvtf d0, w0 // signed integer in w0 -> double in d0
fcvtzs w1, d0 // double in d0 -> signed integer in w1, rounding toward zero
scvtf (“signed convert to float”) and fcvtzs (“float convert to zero, signed”) mirror the x86 pair almost exactly in purpose, just with ARM’s more explicit, spelled-out mnemonic naming convention.
Vectorized Floating-Point: Processing Multiple Values at Once
The real performance payoff of modern floating-point hardware comes from packed (SIMD) operations, which apply the same arithmetic operation to multiple values simultaneously within a single instruction. Here’s a packed-double addition on x86-64 using SSE2, adding two arrays of doubles two elements at a time:
section .data
a dq 1.0, 2.0
b dq 3.0, 4.0
result dq 0.0, 0.0
section .text
global _start
_start:
movupd xmm0, [a] ; load two doubles (unaligned) into xmm0
movupd xmm1, [b] ; load two doubles into xmm1
addpd xmm0, xmm1 ; packed double add: xmm0 = xmm0 + xmm1 (both lanes at once)
movupd [result], xmm0 ; store both results back
addpd (“add packed double”) processes both 64-bit lanes of the 128-bit XMM register in parallel, effectively doubling throughput compared to addsd. On ARM’s NEON, the equivalent looks like this, operating on four 32-bit floats packed into a 128-bit Q register:
ldr q0, [x0] // load 4 floats into q0 (128-bit NEON register)
ldr q1, [x1] // load 4 more floats into q1
fadd v2.4s, v0.4s, v1.4s // add all four lanes simultaneously
str q2, [x2] // store the four results
The .4s suffix tells the assembler to treat the 128-bit register as four 32-bit single-precision lanes — this single instruction performs the equivalent work of four scalar fadd instructions, which is exactly why compilers aggressively auto-vectorize floating-point loops whenever the target supports SIMD and the data layout allows it.
Rounding Modes and Why They Matter
IEEE 754 defines four standard rounding modes — round to nearest (ties to even), round toward zero, round toward positive infinity, and round toward negative infinity — and both x86’s MXCSR and ARM’s FPCR let you select which one is active. This matters more than it might seem: financial calculations, graphics rasterization, and numerical algorithms that need reproducible results across platforms often depend on explicitly setting a specific rounding mode rather than trusting the default, since subtly different rounding can accumulate into materially different results over many operations.
; Reading and modifying MXCSR to change rounding mode on x86
stmxcsr [saved_mxcsr] ; store current MXCSR state
mov eax, [saved_mxcsr]
or eax, 0x6000 ; set round-toward-positive-infinity bits
mov [saved_mxcsr], eax
ldmxcsr [saved_mxcsr] ; load modified control word back
Frequently Asked Questions
Q: Why is the x87 FPU still present in x86-64 CPUs if nobody should use it? Purely for backward compatibility with legacy 32-bit software; new code should always target SSE2 or later instruction sets.
Q: Does ARM’s NEON and VFP share the same physical registers? Yes — on AArch64, the same 128-bit register file is viewed differently depending on the instruction: Sn (32-bit single), Dn (64-bit double), Qn (128-bit NEON vector) are all views into the same underlying register.
Q: Can floating-point arithmetic be made deterministic across different CPUs? It’s difficult but achievable — IEEE 754 defines the arithmetic precisely, but compiler optimizations, instruction scheduling differences, and extended-precision intermediate results (especially with legacy x87) can introduce subtle nondeterminism; strict IEEE-754 compliance flags and consistent instruction set targeting (e.g., always SSE2, never x87) help.
Summary and Key Takeaways
Floating-point arithmetic in Assembly requires dedicated hardware and instruction sets entirely separate from ordinary integer operations, because IEEE 754’s sign/exponent/mantissa encoding needs specialized alignment, normalization, and rounding logic. x86 evolved from the awkward stack-based x87 FPU to the modern, directly addressable XMM register model under SSE2, while ARM uses its VFP/NEON register file (S/D/Q registers) for the same purpose.
Key points to remember:
- IEEE 754 defines float/double bit layout: sign, exponent, mantissa.
- x87 (
ST0–ST7, stack-based) is legacy; SSE2 (XMM0–XMM15, directly addressable) is the modern x86 standard. - ARM uses
S/D/Qregisters (VFP/NEON) with straightforwardfadd/fsub/fmul/fdivmnemonics. - Floating-point comparisons require special instructions (
ucomisd,fcmp) due to NaN handling, unlike ordinary integer comparisons.
References
- IEEE 754-2019 Standard for Floating-Point Arithmetic — IEEE
- Intel® 64 and IA-32 Architectures Software Developer’s Manuals, Volume 1: SSE/SSE2 chapters — Intel Corporation
- ARM Architecture Reference Manual for A-profile architecture, VFP/NEON chapters — Arm Ltd.
- GNU Binutils and GNU Assembler (
as) Documentation — Free Software Foundation