1.1 数据在计算机中的表示:二进制、十六进制、浮点数(IEEE 754)、大端与小端

深入理解计算机底层数据的表示方式,包括二进制与十六进制的转换原理、IEEE 754 浮点数标准的内部结构、精度问题,以及大端序与小端序对网络通信和文件格式的影响。

二进制十六进制IEEE754字节序进制转换浮点数精度补码数据表示

原理

二进制与十六进制:计算机的母语

现代数字计算机的物理基础是半导体开关电路,晶体管只有导通与截止两种状态,分别对应高电平(通常表示 1)和低电平(通常表示 0)。因此,二进制(Binary) 是计算机唯一能直接识别和处理的数制。所有的高级数据类型——无论是字符串、图像、音频还是复杂的对象结构——在底层最终都会被编码为二进制序列。

在工程实践中,直接使用一长串 0 和 1 进行读写极易出错且效率低下。由于 $2^4 = 16$,每 4 位二进制数恰好可以唯一映射到一位 十六进制(Hexadecimal) 数,这使得十六进制成为二进制最紧凑的人类可读表示形式。例如,一个 32 位二进制数 1111 0000 1010 1100 可以简洁地写作 0xF0AC。这种 4 位一组的对应关系是前端开发者在调试网络包、内存地址、颜色值(如 CSS 的 #FF5733)时频繁使用十六进制的根本原因。

有符号整数的表示:原码、反码与补码

计算机需要表示负数。最直观的方案是原码(Sign-Magnitude):用最高位作为符号位(0 为正,1 为负),其余位表示绝对值。然而,原码存在两个严重的缺陷:一是存在 +00000 0000)和 -01000 0000)两种零的表示,造成歧义;二是硬件电路实现加减法时需要额外的逻辑来判断操作数的符号,导致运算器设计复杂。

补码(Two's Complement) 解决了上述所有问题,成为现代计算机表示有符号整数的标准方案。一个 $n$ 位二进制数的补码定义为:正数的补码是其本身;负数的补码是其对应正数的二进制表示按位取反(得到反码)后加 1。从数学上看,$n$ 位补码表示的取值范围是 $[-2^, 2^-1]$。补码的核心优势在于,减法可以统一转换为加法运算:$A - B = A + (-B)_$。这使得 CPU 中的算术逻辑单元(ALU)无需设计独立的减法器,极大地简化了硬件架构。对于前端开发者而言,理解补码有助于解释 JavaScript 中位运算(如 ~>>>)的怪异行为。

IEEE 754 浮点数标准

为了在不同硬件和编程语言之间统一实数的表示,IEEE 于 1985 年发布了 IEEE 754 标准,并在 2008 年进行了修订。该标准定义了浮点数的存储格式、舍入规则以及特殊值(如无穷大、NaN)的处理方式。

一个 IEEE 754 浮点数由三个部分组成:

  1. 符号位(Sign, S):1 位,0 表示正数,1 表示负数。
  2. 指数位(Exponent, E):采用**移码(Bias)**表示。对于 32 位单精度浮点数(float),指数位宽为 8 位,偏移量(Bias)为 127。实际指数 $e = E - 127$。移码的设计使得指数的比较可以当作无符号整数进行,简化了硬件比较电路。
  3. 尾数位(Mantissa/Significand, M):也称为有效数字位。IEEE 754 规定尾数必须规格化(Normalized),即最高位总是 1(对于非零数)。这个隐含的 1 被称为“隐藏位”(Hidden Bit),因此 23 位的尾数实际提供了 24 位的精度。

其真实值计算公式为: $$ V = (-1)^S \times (1.M)_2 \times 2^ $$

双精度(64 位,double 是 JavaScript 中 Number 类型的唯一底层表示(遵循 IEEE 754-2008 的 binary64 格式)。它使用 1 位符号、11 位指数(Bias 为 1023)和 52 位尾数(加隐藏位共 53 位精度)。这意味着 JavaScript 能够精确表示的整数范围是 $[-2^, 2^]$,即 Number.MAX_SAFE_INTEGER9007199254740991)。超出此范围的整数将丢失精度,这也是 9007199254740992 === 9007199254740993 在 JavaScript 中返回 true 的根本原因。

特殊值的处理

  • 当指数位全为 0 且尾数位全为 0 时,表示 ±0
  • 当指数位全为 1 且尾数位全为 0 时,表示 ±Infinity
  • 当指数位全为 1 且尾数位不全为 0 时,表示 NaN(Not-a-Number)。NaN 的尾数非零部分甚至可以用来携带额外的诊断信息(称为 Payload)。

大端序与小端序

当数据宽度超过 1 字节(如 32 位整数占 4 字节)时,就涉及到多字节数据在内存中的存储顺序问题。

  • 大端序(Big-Endian):高位字节存放在低地址处。这种存储方式符合人类从左到右的阅读习惯,因此也被称为“网络字节序”(Network Byte Order)。TCP/IP 协议栈以及绝大多数网络协议都采用大端序。
  • 小端序(Little-Endian):低位字节存放在低地址处。Intel x86 和 x86-64 架构的 CPU 采用小端序。其历史优势在于,对于从低地址开始的逐字节读取,可以自然地先获得低位的有效数据,在早期硬件设计中简化了某些运算逻辑。

前端开发者在处理 ArrayBufferDataView 以及通过 WebSocket 接收二进制数据时,必须明确当前数据的字节序。DataView API 的设计正是为了解决这一问题,它允许开发者在读写时显式指定 littleEndian 参数。

用法

进制转换与位运算

// 十进制与十六进制、二进制的相互转换
const num = 255;
console.log(num.toString(16));   // "ff"
console.log(num.toString(2));    // "11111111"
console.log(parseInt("ff", 16)); // 255
console.log(parseInt("11111111", 2)); // 255

// 使用二进制前缀(ES6)
const flags = 0b1010; // 十进制 10
const mask  = 0x0F;   // 十进制 15

// 位运算:常用于权限控制(ACL)
const PERMISSION_READ    = 0b0001; // 1
const PERMISSION_WRITE   = 0b0010; // 2
const PERMISSION_EXECUTE = 0b0100; // 4

let userPermission = PERMISSION_READ | PERMISSION_WRITE; // 3
console.log((userPermission & PERMISSION_READ) !== 0);    // true,检查读权限
console.log((userPermission & PERMISSION_EXECUTE) !== 0); // false

// 使用无符号右移(>>>)处理补码逻辑
// JavaScript 的 Number 是 64 位浮点数,但位运算会将其强制转换为 32 位有符号整数
const signed = -1;
console.log(signed >>> 0); // 4294967295,将 -1 的 32 位补码视为无符号整数

IEEE 754 精度问题的显式处理

// 经典的浮点数精度陷阱
console.log(0.1 + 0.2); // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3); // false

// 原因:0.1 在二进制中是无限循环小数,存储时发生截断
// 0.1 (十进制) ≈ 0.0001100110011... (二进制)

// 解决方案 1:使用误差范围(Epsilon)进行比较
function floatEqual(a, b, epsilon = Number.EPSILON) {
  return Math.abs(a - b) < epsilon;
}
console.log(floatEqual(0.1 + 0.2, 0.3)); // true

// 解决方案 2:转为整数运算(适用于货币计算)
function addCurrency(a, b) {
  // 假设货币单位为分,避免小数
  return (a * 100 + b * 100) / 100;
}
console.log(addCurrency(0.1, 0.2)); // 0.3

// 解决方案 3:使用 BigInt 处理超大整数
const huge = 9007199254740993n;
console.log(huge === 9007199254740993n); // true
// 注意:BigInt 不能与 Number 混合运算

DataView 与字节序控制

// 创建一个 4 字节的 ArrayBuffer
const buffer = new ArrayBuffer(4);
const view = new DataView(buffer);

// 以大端序写入一个 16 位无符号整数
view.setUint16(0, 0x1234, false); // false = big-endian
console.log(new Uint8Array(buffer)); // Uint8Array [18, 52, 0, 0]

// 以小端序写入(x86 架构内存中的常见形式)
view.setUint16(2, 0x1234, true); // true = little-endian
console.log(new Uint8Array(buffer)); // Uint8Array [18, 52, 52, 18]

// 读取网络数据包(大端序)时
function parseIPv4(octets) {
  // IP 地址通常以 4 个字节的大端序表示
  const view = new DataView(octets.buffer, octets.byteOffset, 4);
  return [
    view.getUint8(0),
    view.getUint8(1),
    view.getUint8(2),
    view.getUint8(3),
  ].join('.');
}

实践

场景一:前端颜色值的进制操作

在 CSS 和 Canvas 开发中,颜色通常以十六进制或 RGBA 表示。理解位运算可以高效地在不同格式间转换。

// 将 #RRGGBB 字符串解析为 RGB 对象
function hexToRgb(hex: string): { r: number; g: number; b: number } {
  const val = parseInt(hex.replace('#', ''), 16);
  return {
    r: (val >> 16) & 0xFF, // 右移 16 位后与 0xFF 取低 8 位
    g: (val >> 8)  & 0xFF,
    b: val & 0xFF,
  };
}

// 将 RGB 对象打包为 32 位整数(常用于 Canvas ImageData)
function rgbToInt(r: number, g: number, b: number, a: number = 255): number {
  // Canvas 的 ImageData 通常使用小端序的 ABGR 或 ARGB
  return (a << 24) | (r << 16) | (g << 8) | b;
}

场景二:处理 WebSocket 二进制帧

WebSocket 协议支持传输二进制帧(ArrayBufferBlob)。在解析自定义二进制协议时,必须严格遵循协议规定的字节序。

// 假设协议头定义如下(大端序):
// | 2 bytes | 4 bytes  | N bytes |
// |  type   | payload_len | payload |
function parseCustomPacket(buffer: ArrayBuffer): { type: number; payload: Uint8Array } {
  const view = new DataView(buffer);
  const type = view.getUint16(0, false); // 大端序读取 type
  const payloadLen = view.getUint32(2, false); // 大端序读取长度
  const payload = new Uint8Array(buffer, 6, payloadLen);
  return { type, payload };
}

场景三:金融系统中的精度安全

在电商或金融前端应用中,任何涉及金额的计算都不能直接使用 JavaScript 的浮点数。业界最佳实践是:在数据层和传输层始终以“分”或更小的整数单位进行处理,仅在最终展示层转换为“元”并格式化。

// 反模式:直接操作元
const total = 0.1 + 0.2; // 危险!

// 正模式:全程使用整数分
const priceInCents = 1999; // 19.99 元
const quantity = 3;
const totalInCents = priceInCents * quantity; // 5997 分
const display = (totalInCents / 100).toFixed(2); // "59.97"

陷阱

| 陷阱描述 | 典型表现 | 解决方案 | |---------|---------|---------| | 浮点数相等性判断 | 0.1 + 0.2 === 0.3 返回 false | 使用 Math.abs(a - b) < Number.EPSILON 进行比较,或转为整数运算 | | parseInt 隐式进制 | parseInt("08") 在某些旧引擎中被当作八进制解析 | 始终显式传入第二个参数:parseInt("08", 10) | | 位运算的 32 位截断 | 0xFFFFFFFF | 0 结果为 -1(有符号) | 若需无符号结果,使用 >>> 0 转换:((0xFFFFFFFF | 0) >>> 0)4294967295 | | 大整数精度丢失 | 9007199254740992 + 1 === 9007199254740992true | 使用 BigInt 类型,或在服务端处理大整数运算 | | 字节序假设错误 | 在 x86 机器上直接使用 Uint32Array 读取网络数据,导致大小端混乱 | 始终使用 DataView 并显式指定 littleEndian 参数 | | NaN 的不等性 | NaN === NaNfalse,且 NaN > 0NaN < 0 均为 false | 使用 Number.isNaN() 判断,而非 === | | toFixed 的银行家舍入 | (2.55).toFixed(1) 可能输出 "2.5" 而非 "2.6"(取决于引擎实现) | 对关键业务舍入逻辑,使用自定义的十进制舍入函数或专业库(如 decimal.js) |

浮点数比较的深层原理

Number.EPSILON 表示 1 与大于 1 的最小可表示浮点数之间的差值(约 $2.22 \times 10^$)。在进行浮点数比较时,Epsilon 的选取应与参与运算的数值量级相匹配。对于极大或极小的数,直接使用 Number.EPSILON 可能并不合适。

关联章节网络

当前章节
关联章节
交叉引用