编码指南

进制全解:二进制、八进制、十六进制、Base32、Base64 与 Base58

位值记数法、十六进制为何胜出、RFC 4648 的字节编码与数值进制的区别、Base58、有符号数的补码表示,以及每种进制真正出现在什么地方。

有两件完全不同的事都被叫做「进制转换」。一件是把同一个数换一种写法,那是算术;另一件是把任意字节表示成可打印字符,那是编码。十六进制同时属于这两个世界,这正是两者被混为一谈的原因。本文把它们分开,手算一遍转换过程,并讲清那个让 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 不是字母,而是值为十的那个数字。

2026 的四种进制,并双向验证
十进制转二进制,反复除以 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 110b位掩码、标志位、子网掩码、硬件寄存器
8(八进制)0-730o(C 中为裸前导 0)Unix 文件权限、umask、部分转义序列
10(十进制)0-9约 3.32人手输入的一切
16(十六进制)0-9 A-F40x、CSS 中的 #、码位的 U+内存地址、字节转储、颜色、哈希、UUID
360-9 A-Z约 5.17URL 与短链里的紧凑数字 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)   ->  0x000000FF

RFC 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-F4100%4D616E
Base32A-Z 2-7560%JVQW4===
Base32hex0-9 A-V560%9LGMS===
Base64A-Z a-z 0-9 + /633%TWFu
Base64urlA-Z a-z 0-9 - _633%TWFu
Base58Base62 去掉 0 O I l约 5.86约 37%SzVj
Base64 如何把三个字节变成 TWFu
"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,完全取决于声明的类型。

继续了解相关检查与工具