发布于
有两件完全不同的事都被叫做「进制转换」。一件是把同一个数换一种写法,那是算术;另一件是把任意字节表示成可打印字符,那是编码。十六进制同时属于这两个世界,这正是两者被混为一谈的原因。本文把它们分开,手算一遍转换过程,并讲清那个让 0xFF 时而是 255、时而是 -1 的有符号表示。
位值记数法就是这里唯一的思想
以 b 为基数书写的数,是各位数字乘以 b 的幂之和,位置从右往左自 0 计数。十进制串 2026 的含义是 2 乘 1000 加 0 乘 100 加 2 乘 10 加 6。这套做法里没有任何东西专属于十;把基数换掉,同一条规则就生成了所有其他记数法。
由此得到的关键结论是:基数是「写法」的属性,不是「数」的属性。2026 这个量,无论写作 2026、11111101010、3752 还是 7EA,都是同一个量。转换只改变它的样子,从不改变它是什么 —— 所以任何一次转换都可以靠反向换回来验证。
有两个方向的手算值得掌握。从十进制转到 b 进制,反复除以 b,自下而上读余数;从 b 进制转回十进制,把每位数字乘以它的位权再求和。对较小的数,这两种算法在纸上都短到能算完;亲手算一遍,是消除「我不确定转换器给我的是什么」这种不安最快的办法。
超过九以后的数字需要新名字,字母就是这么进来的。十六进制借用 A 到 F 来表示 10 到 15。这纯粹是命名约定:在这个语境里 A 不是字母,而是值为十的那个数字。
十进制转二进制,反复除以 2(自下而上读余数):
2026 / 2 = 1013 余 0 1013 / 2 = 506 余 1 506 / 2 = 253 余 0
253 / 2 = 126 余 1 126 / 2 = 63 余 0 63 / 2 = 31 余 1
31 / 2 = 15 余 1 15 / 2 = 7 余 1 7 / 2 = 3 余 1
3 / 2 = 1 余 1 1 / 2 = 0 余 1
-> 11111101010
展开回十进制做校验:
1024 + 512 + 256 + 128 + 64 + 32 + 8 + 2 = 2026
同一个值在各常用进制下:
十进制 2026
二进制 11111101010
八进制 3752 (3*512 + 7*64 + 5*8 + 2)
十六进制 7EA (7*256 + 14*16 + 10)二进制、八进制、十六进制,以及十六进制为什么赢了
二进制是硬件真正存储的形式,但超过一个字节人就读不动了:11111101010 读起来吃力,念出来更吃力。八进制和十六进制作为二进制的速记而存在,它们之所以好用,是因为基数正好是 2 的幂。一个八进制位恰好是 3 个比特,一个十六进制位恰好是 4 个比特,所以它们与二进制之间的转换完全不需要算术,只需要重新分组。
十六进制胜出,是因为 4 个比特能整除一个字节,而 3 个不能。两个十六进制位永远正好是一个字节,边界上不会有进位;八个十六进制位正好是一个 32 位字。八进制要用 3 位才能覆盖 8 个比特,而最高那一位只用到自己 3 个比特中的 2 个,于是字节数据的八进制表示总有一个「不齐」的高位。在八进制流行的 12 位和 36 位机器上这不成问题,在 8 位字节上就成了问题。
但八进制并没有消失。Unix 文件权限至今用八进制书写,原因恰恰是八进制在别处显得别扭的那一点:权限位天然就是三个一组。755 表示属主 rwx、属组 r-x、其他人 r-x,每个八进制位对应一个三元组。umask 和 chmod 的参数遵循同样的逻辑。
有一个历史遗留的坑一直延续到现代代码里。在 C 以及抄袭了它字面量语法的那些语言中,前导零表示八进制字面量,所以 011 是九而不是十一。这正是现代语言引入显式 0o 前缀的原因,也是其中好几种语言如今干脆拒绝裸前导零的原因。
「每位比特数」只有在基数为 2 的幂时才是整数,这也正是那三种进制在系统编程里占绝对主导的原因。
| 进制 | 数字字符集 | 每位比特数 | 常见前缀 | 出现场合 |
|---|---|---|---|---|
| 2(二进制) | 0 1 | 1 | 0b | 位掩码、标志位、子网掩码、硬件寄存器 |
| 8(八进制) | 0-7 | 3 | 0o(C 中为裸前导 0) | Unix 文件权限、umask、部分转义序列 |
| 10(十进制) | 0-9 | 约 3.32 | 无 | 人手输入的一切 |
| 16(十六进制) | 0-9 A-F | 4 | 0x、CSS 中的 #、码位的 U+ | 内存地址、字节转储、颜色、哈希、UUID |
| 36 | 0-9 A-Z | 约 5.17 | 无 | URL 与短链里的紧凑数字 ID |
补码,以及 0xFF 为什么有时是 -1
到目前为止讲的数值进制都没法写负数,负号在记数法之外。硬件用不了负号,所以有符号整数采用定宽的表示约定,而现代机器统一使用的约定是二进制补码。
规则是机械的:要用 n 位表示一个负值,取其正值,逐位取反,再加一。以 8 位表示 -42 为例,42 是 00101010,取反得 11010101,加一得 11010110,也就是 0xD6。最高位起到符号指示的作用 —— 置位表示负 —— 但它并不是独立的符号字段,这正是这套算术能成立的原因:处理器用同一套电路完成有符号与无符号的加减。
对读十六进制转储的人来说,后果是同一组比特有两种读法,而只有声明的类型能在两者间做出裁决。0xFF 作为无符号字节是 255,作为有符号字节是 -1。0x80 无符号是 128,有符号是 -128,并且它是这个范围内唯一一个「取负之后放不下」的值 —— 这就是为什么 C 中对最小整数取 abs() 是未定义行为,而另外几种语言会原样返回那个负数。调试器展示原始字节时并不知道类型,所以它通常给出无符号读法。
补码还解释了符号扩展。把有符号的 8 位 -1 扩宽到 32 位得到的是 0xFFFFFFFF,而不是 0x000000FF,因为符号位会被复制到每一个新增的位上;而把无符号的 0xFF 扩宽得到的就是 0x000000FF。一个不说明字段有无符号的协议,就是一个迟早会被两种方式各解析一遍的协议。
比特 无符号 有符号(补码)
00101010 42 42
10110101 181 -75 (181 - 256)
11010110 214 -42 (214 - 256)
11111111 255 -1
10000000 128 -128 对这个值取负会溢出
推导 8 位下的 -42:
42 = 00101010
逐位取反 = 11010101
加一 = 11010110 = 0xD6
扩展到 32 位:
有符号 -1 (0xFF) -> 0xFFFFFFFF
无符号 255 (0xFF) -> 0x000000FFRFC 4648 编码的是字节,不是数
Base16、Base32 和 Base64 由 RFC 4648 统一规定。尽管名字里也带 Base,它们干的并不是上面那些数值进制的活。它们不把一个数换成另一种记数法,而是把一串字节重新表达为可打印 ASCII,好让它能穿过那些原本会把它弄坏的系统 —— 邮件正文、URL、JSON 字符串、HTTP 头部值。
机制是对比特流重新分组。Base16 每次取 4 个比特,输出十六个字符之一,体积翻倍;Base32 每次取 5 个比特,输出三十二个字符之一,体积增长 60%;Base64 每次取 6 个比特,输出六十四个字符之一,体积增长 33%。由于一个字节是 8 个比特,只有「Base64 遇上 3 的倍数字节」和「Base32 遇上 5 的倍数字节」才刚好对齐,其余情况都需要补位,末尾那些等号就是补位标记。
有两个细节造成了大部分困惑。其一,前导零字节会被原样保留,因为每组比特都是独立编码的 —— 这与数值转换不同,后者的前导零会消失。其二,标准字符集里的加号和斜杠在 URL 中不安全,所以 RFC 4648 定义了 URL 安全变体,把它们替换为减号和下划线,并且通常省略补位。JWT、WebAuthn 以及多数现代令牌格式用的都是这个变体,这也是为什么把令牌粘进标准 Base64 解码器经常会失败。
Base32 虽然更占体积,却值得了解。它的字符集是大写字母加数字 2 到 7,这样挑选是为了让视觉上容易混淆的 0、1、8 全部缺席。因此,凡是需要人念出来、照屏幕敲进去或者印在纸上的内容,它都是更合适的选择:TOTP 密钥、洋葱地址、以及需要塞进 DNS 的载荷用的都是它。
| 编码 | 字符集 | 每字符比特数 | 体积增长 | "Man" 编码后 |
|---|---|---|---|---|
| Base16(十六进制) | 0-9 A-F | 4 | 100% | 4D616E |
| Base32 | A-Z 2-7 | 5 | 60% | JVQW4=== |
| Base32hex | 0-9 A-V | 5 | 60% | 9LGMS=== |
| Base64 | A-Z a-z 0-9 + / | 6 | 33% | TWFu |
| Base64url | A-Z a-z 0-9 - _ | 6 | 33% | TWFu |
| Base58 | Base62 去掉 0 O I l | 约 5.86 | 约 37% | SzVj |
"Man" -> 字节 4D 61 6E
01001101 01100001 01101110 三个字节,共 24 比特
010011 010110 000101 101110 重新分组为四个 6 比特值
19 22 5 46 十进制值
T W F u 按 A-Z a-z 0-9 + / 取下标
输入不是 3 字节整数倍时出现补位:
"Man" (3 字节) -> TWFu
"Ma" (2 字节) -> TWE=
"M" (1 字节) -> TQ==
100 字节输入的实际体积:
Base64 136 个字符 Base32 160 个字符 Base16 200 个字符Base58,以及为人眼设计的编码
Base58 是个异类。它不在 RFC 4648 里,基数不是 2 的幂,也不对比特流做重新分组。它把整串字节当作一个超大整数,再把这个整数转成 58 进制 —— 所以它是一次施加在二进制数据上的、货真价实的数值转换。
它的字符集是六十二个字母数字去掉四个:数字零、大写 O、大写 I、小写 l。这四个正是在多数字体里会彼此塌缩的字符,去掉它们就是全部意义所在。Base58 存在的目的,就是让内容可以被手抄、被电话念、被印在纸上而不产生转录错误,所以比特币地址、IPFS 的 CIDv0 标识符和 Solana 公钥用的都是它。
因为它是算术而不是分组,Base58 有两个 RFC 4648 系编码所没有的性质。一是前导零字节在转换中本会消失,所以规范要求把它们显式补回来,每个零字节对应一个前导的 1 字符。二是它无法按固定大小的分块做流式编解码:整个值必须整体处理,因而复杂度对输入长度是平方级的,不适合大载荷。没有人会用 Base58 去编码一个文件。
实际使用中,Base58 通常以 Base58Check 的形式出现:编码前先追加 4 字节的双重 SHA-256 截断校验和。正是它让钱包能够拒绝一个敲错的地址,而不是把资金打进虚空;这也提醒我们,编码本身不提供任何完整性保证 —— 校验和是额外加在上面的一层。
每种进制真正出现在什么地方
一眼认出进制能省下大量时间。长度恰好为 32、40 或 64 且只含 0-9a-f 的字符串,分别是 MD5、SHA-1、SHA-256 的摘要。以一到两个等号结尾的是 Base64。全大写、不含 0 1 8、长度是 8 的倍数的,是 Base32。大小写混排、不含数字零也不含小写 l 的字母数字串,八成是 Base58。
凡是字节边界重要的地方,十六进制都占主导:内存地址、MAC 地址、IPv6 分组、UUID、CSS 颜色、写作 U+1F600 的 Unicode 码位,以及有史以来的每一份十六进制转储。凡是单个比特自身携带含义的地方,二进制登场:权限与能力标志、子网掩码、硬件寄存器、特性位域。十进制留给人们口头谈论的数量。Base64 及其亲属则用于让字节穿过文本通道 —— 既不是压缩,也不是保密。
需要在这些之间转换时,进制转换工具处理数值进制,Base64 工具处理字节编码。这两个工具的分工,正是本文围绕的那条界线:一个是在换算一个量,另一个是在重新封装一串字节。问错工具,就是有人试图去「解码」一个颜色代码的由来。
- 两个十六进制位永远等于一个字节 —— 用这一点去核对任何十六进制串的长度是否与它该装的数据相符。
- 在 C 系语言里裸前导零表示八进制,请显式写 0o,或者干脆去掉那个零。
- 前导零在 Base16、Base32、Base64 中会保留,在数值转换中则不会,所以 0x0042 和 0x42 是同一个数、却是不同的字节串。
- 令牌在 Base64 解码器里失败时,先试 URL 安全字符集,再去怀疑令牌损坏。
- 有无符号并不存在于比特之中;单看一份十六进制转储,无法判断 0xFF 是 255 还是 -1。
要点回顾
- 进制是书写方式的属性而非数本身的属性,所以任何一次转换都能靠反向换回来验证。
- 十六进制成为系统编程的默认记法,是因为一位正好 4 比特、两位正好一个字节。
- Base16、Base32、Base64 是把字节流重新分组成可打印字符,不是数值转换,所以它们保留前导零字节并且需要补位。
- Base58 是对整串字节做的真正数值转换,去掉了四个易混字符,并且无法流式处理。
- 补码意味着比特本身不带符号:0xFF 是 255 还是 -1,完全取决于声明的类型。