How Does IEEE 754 Store Real Numbers?
Understanding binary32, binary64, rounding, NaN, and accumulated error
How Does IEEE 754 Store Real Numbers?
This article is written for teaching. Hardware, compiler options, and language implementations can change details, so verify behavior that matters in your own environment.
Beginners are often surprised that 0.1 + 0.2 is not exactly 0.3. The computer has not forgotten how to add. It must fit infinitely many real numbers into a finite number of bit patterns, so most values are represented by a nearby approximation.
IEEE 754 standardizes floating-point formats and much more: arithmetic, rounding, infinity, NaN, gradual underflow, conversions, and exception status. This article dissects binary32 and binary64, explains why decimal fractions are rounded, and finishes with practical C++ rules for comparison, accumulation, money, and serialization.
Fixed point and floating point
Fixed-point representation chooses the radix-point position in advance. If integer 12345 always means two decimal places, its value is 123.45. This is convenient for quantities with a fixed scale, but one format cannot cover both extremely small and extremely large magnitudes efficiently.
Floating point resembles scientific notation.
6.25 = 1.5625 × 2²
0.15625 = 1.25 × 2⁻³
It stores a significand and an exponent separately. The range becomes much wider, but the spacing between adjacent values is not uniform.
What IEEE 754 covers
The active IEEE 754-2019 standard specifies binary and decimal interchange and arithmetic formats, operations, conversions, rounding, exception conditions, and default handling. An implementation may use hardware, software, or both.
The two binary formats most programmers encounter are:
| Name | Common language type | Total | Sign | Exponent | Stored fraction | Precision |
|---|---|---|---|---|---|---|
binary32 | usually float | 32 | 1 | 8 | 23 | 24 bits |
binary64 | usually double | 64 | 1 | 11 | 52 | 53 bits |
“Usually” matters. C and C++ type names alone do not require every implementation to use these exact formats. C++ exposes implementation properties through std::numeric_limits, including is_iec559.
The fields of binary32
1 sign bit | 8 exponent bits | 23 fraction bits
For a normal finite value:
(-1)^s × (1.fraction)₂ × 2^(E - 127)
s is the sign and E is the stored exponent. The exponent uses a bias of 127. A normal binary significand always begins with 1, so that leading bit is implicit and does not consume storage. The 23 stored fraction bits therefore provide 24 bits of precision.
binary64 follows the same idea with an exponent bias of 1023 and 52 stored fraction bits, giving 53 bits of precision.
Encoding 6.25 by hand
6.25₁₀ = 110.01₂ = 1.1001₂ × 2²
- The sign is
0. - The stored exponent is
2 + 127 = 129, or10000001₂. - The fraction after the implicit leading 1 is
1001000...0.
0 10000001 10010000000000000000000
This is 0x40C80000 in hexadecimal.
#include <bit>
#include <cstdint>
#include <iomanip>
#include <iostream>
int main()
{
float value = 6.25f;
std::uint32_t bits = std::bit_cast<std::uint32_t>(value);
std::cout << "0x" << std::hex << std::uppercase
<< std::setw(8) << std::setfill('0') << bits << '\n';
}
On an ordinary IEEE binary32 implementation, this prints 0x40C80000.
Reserved exponent patterns
Exponent fields made entirely of zeros or ones have special meanings.
| Exponent | Fraction | Meaning |
|---|---|---|
| all zeros | all zeros | signed zero |
| all zeros | nonzero | subnormal number |
| 1 through 254 | any | normal finite number |
| all ones | all zeros | positive or negative infinity |
| all ones | nonzero | NaN |
This leaves normal binary32 exponents from -126 through 127.
Positive and negative zero
Both +0 and -0 exist because the sign bit remains meaningful when exponent and fraction are zero. They compare equal, but some operations preserve the direction.
#include <cmath>
#include <iostream>
int main()
{
double p = 0.0;
double n = -0.0;
std::cout << std::boolalpha << (p == n) << '\n';
std::cout << std::signbit(n) << '\n';
std::cout << 1.0 / p << '\n';
std::cout << 1.0 / n << '\n';
}
Under the normal IEEE behavior, the divisions produce positive and negative infinity.
Subnormal numbers and gradual underflow
Normal values use an implicit leading 1. Near zero, that rule would leave a gap between zero and the smallest normal value. When the stored exponent is zero and the fraction is nonzero, the leading bit becomes 0:
(-1)^s × (0.fraction)₂ × 2^-126
These subnormal values trade precision for a gradual approach to zero. For binary32, the smallest positive normal value is about 1.17549435 × 10^-38, while the smallest positive subnormal is about 1.40129846 × 10^-45.
Some performance-oriented environments flush subnormals to zero. SIMD and GPU code should document whether such a mode is active.
Infinity
An all-ones exponent and zero fraction represent infinity, with its sign determined by the sign bit. Overflow or division of a nonzero finite value by zero can produce infinity under default handling.
Infinity is not merely the largest finite value. It participates in arithmetic and ordering under separate rules.
#include <cmath>
#include <limits>
double inf = std::numeric_limits<double>::infinity();
bool isInfinity = std::isinf(inf);
NaN and unordered comparisons
An all-ones exponent with a nonzero fraction represents NaN, Not a Number. Invalid operations such as 0.0 / 0.0 or the real square root of a negative value can produce it.
NaN is unordered. Even equality with itself is false.
#include <cmath>
#include <iostream>
#include <limits>
int main()
{
double nan = std::numeric_limits<double>::quiet_NaN();
std::cout << std::boolalpha;
std::cout << (nan == nan) << '\n';
std::cout << (nan < 0.0) << '\n';
std::cout << std::isnan(nan) << '\n';
}
Use isnan, not equality with a NaN constant. IEEE 754 distinguishes quiet and signaling NaNs, but language and hardware support for signaling behavior and payloads varies.
Why 0.1 is not exact
0.1 is the fraction 1 / 10. After reduction, a fraction has a finite binary expansion only when its denominator contains no prime factor other than 2. Ten also contains 5, so the binary digits repeat.
0.1₁₀ = 0.000110011001100110011...₂
Binary64 keeps only 53 significant bits and rounds the rest. The stored value is slightly greater than exact one tenth.
#include <iomanip>
#include <iostream>
int main()
{
std::cout << std::setprecision(20) << 0.1 << '\n';
}
A typical result is 0.10000000000000000555. The familiar 0.1 + 0.2 result reflects rounding of the inputs and of the addition result.
Spacing grows with magnitude
Floating-point numbers are not evenly distributed. Binary32 has 24 bits of precision, so every integer through 16,777,216 can be represented exactly. Beyond that region, adjacent representable values begin to differ by 2 or more.
#include <iostream>
int main()
{
float a = 16'777'216.0f;
float b = a + 1.0f;
std::cout << std::boolalpha << (a == b) << '\n';
}
On binary32 this normally prints true: there is no distinct representable value for a + 1 at that magnitude.
Epsilon is not a universal tolerance
std::numeric_limits<double>::epsilon() is the gap between 1 and the next representable double. It is not a universal comparison threshold for every magnitude.
A practical comparison often combines an absolute tolerance near zero with a relative tolerance elsewhere.
#include <algorithm>
#include <cmath>
bool almostEqual(double a, double b,
double absoluteTolerance = 1e-12,
double relativeTolerance = 1e-9)
{
double difference = std::abs(a - b);
if (difference <= absoluteTolerance)
{
return true;
}
double scale = std::max(std::abs(a), std::abs(b));
return difference <= scale * relativeTolerance;
}
The tolerances must come from the problem domain. Coordinates, time, physics, and financial rules do not share one magic constant.
Rounding modes
When an exact result lies between representable values, a rounding direction chooses the stored result. A common default is round to nearest, with exact ties going to the candidate whose final bit is even.
C and C++ expose supported floating-point modes and flags through <fenv.h> and <cfenv>.
#include <cfenv>
#pragma STDC FENV_ACCESS ON
std::fesetround(FE_DOWNWARD);
volatile double value = 1.0 / 10.0;
std::fesetround(FE_TONEAREST);
Compiler optimization can assume a fixed environment unless the language rules and build options say otherwise. Reproducible numerical code must control both.
Five exception conditions
An IEEE floating-point exception usually means a status condition, not a C++ throw. Default handling commonly produces a result and records a flag.
- invalid operation;
- division by zero;
- overflow;
- underflow;
- inexact result.
#include <cfenv>
#include <iostream>
#pragma STDC FENV_ACCESS ON
int main()
{
std::feclearexcept(FE_ALL_EXCEPT);
volatile double one = 1.0;
volatile double zero = 0.0;
volatile double result = one / zero;
(void)result;
if (std::fetestexcept(FE_DIVBYZERO))
{
std::cout << "division by zero flag\n";
}
}
Operation order matters
Finite-precision addition need not obey associativity because each intermediate result is rounded.
double large = 1e16;
double small = 1.0;
double first = (large + small) - large;
double second = small + (large - large);
The first expression can lose small. Fast-math compiler options may permit further reassociation and alter exception semantics.
Accumulation error
Rounding at every addition can accumulate. Kahan summation keeps a correction term for low-order information lost by the previous addition.
#include <vector>
double kahanSum(const std::vector<double>& values)
{
double sum = 0.0;
double correction = 0.0;
for (double value : values)
{
double adjusted = value - correction;
double next = sum + adjusted;
correction = (next - sum) - adjusted;
sum = next;
}
return sum;
}
Use compensated summation only when the error budget justifies it. Pairwise summation, ordering values by magnitude, or a wider accumulator may also help.
Money and exact quantities
Binary floating point is a good fit for graphics, coordinates, and many scientific calculations. It is usually a poor storage choice for money that must obey exact decimal rounding rules.
Common alternatives are:
- store the smallest unit, such as cents, as an integer;
- use a decimal or fixed-point type;
- state the required rounding point and direction explicitly.
Identifiers and counters that must remain exact also belong in integer types.
Serialization
When a floating-point value crosses a file or network boundary, define the format rather than writing a language type blindly.
- State binary32 or binary64.
- Convert to an integer bit pattern and apply the protocol byte order.
- Decide whether signed zero and NaN payloads must be preserved.
- For text round trips, print enough digits.
C++ max_digits10 gives a digit count sufficient to distinguish values during a same-type decimal round trip.
std::cout << std::setprecision(
std::numeric_limits<double>::max_digits10) << value;
Inspect the implementation
#include <iostream>
#include <limits>
template <typename T>
void printLimits(const char* name)
{
using L = std::numeric_limits<T>;
std::cout << name << '\n';
std::cout << " IEC 559: " << L::is_iec559 << '\n';
std::cout << " radix: " << L::radix << '\n';
std::cout << " significand digits: " << L::digits << '\n';
std::cout << " min normal: " << L::min() << '\n';
std::cout << " min subnormal: " << L::denorm_min() << '\n';
std::cout << " max: " << L::max() << '\n';
}
For floating types, min() is the smallest positive normal value. Use lowest() for the most negative finite value and denorm_min() for the smallest positive subnormal.
Practical rules
- A floating type stores one value from a finite representable set, not every real number.
- Exact equality is useful when exact identity is truly expected; computed continuous quantities need a domain-specific comparison policy.
- Detect NaN with a dedicated predicate.
- Spacing increases with magnitude, which matters for large world coordinates and tiny incremental motion.
- Use integers or suitable decimal types for exact decimal quantities.
- For reproducibility, control compiler floating-point options, FMA behavior, intermediate precision, rounding, and hardware assumptions.
References
- IEEE 754-2019 overview
- Oracle Numerical Computation Guide, IEEE Arithmetic
- What Every Computer Scientist Should Know About Floating-Point Arithmetic
- C++ 0
- C++ floating-point environment
Summary
IEEE 754 uses a sign, significand, and exponent to cover a huge range with a limited number of bits. The cost is approximation, magnitude-dependent spacing, special values, and rounding at intermediate steps. Once those rules are understood, 0.1 + 0.2, NaN comparisons, large-coordinate jitter, and accumulated error become consequences of one coherent model rather than unrelated surprises.