Base64 到底是什么?为什么我的解不出来?
你把一段 Base64 丢进解码器,回给你一句「Invalid character」;或者更糟——它「成功」了,吐出满屏乱码。多半什么都没坏。你只是搞错了 Base64 的类别:它不是加密、不是压缩,只是把字节可逆地重新打包、好让二进制穿过只收文本的通道。想通这一点,每一次解码失败就都塌缩成一份简短、能一一点名的清单。
你把一段 Base64 丢进解码器,它回给你一句 Invalid character。或者更糟——它「成功」了,然后吐出满屏乱码。于是你换一个工具,居然又能解;你在浏览器里 atob(),它抛异常;你在命令行里解,又得到一个略有不同的结果。到这一步你开始怀疑这串东西是不是损坏了,跑去找它是在哪里被截断的。可几乎每一次,什么都没坏。你只是搞错了 Base64 到底是什么。
换个说法就通了:Base64 不是加密,不是压缩,也不是校验和。它只是一种可逆的手段,把任意字节重新打包成 64 个能穿过纯文本通道的字符。它只回答一个问题——「我怎么让原始字节穿过一个只收文本的东西?」——而对保密(谁都能解开)和体积(它反而把数据撑大约三分之一)什么都不承诺。抓住这一点,「为什么解不出来」就不再是玄学,而塌缩成一份很短的、具体的错配清单:字母表不对、填充缺失、一个本该是 + 的位置变成了空格,或者字节明明解对了、只是看着像乱码,因为它压根就不是文本。
这篇文章先花几分钟讲清 Base64 究竟是什么——三个字节进、四个字符出——然后走一遍解码失败或误导人的每一种常见原因,从那个让很多手动解 token 卡壳的「换了字母表」变体,到那个根本算不上失败的「解对了、只是不是文本」陷阱。读到最后,你会有一份清单,可以拿去对付任何一串「拆不开」的东西。
唯一的核心:一次字节到文本的重新打包,不是保险箱
把两样东西分开,大半困惑就散了。Base64 把字节变成文本、再变回来——无损,且不需要任何密钥。它之所以存在,是因为太多通道是为文本定义的,会把原始二进制搅坏或直接拒收:一个 URL、一个 HTTP 头、一个 JSON 字符串、一封邮件正文、一个 data: URI。往里面塞一个空字节或一个高位字符,它就崩。Base64 把任意二进制洗成一个安全的 64 字符子集——A–Z、a–z、0–9、+、/——让它基本能原封不动地穿过这些通道。(有一处例外:标准 Base64 自己用的 +、/、=,在 URL 查询串和 HTML 表单里仍然有特殊含义,所以这两种场景要专门用 URL-safe 变体、或者再套一层 URL 编码——这正是下面失败三的由来。)
有两个后果值得刻进脑子,因为一半的误解都从这里来:
- 它是公开的。这个变换谁都能逆转,不牵涉任何秘密。把密码或 API key 做一次 Base64,并不能保护它——只是让扫一眼的人稍微不那么一目了然。要保密就得加密;Base64 是件外衣,不是一把锁。
- 它更大。每 3 个字节变成 4 个字符——约 33% 的开销。它是压缩的反面。有人指望用它把数据变小,结果恰好相反。
它到底怎么运作:三字节进、四字符出
这套机制值得花三十秒,因为它能解释后面大半的失败。Base64 把输入切成每 3 字节(24 位)一组,再把每 24 位重新切成四个 6 位的数,然后把每个 6 位值(0–63)映射到字母表里的一个字符。三个字节永远变成四个字符:
Man → 01001101 01100001 01101110 (3 字节 = 24 位)
010011 010110 000101 101110 (四个 6 位组)
T W F u → "TWFu"
当输入不是 3 的整数倍时,最后一组不够长,Base64 就用 = 把输出补齐到 4 的倍数:剩 1 个字节 → 两个字符加 ==,剩 2 个字节 → 三个字符加 =。那个 = 不是数据,而是对齐用的。这就是为什么一段标准 Base64 的长度永远是 4 的倍数,也是为什么下面的失败恰好围着三样东西打转:字母表、填充、长度。
失败一:这是 Base64URL,不是 Base64
一类非常常见的解码失败。Base64URL 是一个变体,为了能安全穿过 URL 和 HTTP 头而设计,标准解码器一遇上就噎住。要紧的差异有两处:
+变成-,/变成_。- 结尾的
=填充通常被省掉。
(文本要不要按固定宽度折行,是另一个传输层面的选择,并不属于 Base64URL 的字母表——不过 URL-safe 字符串按惯例是不折行发送的。)最坑的地方在于,+ 和 / 恰好是最可能出现在哈希、密钥、签名里的两个字符——所以这种损坏既常见又无声。这也是手动解 JWT 时非常常见的一类坑:一个 token 的三段都是 Base64URL,所以拿一段丢给标准解码器,要么抛异常、要么吐乱码——这一处不匹配,会让很多「我自己解个码看看」半路脱轨。修法是先翻译——把 -/_ 换回 +//,再补齐填充到 4 的倍数——或者直接用带 URL-safe 模式的解码器。在一个 Base64 工具里,把同一串分别用 URL-safe 和标准模式各跑一遍,你会看到同一个输入,一个读得干干净净,另一个直接报错。
失败二:填充
填充是同一个故事的另一半。一段长度不是 4 的倍数的 Base64,可能只是缺了它的 = 填充——但也可能是非法或被截断的——而各家解码器对此意见不一。严格的抛 Invalid length;宽容的自己补上缺的填充然后解成功。正是这个分歧,让同一串东西在一个工具里能解、在另一个里失败,把人引去追一个根本不存在的「损坏」。长度对 4 取模,就能告诉你落在哪种情况:余 0 是已经完整;余 2 补 ==;余 3 补一个 =;而余 1 根本不是填充问题——它是非法或被截断的字符串,因为合法的 Base64 分组绝不会恰好多出一个字符。(填充只会出现在最末尾、最多两个 =,绝不出现在中间——中间冒出一个 = 本身就是损坏的信号。)而既然 Base64URL 是有意去掉填充的,这个问题和失败一通常会一起出现——一段 URL-safe 的字符串,既换了字母、又没了填充。
失败三:一个本该是 + 的空格
最隐蔽的一类,也是本文与下一篇文章衔接的地方。如果一段 Base64 是搭着 URL 查询串或 HTML 表单跑过来的,它里面的 + 可能已经被变成了空格——因为在 application/x-www-form-urlencoded 里,+ 就代表空格。于是你的解码器在本该是 + 的位置看到一个 ,要么报错,要么给出错误的字节。乱入的换行也是同类破坏:某些 MIME 或 PEM 格式会插入换行(常见是每 64 个或 76 个字符一换,具体取决于格式),而那些换行(以及任何复制粘贴带进来的空白)并不是数据的一部分。修法取决于它从哪来,而且顺序有讲究:先判断它是否经过表单编码——如果是,把那些本来是 +、被变成空格的位置还原成 +——再另外把传输过程中插入的换行和复制粘贴空白删掉。别无条件地把每一个空格都换成 +;只有经过表单编码的那些原本才是加号。这其实是一个披着 Base64 外衣的 URL 编码问题。Base64URL 干脆不用 +,一整类这样的 bug 就被绕开了。
失败四:明明解对了——只是它不是文本
那个「不算失败的失败」。有时解码成功了,你却还是拿到一堆乱码,于是断定这串是坏的。它没坏。解出来的字节,并不要求是可读的文本。Base64 搬运的是任意字节:原始数据可能本来就是一张 PNG、一段 gzip 流、一条 protobuf 消息、或者一坨加密后的字节,把这些原始字节当文本渲染,得到的正是你该预料到的乱码。就算里面确实是文本,那也是某种编码下的文本——拿 Latin-1 去解 UTF-8 的字节,café 就会变成 café。所以在你把一次解码判成「失败」之前,先问这些字节是什么;开头几个字节往往会自报家门(PNG 是 \x89PNG、zip 是 PK、JSON 是 {)。「我能解开它」和「我能读懂它」是两个不同的断言——这正是你在 JWT 里会碰到的同一道分界,一个 token 的签名段就是对一串根本不是文本的原始字节做的 Base64URL。把它丢进 JWT 解析器,你会看到 header 和 payload(载荷)解成 JSON,而签名段则故意保持不可读。(顺带一提:浏览器里的 atob() 返回的是一个二进制字符串——每个字符对应一个字节——而不是解好的 UTF-8 文本;要从 UTF-8 字节得到可读文字,还得再过一道 TextDecoder。)
失败五:双重编码,和 data: 前缀
还有两个常见但容易忽略的情况,却贡献了意外多的「卡住的解码」:
- 双重编码。一个值被 Base64 了两次,于是你第一次解码拿到的是又一串 Base64,而不是原始内容——再解一次。特征是:解出来是一段干净的、看着像 Base64 的 ASCII,而不是你期待的内容。
data:URI 前缀。data:image/png;base64,iVBORw0KGgo...并不是整段都是 Base64——只有逗号之后那部分才是。把整串data:...;base64,喂给解码器,它会在:和;上报错。先把逗号(含逗号)之前的一切去掉。
Base64 解不出来时的排查清单
当一串东西死活拆不开,别先假设它被截断了。按顺序走:
- 空白与传输。先清掉传输插入的换行和复制粘贴空白。另外,如果它经过表单编码,把被变成空格的
+还原回来——但别一刀切地转换每一个空格(失败三)。 - 字母表。看到
-或_了吗?那是 Base64URL——翻译成+//,或把解码器切到 URL-safe 模式(失败一)。 - 填充与长度。先算长度除以 4 的余数:余 2 补
==,余 3 补一个=;余 1 说明这串是非法或被截断的,而不只是少了填充——别盲目补齐(失败二)。 - 前缀。解码前先剥掉开头的
data:...;base64,(失败五)。 - 先解码,再问字节是什么。如果它「成功」了却读着像乱码,那载荷本就是二进制或另一种字符集——根本不算解码失败(失败四)。如果你第一次解出来的又是 Base64,那就再解一次。
这五条底下,还是那个唯一的核心:Base64 只是一次可逆的字节↔文本重新打包,仅此而已。它不藏你的数据,不缩小它,也不保证结果可读——而且解码成功,只说明字符能还原成字节,并不代表内容完整、也不代表内容没被改动过。大多数「解不出来」的 bug 都不是损坏——而是字母表不对、填充缺失、一个 + 变成了空格、或者那些字节本就不是文本。点名到底是哪一种,那串看似坏掉的东西,就会干干净净地拆开。