需要使用 Base64 格式吗?那么本网站正好适合您!使用我们的在线工具对数据进行编码或解码,便捷好用。

Rust 中的 Base64 编码:完整指南

你正要送一些字节穿过一扇只认文本的门,而入场费是一串字母、数字、加号和斜杠组成的字符串,长度大约比你出发的东西长出三分之一。欢迎来到 Base64,互联网的收费站。本站首页把这个格式讲得足够透彻,所以这里只需要重述一下它的形状:Base64 把三个输入字节写成四个字符,取自一张 64 符号的字母表,末尾的一两个 = 字符告诉读者真实数据在哪里结束。四换三这笔交易就是这个格式的全部经济学,而本指南讲的是如何在 Rust 里把它做好。

首先要知道的是,Rust 标准库不会替你干这件事。std 里没有任何藏着的 base64_encode(),也没有哪句 use std::... 能改变这个事实。整个生态最终选定了一个名字就叫 base64 的 crate,而它已经成了承重墙:0.23.1 版本于 2026 年 8 月 4 日发布,这个 crate 自 2015 年 12 月以来已经推出 45 个版本,下载量计数器接近 15 亿。下面每个编码例子都用那一个 crate,外加两个小帮手,一个管换行,一个管 PEM 封装。

工具链与 crate

先是工具链,每个世界一条命令:

# Debian / Ubuntu
sudo apt install rustc cargo
# 或者官方安装器,它会装好 rustup 和 cargo
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

然后是 crate,放进任意一个 cargo 项目里:

cargo new my-app
cd my-app
cargo add base64

这单独一行就是完整的安装,它引入的依赖恰好是零个。这个 crate 自带三个你应该知道的可选 feature:std(默认开启;给你 std::io 流式、标准的 Error 实现和堆分配)、alloc(嵌入式 no_std 构建的带分配 API)和 simd-unsafe(默认开启;SIMD 引擎,几节之后登场)。最低支持的 Rust 版本是 1.71.0,所以任何较新的版本都能跑它。它周围坐着几位帮手,负责核心刻意不做的工作:

  • line-wrap(0.2 版本)插入 MIME 和 PEM 要求的 76 或 64 字符换行;base64 crate 自己拒绝换行,而且是故意的,你后面会看到。
  • pem(4 版本)构建和解析证书与密钥的 -----BEGIN ...----- 块;它内部依赖 base64,负责加上封装和换行。
  • base64ct(1.8 版本)是 RustCrypto 项目的恒定时间解码器,留给读取那一半才是敏感一半的往返。
  • base64-turbo(0.3 版本)是一个更新的高吞吐编解码器,在现代硬件上峰值超过 100 GiB/s。

一次编码,四个字符

能有的最小仪式长这样,而且它已经证明了整个往返:

use base64::prelude::*;
fn main() {
  let packed = BASE64_STANDARD.encode("Hello, world!");
  println!("{packed}");
  // SGVsbG8sIHdvcmxkIQ==
  let back = BASE64_STANDARD.decode(packed).unwrap();
  println!("{}", String::from_utf8(back).unwrap());
  // Hello, world!
}

有两样东西值得注意。prelude 模块不动声色地一次递给你两样东西:BASE64_STANDARD 引擎,以及你正在调用其方法的 Engine trait,这就是为什么这个例子只需要一句光秃秃的 use base64::prelude::*;。而 encode() 接受任何能读成字节的东西,多亏了 AsRef<[u8]> 约束:&str&[u8] 字面量、Vec<u8>,你说得出什么它就读得进什么。例子里的解码那一半只是在那里盯着编码器看,因为反方向在姊妹站点上有自己的完整指南。如果你想要 crate 自己的冒烟测试,它的文档编码 asdf 得到 YXNkZg== 回来;同一张字母表,同一套数学。

每个字节的确切价格

世上每一个存在的 Base64 编码器都收同一笔税,而一旦你看懂了这套数学,就能给它做预算。每个输出字符携带 6 比特,每个输入字节携带 8 比特,两者都能整除的最小堆是 24 比特:恰好 3 个字节进,恰好 4 个字符出。这个比例就是整场戏,所以 3 千字节文件变成 4 千字节,10 兆字节上传变成 13.3。填充是显形的舍入误差:当输入不是 3 字节的整数倍时,最后一组有剩余容量,编码器用 = 把它填满,好让输出长度保持 4 的倍数。下面是 RFC 4648 的真值表,标准引擎精确地复现了它:

输入 长度 mod 3 编码后 输出长度
""(空) 0 ""(空) 0
f 1 Zg== 4
fo 2 Zm8= 4
foo 0 Zm9v 4
foobar 0 Zm9vYmFy 8
use base64::prelude::*;
let words: [&[u8]; 4] = [b"", b"f", b"fo", b"foo"];
for input in words {
  println!("{:?} -> {:?}", String::from_utf8_lossy(input), BASE64_STANDARD.encode(input));
}
// "" -> ""
// "f" -> "Zg=="
// "fo" -> "Zm8="
// "foo" -> "Zm9v"

把第一行读两遍,因为它是每个人脑子里都会弄错的那一行:空输入编码为空字符串,而不是 AA==。字符串 AA== 是恰好一个字节 - 一个 NUL - 的编码,那才是真正不同的负载。而当你需要在编码前给缓冲区定尺寸时,crate 把数学作为一个 const fn 交到你手上,你甚至可以在编译期给数组定尺寸:

let padded = base64::encoded_len(15, true).unwrap();
let slim = base64::encoded_len(15, false).unwrap();
println!("{padded} / {slim}");   // 20 / 20
println!("{:?}", base64::encoded_len(13, true));  // Some(20)
println!("{:?}", base64::encoded_len(13, false)); // Some(18)
println!("{:?}", base64::encoded_len(14, false)); // Some(19)
println!("{:?}", base64::encoded_len(100, false)); // Some(134)

盯住 13 和 14 字节那两行,因为它们正是拍脑袋算术会绊倒的地方:13 个字节无填充要 18 个字符、带填充要 20 个,而 14 个字节要 19 和 20。这个函数返回 Option,只有当长度计算会溢出时才是 None,所以对任何真实存在于内存中的输入,unwrap() 都是安全的。对邮件形状的世界,这笔税还有附加费:MIME 在 76 字符处换行,老的经验法则是带换行的 Base64 大约花掉原始大小的 1.37 倍,外加头部开销。crate 自己的 FAQ 对填充本身有个不那么客气的看法:= 字节"对解码没有任何影响,除了提供一个说'那个填充是错的'的机会",而且"毫无疑问,有以 EB 计的存储和传输浪费在了毫无意义的 = 字节上"。

填充:一个关于读者的决定

base64 0.23 里,裸的编码函数已被弃用 - 现在的方式是调用引擎上的方法,而引擎就是一种策略:用哪张字母表写,加哪种填充。预设住在 base64::engine::general_purpose 里,其中四个受欢迎的被重新导出进 prelude

引擎 字母表 加填充 最适合
STANDARD / BASE64_STANDARD + / 一切,默认选择
STANDARD_NO_PAD / BASE64_STANDARD_NO_PAD + / 你也消费的精简负载
URL_SAFE / BASE64_URL_SAFE - _ 仍想要填充的 URL 内容
URL_SAFE_NO_PAD / BASE64_URL_SAFE_NO_PAD - _ JWT、URL、对象 ID

不带填充的编码不是 crate 勉强容忍的权宜之计,而是一等公民立场,预配置的 NO_PADPAD 配置常量与 0.23.0 加入的 *_INDIFFERENT 兄弟们并肩而立。如果预设不合适,你用一张 Alphabet 和一个只有一个旋钮的 GeneralPurposeConfig 打造自己的引擎,而且因为引擎构建得很便宜,你把结果存进 const,而不是每次请求都重建:

use base64::engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig};
use base64::prelude::*;
const SLIM: GeneralPurpose = GeneralPurpose::new(
  &base64::alphabet::STANDARD,
  GeneralPurposeConfig::new().with_encode_padding(false),
);
fn main() {
  println!("{}", SLIM.encode("fo"));   // Zm8
  println!("{}", BASE64_STANDARD.encode("fo")); // Zm8=
}

现在这个决定变成了关于别人解码器的决定,而那从来不只是美学问题。解码端的严格规则来自 DecodePaddingMode,下面的表回答"对方能读到我写的东西吗?":

你用它编码 严格的 STANDARD 解码器 STANDARD_NO_PAD 解码器 INDIFFERENT 解码器
STANDARD(带填充) 读得懂 拒绝 = 读得懂
STANDARD_NO_PAD 拒绝:缺填充 读得懂 读得懂
URL_SAFE_NO_PAD 拒绝:字母表不对 拒绝:字母表不对 只有配 URL 字母表才读得懂

实用规则从那张表里落出来。如果你控制两端,选一个引擎到处用,并优先不填充以省字节。如果你消费外部世界的数据,你的解码器对应该发哪种引擎有投票权:原样的 STANDARD 解码器需要你的填充,而 STANDARD_PAD_INDIFFERENT 解码器两者都收。而且这个选择还带一点安全风味。允许同一负载的带填充和不带填充两种写法,会让 Base64 变得可塑;crate 自己文档链接到的 2022 年论文 "实践中的 Base64 可塑性"(Chatzigiannis 和 Chalkias,ePrint 2022/361)展示了为什么。一个同样的数据能写成两种不同形式的协议,总爱让把编码字符串当身份用的代码吃一惊,所以当你自己的格式定义了一种规范写法,就在边界上执行它。

Base64url:为 token 和链接而生

标准 Base64 字母表的最后两个字母是 +/,而在 URL 里,这两个是全语言里最贵的字符:加号变成 %2B,斜杠变成 %2F,填充变成 %3D。RFC 4648 第 5 节用 URL 和文件名安全的字母表解决了这个问题,把两个麻烦制造者换成 -_,通常连填充也一起跳过。引擎让这个区别变得无法忽视:

use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
let packed = URL_SAFE_NO_PAD.encode(b"\xfb\xef\xbe");
println!("{packed}");          // ----
let back = URL_SAFE_NO_PAD.decode(packed).unwrap();
println!("{back:02x?}");       // [fb, ef, be]

三字节尽可能最恶劣的输入,变成了一个四字符字符串,可以原样粘进 URL、文件名、cookie 或数据库键,一个百分号转义都不用。这就是 JSON Web Token 居住的字母表:一个 JWT 是三个用点连接的 base64url 部分,用 jsonwebtoken crate(2026 年是 11 版本)铸造一个看起来是这样:

use serde::Serialize;
use jsonwebtoken::{EncodingKey, Header, encode};
#[derive(Debug, Serialize)]
struct Claims {
  sub: String,
  company: String,
  exp: u64,
}
let key = b"secret";
let my_claims = Claims {
  sub: "b@b.com".to_owned(),
  company: "ACME".to_owned(),
  exp: 19_000_000_000,  // 远在将来
};
let token = encode(&Header::default(), &my_claims, &EncodingKey::from_secret(key)).unwrap();
println!("{token}");
// eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJiQGIuY29t...

11 版本有一个会咬到新手的安装要求:这个 crate 要求 Cargo.toml 里恰好启用 rust_cryptoaws_lc_rs 其中一个 feature,如果两个都没开,它会在你第一次签名或验证 token 时 panic。注意结构体里的 exp claim:这个 crate 的验证默认把它当作必填项,所以真实 token 反正都带着它,而 token 里的 base64url 字母表完全是库自己的事。如果你只是检视 token 而不是铸造 token,姊妹文章展示了五行偷看法。还有一句黄金法则的提醒,它对 token 尤其有力:JWT 的三个部分全都不需要密钥就能读。Base64 是靠窗座位,不是锁。

文本进,字节出

编码器不会读心,所以在 Rust 里,"编码这个字符串"永远意味着"编码这个字符串的 UTF-8 字节",因为 str::as_bytes() 交给你的正是那些。好消息是现代网络几乎全是 UTF-8,所以诚实的路又短又开心:

use base64::prelude::*;
let text = "café";
let packed = BASE64_STANDARD.encode(text.as_bytes());
println!("{packed}");   // Y2Fmw6k=

所有多字节情况都表现正常:

原始文本 Base64 往返结果
café Y2Fmw6k= 干净
日本語 5pel5pys6Kqe 干净
😀 8J+YgA== 干净
π ≈ 3.14159 z4Ag4omIIDMuMTQxNTk= 干净

真正唯一的一个决定,是从哪些字节出发。如果数据以字节形式到达而不是文本,磁盘上读出的文件或网络调用返回的缓冲区,那就跳过字符串,直接编码 Vec<u8>。对 PNG 或 protobuf 这样的非 UTF-8 负载,那也是唯一正确的答案。把一张图片丢到代码旁边,让读取指向它:

use base64::prelude::*;
let file_bytes = std::fs::read("sprite.png").unwrap();
let size = file_bytes.len();
let packed = BASE64_STANDARD.encode(file_bytes);
println!("{size} bytes -> {} base64 chars", packed.len());
// 每个编码后的 PNG 都以 iVBORw0K 开头
assert!(packed.starts_with("iVBORw0K"));

最后那个断言是一次免费的健全性检查,也是整个互联网上最容易辨认的前缀之一。而如果你把同一段逻辑文本通过两种不同字符集编码,或者把误读成另一种字符集的字节拿去编码,往返回来时会是一脸正经的乱码。编码器从不说谎,它只是编码你交给它的字节。这既是它最大的力量,也是它唯一的陷阱。

当格式想要行

base64 crate 刻意不插入换行符,而且这不是它第一次做这个决定。0.5.0 版本自带内置 MIME 换行,换行符可配置,0.10.0 版本把它移除了,库做了一个决定:对一个通用 crate 来说,换行太有主见,还把 no_std 的故事搞复杂了。如果格式要求行,line-wrap crate 正是为此而生的。它唯一的函数 line_wrap() 接收你预分配的缓冲区、输入长度、列宽限制和换行符,返回它插入的换行字节数:

use base64::prelude::*;
let data = BASE64_STANDARD.encode(vec![b'a'; 300]);  // 400 个字符
let mut buf = vec![0u8; data.len() + 16];
buf[..data.len()].copy_from_slice(data.as_bytes());
let endings = line_wrap::line_wrap(&mut buf, data.len(), 76, &line_wrap::crlf());
buf.truncate(data.len() + endings);
let wrapped = String::from_utf8(buf).unwrap();
println!("{} chars in, {} bytes out, {} line endings", data.len(), wrapped.len(), endings);
// 400 chars in, 410 bytes out, 10 line endings(五个 CRLF 对)

给缓冲区预留出换行符的空间,调用函数,然后截断到报告的总长度。五个 CRLF 对是 MIME 76 列规则的代价。对 PEM,换一下限制和换行符,64 列配 line_wrap::lf(),你就有了封装体正文。然后 pem crate 一次调用加上横幅:

let pem_block = pem::encode(&pem::Pem::new("CERTIFICATE", b"0123456789abcdef"));
println!("{pem_block}");
// -----BEGIN CERTIFICATE-----
// MDEyMzQ1Njc4OWFiY2RlZg==
// -----END CERTIFICATE-----
let back = pem::parse(pem_block).unwrap();
println!("{}: {} bytes", back.tag(), back.contents().len());
// CERTIFICATE: 16 bytes
// 还有 Unix 风格的换行符,万一消费者挑剔
let lf_block = pem::encode_config(
  &pem::Pem::new("KEY", b"0123456789abcdef"),
  pem::EncodeConfig::new().set_line_ending(pem::LineEnding::LF),
);

默认情况下,pem::encode 使用 CRLF,这是 PEM 的历史惯例。set_line_ending 构建器为那些期待它的工具切换到 LF。注意 pem crate 不做什么:它从不调用你能看见的 base64 函数,因为编码是它的内部事务。当格式想要行时,架构就是每个任务一个 crate。

常量空间里的流式

对大到装不进一个变量的数据,crate 用与 Rust 其余 io 相同的流式哲学来回应:write::EncoderWriter 包装任意写入器,以常量空间 base64 编码你写入的一切。对缓冲区的完整仪式长这样,而本节的主角是 finish() 调用:

use std::io::Write;
use base64::prelude::*;
use base64::write::EncoderWriter;
fn main() {
  let mut encoder = EncoderWriter::new(Vec::new(), &BASE64_STANDARD);
  encoder.write_all(b"the quick brown fox jumps over the lazy dog").unwrap();
  let packed = encoder.finish().unwrap();
  println!("{}", String::from_utf8(packed).unwrap());
  // dGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZw==
}

为什么 finish() 是主角?因为它是唯一刷新最后不完整分组并加上填充的调用,而编码器有一个不做这件事的兄弟方法。crate 自己的文档说得直白:finish() "编码任何剩余的输入字节,并在适当时加上填充。它会在释放时自动被调用(参见 Drop 实现),但调用底层写入器时发生的任何错误都会被压制。如果你想处理这类错误,就自己调用 finish()。" Drop 实现的行为像 BufWriter:它刷新,但忽略 drop 期间的错误。所以最后那个不完整分组不会丢,但"它大概跑通了"不是发布策略,因为它本可以告诉你的那个写错误已经消失了。

同样的流隔一层也能工作,通过 io::copy,当你想一次调用跑完整条流水线时;还有一个附赠的封装,留给"我只是需要它出现在格式字符串里"的时刻:

use std::io;
use base64::prelude::*;
use base64::write::EncoderWriter;
let file = b"the quick brown fox jumps over the lazy dog".to_vec();
let mut cursor = io::Cursor::new(file);
let mut encoder = EncoderWriter::new(Vec::new(), &BASE64_STANDARD);
io::copy(&mut cursor, &mut encoder).unwrap();
let packed = encoder.finish().unwrap();
println!("{}", String::from_utf8(packed).unwrap());
use base64::display::Base64Display;
use base64::prelude::*;
let value = Base64Display::new(b"\0\x01\x02\x03", &BASE64_STANDARD);
println!("base64: {value}");   // base64: AAECAw==

那个 Base64Display 封装是一块小宝石:它把字节以 Base64 格式化进任何格式字符串,不产生一次堆分配,这让日志行和调试输出突然变得赏心悦目。

分配,以及它的缺席

方便的方法会分配内存,对你大部分人生来说这是正确的交换。但 Engine trait 暴露了三种编码风味,下面的表就是整个决策矩阵:

方法 输出 分配
encode() 一个新的 String 总是
encode_string() 追加到你的 String 只在必须增长时
encode_slice() 写进你的 &[u8] 从不
use base64::prelude::*;
let input = b"Hello, world!";
let mut buf = vec![0u8; base64::encoded_len(input.len(), true).unwrap()];
let written = BASE64_STANDARD.encode_slice(input, &mut buf).unwrap();
buf.truncate(written);
println!("{}", std::str::from_utf8(&buf).unwrap());   // SGVsbG8sIHdvcmxkIQ==
// 或者把缓冲区完全留在栈上
let mut stack = [0u8; 24];
let n = BASE64_STANDARD.encode_slice(b"abc 123", &mut stack).unwrap();
println!("{}", String::from_utf8(stack[..n].to_vec()).unwrap());  // YWJjIDEyMw==
// 如果你尺寸算错了,得到的是错误,不是缓冲区溢出
let mut tiny = [0u8; 5];
println!("{:?}", BASE64_STANDARD.encode_slice(input, &mut tiny));
// Err(OutputSliceTooSmall)

encoded_len() 定尺寸,用 encode_slice() 写,尺寸算错时你得到的是干净的 EncodeSliceError::OutputSliceTooSmall,而不是未定义行为。在系统编程语言里,这决定了一个下午是无聊还是漫长。对嵌入式工作,同样的函数藏在 alloc feature 后面,所以你可以保留 API、丢掉堆。

速度:SIMD 引擎

2026 年 7 月发布的那个 0.23.0 版本带来了头条特性:针对标准和 URL 安全字母表的 SIMD 加速引擎。一共有三个,它们按信任你硬件的程度分档:

引擎 运行时检测 能在 no_std 工作
Simd 是,挑 AVX2 或 NEON,回退到标量引擎 否,检测需要 std
Avx2 否,假定 CPU 有 AVX2 是,在 x86_64 目标上
Neon 否,假定 CPU 有 NEON 是,在 aarch64 目标上
use base64::engine::general_purpose::GeneralPurposeConfig;
use base64::engine::{Avx2, Simd};
use base64::Engine;
let turbo = Simd::standard(GeneralPurposeConfig::new());
println!("{}", turbo.encode("simd works!"));
// c2ltZCB3b3JrcyE=
if let Some(fixed) = Avx2::standard(GeneralPurposeConfig::new()) {
  println!("{}", fixed.encode("hello avx2"));  // aGVsbG8gYXZ4Mg==
}

Simd 构造器只做一次 CPU 检测,返回它找到的最佳内核,如果都不适用就返回标量引擎,所以你在 const 里或启动时构建一次,然后反复使用;在有能力的硬件上,编码和解码都比标量路径快好几倍。一个诚实的脚注:SIMD 路径是这个 crate 里唯一碰 unsafe 的地方,这就是 feature 叫 simd-unsafe 的原因。关掉这个 feature,整个 crate 又回到 #![forbid(unsafe_code)],标量引擎继续干诚实的活。如果原始吞吐量就是全部目的,base64-turbo crate 把极限推得更远,运行时检测后面跟着 AVX512、AVX2 和 NEON 内核,峰值超过 100 GiB/s,其余一切上都是 100% 安全的标量回退。base64 crate 是 MIT/Apache-2.0 双许可,所以这一切都是免费的,速度也包括在内。

另外四张字母表

RFC 的字母表是默认,但 base64 crate 还随箱附送四张,每一张都是为某个需要自己小转弯的真实协议立起的小纪念碑:

字母表 小转弯 谁在用 abc 123 编码为
alphabet::CRYPT ./ 打头,然后是数字和字母,无填充 经典 Unix crypt(3) 密码哈希 MK7X612mAk
alphabet::BCRYPT ./ 打头,然后是字母,再是数字 bcrypt 密码哈希 WUHhGBCwKu
alphabet::IMAP_MUTF7 逗号顶替斜杠,无填充 IMAP 的修改版 UTF-7 邮箱名 YWJjIDEyMw
alphabet::BIN_HEX 标点味很重的字母表,避开容易混淆的字母 BinHex 4,老 Macintosh 文件封装 B@*M)$%b-`
use base64::engine::general_purpose::{GeneralPurpose, NO_PAD};
use base64::Engine;
let crypt = GeneralPurpose::new(&base64::alphabet::CRYPT, NO_PAD);
println!("{}", crypt.encode(b"abc 123"));   // MK7X612mAk
let bcrypt = GeneralPurpose::new(&base64::alphabet::BCRYPT, NO_PAD);
println!("{}", bcrypt.encode(b"abc 123"));  // WUHhGBCwKu
let imap = GeneralPurpose::new(&base64::alphabet::IMAP_MUTF7, NO_PAD);
println!("{}", imap.encode(b"abc 123"));    // YWJjIDEyMw

同一个输入,三个不同输出,在各自的方言里都是有效的 Base64。crypt 字母表是那个真正有超能力的:因为它的符号顺序与比特模式对齐,对编码后的字符串排序,得到的顺序和对原始字节排序相同。这就是 GEDCOM 5.5(1996 年)用它处理多媒体字段的原因(5.5.1 修订版砍掉了这个特性),而 crate 至今还把这张字母表随箱附送。如果你需要的方言不在 crate 里,你可以用一张 64 字符的字符串定义它,因为 Alphabet::new() 会替你建好编码和解码表:

use base64::alphabet::Alphabet;
use base64::engine::general_purpose::{GeneralPurpose, PAD};
use base64::Engine;
// 怪诞世界的 base64:+/ 放在开头而不是结尾
let alphabet = Alphabet::new(
  "+/ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789",
).expect("a valid 64 char alphabet");
let bizarro = GeneralPurpose::new(&alphabet, PAD);
println!("{}", bizarro.encode(b"hello 99"));  // YETqZE6eMRi=
// 而标准引擎说:
println!("{}", base64::prelude::BASE64_STANDARD.encode(b"hello 99"));
// aGVsbG8gOTk=

关于自定义字母表路线的一个警告:你一旦发明一种方言,就成了地球上唯一能读你数据的人,所以只在协议要求时这么做,并写一条注释说明是哪种。

编码器工作的地方

Base64 编码在 Rust 项目里出现在一组可预期的场景中:

  • JSON API 里的文件上传:文件是一个穿着文本戏服的字节字段,以压倒性优势成为最常见用途。
  • HTML 和 CSS 里的 Data URIdata:image/png;base64,... 那种,小图标上很妙,主视觉上存疑。
  • JWT 和 OAuth:base64url 是方言,jsonwebtoken crate 是工具。
  • 证书和密钥的 PEM 块-----BEGIN CERTIFICATE----- 段落,把 Base64 每 64 字符换一行地包起来。
  • XML 和配置文件里的二进制<data encoding="base64"> 模式,你至今仍能在导出的书签和配置转储里找到它。
  • LDAP 和 LDIF 文件:用 Base64 让二进制属性值保持在一行上。
  • 二维码负载和剪贴板交接:文本能活过这段旅程,二进制不能。
  • HTTP Basic 认证头Basic TWFuOnBhc3M= 是一个凭据对,也是一个提醒:这是个打包问题,不是隐藏问题。

还有那条统管一切的黄金法则:Base64 是打包胶带,不是锁。它不是加密,也不是压缩 - 它是压缩的反面 - 而任何一个拿着这篇文章的人都能用一行代码反转它做的一切。尽管编码,但永远不要编码一个密码、一个 API 密钥或一个秘密然后管它叫受保护。如果它必须被隐藏,用真正的加密;如果它很大,考虑一下 multipart 上传是不是本来就比这笔税便宜。

十年的小步

这个格式比万维网还老。1987 年,隐私增强邮件协议(RFC 989)需要在 7 位邮件通道上搬运二进制数据,它把这种编码标准化为恰好 64 字符的行。互联网上的每一个 -----BEGIN CERTIFICATE----- 块都是那个决定的后代,这就是为什么 PEM 文件今天仍在 64 处换行。1996 年,MIME 规范(RFC 2045)采纳了这个方案,按它 64 字符的字母表把它命名为 "base64",并把换行挪到 76 字符。在那之前,Unix 机器出厂时带 uuencode,Mac 出厂时带 BinHex,各有各的字母表,两者如今仍在老系统里像带文件头的化石一样冒头。2006 年,RFC 4648 成为人人引用的标准:字母表表格、base64url 变体,以及本文每个引擎都实现的规范编码规则。它的第 3.5 节要求编码器把未使用的尾部比特置零,crate 也确实这么做;如果你的负载之后触发了严格解码器的 InvalidLastSymbol 检查,损坏发生在上游。

crate 自己的历史押着同样的韵。它 2015 年 12 月出现在 crates.io,0.5.0 版本自豪地加上了 MIME 换行,换行符可配置。然后 2018 年的 0.10.0 版本移除了换行和空白处理,库做了一个决定:通用 crate 只管编码,把诗歌留给应用层;同一个版本加入了流式 EncoderWriter。2022 年的 0.20.0 版本引入引擎抽象,让规范填充成为默认,0.21.0 弃用旧自由函数、改用引擎方法,编译器备注是 "Use Engine::encode"(它们仍然能用,所以大量遗留代码至今编译得很开心)。2024 年,0.22.0 版本磨利了错误语义,并把解码速度提升了 5% 到 10%。2026 年 7 月,0.23.0 版本带着 SIMD 引擎、自定义填充符号、更清晰的错误消息和 MSRV 提升到 1.71 而来,8 月 4 日的 0.23.1 补丁为非 SIMD 架构修好了测试套件。

值得一笑的事

因为一份完整的指南应当以微笑收尾:

  • 单词 "base64" 编码后是 YmFzZTY0。一个描述自己的格式,相当于技术上的一面用摩尔斯电码说话的镜子。
  • 空字符串编码为空字符串。"空无一物"是唯一不花钱的输入,这算是一种免税。
  • AA== 不是"空无一物"的编码;它是一个 NUL 字节的编码。在 Base64 里,"空无一物"和"一个零"是两种不同的生物,解码器分得清它们。
  • 每个 Base64 编码的 PNG 都以 iVBORw0K 开头。那是 PNG 魔数裹着打包胶带,整个互联网上最容易辨认的前缀之一。
  • 在 URL 里,标准 Base64 字符需要转义戏服:加号变 %2B,斜杠变 %2F,填充变 %3D。Base64url 的存在,是为了让字符们能用自己的脸示人。
  • YouTube 视频 ID 是无填充的 base64url:8 字节的 ID 变成那个你可以粘贴到任何地方的 11 字符字符串。整个互联网上无填充模式最显眼的用途之一。
  • 老的 crypt(3) 密码字母表排序是正确的:排序后的编码字符串,和排序后的明文排成同一个顺序。GEDCOM 5.5(1996 年)用那张字母表处理多媒体字段,5.5.1 修订版砍掉了这个特性,crate 至今还把它随箱附送。
  • BinHex,老 Macintosh 封装,建字母表时刻意排除了 7Ogo 这类视觉上容易混淆的字符。一个为人类眼睛设计的编码器,在一个还没有拼写检查的世界里。
  • crate 自己的 FAQ 对填充直言不讳:毫无疑问,有以 EB 计的存储和传输浪费在了毫无意义的 = 字节上。收费站从 1987 年起就在收费了。
  • Base64 不是加密。要是它是,你就读不懂本文任何一个例子的输出。它是靠窗座位,不是金库。

简短版

按数据将走过的路挑引擎:BASE64_STANDARD 用于你自己也会解码的一切,_NO_PAD 引擎用于你控制两端且想把字节拿回的时候,URL_SAFE_NO_PAD 用于 token 和 URL,自定义 Alphabet 只在协议坚持时再用。用 encoded_len() 给缓冲区定尺寸,大东西通过 EncoderWriter 流式处理并永远以 finish() 收尾,line-wrappem 只在格式要求时换行,能上 SIMD 引擎就让它们干重活,并记住四换三这笔交易是穿过只认文本那扇门的代价。尽管编码,只保护真正需要一把真锁的东西。而当你需要走相反的方向,把字符串拆回开启旅程的那些字节时,姊妹文章讲的就是 Rust 里的解码,连一整套精确错误消息的成绩单都给你备齐了。

最后更新: 2026-09-08

相关文章: Rust 中的 Base64 解码:完整指南