IEEE 754(IEEE Standard for Floating-Point Arithmetic,即IEEE二进制浮点数算数标准),定义了表示浮点数的格式(包括-0)与反常值(denormal number),无穷Infinity与非数值(NaN,Not a Number)等数值表示规则和方法 。
二进制浮点数表示法

JS使用IEEE 754双精度64位二进制格式来表示数值类型 。在IEEE 754标准中,一个双精度64位数字包含以下几个部分(从左往右):
sign(符号位,占用1bit),表示这个数是正/负数,0表示正数;1表示负数
exponent(指数位,占用11bit),表示将这个浮点数以科学计数法形式展示的指数
mantissa(有效数字位,占用52bit),表示这个浮点数以科学计数法形式展示的底数
二进制、十进制互相转换
回顾一下,二进制和十进制互相转换的方法:
二进制转十进制:例如一个二进制整数:01101101,转换为十进制的运算过程如下:
0×27+1×26+1×25+0×24+1×23+1×22+0×21+1×20=109
得到 (1101101)2=(109)10。
十进制转二进制:例如十进制数109,转换为二进制的运算过程如下:
109÷254÷227÷213÷26÷23÷21÷2=54=27=13=6=3=1=0余 1余 0余 1余 1余 0余 1余 1
得到 (109)10=(1101101)2。
十进制转二进制:对于小数例如0.8125,使用“乘2取整法”:
0.8125×20.625×20.25×20.5×2=1.625=1.25=0.5=1.0⇒⇒⇒⇒取整 1取整 1取整 0取整 1
得到 (0.8125)10=(0.1101)2
二进制转十进制:对于二进制小数如0.1101:
1×2−1+1×2−2+0×2−3+1×2−4=0.8125
得到 (0.1101)2=(0.8125)10
双精度64位浮点数表示
如果使用IEEE 754标准来表示双精度浮点数数值0.1,可按照如下步骤来手动计算推导:
首先,可以比较简单地知道符号位(Sign)的位的值为0;
S =0
接下来,利用“乘2取整法”,将0.1转换为二进制:
0.1×20.2×20.4×20.8×20.6×20.2×20.4×20.8×20.6×2=0.2=0.4=0.8=1.6=1.2=0.4=0.8=1.6=1.2 ...⇒⇒⇒⇒⇒⇒⇒⇒⇒....取整 0取整 0取整 0取整 1取整 1取整 0取整 0取整 1取整 1
得到 (0.1)10=(0.00011)2,是一个无限循环小数,循环节为 0011。
然后使用科学计数法,将这串二进制小数写成科学计数法形式:
(0.000110011...)2=(1.110011001...)2×2−4
可以知道,指数位为-4,因为指数部分(Exponent)采用“偏移编码”(目的是为了在二进制中能同时表达正指数和负指数,并且避免使用额外的符号位,还可以保留特殊值),所以实际的指数 E =−4+1023(为什么是+1023?因为指数位占用11bit,211=2048,中点是1023)。
故:
E =−4+1023
1019转换为11位的二进制:
(1019)10=(01111111011)2
接下来是有效数字位(mantissa),即二进制科学计数法的底数 (1.110011001...)2,取出小数点后52位(为什么只取小数点后的值?因为在科学计数法下,只保留小数点前1位,而在二进制形式下小数点前的那一位永远会是1,所以可以直接直接忽略)。
所以:
M =1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001
最终,我们得到0.1的双精度64位浮点数每位的值:
| S |
E |
M |
| 0 |
01111111011 |
1001100110011001100110011001100110011001100110011001 |
封装函数doubleToBinaryString和binaryStringToDouble
/**
* 数字转 IEEE 754 双精度二进制字符串
* @param {Number} num 任意数字
*/
function doubleToBinaryString(num) {
const buffer = new ArrayBuffer(8); // 64 位 = 8 字节
const view = new DataView(buffer);
// 把数字写入 buffer(使用大端模式)
view.setFloat64(0, num, false); // false 表示使用 big-endian
let binaryStr = "";
for (let i = 0; i < 8; i++) {
const byte = view.getUint8(i);
binaryStr += byte.toString(2).padStart(8, '0');
}
return binaryStr;
}
/**
* IEEE 754 双精度二进制字符串转数字
* @param {String} binaryString 任意表示双精度二进制的字符串
*/
function binaryStringToDouble(binaryString) {
const bstr = binaryString.replaceAll(/[^\d]/g, '') // 仅保留数字
if (bstr.length !== 64) {
throw new Error("输入必须是 64 位二进制字符串");
}
// 将每 8 位转成字节,填入 Uint8Array
const bytes = new Uint8Array(8);
for (let i = 0; i < 8; i++) {
const byteStr = bstr.slice(i * 8, (i + 1) * 8);
bytes[i] = parseInt(byteStr, 2);
}
// 用 DataView 读取 float64(双精度)数字
const view = new DataView(bytes.buffer);
return view.getFloat64(0, false); // false 表示 big-endian(高位在前)
}
浮点数的基础运算原理
此处,以0.1 + 0.2为例,介绍一下双精度数值两者+运算的运算过程:
首先,使用封装好的doubleToBinaryString函数,得到两者的IEEE 754的结构:
| Number |
S |
E |
M |
| 0.1 |
0 |
011 1111 1011 |
1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1010 |
| 0.2 |
0 |
011 1111 1100 |
1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1010 |
然后,对齐指数(E):
0.1的指数位为011 1111 1011,(01111111011)2=(1019)10,故,指数为 1019−1023=−4
0.2的指数位为011 1111 1100,(01111111100)2=(1020)10,故,指数为 1020−1023=−3
所以,将指数最小的0.1的尾数向右移一位,以对齐指数。
然后将两个尾数直接相加(逢二进一),这里需要注意尾数前面都隐含了一个1.:
+ = 00.1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1101 001.1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1010 010.0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0111 0(0.1)(0.2)
然后再将得到的和,转换为科学计数法,需要将尾数右移动一位得到:1.0 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0111 0,指数 1+1023−3=1021,所以得到指数位为011 1111 1101:
E =(1021)10=(01111111101)2
剩下的尾数位,去掉前导的1.,保留52位(此处造成了精度缺失):
M =0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 10
最后,我们得到了0.1 + 0.2的IEEE 754双精度二进制字符串:
| Number |
S |
E |
M |
| 0.1+0.2 |
0 |
011 1111 1101 |
0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 |
如果将这个得到的IEEE 754二进制位字符串0|011 1111 1101|0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011转换成十进制,调用之前封装的函数binaryStringToDouble传入这个二进制位字符串,得到结果为0.3:
> binaryStringToDouble('0|011 1111 1101|0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011')
0.3
嗯,怎么恰好等于0.3了?JS运行0.1 + 0.2得到的结果不是会输出0.300_000_000_000_000_04吗?
原来,IEEE 754浮点运算还定义了应当“舍入”的情况:
当尾数超过52位时,需要用“舍入”来决定如何去保留52位
舍入的情况
回到前面的得到的没有精度缺失前的结果:
M =0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 10
需要进行“舍入”(入)操作,保留52位,那么将得到:
M Mround =0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 10=0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0100 00
即,0|011 1111 1101|0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0100
JS传参调用函数 binaryStringToDouble:
> binaryStringToDouble('0|011 1111 1101|0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0100')
0.30000000000000004 /* (0.300_000_000_000_000_04) */
原来,相比真实结果0.1 + 0.2 = 0.3,多出来的0.000_000_000_000_000_04,是舍入(入)操作引起的,所以“0.1 + 0.2 > 0.3”。
最后
在JavaScript(以及大多数编程语言)中,浮点数运算的结果,往往是不太靠谱的,尤其是在比较数值大小时,需要格外注意。
在进行浮点数值运算,并展示结果时,建议经常手动去做一次“四舍五入”操作,或使用BigDecimal等运算库,或直接避免使用浮点数并换用整数来存储精细度要求高的数值数据。