为什么是 2、8、16:进制是比特分组,不是随意选择
程序员恰好使用这几种进制,是因为每一种都与比特整齐对应。一个十六进制位恰好是 4 个比特(半字节),两个十六进制位就是一个字节——0xFF 即 11111111 即 255,32 位值永远是 8 个十六进制位。内存地址、哈希摘要、MAC 地址、颜色值都用十六进制书写,原因就在这里:你能直接从文本上读出字节边界。八进制把比特三个一组,与 Unix 权限模型完美契合:rwx 是三个比特,所以 rwxr-xr-x 就是 111 101 101,即 755。
相比之下,十进制与比特边界毫无关系——255 这个数字完全无法直观告诉你哪些位被置位。十进制转二进制需要反复除以 2;十六进制转二进制则是逐位查表。这就是为什么老练的工程师能心算十六进制与二进制互转,却没人对大数做十进制到二进制的心算。
一个有用的记忆锚点是幂次阶梯:2^8 = 256,2^10 = 1024,2^16 = 65,536,2^32 = 4,294,967,296。由此可以推出实践中遇到的大多数上限——字节的 0–255 范围、TCP 端口最大值 65,535、32 位地址空间的 4 GiB 上限。另外注意 Base64 不是这种意义上的进制:它是把 6 比特一组映射为字符的二进制转文本编码。Base64 无法参与算术运算,用进制转换器去转它属于范畴错误。
hex F F octal 7 5 5
| | | | |
bin 1111 1111 bin 111 101 101
= 0xFF = 255 = rwxr-xr-x (chmod 755)
# read a 32-bit value byte by byte
0xDEADBEEF = DE AD BE EF = 222 173 190 239
# the powers ladder
2^8 = 256 2^10 = 1,024 2^16 = 65,536 2^32 = 4,294,967,296补码:符号取决于位宽
位模式 11111111 本身没有符号。按无符号 8 位整数解释是 255;按有符号(补码)8 位整数解释是 -1。同样的字节,两种契约。补码之所以胜出,是因为算术是统一的:取负就是"全部取反再加一",同一套加法器电路可以同时处理正负数。n 位有符号整数覆盖 -(2^(n-1)) 到 2^(n-1)-1——8 位就是 -128 到 127。这种不对称是真实存在且偶尔咬人的:在 8 位寄存器中对 -128 取负会溢出回 -128,这正是 C 语言中对最小负整数取 abs() 是未定义行为的原因。
这对转换器很重要:没有声明位宽的转换器无法展示负数——不存在"-5 的二进制"这种东西,只存在"-5 的 8 位(11111011)或 32 位(…11111011)补码"。
JavaScript 还有自己的花样:位运算符会把数字强制转成有符号 32 位整数。所以 0xFFFFFFFF 作为字面量是 4294967295,但 0xFFFFFFFF | 0 是 -1,因为第 31 位成了符号位。无符号右移零位(value >>> 0)是把值重新解释为无符号 32 位的标准写法,而类型化数组(Int8Array 与 Uint8Array)则把同一批字节的两种读法明示出来。当寄存器转储或抓包里的十六进制值看起来"巨大而怪异"时,先检查它是不是被按无符号打印的负数。
(0xFF | 0) // 255 — fits in int32, stays positive
(0xFFFFFFFF | 0) // -1 — bit 31 became the sign bit
(-1 >>> 0).toString(16) // 'ffffffff'
(-1 >>> 0).toString(2) // '11111...' (32 ones)
// same byte, two contracts
new Int8Array([0xFF])[0] // -1
new Uint8Array([0xFF])[0] // 255
// negate = flip bits, add one
// 5 = 00000101
// ~5 = 11111010
// +1 = 11111011 = -5 in 8-bit two's complementIEEE 754 与 2^53 悬崖:JavaScript 何时悄悄舍入你的整数
JavaScript 只有一种数字类型:IEEE 754 双精度浮点数。double 有 52 位尾数加一个隐含位,因此能精确表示到 2^53(9,007,199,254,740,991——Number.MAX_SAFE_INTEGER)为止的所有整数。再往上一步,可表示的值就开始跳跃:2^53 + 1 被舍入为 2^53,比较 9007199254740992 === 9007199254740993 结果为 true。不抛任何异常。舍入是无声的,这正是它危险的地方。
这在真实系统中不断偷袭开发者,因为 64 位整数 ID 无处不在:数据库 bigint 列、Twitter/X 雪花 ID、Discord ID、订单号。JSON 本身没有整数大小限制,但 JSON.parse 把 19 位 ID 实例化为 Number 的那一刻,低位数字就没了——而这个损坏的 ID 会以貌似合理的样子在你的代码里畅通无阻。解决办法:大 ID 用字符串传输,或用支持 BigInt 的 reviver 解析。BigInt 支持任意精度,其字符串形式还接受进制前缀。
同样的 IEEE 754 也解释了著名的 0.1 + 0.2 !== 0.3,它本质上是个进制转换问题:0.1 在十进制下有限,在二进制下却是无限循环小数(0.0001100110011...),必须舍入才能塞进 52 位。凡是分母不是纯 2 的幂的十进制小数都有这个性质。这就是为什么金额应该用整数分来存,也是为什么一个支持小数的进制转换器必须能展示循环展开。
Number.MAX_SAFE_INTEGER // 9007199254740991 (2^53 - 1)
9007199254740992 === 9007199254740993 // true (!)
// 64-bit ID from an API, silently corrupted
JSON.parse('{"id": 900719925474099267}').id
// -> 900719925474099300
// exact alternatives
BigInt('900719925474099267') // 900719925474099267n
BigInt('0xff') // 255n — radix prefix works
(255n).toString(16) // 'ff'
// the fraction side of the same coin
0.1 + 0.2 // 0.30000000000000004
// 0.1 in binary = 0.000110011001100... (repeats forever)各语言中的进制字面量与解析陷阱
现代语言在前缀上达成了一致:0x 表示十六进制,0b 表示二进制,0o 表示八进制(JavaScript、Python、Rust、Swift 都接受),还有下划线数字分隔符如 1_000_000 提升可读性。历史遗留的疮疤是 C 风格八进制:仅凭前导零,010 就表示 8。这种写法造成了足够多的真实 bug——补零的数据列被粘进代码、chmod 值被当十进制传参——以至于严格模式的 JavaScript 把旧式八进制字面量定为语法错误,并引入 0o 替代。如果某个 API 用普通数字接收文件模式,644 和 0o644 是完全不同的值(十进制 644 等于 0o1204)。
JavaScript 的解析函数各有脾气。parseInt(str, radix) 应当永远显式传 radix:旧引擎会把前导零解释为八进制,即便今天 parseInt 也只自动识别 0x,不识别 0b 和 0o。Number('0b101') 理解全部三种现代前缀,但 Number('') 是 0;parseInt 遇到第一个非法字符就停止而不是报错——parseInt('12px', 10) 得 12,是功能还是地雷视当天心情而定。parseInt 还会先把参数转成字符串,在极小的浮点数上产生著名的荒谬结果(parseInt(0.0000005) 经由字符串 '5e-7' 得到 5)。
输出方面,toString(radix) 支持 2 到 36 进制,配合 padStart 可以生成定宽转储:(5).toString(2).padStart(8, '0') 输出 00000101。64 位级别的位运算请用 BigInt——基于 Number 的位运算符会截断到 32 位,毁掉高位一半。
parseInt('08') // 8 today; 0 in pre-ES5 engines — always pass radix
parseInt('0x1F') // 31 — hex auto-detected
parseInt('0b101') // 0 — binary prefix NOT understood
Number('0b101') // 5 — all modern prefixes work
parseInt('12px', 10) // 12 — stops at first invalid char
parseInt(0.0000005) // 5 — via the string '5e-7' (!)
0o755 // 493 — modern octal literal
1_000_000 // digit separators, ignored by the engine
(255).toString(2).padStart(8, '0') // '11111111'
(0xDEADBEEFn << 8n).toString(16) // 'deadbeef00' — 64-bit-safe in BigInt实战位运算:权限、标志位与颜色
一线开发者真正需要手动换算进制的三个场景是 Unix 权限、功能标志位和颜色——每个都是值得内化的小惯用法。
权限:八进制的每一位是读+写+执行,权重为 4-2-1。755 分解为 7 = 4+2+1(属主 rwx)、5 = 4+1(属组 r-x)、5(其他 r-x)。644 是 rw-、r--、r--。setuid/setgid/sticky 位构成可选的第四个前导位——4755 属于 setuid root 的领域,所以审计时看到前导的 4 或 2 要提高警惕。符号表示转八进制的心算不过是把权重相加。
标志位:把每个标志定义为 1 左移其索引位,用按位或组合,用按位与检测,用与非清除,用异或切换。转换器里的二进制视图就是调试视图:标志值 13 是 1101,说明第 0、2、3 位被置位。这种布局与文件权限掩码、Linux capabilities、CSS font-feature 位域以及无数网络协议里的 flags 整数完全相同。JavaScript 有一个注意事项:标志超过 31 个就必须换用 BigInt 或字数组,因为存在 32 位截断。
颜色:CSS 十六进制颜色就是三个字节(带 alpha 则四个)的拼接。提取通道就是移位加掩码:红色是值右移 16 位再与 0xFF 按位与。这就是 0xFF0000 是纯红的原因,也是转换器中十六进制视图与十进制视图 16711680 描述同一个像素的原因。
// permissions: weights 4-2-1 per octal digit
// 755 = rwx r-x r-x 644 = rw- r-- r-- 4755 = setuid + 755
// flags: define, set, test, clear, toggle
const READ = 1 << 0; // 1
const WRITE = 1 << 1; // 2
const EXEC = 1 << 2; // 4
let flags = READ | EXEC; // 5 = 101
(flags & WRITE) !== 0 // false
flags &= ~READ; // clear -> 100
flags ^= EXEC; // toggle -> 000
// colors: bytes packed into one integer
const c = 0xff8800; // 16746496
const r = (c >> 16) & 0xff; // 255
const g = (c >> 8) & 0xff; // 136
const b = c & 0xff; // 0