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 字符换行;
base64crate 自己拒绝换行,而且是故意的,你后面会看到。 - 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_PAD 和 PAD 配置常量与 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_crypto 或 aws_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 URI:
data:image/png;base64,...那种,小图标上很妙,主视觉上存疑。 - JWT 和 OAuth:base64url 是方言,
jsonwebtokencrate 是工具。 - 证书和密钥的 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 封装,建字母表时刻意排除了
7、O、g和o这类视觉上容易混淆的字符。一个为人类眼睛设计的编码器,在一个还没有拼写检查的世界里。 - crate 自己的 FAQ 对填充直言不讳:毫无疑问,有以 EB 计的存储和传输浪费在了毫无意义的
=字节上。收费站从 1987 年起就在收费了。 - Base64 不是加密。要是它是,你就读不懂本文任何一个例子的输出。它是靠窗座位,不是金库。
简短版
按数据将走过的路挑引擎:BASE64_STANDARD 用于你自己也会解码的一切,_NO_PAD 引擎用于你控制两端且想把字节拿回的时候,URL_SAFE_NO_PAD 用于 token 和 URL,自定义 Alphabet 只在协议坚持时再用。用 encoded_len() 给缓冲区定尺寸,大东西通过 EncoderWriter 流式处理并永远以 finish() 收尾,line-wrap 和 pem 只在格式要求时换行,能上 SIMD 引擎就让它们干重活,并记住四换三这笔交易是穿过只认文本那扇门的代价。尽管编码,只保护真正需要一把真锁的东西。而当你需要走相反的方向,把字符串拆回开启旅程的那些字节时,姊妹文章讲的就是 Rust 里的解码,连一整套精确错误消息的成绩单都给你备齐了。
最后更新: 2026-09-08
相关文章: Rust 中的 Base64 解码:完整指南