现在位置: 首页 > 计算机组成原理 > 正文

小数怎么表示 -- 浮点数与 IEEE 754

本讲你将理解:小数如何在只有 0 和 1 的世界里表示,以及为什么浮点数会有精度误差。


生活化类比:科学计数法

你在物理课上一定见过科学计数法:

光速 = 3.0 x 108 m/s,电子质量 = 9.1 x 10-31 kg。

这种方式用很少的数字就能表示非常大或非常小的数。

计算机表示小数用的也是完全相同的思路——二进制科学计数法

任何二进制小数都可以写成:

1.xxxxxxxxxx x 2yyyyy

这个形式只需要存储三样东西:

  • 符号:正号还是负号?
  • 尾数(xxxxxxxxxx):有效数字部分
  • 指数(yyyyy):小数点的位置

IEEE 754 标准就是把这三样东西按固定格式塞进 32 个 bit 里。


IEEE 754 单精度的三段结构

32 位(单精度 float)被精确地划分为三段:

符号位 Sign 0 1 位
指数位 Exponent 00000000 8 位
尾数位 Mantissa (Fraction) 00000000000000000000000 23 位
符号位 (1 bit)
指数位 (8 bits)
尾数位 (23 bits)

第一段:符号位(1 bit)

最简单的一段。

0 表示正数,1 表示负数。

第二段:指数位(8 bits)

指数决定小数点的位置(这就是「浮点」名字的由来——小数点可以「浮动」)。

但指数不是直接存的,而是存储「实际指数 + 127」。

这个 127 叫做偏移量(Bias)

为什么要加偏移量?

因为 8 位只能表示 0~255(无符号),但实际指数需要是负数(表示小于 1 的数)。

加偏移量 127 后:

  • 存储值 127 对应实际指数 0(127 - 127 = 0)
  • 存储值 0 对应实际指数 -127
  • 存储值 255 对应实际指数 +128

第三段:尾数位(23 bits)

尾数存储的是「1.xxxxx」中的「xxxxx」部分,省略了前导的 1。

因为二进制科学计数法中,归一化后的形式总是 1.xxx,所以前面的 1 不需要存储。

这巧妙地多节省了 1 bit 的精度。

理解三段结构的关键:浮点数 = (-1)^符号 x 1.尾数 x 2(指数-127)。这就是 IEEE 754 的全部精髓。


交互演示:32 位浮点数拆解器

输入一个浮点数,观察它在 IEEE 754 单精度格式下的三段结构。

分段横条会高亮显示三段分别对应哪些 bit。

IEEE 754 单精度拆解器

输入浮点数:

试试输入 0.1,观察尾数部分。

0.1 的二进制尾数是一个无限循环小数——23 位完全不够存储。

这就引出了一个著名的计算机现象。


为什么 0.1 + 0.2 不等于 0.3

如果你在 Python 或 JavaScript 中运行 0.1 + 0.2 == 0.3,结果是 False

这不是 bug,而是浮点数表示法的必然结果。

原因很简单:0.1 无法用二进制精确表示

就像十进制中,1/3 = 0.33333... 是一个无限循环小数。

二进制中,0.1 (十进制) = 0.000110011001100110011... (二进制),也是一个无限循环小数。

IEEE 754 只有 23 位尾数来存储这个无限循环小数。

超出 23 位的部分只能被截断(舍入),这就产生了舍入误差。

0.1 在 float32 中实际存储的值大约是 0.100000001490116119384765625。

0.2 在 float32 中实际存储的值大约是 0.20000000298023223876953125。

两者相加再截断后,结果不等于精确的 0.3。

金额计算必须避免浮点数

在涉及金额的场景,应该使用整数(以「分」为单位)或专门的 Decimal 类型。

Python 的 Decimal、Java 的 BigDecimal、C# 的 decimal 都是为此设计的。

用浮点数存储金额,等你发现 0.01 的累积误差变成 0.010000000000000002 时,问题已经种下很久了。


浮点数的取值范围与特殊值

IEEE 754 为指数和尾数保留了几个特殊组合:

类别符号位指数 (存储)尾数含义
0 或 10 (全 0)全 0+0 或 -0
非归一化数0 或 10 (全 0)非全 0非常接近 0 的值
归一化数0 或 11 ~ 254任意正常的浮点数
无穷大0 或 1255 (全 1)全 0+Infinity 或 -Infinity
NaN任意255 (全 1)非全 0非数值(如 0/0)

单精度 float (32 位) 的近似范围:

  • 最大正值:约 3.4 x 1038
  • 最小正值(归一化):约 1.2 x 10-38
  • 最小正值(非归一化):约 1.4 x 10-45
  • 有效精度:约 7 位十进制有效数字

双精度 double (64 位) 的范围更大、精度更高:

  • 11 位指数(偏移量 1023)
  • 52 位尾数
  • 有效精度:约 15~16 位十进制有效数字

浮点数精度损失的三种情况

情况一:十进制到二进制的转换误差

如前面 0.1 的例子。

任何在十进制中是「干净」的小数,在二进制中都可能是无限循环的。

只有分母是 2 的幂的分数(如 0.5、0.25、0.125)才能在二进制中精确表示。

情况二:大数加小数

浮点数的精度不是均匀分布的——数值越大,能区分的最小差值越大。

比如 16777216.0 + 1.0 = 16777216.0(在 float32 中结果不变)。

因为 32 位浮点数只能精确表示约 7 位有效数字,16777216 本身已经占了 8 位。

加 1 产生的变化落入了尾数无法表示的精度范围,直接被「吞掉」了。

情况三:相近数相减

两个非常接近的浮点数相减,会导致有效数字的灾难性丢失

比如 1.2345678 - 1.2345677 = 0.0000001,但实际计算可能得到 0.000000099999... 这样误差很大的结果。

了解浮点数的精度限制不是让你「害怕」用它,而是让你在合适的场景选择合适的工具。科学计算、图形渲染、机器学习——浮点数在这些领域完全够用。但金融、会计、精确计数——请远离浮点数。


代码演示:浮点数的秘密

实例

# 浮点数精度问题完整演示(runoob 演示)
import struct

def float_to_ieee754_binary(num):
    """将浮点数转换为 IEEE 754 单精度三段表示"""
    # 使用 struct 将浮点数打包为 4 字节(大端序)
    packed = struct.pack('>f', num)
    # 转换为 32 位二进制字符串
    bits = ''.join(f'{b:08b}' for b in packed)

    sign = bits[0]
    exponent = bits[1:9]
    mantissa = bits[9:32]

    exp_stored = int(exponent, 2)
    exp_actual = exp_stored - 127

    # 计算尾数的小数值
    frac = 0
    for i, bit in enumerate(mantissa):
        if bit == '1':
            frac += 2 ** -(i + 1)

    value = (-1 if sign == '1' else 1) * (1 + frac) * (2 ** exp_actual)

    return {
        'bits': bits,
        'sign': sign,
        'exponent': exponent,
        'mantissa': mantissa,
        'exp_stored': exp_stored,
        'exp_actual': exp_actual,
        'fraction': frac,
        'reconstructed': value
    }

# =============================================
# 演示 1: 对比 0.5 和 0.1 的表示
# =============================================
print("=" * 60)
print("演示 1: 为什么 0.5 精确而 0.1 不精确")
print("=" * 60)

for num in [0.5, 0.1]:
    r = float_to_ieee754_binary(num)
    print(f"\n浮点数: {num}")
    print(f"  符号位: {r['sign']} ({'正' if r['sign'] == '0' else '负'})")
    print(f"  指数位: {r['exponent']} (存储={r['exp_stored']}, 实际={r['exp_actual']})")
    print(f"  尾数位: {r['mantissa']}")
    print(f"  实际存储值: {r['reconstructed']}")

# =============================================
# 演示 2: 经典的 0.1 + 0.2 != 0.3
# =============================================
print("\n" + "=" * 60)
print("演示 2: 0.1 + 0.2 != 0.3")
print("=" * 60)

# 使用更高精度显示
import decimal
ctx = decimal.getcontext()
ctx.prec = 50

a = decimal.Decimal(0.1)
b = decimal.Decimal(0.2)
c = decimal.Decimal(0.3)

print(f"0.1 的实际值: {a}")
print(f"0.2 的实际值: {b}")
print(f"0.3 的实际值: {c}")
print(f"0.1 + 0.2  = {a + b}")
print(f"0.1 + 0.2 == 0.3 ? {0.1 + 0.2 == 0.3}")

# =============================================
# 演示 3: 大数「吞」小数
# =============================================
print("\n" + "=" * 60)
print("演示 3: 大数加小数被吞没")
print("=" * 60)

big = 16777216.0
small = 1.0
result = big + small
print(f"{big} + {small} = {result}")
print(f"结果等于原数? {result == big}")

# 展示这个数达到 float32 的精度极限
print(f"\n原因: {big} 在 float32 中需要 {len(format(struct.unpack('>I', struct.pack('>f', big))[0], '032b')[:23])} 位尾数")
print(f"相邻两个 float32 值的最小间隔约为 2^24 / 2^23 = 2.0")

# =============================================
# 演示 4: 正确的大小写转换技巧
# =============================================
print("\n" + "=" * 60)
print("演示 4: RUNOOB 案例 - 金额的正确处理")
print("=" * 60)

from decimal import Decimal

# 错误做法:用浮点数
float_total = 0.1 + 0.2
print(f"错误(浮点数): 0.1 + 0.2 = {float_total}")

# 正确做法:用 Decimal
decimal_total = Decimal('0.1') + Decimal('0.2')
print(f"正确(Decimal): Decimal('0.1') + Decimal('0.2') = {decimal_total}")

# 或者用整数(以分为单位)
cents_total = 10 + 20  # 0.1元 = 10分, 0.2元 = 20分
print(f"正确(整数分): 10分 + 20分 = {cents_total}分 = {cents_total/100}元")

# =============================================
# 演示 5: 浮点数的特殊值
# =============================================
print("\n" + "=" * 60)
print("演示 5: 浮点数的特殊值")
print("=" * 60)

import math

# 无穷大
print(f"float('inf')    = {float('inf')}")
print(f"1.0 / 0.0       = {1.0 / 0.0}")
print(f"float('inf') > 1e1000000 = {float('inf') > 1e1000000}")

# NaN
print(f"float('nan')     = {float('nan')}")
print(f"0.0 / 0.0        = {0.0 / 0.0}")
print(f"float('nan') == float('nan') ? {float('nan') == float('nan')}")
print(f"  (NaN 不等于任何值,包括它自己!)")

# 检查 NaN
print(f"math.isnan(float('nan')) = {math.isnan(float('nan'))}")

实战建议:何时用什么

场景推荐类型原因
科学计算、图形渲染float 或 double精度足够,硬件加速支持好
金融金额计算Decimal / BigDecimal精确十进制运算,无舍入误差
计数器、索引int / long精确整数,没有精度问题
比较浮点数用容差比较不应直接 ==,应用 abs(a-b) < epsilon
货币存储/传输整数(以分为单位)避免序列化时的浮点误差