C++(Cpp)中的 Base64 编码:完整指南
反过来这个问题才是大头条:你手里有字节 - 一个证书、一张图像、一团随机数据、一个签名 - 你需要它们穿过一种只说文本的介质:JSON 字段、邮件头、URL、环境变量。本站首页已经把这种格式讲得很透彻,所以这里只给短版:3 个字节变成 4 个字母表字符,短尾部得到 1 个或 2 个 = 标记,编码后的形态比原始数据大约大 33%。编码是增长的方向,所以本文每一个缓冲区的尺寸都按它来定,算术就是一行话 - 4 * ((n + 2) / 3) - 不管你选哪个编码器都不变。
和解码一侧一样,C++ 本身不会替你编码哪怕一个字节。标准库有 30 年的时间去长出一个 base64 函数,却把时间全花在了别处,于是每个 C++ 程序都自带编码器,候选席上坐着四位性格迥异的选手,此外还可以自己写大约 40 行。一位是从 1990 年代就扛起 TLS 的老马,填充、加 NUL 结尾、折行,从不等许可。一位是藏在作者们标注为 "detail" 的命名空间里的快速仅头文件编解码器。一位是 2002 年的迭代器,显然从未见过填充字符。还有一位是操作系统发布了数十年的函数,会在你的令牌末尾加上 CRLF。第五个选项是你自己的。一旦你弄清每个编码器添加什么、拒绝什么、又在背后悄悄附上什么,编码就不再是 off-by-one bug 的温床了。让我们开始打包吧。
标准从未发货一个打包器
从 C++98 起的每一份标准 - 一共八份,直到 C++26 - 都看了看那个 64 字符的字母表,然后走开了。没有 <base64>,没有 std::base64,<string> 和 <vector> 里也没有任何东西能替你打包字节。C++26 的技术工作在 2026 年 3 月英国 Croydon 的 ISO C++ 会议上完成并以 114-12-3 的投票通过,它确实新增了一个 <text_encoding> 头文件用于文本编解码工作;委员会接下来的会议 - 2026 年 6 月(Brno)和 2026 年 11 月(巴西 Búzios)- 将开启 C++29 工作草案,而不是回头重审 C++26。Base64 不在标准里,也难怪委员会:文本编码是关于字符集的,base64 是关于字节的,所以那个新头文件从来就不是合适的家。实践中是生态干了这份活:OpenSSL 的 EVP base64 例程在每一个 OpenSSL 版本里都有,Boost 的库带着两个相互独立的编码器,Windows 发货了一个 CryptoAPI 函数外加一张为这活准备的标志表,而一段 40 行的片段从 2008 年起就被复制粘贴遍了这门语言。如果你的项目基于 CMake,整个依赖配置就是三行:
find_package(OpenSSL REQUIRED)
find_package(Boost REQUIRED)
target_link_libraries(my_app PRIVATE OpenSSL::Crypto)
要记住的 Boost 版本是 1.92.0,来自 2026 年 8 月,出自一个 1998 年创立、从 1999 年第一个发布版起就在发货库的项目。下文两个 Boost 编码器都是仅头文件 - 根本没有需要链接的东西 - 而 OpenSSL 要 -lcrypto,大多数沾过 TLS 的 C++ 程序二进制里已经带有它了。
先说算术:本文每个缓冲区的尺寸
Base64 把字节按 3 个一组打包,所以输出长度有一种形状,知道之后就永远不会意外:每 3 个输入字节,4 个字符出来,短尾部被填充到一个完整组。n 个字节输入的精确计数是:
4 * ((n + 2) / 3)
这个 +2 是取整技巧:整数除法向下取整,所以先加 2 就能向上进到 3 的下一个倍数。从那里开始,本文每一个缓冲区尺寸都只是代换。OpenSSL 的一次性函数要一个能装下编码数据加上它在末尾追加的 NUL 的缓冲区 - 手册页用一个例子说明这个契约:16 个输入字节变成 24 个编码字节加 1 个 NUL,缓冲区里共 25 字节,函数返回不含 NUL 的长度。它的流式路径按 48 字节的块处理输入,手册页把输出尺寸定为每块 65 字节(64 个字符加每个块必然产生的那个换行),再为 NUL 留 1 个字节。Boost.Beast 的头文件把精确公式作为一个 constexpr 函数交给你。而你自己的代码预留 (n + 2) / 3 * 4 就完事了。这些是你真正会遇到的数字:
| 输入 | 输出(带填充) | 要留意的 |
|---|---|---|
| 1 字节 | 4 字符 | 最小的带填充形式:QQ== |
| 2 字节 | 4 字符 | 3 个数据字符加 1 个填充 |
| 3 字节 | 4 字符 | 一个完整组,一点填充都没有 |
| 48 字节 | 64 字符 | 恰好一个 OpenSSL 流式块 |
| 500 字节 | 668 字符 | 按 64 折行就是 11 行,含换行共 679 字符 |
| 1 GB | 约 1.33 GB | 给列、文件和线路都预算好这笔税 |
如果接收方是一个定长的列、一个缓冲区或文本文件里的一行,这个公式就是整个设计文档。它可能咬到你的唯一方向是反方向:解码侧需要 3n/4 减去填充,而用编码公式定尺寸的解码缓冲区是一次经典的过度分配,长大后就是一张内存 bug 工单。给缩小方向定尺寸是姊妹指南的问题;在这里,你永远只增长。
这是全貌,因为差别全在附加物上 - 填充、换行、NUL - 而不是核心打包,核心打包每一行都实现得一模一样:
| 编码器 | 来自哪里 | 填充 | 要预算的额外字节 | 要记住的怪癖 |
|---|---|---|---|---|
EVP_EncodeBlock |
<openssl/evp.h>,链接 -lcrypto |
总是 | 1(缓冲区里的一个 NUL) | 手册页的 16 字节例子就是契约 |
EVP_EncodeUpdate + Final |
同上 | 总是 | 每个 48 字节块 65 | 硬性在 64 字符处折行,每个块都以换行结尾 |
Boost.Beast encode |
boost/beast/core/detail/base64.hpp,仅头文件 |
总是 | 0 | 住在一个名叫 detail 的命名空间里 |
| Boost.Serialization 迭代器 | boost/archive/iterators/base64_from_binary.hpp,仅头文件 |
从不 | 0 - 那 1 个或 2 个填充由你自己加 | 工具箱里最老的编码器,2002 年 |
CryptBinaryToStringA |
wincrypt.h,crypt32.lib |
总是 | 2(一个 CRLF),除非 NOCRLF |
带着其余工具箱都没有的 URL 安全标志 |
| 你自己写的 40 行 | 哪儿都没有:它是你的 | 由你定 | 由你定 | 每一个边界情况都永远归你负责 |
核心算法在每一行里都相同 - 这是一份 1987 年格式里令人安心的部分。不同的是每个实现在数据周围添加了什么,而本文几乎所有的陷阱都是其中一次添加遇到了一个没料到它的消费者。
OpenSSL:你的 TLS 栈已经链接上的编码器
如果你的程序已经为 TLS 链接了 OpenSSL,就不需要加任何东西。一次性函数就是一个调用:
int EVP_EncodeBlock(unsigned char *t, const unsigned char *f, int n);
给它源字节和长度,它写出带填充的单行编码。这个契约值得背下来,因为手册页用一个例子陈述了它:每 3 个输入字节,4 个输出字节;不能被 3 整除的尾部被填充,使输出总能被 4 整除;再额外加上一个 NUL 终止字符。文档里的例子是 16 字节进,24 个编码字节加 1 个 NUL,缓冲区里共 25 字节,函数返回 24 - 不含 NUL 的长度。相应地给缓冲区定尺寸,包装层就是几行:
#include <cstddef>
#include <cstdio>
#include <string>
#include <openssl/evp.h>
std::string openssl_encode(const std::string &in) {
std::string out;
out.resize(4 * ((in.size() + 2) / 3) + 1);
int n = EVP_EncodeBlock(reinterpret_cast<unsigned char *>(out.data()),
reinterpret_cast<const unsigned char *>(in.data()),
static_cast<int>(in.size()));
if (n < 0) return {};
out.resize(static_cast<size_t>(n));
return out;
}
int main() {
std::printf("%s\n", openssl_encode("Mane").c_str());
std::printf("%s\n", openssl_encode("M").c_str());
std::printf("%s\n", openssl_encode("").c_str());
}
注意 std::string 替你做了 C 会强加于你的事:它长到恰好是返回的长度,所以 OpenSSL 追加的那个 NUL 只是落在追踪长度之外,永远不会成为数据的一部分。编码 "Mane" 你得到 TWFuZQ==,经典的 4 字符尾部带着它单个的填充;编码 1 个字节你得到一对 2 字符数据穿着 2 字符填充的戏服;什么都不编码你得到空字符串,这是 base64 编码器表现得恰好像恒等函数的那一个案例。整个函数里唯一一行真正的逻辑是 resize:它把"写入的字节加一个 NUL"变成"恰好就是数据"。
对于分片到达的数据 - 一个文件、一个套接字、一个你不想缓冲的流 - OpenSSL 有一个你喂入并收尾的上下文,而手册页的块算术格外明确。只有完整的 48 字节块会被立即处理;剩余部分被留在上下文里,由后续调用或最终调用放出。每个被处理的块写出 64 个字符加一个换行 - 65 字节 - 而最终调用处理那个不完整的块,这就是它文档里上限是 65 字节加 NUL 的原因。调用之前要知道的后果是:这个 API 在 64 字符处折行。不可配置。流式编码器就是这样。
#include <algorithm>
#include <cstdio>
#include <string>
#include <vector>
#include <openssl/evp.h>
std::string openssl_encode_wrapped(const std::string &in) {
EVP_ENCODE_CTX *ctx = EVP_ENCODE_CTX_new();
EVP_EncodeInit(ctx);
std::string out;
out.reserve(4 * ((in.size() + 2) / 3) + in.size() / 48 + 2);
std::vector<unsigned char> buf(128);
int outl = 0;
for (size_t pos = 0; pos < in.size();) {
size_t take = std::min<size_t>(48, in.size() - pos);
EVP_EncodeUpdate(ctx, buf.data(), &outl,
reinterpret_cast<const unsigned char *>(in.data()) + pos,
static_cast<int>(take));
out.append(reinterpret_cast<const char *>(buf.data()), outl);
pos += take;
}
EVP_EncodeFinal(ctx, buf.data(), &outl);
out.append(reinterpret_cast<const char *>(buf.data()), outl);
EVP_ENCODE_CTX_free(ctx);
return out;
}
int main() {
std::string s = openssl_encode_wrapped(std::string(500, 'A'));
std::printf("500 bytes -> %zu chars\n", s.size());
int lines = 0;
size_t longest = 0, run = 0;
for (char c : s) {
if (c == '\n') { lines++; run = 0; }
else run++;
longest = std::max(longest, run);
}
std::printf("lines=%d longest=%zu lastchar=%c\n", lines, longest, s.back());
}
喂给它 500 个字节的字母 A,账目和手册页承诺的完全对得上:668 个编码字符,而且因为输出被切成 64 字符的行,你得到 11 行、共 679 个字符,最后一个字符就是一个换行。那个尾部换行就是会搞坏消费者的东西:把结果粘进 JSON 字符串,你得到一个本该是引号位置上的控制字符;把它当令牌段用,你就发明了一个新段。经验法则:单行数据(令牌、头部、配置值)用块 API,消费者要 MIME 形状的折行输出时用流式 API,拿不准时,在数据跨越一个不期待它的边界之前,用一个 while (out.back() == '\n') 循环把尾部换行剥掉。
Boost.Beast:detail:: 命名空间里的快速打包器
Boost 的 HTTP 库在一个不太起眼的地址 boost/beast/core/detail/base64.hpp 上自带一个 base64 编解码器。detail:: 命名空间是 Boost 表示"这是我们内部事务"的方式,维护者们也拒绝把编解码器提升为公开 API。反正大家都在用:它小、它快、它仅头文件(在 include 之前定义 BOOST_BEAST_HEADER_ONLY,就没有任何需要链接的东西),而且它还是 Boost.Beast 自己的 WebSocket 握手用于 Sec-WebSocket-Accept 计算的同一个编解码器,意味着它已经咀嚼真实流量好几年了。
编码一侧的 API 平静得近乎冒犯。constexpr 辅助函数给你精确的输出尺寸 - 4 * ((n + 2) / 3),和算术小节同一个公式,现在还有个编译器替你检查 - 而 encode 函数把带填充的结果写进你的缓冲区,并告诉你用了多少字符。没有错误通道,因为编码不会失败:任何字节都是合法输入,输出长度是输入长度的纯函数。包装层:
#define BOOST_BEAST_HEADER_ONLY
#include <boost/beast/core/detail/base64.hpp>
#include <cstddef>
#include <cstdio>
#include <string>
namespace b64 = boost::beast::detail::base64;
std::string beast_encode(const std::string &in) {
std::string out(b64::encoded_size(in.size()), '\0');
std::size_t n = b64::encode(out.data(), in.data(), in.size());
out.resize(n);
return out;
}
int main() {
std::printf("%s\n", beast_encode("Mane").c_str());
std::printf("%s\n", beast_encode("M").c_str());
}
编码 "Mane" 你得到 TWFuZQ==;编码单个字节 M 你得到 TQ== - 和 OpenSSL 包装层产出的同样的字节,没有 NUL 要操心,也没有行要剥。两件事值得归档。第一,出处:源码版权 2016-2019 属 Vinnie Falco,页脚将其中一部分归功于 Rene Nyffenegger 2004-2008 的片段 - 同一首开启了 C++ base64 故事的民谣,如今随 Boost 一起发布,在你的二进制里,为整个 Web 做 WebSocket 握手。第二,实际的那件:因为编解码器填充且从不折行,它是任何必须单行的东西的正确工具 - 令牌、头部、API 载荷 - 而 encoded_size 公式给你的缓冲区恰好正确,从不是近似。
Boost.Serialization:忘了填充存在的迭代器
C++ 生态里最古老的 base64 不是一个函数,而是一组可组合的迭代器适配器,由 Robert Ramey 在 2002 年为 Boost 的序列化库编写。编码方向是一条双适配器链:一个宽度变换器把你的原始字节按 8 对 6 重新分组,以及一个把每个重新分组的值转换成字母表字符的迭代器:
#include <boost/archive/iterators/base64_from_binary.hpp>
#include <boost/archive/iterators/transform_width.hpp>
#include <cstddef>
#include <cstdio>
#include <string>
namespace it = boost::archive::iterators;
std::string boost_iter_encode(const std::string &in) {
using enc =
it::base64_from_binary<it::transform_width<const char *, 6, 8>>;
std::string out(enc(in.data()), enc(in.data() + in.size()));
switch (in.size() % 3) {
case 1: out += "=="; break;
case 2: out += '='; break;
default: break;
}
return out;
}
int main() {
std::printf("%s\n", boost_iter_encode("Mane").c_str());
std::printf("%s\n", boost_iter_encode("M").c_str());
}
迭代器只做核心打包,别的什么都不做 - 没有填充、没有 NUL、没有换行,也没有错误通道,因为核心打包不会失败。编码 "Mane",迭代器面不改色地交给你 6 个字符,TWFuZQ:4 个字节的真实编码是 8 个字符,而一个 2002 年的迭代器从没觉得那是自己的事。这就是为什么那个 switch 语句是承重结构,不是装饰:差 1 个字节凑不满一组加两个填充,差 2 个字节加 1 个。少了 switch 的同一条链就是你忘记那一步时会得到的东西,结果是一个宽容解码器能解开的字符串、严格解码器会拒绝的字符串,还把你 API 消费者的错误消息变成了谜。(同一族迭代器的解码侧就是那个对单个多余空格抛异常的 - 姊妹指南里细说。)
40 行,零依赖
Base64 足够小,一个正确的编码器是值得自己拥有的体面之物,而在 C++ 里,回报比任何其他语言都更好:std::string 让缓冲区管理变得愉快,公式一开始就给你精确的尺寸,手写编码器是唯一毫无意见的那个 - 没有 NUL、没有换行、没有平台习惯 - 这正是你放在配置文件或 API 边界下想要的东西。这个版本按 3 字节分组对照一张 64 字符的表打包:
#include <cstddef>
#include <cstdio>
#include <string>
std::string base64_encode(const std::string &in) {
static const char *table =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string out;
out.reserve((in.size() + 2) / 3 * 4);
const unsigned char *p =
reinterpret_cast<const unsigned char *>(in.data());
size_t n = in.size();
for (size_t i = 0; i < n; i += 3) {
unsigned v = p[i] << 16;
if (i + 1 < n) v |= p[i + 1] << 8;
if (i + 2 < n) v |= p[i + 2];
out.push_back(table[(v >> 18) & 63]);
out.push_back(table[(v >> 12) & 63]);
out.push_back(i + 1 < n ? table[(v >> 6) & 63] : '=');
out.push_back(i + 2 < n ? table[v & 63] : '=');
}
return out;
}
int main() {
std::printf("%s\n", base64_encode("Mane").c_str());
std::printf("%s\n", base64_encode("M").c_str());
std::printf("%s\n", base64_encode("M\312\277").c_str());
}
走一遍各个部分。reserve 那一行就是算术小节:(n + 2) / 3 * 4 个字符,分毫不差,所以循环中途不会重新分配。向 const unsigned char * 的 reinterpret_cast 不是仪式 - 在 char 为有符号的平台上,超过 127 的字节否则就是一个负数,而它一碰到表索引,你得到的就是穿着实验服的未定义行为。每次迭代拉取最多 3 个字节进一个 24 位值,把四个 6 位切片推进表里,在尾部用 = 顶替那个不在场的字节 - i + 1 < n 和 i + 2 < n 这两个守卫就是全部的填充逻辑。喂给它 "Mane" 你得到 TWFuZQ==。喂给它单个 M 你得到 TQ==。喂给它超过 127 的字节 - 例子第三行里的 0xCA 0xBF 对 - 输出保持纯 ASCII(Tcq/),因为超过 127 的字节就只是字节,而表不在乎它是什么意思。40 行,零依赖,每一个边界情况都是你写下的一行,这就是全部意义所在。
Windows CryptoAPI:操作系统自带的打包器
在 Windows 上,操作系统本身就带一个 base64 编码器,比本文大多数框架都老:wincrypt.h 里的 CryptBinaryToStringA,在 crypt32.lib 中,是随 Windows 发货了几十年的 CryptoAPI 的一部分。它把字节数组转换成格式化字符串,而它的标志表读起来像这份格式完整历史的菜单:
| 标志 | 值 | 你得到什么 |
|---|---|---|
CRYPT_STRING_BASE64HEADER |
0x0 |
用证书 BEGIN/END 头行包裹的 Base64 |
CRYPT_STRING_BASE64 |
0x1 |
纯 base64,没有头 |
CRYPT_STRING_BASE64URI |
0xD |
URL 安全字母表:+ 变成 -,/ 变成 _,依据 RFC 4648 第 5 节 |
CRYPT_STRING_NOCRLF |
0x40000000 |
末尾不追加换行 |
CRYPT_STRING_NOCR |
0x80000000 |
用裸 LF 代替默认的 CRLF |
第一件要知道的事是默认行为:除非你传 CRYPT_STRING_NOCRLF,函数会在字符串末尾追加一个回车/换行对 - 文档化的行为是每个非二进制格式都会得到换行序列 - 所以一个必须单行放下的 base64 令牌要的是 BASE64 | NOCRLF,而这个组合就是地道的调用方式。第二件是调用约定,经典的 Windows 两步:先用 NULL 缓冲区调用问需要多少空间(答案包含终止 NUL),分配,再调用一次,读回不含 NUL 的长度:
#include <windows.h>
#include <wincrypt.h>
#include <cstddef>
#include <string>
std::string win32_encode(const std::string &in,
DWORD flags = CRYPT_STRING_BASE64) {
DWORD need = 0;
if (!CryptBinaryToStringA(reinterpret_cast<const BYTE *>(in.data()),
static_cast<DWORD>(in.size()),
flags | CRYPT_STRING_NOCRLF,
nullptr, &need))
return {};
std::string out(need, '\0');
DWORD got = 0;
if (!CryptBinaryToStringA(reinterpret_cast<const BYTE *>(in.data()),
static_cast<DWORD>(in.size()),
flags | CRYPT_STRING_NOCRLF,
out.data(), &got))
return {};
out.resize(got);
return out;
}
再补两条。URI 标志是整篇文章里唯一的原生 base64url - 在 Windows 上你可以直接编码令牌字母表,下面小节的转码方式严格是给其他平台用的。而 CRYPT_STRING_BASE64HEADER 这一项,值为 0,也是你传零时拿到的标志,所以一个"本意"一个标志都不传的调用会悄悄把数据裹进证书头行 - PEM 时代的加框习惯,生成 .pem 文件时有用,对其他一切则是个惊喜。链接 crypt32.lib,这个函数就归你使用整个程序的一生。
Base64url:令牌和 URL 用的字母表
标准字母表有两个字符在 URL 里活不下来:+ 在查询字符串里表示空格,/ 在路径里表示目录。RFC 4648 第 5 节用两个字符替换修复了这个问题 - + 变成 -,/ 变成 _ - 并且对结果直言不讳:这种编码"不应被视为与 base64 编码相同"。它是 JWT、OAuth PKCE 代码挑战、YouTube 视频 ID 和大多数 API 令牌使用的字母表,而且它通常连 = 填充也一并丢掉,因为在令牌里长度是隐式已知的,填充只会是等着发生的百分号转义。
本文的编码器里,只有 Windows 的标志原生输出这个字母表 - OpenSSL 没有 URL 安全模式,两种 Boost 也没有 - 所以大多数平台上的配方是:编码标准字母表,替换那两个字符,去掉填充。一打行的事:
#include <cstddef>
#include <cstdio>
#include <string>
/* 来自"40 行,零依赖"小节的 base64_encode */
std::string base64url_encode(const std::string &in, bool pad = false) {
std::string out = base64_encode(in);
for (char &c : out) {
if (c == '+') c = '-';
else if (c == '/') c = '_';
}
if (!pad)
while (!out.empty() && out.back() == '=')
out.pop_back();
return out;
}
int main() {
std::printf("%s\n", base64url_encode("M\312\277").c_str());
std::printf("%s\n", base64url_encode("M").c_str());
std::printf("%s\n", base64url_encode("M", true).c_str());
}
输出的第一行是 Tcq_,标准字母表会在那里写 /;后两行展示填充开关在起作用 - TQ 默认不带填充,TQ== 是消费者要它回来时的样子。那个 pad 参数是你要想清楚的一个,因为消费者们意见不一:JWT 段不要填充,PKCE 挑战不要填充,但一个落进严格校验长度的字段里的 base64url 值可能要把它加回来,而开关是一个 bool,不是重写。还要记住反方向的失败模式:标准字母表数据里的 - 就是无效,所以两个字母表在字节层面不可互换 - 用错字母表编码的令牌解不开,它会失败,而安全边界上你要的就是这种失败。
折行:64、76,或者从不
折行后的 base64 在野外有三种行宽,每一种都有来历。OpenSSL 流式编码器固定在 64 字符 - PEM 的习惯,1987 年 Privacy-Enhanced Mail 标准就是按 64 折行的。MIME 在 1993 年为邮件标准化这种编码时改成了 76 字符,而这个数字是 coreutils base64 命令的默认值(-w 标志设置行宽,-w 0 彻底关掉折行)也是生态里大多数工具的默认值。RFC 4648 自己则不站队:它引用 76 作为 MIME 的上限,并告诉实现除非引用规范指示折行,否则干脆不要折。你输出哪一种取决于谁来消费,设计约束是消费者 - 而不是格式。
折行是对编码字符串的后续处理步骤,绝不是输入步骤:4 字符组才是意义的单位,所以按行宽的任何倍数切字符串都是安全切口 - 每个行边界都落在组与组之间。C++ 版本是一个循环:
#include <cstddef>
#include <cstdio>
#include <string>
/* 来自"40 行,零依赖"小节的 base64_encode */
std::string wrap_lines(std::string s, size_t width = 76) {
std::string out;
for (size_t i = 0; i < s.size(); i += width)
out += s.substr(i, width) + "\r\n";
return out;
}
int main() {
std::string mime = wrap_lines(base64_encode(std::string(200, 'x')));
int lines = 0;
for (char c : mime)
if (c == '\n') lines++;
std::printf("mime: %d lines, %zu chars\n", lines, mime.size());
}
算一笔账:200 字节编码成 268 个字符,按 76 折行并用 CRLF 结尾就是 4 行 - 三个满行加一个 40 字符的尾巴 - 线上共 276 个字符。片段里选 CRLF 是邮件的选择;其他一切场合,LF 是现代默认,而唯一不可妥协的规则是一致性 - 一个期待 CRLF 的解码器若够严格,会把孤立的 LF 当成数据字符读进去。(MIME 的规则是解码器必须忽略换行,这就是邮件从未为这个差异吃过亏的原因。)第三个要知道的习惯:openssl base64 命令 - 披着风衣的 enc 程序,在 argv[0] 里检查自己的名字 - 不带 -A 时按 64 折行,带 -A 时输出一行,它是命令行上唯一一个你每次都重新检查行为而不是凭记忆信任的工具。
JSON 和配置里的二进制
JSON 字符串有一份它不能原样包含的字符小清单:引号、反斜杠,以及 0x20 以下的控制字符。一个证书、一个随机密钥、一个签名 - 它们全都装满了字节,如果硬要住进原始字符串字段,就会变成一场转义连锁反应,控制字符还会让一些解析器当场卡壳。Base64 就是解法,也是每个必须携带二进制的配置格式给你的默认答案:值被存成一行纯字母表字符,JSON 库的引号规则无事可做。
C++ 模式就是整个实现:读字节(当然是二进制模式),编码,存字符串。消费者在另一边解码。唯一的 JSON 专属陷阱是折行字符串:一个按 76 字符折行的证书直接粘进 JSON 文件,就是一串字面控制字符,视解析器的心情,要么是解析错误,要么是静默损坏。如果值必须为人类眼睛折行,它就必须被转义,或者干脆一行 - 而对机器对机器的配置,一行就是答案。另一个陷阱是没有标签的值:一个 2014 年手册页里写着 base64 的配置列,通常是带填充的标准字母表,而 API 时代的令牌是不带填充的 URL 安全形式,解码指南里那个四字符测试 - 它含 + 或 / 吗,含 - 或 _ 吗,末尾有 = 吗?- 就是整个诊断。
Data URI:粘进网页的文件
data URI 是一种载荷就明明白白写在地址里的 URL:data:、可选的媒体类型、可选的 ;base64 标记、一个逗号,然后是数据本身 - 这就是 RFC 2397 的整个方案。浏览器用它们把图像、字体和小脚本直接嵌进 HTML 和 CSS,不产生额外请求;如果一个页面关掉网络还能正常工作,data URI 是头号嫌疑人。在 C++ 一侧,编码的工作就是拼出这个字符串,一次带一个常量的字符串拼接:
#include <cstdio>
#include <string>
/* 来自"40 行,零依赖"小节的 base64_encode */
std::string make_data_uri(const std::string &mime_type,
const std::string &binary) {
return "data:" + mime_type + ";base64," + base64_encode(binary);
}
int main() {
std::printf("%s\n", make_data_uri("text/plain", "hi").c_str());
}
陷阱全在细节里。;base64 标记恰好是 7 个字符,这正是 off-by-one bug 会扑上去的长度:一个只检查 6 个字符的解析器,就是会接受 data:text/plain;base4,... 然后面不改色解码出垃圾的解析器。而且 base64 data URI 的载荷是一行 - 换行不属于 URI 语法,所以如果你的编码器把图像按 76 折行了(MIME 形状的编码器默认就会),这个 URI 在到达浏览器之前就已经坏了。给这个消费者的规则:编码,不要折行,保持媒体类型准确 - 给 JPEG 贴错 image/png 的那种谎,只会在凌晨两点的破损缩略图里现形。
令牌:JWT、PKCE 和 API 密钥
互联网上赌注最高的 base64 就在令牌里。一个 JSON Web Token 是用点号粘起来的三个 base64url 段:一个头部 JSON、一个声明 JSON,以及一个对字符串 header.claims 计算的签名。C++ 没有内置的 JWT 类型,但构造一个只需要上面的 base64url 编码器加一次 HMAC 调用,因为整个令牌都是 base64url,直到它不是 - 直到它成为签名:
#include <cstddef>
#include <cstdio>
#include <string>
#include <openssl/evp.h>
#include <openssl/hmac.h>
/* 来自前面小节的 base64_encode 和 base64url_encode */
std::string jwt_hmac256(const std::string &signing_input,
const std::string &secret) {
unsigned char digest[EVP_MAX_MD_SIZE];
unsigned int len = 0;
HMAC(EVP_sha256(), secret.data(), static_cast<int>(secret.size()),
reinterpret_cast<const unsigned char *>(signing_input.data()),
signing_input.size(), digest, &len);
return std::string(reinterpret_cast<const char *>(digest), len);
}
int main() {
const std::string header_json = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}";
const std::string claims_json =
"{\"sub\":\"1234567890\",\"name\":\"John Doe\",\"iat\":1516239022}";
std::string head = base64url_encode(header_json);
std::string claims = base64url_encode(claims_json);
std::string signing_input = head + "." + claims;
std::string sig = base64url_encode(jwt_hmac256(signing_input, "secret"));
std::printf("token: %s\n", (signing_input + "." + sig).c_str());
}
运行例子,出来的令牌是一个教科书式的 HS256 令牌:头部解码出 {"alg":"HS256","typ":"JWT"},声明解码出主题、名字和一个签发时间戳,签名是对两个已编码段做 HMAC-SHA256 的 base64url。三个细节撑起整个设计。签名输入是已编码的段,不是原始 JSON - 对 JSON 签名,你就签错了字节。段是不带填充的 base64url - 填充会坐在 URL 中间,而这个字母表的全部意义就是把令牌保持为一个干净的字符串。还有 HS256 意味着共享密钥,那是服务器对服务器的算法:住在客户端代码里的密钥不是密钥,用它签出的令牌不是凭据。(OAuth 的 PKCE 流程隔着一层用同一个字母表:一个随机验证器,用 SHA-256 哈希,不带填充地 base64url 成代码挑战 - base64url 小节的编码器就是整个客户端侧实现。)
HTTP Basic 认证
HTTP 里最老的 base64 是凭据头:Authorization: Basic 后面跟着 user:password 的 base64,一个老到早于 JSON 的方案。构造它就是一次拼接:
#include <cstdio>
#include <string>
/* 来自"40 行,零依赖"小节的 base64_encode */
std::string basic_auth_header(const std::string &user,
const std::string &pass) {
return "Basic " + base64_encode(user + ":" + pass);
}
int main() {
std::printf("%s\n", basic_auth_header("user", "password").c_str());
}
输出就是你在抓包里很可能见过的字符串:Basic dXNlcjpwYXNzd29yZA==。两条 C++ 备注。拼接 user + ":" + pass 就是含冒号的密码会让对端朴素解析器犯晕的地方 - 解析规则是"在第一个冒号处切分",所以构造侧往任意一个字段里放什么都自由。而且如果凭据不是 ASCII,这个方案的安全读法是把用户 ID 和密码在 base64 之前当作 UTF-8,在 C++ 里这意味着你的 std::string 已经在工作了 - 只要你填进去的是 UTF-8 字节,而不是本地化设置随手决定的东西。安全备注伴随这个方案的每一次出场:Basic 认证是混淆,不是保护。这个头对任何能读网络的人来说都是明文裸奔,所以它只在 TLS 之后才可接受,而且即便如此,它也是机器对机器调用的选择,不是给人类用的。(Boost.Beast 的编解码器 - detail:: 命名空间里的那个 - 在 Boost 的 WebSocket 实现里做同一件头部工作,把成为 Sec-WebSocket-Accept 键的那个 SHA-1 摘要 base64 编码,这是这个模式从 2017 年就在干这活的安静证据。)
邮件:七位规则,Base64 作答
邮件是 base64 学会习惯的地方,而这些习惯至今仍在承重。SMTP 最初形态是为承载七位 ASCII 而生的,所以任何二进制内容在出发前都必须被改写成可打印文本。Privacy-Enhanced Mail 在 1987 年用 64 字符的行加一个粘在末尾的 RSA-MD2/MD5 消息完整性校验做到了这一点,MIME 在 1993 年为邮件标准化这种编码时把上限放宽到 76 字符,并加上了合规解码器必须直接忽略换行的规则。电子邮件附件今天仍然是 base64,按 76 折行,精确的算术算出来是 4/3 乘以 78/76 - 约为原始大小的 137%,外加大约 814 字节头部。
C++ 一侧是上面的编码器加折行函数 - 编码成一行,按 76 用 CRLF 折行,完事。两个邮件专属细节:最后一行可以带也可以不带尾部换行(解码器被要求忽略它,所以两种都合法、也都常见),而且折行后的值不是 JSON 值、不是环境变量、也不是令牌 - 它是属于 MIME 正文的一团数据,把它挪到任何别处,折行就从习惯变成了 bug。反方向 - 一个按 76 折行到达的附件 - 是姊妹指南的领地,那里有四个 C++ 解码器以四种不同方式对待换行。
文件、流和 2 GB 的天花板
编码文件是解码指南文件工作的镜像:以二进制模式打开(在 Windows 上,文本模式读取会把 CRLF 对翻译成单个换行,在编码器看到之前就改变了你的数据),读入字节,编码,以二进制写出。小文件版本是一个函数的工作:
#include <cstdio>
#include <fstream>
#include <iterator>
#include <string>
#include <vector>
/* 来自"40 行,零依赖"小节的 base64_encode */
std::string encode_file(const std::string &path) {
std::ifstream in(path, std::ios::binary);
if (!in) return {};
std::vector<unsigned char> bytes{std::istreambuf_iterator<char>(in),
std::istreambuf_iterator<char>()};
return base64_encode(
std::string(reinterpret_cast<const char *>(bytes.data()), bytes.size()));
}
int main() {
std::string b64 = encode_file("/etc/hostname");
std::printf("file -> %zu chars\n", b64.size());
}
天花板就是小节标题里那个 C++ 专属事实:EVP API 的每一个长度参数都是 int。所以单次 EVP_EncodeBlock 调用最多能编码大约 2 GB 的输入,而那次调用的输出缓冲区 - 大 1.33 倍 - 根本装不进 int。在天花板以下,块 API 对付装得进内存的文件没问题。在天花板以上,或者对一个你不想装进内存的文件,你就分块 - 而分块规则是这个循环里唯一的 base64 专属约束:分块必须是 3 字节的倍数,因为分组按 3 进行,一个落在组中间的分块边界会改变输出。3072 - 三个 1024 字节块 - 是让人舒服的分块大小,循环变成:
#include <algorithm>
#include <cstddef>
#include <string>
/* 来自"40 行,零依赖"小节的 base64_encode */
std::string encode_streamed(const std::string &data) {
std::string out;
for (size_t pos = 0; pos < data.size();) {
size_t take = std::min<size_t>(3072, data.size() - pos);
out += base64_encode(data.substr(pos, take));
pos += take;
}
return out;
}
每个分块独立编码,拼接结果与一次性结果完全相同 - 这正是让分块安全的那个性质,它直接来自 3 字节分组。(编码器小节的 OpenSSL 流式上下文做同样的活,还免费加上 64 字符折行,当消费者要 MIME 形状时它是正确的工具。)输出侧的预算和输入侧相同:一个 10 GB 的文件变成一个 13.3 GB 的字符串,所以缓冲区 - 或者你正在写的文件 - 用算术小节的公式定尺寸,而 int 天花板说,2 GB 以上分块路径不是便利 - 它是唯一的路径。
环境变量和命令行
环境变量有和 JSON 字符串一样的问题,答案还更糟:它们完全带不了 NUL 字节,控制字符也不是它们的朋友。标准技巧是把数据 base64 编码以便穿过 shell,在 C++ 里编码方向就是一行话:
#include <cstdio>
#include <cstdlib>
#include <string>
/* 来自"40 行,零依赖"小节的 base64_encode */
int main() {
setenv("MY_PAYLOAD", base64_encode("hello, env").c_str(), 1);
std::printf("env: %s\n", getenv("MY_PAYLOAD"));
}
落进环境里的值是 aGVsbG8sIGVudg==:纯字母表,对 shell 安全,对 .env 文件安全,对 CI 仪表盘安全,在任何有 base64 解码器的机器上都能解开。命令行本身和解码侧一样是双工具故事,带着编码方向的标志:coreutils 的 base64(或较新发行版附带的 uutils 重新实现;用 base64 --version 查看)默认按 76 折行,-w 0 给你一行;openssl base64 - 在 argv[0] 里检查自己名字并切换进 base64 模式的 enc 程序 - 按 64 折行,用 -A 得到单行:
# 单行,给令牌和配置
base64 -w 0 < payload.bin > payload.b64
openssl base64 -A < payload.bin > payload.b64
# 折行,给邮件和文本文件
base64 < payload.bin > payload-76.b64
openssl base64 < payload.bin > payload-64.b64
两者都不原生说 base64url,所以你在 shell 里铸造的令牌进 URL 之前要接受转码处理。而命令行是编码侧静默失败习惯最危险的地方:一个在消费者期待单行时折行的编码器不会报错,它只会产出一个带换行的字符串 - 这正是你现在正在生产环境里追捕的那种失败。对任何要紧的事,在你的程序里编码,那里缓冲区由公式定尺寸,行形状是你控制的变量。
陷阱:C++ 版
- 你没点单的 NUL。
EVP_EncodeBlock在数据之后追加一个 NUL 终止符。手册页的例子:16 字节进,24 个编码字节加 NUL,缓冲区里 25 个,返回 24。为额外的那个字节留空间,再 resize 到返回值,否则你的令牌以一个零字节收尾。 - 硬 64。 OpenSSL 流式 API 在 64 字符处折行,每个块以换行结尾,而且没有任何标志可以改变它。折行的编码器输出喂给单行消费者,就是一个控制字符 bug。
- 48 字节块。
EVP_EncodeUpdate只对完整的 48 字节输入块产出输出;剩余部分坐在上下文里等EVP_EncodeFinal。每块预算 65 个输出字节再加 NUL,而且别把*outl读成"我数据的字节数" - 它是本次调用写入的字节数,对一次很小的首次调用来说是零。 - 迭代器缺失的填充。 Boost.Serialization 那条链从不输出
=。一个 2002 年的迭代器编码 "Mane" 给你 6 个字符。自己把填充加上,否则严格的消费者会拒绝这个字符串。 - 你没要来的 CRLF。
CryptBinaryToStringA会追加一个 CR/LF 对,除非你传CRYPT_STRING_NOCRLF。用默认标志构建的 base64 令牌比它应有的长两个字符,倒数第二个字符是回车。 - NULL 调用把 NUL 也算进去了。 Windows 的尺寸探测返回包含终止空字符的长度;真正的调用交回不含它的长度。把两者搞混是经典的 off-by-one,要么写越界一个字节,要么丢掉最后一个字符。
- 先编码,后折行。 折行是对编码字符串的后续处理步骤。按行宽的倍数切 - 永远安全,因为每个 4 字符组都是自包含的 - 而且永远不要折行原始字节,换行不属于那里。
- 3 个一组地分块。 如果你分片编码大载荷,分片边界必须落在 3 字节组上,否则分组 - 以及输出 - 都会变。3072 是个友好的分块;3071 是个 bug。
- int,不是 size_t。 每个 EVP 长度参数都是
int。单次调用的上限是大约 2 GB 的输入,而那部分输入的输出根本装不进int。超过上限,分块或流式路径不是偏好问题。 - signed char。 如果你从
char *打包而不加无符号转换,在char为有符号的平台上,超过 127 的字节是负数,拿它索引一张表就是未定义行为。const unsigned char *不是仪式。 - 折行的 JSON 字符串。 一个按 76 字符折行的值粘进 JSON 文件,就是一串字面控制字符。要么它是一行,要么它被转义,要么它不该出现在 JSON 里。
- 填充是契约。 有些消费者要填充(MIME、大多数解码器),有些不要(JWT、PKCE、URL 里的令牌),还有些严格的会直接拒绝缺失或非规范的填充。填充不是装饰;它是格式协议的一部分。
- 两个字母表。 标准字母表数据里的
-或_无效,URL 安全数据里的+或/无效。字母表在字节层面不可互换 - 为目的地用对的那个编码,转码要刻意进行。 - std::string 和 strlen。
std::string乐见零字节,但一旦你把 C 字符串交给遗留 API,strlen就在第一个 NUL 处停下。传指针和长度,永远不要只传裸指针。 - 预算。 输出是输入的 4/3:如果输入是 1.5 GB,输出就是 2 GB - 这也正是
int天花板。接收缓冲区、列、线路,都用公式而不是猜测来定尺寸。
C++ 如何得到它的 Base64
这份格式的历史比这门语言的现代时代更老,而 C++ 的故事就是这门语言一次又一次不发货它的故事。如今称为 MIME base64 的这种编码的第一个标准化用途是 Privacy-Enhanced Mail 协议,1987 年提出,用 64 字符的行,末尾粘着一个 RSA-MD2/MD5 消息完整性校验;"base64" 这个名字直到 1993 年才到来,是 MIME 标准给它起的。C++ 于 1998 年以 C++98 的身份登场 - 比 MIME 晚五年 - 这门语言的开发者伸手去拿的第一份 base64 代码是 Rene Nyffenegger 2004-2008 年的 C 函数对,被一个 2008 年 12 月 4 日的 Stack Overflow 问题传遍全网。这个故事最美妙的部分:一个回答链接到了 Nyffenegger 自己的页面并把实现搬了过来,连同许可头一起,还有一个高票回答把他的方案和其他选手做了基准对比。这首民谣带着许可头 - 作曲者本人从未在评论区露面。
然后,生态做了生态一贯做的事。2002 年,Robert Ramey 的 Boost.Serialization 发布了迭代器适配器 - C++ 工具箱里最古老的 base64,解码方向严格,编码方向以从不填充闻名,比 RFC 3548 把它早已执行的字母表规则成文还早一年。2017 年,Boost 1.66 带来了 Beast,以及随之而来的仅头文件编解码器,直到今天发布时页脚里还带着 Nyffenegger 的署名。OpenSSL 的 EVP_EncodeBlock 和伙伴们在每一个 OpenSSL 版本里都有,所以这匹老马待在工具箱里的时间,和这门语言争论它该不该进标准的时间一样长。在 Windows 上,故事就是操作系统把它发货了:一个函数,一张标志表,完全不关标准的事。与此同时,标准本身走过 C++11、C++14、C++17、C++20、C++23(2024 年发布),现在到 C++26,而它们每一个都看了看那个 64 字符的字母表,然后走开了。C++26 的技术内容在 2026 年 3 月英国 Croydon 的 ISO C++ 会议上完成并以 114-12-3 的投票通过,它确实新增了一个 <text_encoding> 头文件用于文本编解码工作;委员会接下来的会议 - 2026 年 6 月(Brno)和 2026 年 11 月(巴西 Búzios)- 将开启 C++29 工作草案,而不是回头重审 C++26。Base64 不在标准里。八份标准,三十年,一个文本编码头文件 - 而委员会现在已经有过每一个可能的理由去加入 base64,又对每一个都放弃了。C++ 里 base64 的实用史,过去是、现在依然是它的库的历史:一对 EVP 函数、两种 Boost 口味、一个 Windows 标志,以及一段你自己拥有的 40 行片段。
值得知道的小知识
- OpenSSL 流式编码器的 48 字节块是一个不出现在任何 RFC 里的数字。它是 16 个 base64 组,选这个数字是为了让输出行恰好 64 字符 - PEM 的习惯 - 它是 1987 年还在 2026 年承重工作的最后几处地方之一。
- Boost.Beast 的
encoded_size就是算术小节的constexpr函数版:4 * ((n + 2) / 3),给它常量就在编译时求值。标准库始终没能拥有这一行话;Boost 把它装进一个detail::命名空间发货了。 - 最小的带填充 base64 是 4 个字符,
QQ==:一个字节穿着两字符的戏服。最小的不带填充是 2 个字符,QQ。填充数也是一条消息:两个填充意味着最后一组有 1 个字节,一个填充意味着有 2 个,没有填充意味着有 3 个 - 接收方仅凭尾部就能恢复输入长度。 - MIME 的开销数学是精确的:4/3 乘以 78/76,这就是邮件附件到达时约为原始大小 137% 的原因,外加约 814 字节头部。本文每个编码器都缴同样的税;折行宽度只改变账单怎么开。
- 在典型的 libstdc++ 或 MSVC 上,
std::string通过短字符串优化把小载荷放在栈缓冲区里,而不是去分配。9 字节的输入编码成 12 个字符,从不触碰堆。你的令牌的 base64 形式可能字面意义上就住在一个栈帧里,这是标准库从不宣传的那种免费午餐。 - 你在 shell 里可能顺手去拿的
openssl base64命令根本不是一个命令。它是enc程序在argv[0]里检查自己的名字并切换人格。一个靠字符串比较实现的别名,一种 C++ 式的做法,却写在 C 里。 - YouTube 视频 ID 是 base64url:11 个字符,不带填充,在 URL 附近绝不会有
+或/。这个星球上观看量最大的编码格式,跑在 RFC 4648 用一个能装进一页纸的章节加入的"URL 和文件名安全"变体上。 - 四个 A -
AAAA- 编码三个零字节,因为 A 是字母表的零。如果你见过一团全由一个字符组成的 base64,现在你知道它在说什么了:什么都没有。 - 同样的那对函数出现在 2008 年一个 Stack Overflow 问题的回答里、Boost.Beast 的源码里(带着署名页脚),以及无数私有代码库的头文件里。问一个 C++ 开发者他们的 base64 从哪来,最诚实的回答是"我不知道,互联网也不知道"。
另一个方向
你刚才打包的一切,在另一边都会由同一个工具箱拆开,而拆开那一侧有它自己的一套习惯:给尾部补零的 OpenSSL 一次性函数、一个 2025 年改变了流式解码器对带填充输入返回什么的 bug 修复、在杂字符处停下且绝口不提的 Boost.Beast 解码、对单个空格抛异常的迭代器,以及指向那个让你受伤的精确字节的 40 行严格解码器。完整的拆包故事 - 四个解码器的脾气、base64url 转码、文件、MIME 的 76 字符习惯,还有两个静默失败的命令行工具 - 住在姊妹站的 C++ 解码指南里。去读一读,然后回来包一个大家伙。这就是全部的游戏:没有标准库,四家供应商对换行和 NUL 有四种不同意见,一个为本文每个缓冲区定尺寸的公式,以及一道每个接收方都能退款的 33% 税。打包愉快。
最后更新: 2026-09-08