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

C++(Cpp)中的 Base64 解码:完整指南

你拿到了那串字符串。一长条字母和数字交织的飘带,偶尔冒出个 +/,末尾也许还有一两个 =,而在你的工单、合同或数据库列的某个角落,都写着一纸它是 Base64 的承诺。现在你需要在 C++ 里把原始字节拿回来,而且要拿得准确无误。本站首页已经把这种格式讲得很透彻,所以这里只给短版:4 个字母表字符承载 3 个字节,末尾 1 个或 2 个 = 的尾巴标记着真实数据停止的位置,编码后的形态比原始数据大约大 33%。解码是缩小的方向,所以解码器永远不会需要比它已经握在手中的数据更多的内存。这是一个货真价实令人愉悦的性质,也是朝这个方向工作时那份安静乐趣之一。

更大的头条是:C++ 本身不会替你解码哪怕一个字符。标准库有 30 年的时间去长出一个 base64 函数,却把时间全花在了别处,于是每个 C++ 程序都自带解码器,候选席上坐着三位性格迥异的选手,此外还可以自己写大约 40 行。一位是从 1990 年代就扛起互联网一路奔跑的老马,一位是闷葫芦,中途停下也绝不吭声,还有一位是挑剔鬼,看到哪怕一个多余的空格就会抛出异常。一旦你弄清楚每位各自原谅什么、拒绝什么、又在背后悄悄做什么,解码就不再是神秘 bug 的温床了。让我们拆开几个包裹吧。

工具箱:四位解码器,四种脾气

先一眼看全貌。四个解码器都处理标准字母表;差别出在边角上,而 bug 就住在边角里。

解码器 来自哪里 错误模型 要记住的怪癖
OpenSSL EVP <openssl/evp.h>,链接 -lcrypto 输入损坏时返回 -1 一次性版本会给尾部补零
Boost.Beast <boost/beast/core/detail/base64.hpp>,仅头文件 没有:它就是停了 没有任何形式的错误通道
Boost.Serialization 迭代器 <boost/archive/iterators/binary_from_base64.hpp>,仅头文件 抛出 dataflow_exception = 当作真正的零值
你自己写的 40 行 哪儿都没有:它是你的 由你定,精确到字节位置 每一个边界情况都永远归你负责

安装就是每个发行版一个包名。OpenSSL 在 Debian 和 Ubuntu 上是 libssl-dev,Fedora 和 RHEL 上是 openssl-devel,Arch 上是 openssl,macOS 上是 brew install openssl。Boost(当前版本是 2026 年 8 月的 1.92.0,这个项目从 1998 年起就在造库):libboost-devboost-devel。下文两个 Boost 解码器都是仅头文件,根本不需要链接。如果你的项目基于 CMake,整个配置就是三行:

find_package(OpenSSL REQUIRED)
find_package(Boost REQUIRED)
target_link_libraries(my_app PRIVATE OpenSSL::Crypto)

上代码之前先说一版本本注意,因为它会改变你的解码器返回什么。OpenSSL 3.5 于 2025 年 4 月发布,是一条长期支持线,它修复了流式解码器里一个真实的 bug(马上细说),而 2026 年 4 月更新的 4.0 特性版本继承了这一修复。如果你的构建钉死在旧的 3.0 或 3.3 上,在相信任何尾部长度之前,先读一读下面 OpenSSL 小节的版本段落。

OpenSSL:你大概率已经链接上的解码器

如果你的 C++ 程序沾过 TLS、哈希或证书,OpenSSL 就已经在二进制里了,而它的 EVP base64 例程是这个行当中经受过最多实战检验的解码器。一次性函数就是一个调用:

int EVP_DecodeBlock(unsigned char *t, const unsigned char *f, int n);

给它一段 base64 字符的缓冲区和长度,它会把解码后的字节写进 t。它修剪开头的空白,修剪末尾的空白、换行符和回车符,然后执行毫无妥协的规则:内部不允许空白,修剪后的长度必须是 4 的倍数。每 4 个输入字符恰好产生 3 个输出字节,下面是让人意外的部分:填充字符会被解码成 6 个零位,而手册页淡定地注明,调用者有责任自己考虑末尾的填充。换句话说,这个函数替你做了算术,然后又在末尾悄悄送上最多 2 个附赠的零字节。地道的 C++ 包装把缓冲区算术藏在一个 std::string 后面:

#include <cstddef>
#include <cstdio>
#include <string>
#include <vector>
#include <openssl/evp.h>

std::string openssl_decode(const std::string &b64) {
  std::vector<unsigned char> out(b64.size() * 3 / 4 + 4);
  int n = EVP_DecodeBlock(out.data(),
                          reinterpret_cast<const unsigned char *>(b64.data()),
                          static_cast<int>(b64.size()));
  if (n < 0) return {};
  size_t pads = 0;
  for (size_t i = b64.size(); i > 0 && b64[i - 1] == '='; --i) ++pads;
  return std::string(reinterpret_cast<const char *>(out.data()),
                     static_cast<size_t>(n) - pads);
}

int main() {
  std::string one = openssl_decode("TQ==");
  std::printf("%zu bytes: %02x\n", one.size(),
              static_cast<unsigned char>(one[0]));
  std::string word = openssl_decode("TWFuZQ==");
  std::printf("%zu bytes: %s\n", word.size(), word.c_str());
}

运行它,补零就出现在文档承诺的确切位置:解码 4 个字符的字符串 TQ== 会得到 3 个字节,字母 M 加两个零,然后包装层把它们修剪掉。喂给它 TWFuZQ==,你会得到干干净净的 "Mane" 4 个字节。喂给它一个字母表外的字符,你会拿回一个空字符串,因为函数用 -1 作答。注意 std::string 在这个包装里悄悄做的事:它自己追踪长度,并且乐意见零字节,所以一个解码出来的 JPEG 可以住进你的文本类型里,被比较、被哈希、被传来传去。在 C 里你会为有一个长度变量而感恩戴德;在这里,字符串就是能正常工作。

对于分片到达的数据,OpenSSL 交给你一个上下文,你往里喂块、从里面取结果,这三个函数有一套简洁的返回值词汇:

调用 返回 含义
EVP_DecodeUpdate -1 无效字符,或数据中间出现填充字符
EVP_DecodeUpdate 1 还期待更多输入
EVP_DecodeUpdate 0 数据结束:最后一组带有填充,或出现了软性的输入结束标记
EVP_DecodeFinal 1 / -1 流干净地结束了 / 剩余字符数不是 4 的倍数

两个行为让流式解码器成为工具箱里最宽容的一个。它跳过流中任何位置的空格、制表符、回车和换行,所以一个每行 76 个字符、用 CRLF 换行的 MIME 邮件块,能像单行字符串一样顺畅流过;而且它报告的是真实的字节数:那个骗过一次性函数的 TQ==,在这里恰好给你 1 个字节,不需要任何算术。它一次最多嚼 64 个 base64 字符的输入,靠一个 80 字节的内部缓冲区工作,并把凑不满一组 4 个的部分缓存起来,这就是为什么你可以用任意大小的分片喂它。包装层:

#include <algorithm>
#include <cstdio>
#include <string>
#include <vector>
#include <openssl/evp.h>

std::string openssl_decode_stream(const std::string &b64) {
  EVP_ENCODE_CTX *ctx = EVP_ENCODE_CTX_new();
  EVP_DecodeInit(ctx);
  std::string out;
  std::vector<unsigned char> chunk(1024);
  int outl = 0;
  for (size_t pos = 0; pos < b64.size(); pos += chunk.size()) {
    size_t take = std::min(chunk.size(), b64.size() - pos);
    int ret = EVP_DecodeUpdate(ctx, chunk.data(), &outl,
                               reinterpret_cast<const unsigned char *>(b64.data()) + pos,
                               static_cast<int>(take));
    if (ret < 0) {
      EVP_ENCODE_CTX_free(ctx);
      return {};
    }
    out.append(reinterpret_cast<const char *>(chunk.data()), outl);
    if (ret == 0) break;
  }
  unsigned char tail[3];
  int tail_l = 0;
  int fin = EVP_DecodeFinal(ctx, tail, &tail_l);
  EVP_ENCODE_CTX_free(ctx);
  if (fin != 1) return {};
  out.append(reinterpret_cast<const char *>(tail), tail_l);
  return out;
}

现在来说工具箱表格里那条版本脚注,因为它正是那种会把"已解决"工单重新打开的东西:在 3.5 之前的每个 OpenSSL 版本里,流式路径都有和块解码器一样的补零习惯。它在 2025 年 2 月以 issue 26677 被报告;修复提交在 2025 年 2 月 27 日进入 master,关联的 pull request 当天关闭。官方手册页现在在它的历史章节里记录了这件事:从 OpenSSL 3.5 起,EVP_DecodeUpdate 产生文档一直声称的那个字节数,不再把填充解码成零位。如果你的代码库钉死在旧版 OpenSSL 上,而尾部长度看起来差了一两个字节,这是第一件该检查的事。还有一怪癖是从 PEM 时代继承来的:连字符 - 根本不是字母表字符 - 它是一个软性的输入结束标记。如果你的流在 4 的倍数个有效字符之后含有一个,解码器会返回 0 并要求你停下,这就是为什么一个真正需要 - 字符的 base64url 字符串无法简单地解码 - 先在下面小节里转码,时间旅行者就会消失。

Boost.Beast:从不抱怨的快速解码器

如果 Boost 已经在项目里,它的 HTTP 库在一个不太起眼的地址 boost/beast/core/detail/base64.hpp 上自带一个 base64 编解码器。detail:: 命名空间是 Boost 表示"这是我们内部事务"的方式,维护者们也拒绝把它提升为公开 API。反正大家都在用,因为它小、快、仅头文件:在 include 之前定义 BOOST_BEAST_HEADER_ONLY,就没有任何需要链接的东西。顺便说一句,它还是 Boost.Beast 自己的 WebSocket 握手用于 Sec-WebSocket-Accept 计算的编解码器,所以它已经咀嚼真实流量好几年了。

解码函数的性格才是意外。它接收你的输出缓冲区、输入和长度,返回一对值:写入的八位组数和读取的字符数。它在第一个 = 处停下,在第一个无效字符处停下 - 两种情况下都一声不吭。没有错误码,没有异常,没有状态标志。损坏的数据、折行的数据、被截断的尾部,统统产生一个成功的部分结果:

输入 解码出的字节 读取的字符数 为何停下
"TWFuZQ==" 4 字节 "Mane" 6 在第一个 = 处停下
"TWF!ZQ==" 2 字节 "Ma" 3 ! 处停下
"TWFu\nZQ==" 3 字节 "Man" 4 \n 处停下
"TWFuZQ" 4 字节 "Mane" 6 尾部被截断了
"TQ==" 1 字节 "M" 2 填充处理得很好

把那张表再看一遍,因为它就是一个从不抱怨的解码器的完整威胁模型:它尽力了,它停在了它停下的地方,而是否注意到要靠你自己。检查有一个小别扭:对于带填充的输入,"读取的字符数"停在第一个 = 处,所以比较之前要先把填充加回去,同时你还要求总数是 4 的倍数,因为真实数据只有这一种形状:

#define BOOST_BEAST_HEADER_ONLY
#include <boost/beast/core/detail/base64.hpp>
#include <cstddef>
#include <string>

namespace b64 = boost::beast::detail::base64;

std::string beast_decode(const std::string &in) {
  std::string out;
  out.resize(in.size() / 4 * 3 + 3); /* 为奇数长度留出余量 */
  auto result = b64::decode(out.data(), in.data(), in.size());
  out.resize(result.first);
  size_t pads = 0;
  for (size_t i = in.size(); i > 0 && in[i - 1] == '='; --i) ++pads;
  if (result.second + pads != in.size() || in.size() % 4 != 0)
    return {}; /* 它提前停了,或者尾部不可能成立 */
  return out;
}

两个值得归档的细节。第一,头文件指引你的 decoded_size(n) 辅助函数只有在 n 是 4 的倍数时才是有效的上界 - 函数自己的注释就是这么说的 - 这就是为什么上面的包装层加了几字节余量,而不是对任意长度都信任它。第二,出处:源文件带有 Vinnie Falco 的 2016-2019 版权,页脚将其中一部分归功于 Rene Nyffenegger 的 2004-2008 代码片段。那个片段就是那对在整个英语互联网上被复制粘贴了二十年的 base64 函数,如今它随 Boost 一起发布,在你的二进制里,为整个 Web 做着 HTTP Basic 认证。

Boost.Serialization:对多余空格会抛异常的解码器

Boost 的序列化库承载着 C++ 生态里最古老的 base64:一组 2002 年的可组合迭代器适配器,由 Robert Ramey 编写,它把"接收时要宽容"这句话当作人身攻击。解码方向住在 binary_from_base64.hpp(没错,这个名字是从输出端视角起的)里,并与一个宽度变换器配对,后者把 6 位值重新打包成 8 位字节:

#include <boost/archive/iterators/binary_from_base64.hpp>
#include <boost/archive/iterators/transform_width.hpp>
#include <cstddef>
#include <string>

namespace it = boost::archive::iterators;

std::string boost_decode(const std::string &in) {
  using dec =
      it::transform_width<it::binary_from_base64<const char *>, 8, 6>;
  std::string out(dec(in.data()), dec(in.data() + in.size()));
  size_t pads = 0;
  for (size_t i = in.size(); i > 0 && in[i - 1] == '='; --i) ++pads;
  out.resize(out.size() - pads);
  return out;
}

内层迭代器把每个 base64 字符转换成它的 6 位值,外层迭代器把这些值重新分组为字节。它的严格是彻底的:任何字母表外的字符 - 包括单个空格 - 都会让迭代器抛出带有消息 "attempt to decode a value not in base64 char set" 的 boost::archive::iterators::dataflow_exception。这就是 RFCs 后来明确写下的"除非另有说明,否则拒绝"的行为 - 实现在 2002 年,比 RFC 3548 把同一条规则成文整整早了一年。实际后果是:MIME 折行的输入必须在碰到这个迭代器之前剥掉换行。第二个怪癖更微妙:在查找表里,填充字符 = 并没有被跳过 - 它被渲染成值零。因此解码 TWFuZQ== 会产出 6 个字节 - 4d 61 6e 65 00 00 - 因为两个填充字符都贡献了真实的(零)数据,片段里 resize(size - pads) 那一行是承重结构,不是装饰。解码 TQ== 你会得到 3 个字节,修剪到单个字母 M,恰好落在你想落的地方。

你自己拥有的 40 行

Base64 足够小,一个正确的解码器是值得自己拥有的体面之物,而在 C++ 里,回报比任何其他语言都更好:std::string 让缓冲区管理变得愉快,手写解码器还能做到上面所有库版本都做不到的一件事,即指向那个让你受伤的精确字节。这个版本遵循 RFC 4648 的严格读法 - 修剪两端,拒绝内部空白,拒绝中间的填充,执行长度规则,甚至检查 RFC 说合规编码器必须清零的那些填充位:

#include <cstddef>
#include <cstring>
#include <string>

std::string strict_decode(const std::string &in, size_t *error_pos = nullptr) {
  static const char *table =
      "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
  auto fail = [&](size_t pos) {
    if (error_pos) *error_pos = pos;
    return std::string();
  };
  size_t start = 0;
  size_t end = in.size();
  while (start < end && (in[start] == ' ' || in[start] == '\t' ||
                         in[start] == '\r' || in[start] == '\n'))
    ++start;
  while (end > start && (in[end - 1] == ' ' || in[end - 1] == '\t' ||
                         in[end - 1] == '\r' || in[end - 1] == '\n'))
    --end;
  size_t pads = 0;
  while (end > start && in[end - 1] == '=') {
    --end;
    ++pads;
  }
  size_t body = end - start;
  if (pads > 2 || (pads == 1 && body % 4 != 3) ||
      (pads == 2 && body % 4 != 2) || (pads == 0 && body % 4 == 1))
    return fail(in.size());
  if (body >= 2 && body % 4 != 0) {
    int leftover = static_cast<int>((body % 4) * 6 % 8);
    int last = static_cast<int>(std::strchr(table, in[end - 1]) - table);
    if ((last & ((1 << leftover) - 1)) != 0)
      return fail(end - 1); /* 非规范的填充位 */
  }
  int value = 0;
  int bits = -8;
  std::string out;
  out.reserve(body / 4 * 3);
  for (size_t i = start; i < end; ++i) {
    const char *p = std::strchr(table, in[i]);
    if (!p)
      return fail(i);
    value = (value << 6) + static_cast<int>(p - table);
    bits += 6;
    if (bits >= 0) {
      out.push_back(static_cast<char>((value >> bits) & 0xFF));
      bits -= 8;
    }
  }
  return out;
}

走一遍它执行了哪些规则。首尾空白被修剪,因为从邮件头里复制来的数据很可能戴着一层空白到达。内部空白被拒绝,因为 RFC 4648 说解码器必须拒绝字母表外字符,除非周边规范另有说明,而在安全边界上,你要的就是严格读法。数据中间的填充字符被拒绝,任何无法对应真实数据的长度也被拒绝:差一个字符凑不满一组是不可能的,单个填充只有在三个主体字符之后才合法。末尾的规范检查是大多数实现会跳过的那一个:如果最后一组有 1 个或 2 个填充字符,最后一个字母表字符未使用的低位必须是零,否则同样的字节可以被写成两个肉眼可见不同的字符串。正是这种可塑性让检查得以存在,而代价只有 4 行。最后,这个解码器接受不带填充的输入,而这正是 JWT 片段的样子。而且失败时它会交出位置:喂给它 TWF!ZQ==,错误停在索引 3,就在那个感叹号上,这就是 bug 报告和修复之间的区别。

Base64url:令牌说的字母表

标准字母表有两个字符在 URL 里活不下来:+ 在查询字符串里表示空格,/ 在路径里表示目录。RFC 4648 第 5 节用两个字符替换修复了这个问题 - + 变成 -/ 变成 _ - 并且对结果直言不讳:这种编码"不应被视为与 base64 编码相同"。它是 JWT、OAuth PKCE 代码挑战、YouTube 视频 ID 和大多数 API 令牌使用的字母表,而且它通常连 = 填充也一并丢掉,因为在令牌里长度是隐式已知的,填充只会是等着发生的百分号转义。上面的 C++ 解码器没有一个原生说这种字母表 - OpenSSL 甚至把 - 当作那个软性的输入结束标记 - 所以解决办法是在解码前做一小段转码。它短到可以轻松记在脑子里:

#include <string>

std::string url_to_standard(std::string in) {
  for (char &c : in) {
    if (c == '-') c = '+';
    else if (c == '_') c = '/';
  }
  switch (in.size() % 4) {
    case 2: in += "=="; break; /* 恢复被丢弃的填充 */
    case 3: in += '=';  break;
    default: break;
  }
  return in;
}

把它应用到一个真实的 JWT 上,前两段就是等着现身的纯 JSON。一个经典的示例令牌解码出头部 {"alg":"HS256","typ":"JWT"} 和一组声明,里面有一个主题、一个名字和一个签发时间戳。第三段用同样的方式解码,给你原始的签名字节 - 不是文本,也不是任何事情的证明。解码令牌告诉你它声称什么;验证签名告诉你是否该相信它,而那是密码学的工作,没有任何 base64 库会替你完成。在 Windows 上,情况是同样方向的单边:编码一侧有 CRYPT_STRING_BASE64URI 标志的 CryptBinaryToStringA,但解码方向根本没有 URL 安全标志,所以上面的转码在每一个平台上都配得上在你肌肉记忆里占一席之地。

从字节回到文本

问一个 C++ 解码器"我刚解码的是什么字符集?",你会得到这门语言最诚实的回答:没有。解码器从头到尾都是面向字节的。它们看不到文本;它们看到字节,然后把打包进去的字节原封不动地还给你。如果原始数据是 UTF-8,你现在拿到的就是 UTF-8,不需要再多做什么。C++ 有个巧妙的地方:std::string 本身就是带长度成员的字节容器,所以经典的 C 失败模式 - 字符串函数在第一个 NUL 处停下 - 基本蒸发了。一个解码出的文件、一个解码出的证书、一个解码出的图像:它们都可以住在一个 string 里,被 == 比较、被哈希、按值传递,里面的零字节就只是字节。只是别把它转成 C 字符串再用 strlen 测量;请用 size()

对于那些还潜伏在旧数据库、导出文件和手写工具里的遗留编码 - ISO-8859-1、Windows-1252 和它们的亲戚们 - 标准工具是随 glibc 一起发布的 POSIX iconv。先解码成字节,再用与来源匹配的编码把这些字节转换成 UTF-8:

#include <iconv.h>
#include <cstddef>
#include <string>
#include <vector>

std::string to_utf8(const std::vector<unsigned char> &raw,
                    const char *source_charset) {
  iconv_t cd = iconv_open("UTF-8", source_charset);
  if (cd == (iconv_t)-1) return {};
  char *inptr = reinterpret_cast<char *>(const_cast<unsigned char *>(raw.data()));
  size_t inleft = raw.size();
  std::vector<char> utf8buf(raw.size() * 4 + 8);
  char *outptr = utf8buf.data();
  size_t outleft = utf8buf.size();
  if (iconv(cd, &inptr, &inleft, &outptr, &outleft) == (size_t)-1) {
    iconv_close(cd);
    return {};
  }
  iconv_close(cd);
  return std::string(utf8buf.data(),
                     static_cast<size_t>(outptr - utf8buf.data()));
}

往返在两个方向上都是无损的:把一个字符串按 ISO-8859-1 打包,base64 它,发出去,解码它,转换它,你拿到的就是出发时的东西,重音字符完好无损。而二进制数据根本没有字符集 - PNG 就是 PNG,不管你喜不喜欢,这是整篇文章里最令人解放的答案。

解码文件

小文件是一支四步舞:以二进制打开,读进 vector,解码,再把结果以二进制写回。二进制模式,每次都是,每个平台都是 - 在 Windows 上,文本模式读取会把 CRLF 对翻译成单个换行,在解码器看到之前悄悄改变你的数据:

#include <fstream>
#include <iterator>
#include <string>
#include <vector>

std::vector<unsigned char> read_file(const std::string &path) {
  std::ifstream in(path, std::ios::binary);
  return {std::istreambuf_iterator<char>(in),
          std::istreambuf_iterator<char>()};
}

注意上面代码里的花括号。如果只用圆括号,形如 std::vector<unsigned char> bytes(istreambuf_iterator<char>(file), istreambuf_iterator<char>()) 的行就是声名狼藉的"最恼人的解析":编译器会把它读成一个返回 vector 的函数的声明,而且这么做完全正确。上面的花括号初始化形式直接绕开了这条语法规则。字节到手后,把它们喂给本文任意一个解码器,用 std::ios::binarystd::ofstream 写出结果,用 write(data.data(), data.size()) 而不是流操作符,这样任何内嵌的零字节都能活到落盘。一个 .b64 文件和它解码后的孪生体,差异恰好是你进来时缴纳的那 33% 税,这构成一个令人满意的校验和时刻。

大文件:读写都走流式

对于大到内存装不下的文件,OpenSSL 小节里的流式解码器就能干完整个活:读一块,推过上下文,把出来的写出去,重复。任何时刻内存里都只有一个小缓冲区,所以 10 GB 的 base64 文件和 10 KB 的用同一份代码解码,而且 MIME 式折行在流经过程中不需要任何预处理,因为流式解码器对换行只会耸耸肩:

#include <fstream>
#include <string>
#include <vector>
#include <openssl/evp.h>

bool decode_stream_to_file(const std::string &in_path,
                           const std::string &out_path) {
  std::ifstream in(in_path, std::ios::binary);
  std::ofstream out(out_path, std::ios::binary);
  if (!in || !out) return false;
  EVP_ENCODE_CTX *ctx = EVP_ENCODE_CTX_new();
  EVP_DecodeInit(ctx);
  std::string chunk(65536, '\0');
  std::vector<unsigned char> decoded(49152);
  bool ok = true;
  for (;;) {
    std::streamsize got = in.read(chunk.data(), chunk.size()).gcount();
    if (got < 0) { ok = false; break; }
    if (got == 0) break;
    int outl = 0;
    int ret = EVP_DecodeUpdate(ctx, decoded.data(), &outl,
                               reinterpret_cast<const unsigned char *>(chunk.data()),
                               static_cast<int>(got));
    if (ret < 0) { ok = false; break; }
    out.write(reinterpret_cast<const char *>(decoded.data()), outl);
    if (ret == 0) break;
  }
  unsigned char tail[3];
  int tail_l = 0;
  if (ok && EVP_DecodeFinal(ctx, tail, &tail_l) != 1)
    ok = false;
  if (ok)
    out.write(reinterpret_cast<const char *>(tail), tail_l);
  EVP_ENCODE_CTX_free(ctx);
  return ok;
}

缓冲区大小不是随便定的:EVP 函数的长度参数是 int,所以单次调用安全到 2 GB,而上面的数字让每块输入保持 64 KB,输出缓冲区 48 KB,恰好是 4 中的 3。那个 int 上限就是流式路径存在的全部理由,它值得作为一个硬事实被知道,而不是作为一个平台 bug 被意外发现。如果输入事实证明是损坏的,函数会在第一个无法解码的块处返回 false,输出文件保留之前有效的一切 - 这取决于你的管道,可能正好就是你要的部分结果。

HTTP、API 和那些藏字节的 JSON 字段

Base64 以两种形态出现在 HTTP 里。第一种是数据:一个 JSON 响应,"certificate""avatar" 字段里装满 base64;一个上传端点,用文本安全的列接收字节;一个下载端点,把 .b64 文件交给你。模式永远相同 - 解析 JSON,把字符串抽出来,解码它,把结果当作字节 - 而本文的解码一侧就是整个实现。第二种形态是凭据:Authorization: Basic 头是 user:password 的 base64,三十年来它一直是标准里唯一的 base64 用例。解析它只需两步,而第一步正是人们会在接近二进制的中间伸手去拿一个以 NUL 结尾的 C 字符串、然后纳闷为什么的地方:

#include <optional>
#include <string>

/* 来自"你自己拥有的 40 行"小节的 strict_decode */

std::optional<std::pair<std::string, std::string>> parse_basic_auth(
    const std::string &b64) {
  std::string raw = strict_decode(b64);
  size_t colon = raw.find(':');
  if (colon == std::string::npos)
    return std::nullopt;
  return std::make_pair(raw.substr(0, colon), raw.substr(colon + 1));
}

Basic 前缀之后的头值传给它,它会以正规带长度追踪的字符串形式交给你用户名和密码;如果数据不是一个 user:pass 对,就什么都不给。安全备注属于这里,尽管它不是 C++ 话题:Basic 认证是混淆,不是保护。这个头对任何能读网络的人来说都是明文裸奔,所以它只在 TLS 之后才可接受,而且即便如此,它也是机器对机器调用的选择,不是给人类用的。

JWT:读出令牌声称的内容

一个 JSON Web Token 是用点号粘起来的三个 base64url 段:头部、声明、签名。前两个是 JSON 对象;第三个是对字符串 header.claims 的密码学签名,用头部里点名的算法计算。C++ 没有内置的 JWT 类型,但读令牌只需要 base64url 小节的转码加一个解码器就够了,因为有意思的部分是

#include <cstddef>
#include <cstdio>
#include <string>

/* 来自前面小节的 url_to_standard 和 strict_decode */

int main() {
  const std::string token =
      "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
      "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ."
      "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
  size_t dot1 = token.find('.');
  size_t dot2 = token.find('.', dot1 + 1);
  std::string header = strict_decode(
      url_to_standard(token.substr(0, dot1)));
  std::string claims = strict_decode(
      url_to_standard(token.substr(dot1 + 1, dot2 - dot1 - 1)));
  std::printf("header: %s\n", header.c_str());
  std::printf("claims: %s\n", claims.c_str());
}

头部返回 {"alg":"HS256","typ":"JWT"},声明返回 {"sub":"1234567890","name":"John Doe","iat":1516239022} - 主题、名字和一个签发时间戳。这就是整个读取侧,而且它真的有用:记录令牌声称什么、通过查看过期字段调试 401、或者决定相信哪些声明,全都只差一次解码。它不是的是验证。签名段也是 base64url,解码它给你 32 或 64 个原始字节,单凭它们证明不了什么;签名只有当你用共享密钥或公钥重新计算 header.claims 的哈希并比较时才有意义。对待一个解码后的 JWT 要像对待一封信:它写着它写的,验证封口是另一件密码学工作。

Data URI:把自己粘进网页的文件

data URI 是一种载荷就明明白白写在地址里的 URL:data: 后面跟着可选的媒体类型、可选的 ;base64 标记、一个逗号,然后是数据本身 - 这就是 RFC 2397 的整个方案。浏览器用它们把图像、字体和小脚本直接嵌进 HTML 和 CSS,不产生额外请求;如果你见过一个关掉网络还能正常工作的页面,data URI 是头号嫌疑人。在 C++ 一侧,解码的工作是把 URI 拆开,然后把载荷过一遍你惯用的解码器,因为当 ;base64 标记在场时,载荷就是普通的标准 base64 - 通常带填充,通常单行:

#include <cstddef>
#include <string>

std::string data_uri_payload(const std::string &uri, bool *is_base64) {
  const std::string prefix = "data:";
  if (uri.rfind(prefix, 0) != 0)
    return {};
  size_t comma = uri.find(',');
  if (comma == std::string::npos)
    return {};
  std::string meta = uri.substr(prefix.size(), comma - prefix.size());
  *is_base64 = meta.size() >= 7 &&
               meta.compare(meta.size() - 7, 7, ";base64") == 0;
  return uri.substr(comma + 1);
}

data:image/png;base64,iVBORw0KGgo=... 调用它,它会交给你载荷,外加一个告诉你走哪条解码路径的标志。两个陷阱。第一,标记不在场时,载荷是 URL 编码的文本,不是 base64,所以那个标志不是走形式 - 一个看起来像 base64 但实际以百分号编码文本生成的 URI,会解码出一堆垃圾。第二,有些生成器会像 MIME 那样给长的 data URI 加换行折行;你的严格解码器会拒绝它们,所以如果来源不受你控制,就在解码前剥掉换行。解码一个 data:image/png URI 的载荷,给你分毫不差的 PNG 字节,连头部都在,这就是整个练习那份安静的满足感。

邮件、MIME 和 76 字符的习惯

邮件就是 base64 学会折行的原因。SMTP 最初形态是为承载七位 ASCII 而生的,所以任何二进制内容在出发前都必须被改写成可打印文本。Privacy-Enhanced Mail 在 1987 年用 64 字符的行做到了这一点;MIME 在 1993 年为邮件标准化这种编码时,把上限放宽到 76 字符,并加了一条规则:合规的解码器必须直接忽略换行。这个习惯活了下来:电子邮件附件今天仍然是 base64,按 76 折行,精确的算术算出来是 4/3 乘以 78/76 - 约为原始大小的 137%,外加几百字节头部。你的 C++ 解码器把它一路缩回 100%,这正是整个格式的意义所在。

C++ 里的曲折在于:本文的解码器们对换行意见不一,而且每个都有自己的理由。OpenSSL 流式解码器跳过流中任何位置的换行,这正是 MIME 的规则。OpenSSL 一次性函数拒绝数据内部任何空白。Boost.Beast 在第一个换行处停下,却不吭声。Boost 迭代器看到单个空格就抛异常。所以当数据来自邮件时,你的第一个决定是用哪个解码器,或者自己剥掉换行 - 对 \r\n 做一次一行式的 erase-remove 扫描 - 然后让你喜欢的任何解码器去做真正的活。预先剥掉是无聊但可靠的选择,也是让你的解码器选择独立于数据来历的那个选择。

数据库、配置文件和环境变量

Base64 的第三个家是存储层:遗留数据库里的一个列,文档上只写着 "base64" 再无其他;JSON 文件里的一团配置;环境变量里的一个载荷,被某个服务 Base64 编码过以便穿过 shell。解码模式到处都一样 - 读字符串,解码,当作字节 - 但没有标签的输入值得一段专门说明,因为有时候你真的不知道用的是哪个字母表。你不知道,但你可以测试,因为四个字符能干掉大部分工作:

  • 含有 +/ - 只有标准字母表可能是对的。
  • 含有 -_ - 只有 URL 安全字母表可能是对的。
  • 两者都没有,但以 = 结尾 - 带填充的标准,或一个带填充的 URL 安全字符串,其载荷恰好从不需要那两个被替换的字符。
  • 两者都没有,也没有填充 - 两个字母表都可能;原始 URL 安全形式在网上更常见,所以对生于 URL 或令牌的数据,先试它。

不使用这四个区分字符中任何一个的字符串,在两个字母表下解码结果完全相同,所以对它们,尝试顺序取决于数据从何而来:生于邮件的想要标准字母表,生于 URL 的想要 URL 字母表。也别忘记对同一个字符串同时试带填充和不带填充两种读法 - 少一个 = 就是"被拒"和"解决"之间的区别,这就是上面那个严格解码器两种都接受的原因。

命令行上有两个 Base64

对于一次性任务,一台 Linux 机器上通常有两个 base64 解码器,而它们的差异恰恰落在咬人的地方。第一个是 GNU coreutils 的 base64(一些较新的发行版改为附带 uutils 的重新实现,两者说的是同一套标志 - 用 base64 --version 查看)。它符合 RFC 4648,编码时按 76 字符折行(用 -w 0 可以关掉),解码时乐见任何位置的换行;它的 -i 标志让容忍垃圾变得明明白白,而不是靠意外。第二个是 OpenSSL 的,这里有个反转:openssl base64 根本不是自己的应用。从 1.1.0 系列(2016 年)起,enc 程序会检查自己的调用名,如果被叫作 "base64",它就把自己切换进 base64 模式 - 对 argv[0] 做字符串比较,这就是 C 发货别名的方式。没有 -A 时,它期待输入的前 1024 字节里某处有个换行,所以一个长的单行字符串会空着回来,退出码 0。有了 -A 它读一行,而 enc 命令有据可查的 bug 清单是一座两件藏品的小博物馆:-A 选项在大文件上不能正常工作,以及没有 -A 时,如果前 1024 字节里没有换行,输入的前两行会被忽略。在管道里,一个静悄悄的空文件看起来正好就是成功解码了一个空数据。

# 诚实的一行命令
base64 -d < payload.b64 > payload.bin
openssl base64 -d -A < payload.b64 > payload.bin

两者都不原生说 base64url,这是转码片段配得上住进你肌肉记忆的又一理由。对任何要紧的事,在你的程序里解码,那里错误以你可以测试的数字形式回来,而一个安静工具的退出码不再是你的唯一信号。

专门咬 C++ 的陷阱

  • 补零。 EVP_DecodeBlockTQ== 返回 3 个字节:字母 M 加两个零。从填充里恢复真实长度,或者用流式 API,它对计数是诚实的。
  • 3.5 之前的流式怪癖。 在 3.5.0(2025 年 4 月)之前的 OpenSSL 版本里,EVP_DecodeUpdate 有同样的补零习惯。针对 3.0 或 3.3 钉死的代码可能在尾部长度上骗你;修复记录在手册页的历史章节里。
  • 静默的停止。 Boost.Beast 的 decode 没有错误通道:它在任何无效字符、任何换行、任何不可能的尾部长度处停下,然后面不改色地返回一个部分结果。检查 consumed + pads == input.size() 且总数是 4 的倍数,否则你解码的就是它决定要解码的东西。
  • decoded_size 陷阱。 b64::decoded_size(n) 假定 n 能被 4 整除。2 个字符的输入可以产出 1 个字节,而 decoded_size(2) 说是零 - 为奇数长度加余量。
  • 零字节填充。 Boost 迭代器把 = 解码成值零,所以 TWFuZQ== 变成 6 个字节,包含两个尾部零。减去填充个数,否则就好好欣赏你的幽灵吧。
  • 空白,四种处理方式。 OpenSSL 流式跳过它,OpenSSL 一次性在内部拒绝它,archive 迭代器因它抛异常,Beast 在它处停下。粘贴来的字符串喜欢带着空白,而每个解码器对它都有自己的看法。
  • signed char 索引。 如果你自己按字符索引来搓解码表,请用 unsigned char 索引。在 char 为有符号的平台上,超过 127 的字节会变成负索引,那是穿着实验服的未定义行为。
  • 减号是时间旅行者。 在 OpenSSL 里,- 是 PEM 时代的软性输入结束标记,不是字母表字符。解码前先转码 base64url。
  • int,不是 size_t。 EVP 的长度参数是 int。超过 2 GB,只有分块的流式路径是安全的,这就是它存在的原因。
  • Windows 上的文本模式。 以文本打开文件会把 CRLF 翻译成 LF,在解码之前搞坏你的输入。std::ios::binary,每次都是,每个平台都是。
  • 最恼人的解析。 std::vector<char> v(istreambuf_iterator<char>(f), istreambuf_iterator<char>()) 是一个函数声明。用花括号初始化或指针配对。
  • 非规范的填充位。 宽容的解码器可能接受未使用的填充位非零的字符串,所以两个肉眼不同的字符串解码成同样的字节(base64 可塑性)。在安全边界上,拒绝你不需要的 - RFC 4648 说解码器可以恰好这么做。
  • 命令行静默失败。 不带 -Aopenssl base64 -d 会吞掉单行输入(空输出,退出码 0);有据可查的 bug 覆盖两个方向的大文件和无换行输入。在管道里检查你的输出。
  • 对二进制用 strlen。 std::string 让零字节安生,但一旦你把 C 字符串交给遗留 API,strlen 就在第一个 NUL 处停下。传长度和指针,永远不要只传裸指针。

C++ 里 Base64 的简史

这种格式比这门语言的现代时代更老。如今称为 MIME base64 的这种编码的第一个标准化用途是 Privacy-Enhanced Mail 协议,1987 年提出,用 64 字符的行,末尾粘着一个 RSA-MD2/MD5 消息完整性校验;"base64" 这个名字直到 1993 年才到来,是 MIME 标准给它起的。C++ 于 1998 年以 C++98 的身份登场 - 比 MIME 晚五年 - 这门语言的开发者伸手去拿的第一份 base64 代码是 Rene Nyffenegger 的 C 函数对(2004-2008),被一个 2008 年 12 月 4 日的 Stack Overflow 问题传遍全网。这个故事最美妙的部分是那位没露面的人:作者本人从未在帖子里发过回答,但他的片段成了人人复制的民谣。帖子中的一个回答从作者自己的网站转载了他的完整实现,以防那个网站哪天消失。

然后,生态做了生态一贯做的事。2002 年,Robert Ramey 的 Boost.Serialization 发布了迭代器适配器 - C++ 工具箱里最古老的 base64,严格到对单个空格都抛异常,比 RFC 3548 把它早已执行的规则成文还早一年。2017 年,Boost 1.66 带来了 Beast,以及随之而来的仅头文件编解码器,直到今天发布时页脚里还带着 Nyffenegger 的署名。与此同时,标准本身走过 C++11、C++14、C++17、C++20 和 C++23(2024 年发布),而它们每一个都看了看那个 64 字符的字母表,然后走开了。C++26 为文本编解码工作新增了一个 <text_encoding> 头文件,早在 2022 年就已获批;它的技术内容在 2026 年 3 月伦敦的 ISO 会议上完成,委员会以 114-12-3 的投票把它送往发布,而委员会 2026 年之后的会议 - 包括 11 月 16-21 日在巴西 Búzios 的那次 - 都将用来开启下一份工作草案 C++29,而不是为它投票。Base64 从未出现在草案里。七份标准,三十年,一个文本编码头文件 - 而委员会现在已经有过每一个可能的理由去加入 base64,又对每一个都放弃了。C++ 里 base64 的实用史,过去是、现在依然是它的库的历史:OpenSSL 的 EVP 例程、两种 Boost 口味、一个 Windows API 调用,以及一段你自己拥有的 40 行片段。

有趣的事实,C++ 版

  • 同样的那对函数出现在 2008 年一个 Stack Overflow 问题的回答里、Boost.Beast 的源码里(带着署名页脚),以及无数私有代码库的头文件里。问一个 C++ 开发者他们的 base64 从哪来,最诚实的回答是"我不知道,互联网也不知道"。
  • Boost 的 archive 迭代器是本文最古老的 base64,版权 2002 - 与 .NET Framework 1.0 SDK 发布的同一年。它们对单个空格抛异常,这意味着它们在 RFC 们追上来之前就一直在执行"拒绝字母表外字符"这条规则:RFC 3548 于 2003 年把它成文,RFC 4648 在 2006 年再次重申。
  • OpenSSL 的流式解码器靠一个 80 字节的内部缓冲区工作,但它每 64 个 base64 字符就冲刷一次,这正是 PEM 装甲从 1987 年一直沿用的行宽。那个安静的 64 是 2026 年旧格式仍在承重工作的最后几处地方之一。
  • 最小的带填充 base64 是 4 个字符,TQ==:一个字节穿着两字符的戏服。最小的不带填充是 2 个字符,TQ。你能解码到哪一个,完全取决于当初是谁编码的,而那个人编码时没在想你的事。
  • MIME 的数学是精确的:4/3 乘以 78/76,这就是邮件附件到达时约为原始大小 137% 的原因(上面还外加几百字节头部)。你的 C++ 解码器把它缩回 100%,这就是整个练习那份安静的乐趣。
  • 在典型的 libstdc++ 或 MSVC 上,std::string 通过短字符串优化把小载荷放在栈缓冲区里,而不是去分配。9 字节的输入解码成 6 字节,从不触碰堆。你那个小配置团的 base64 形式可能字面意义上就住在一个栈帧里,这是标准库从不宣传的那种免费午餐。
  • 你在 shell 里可能顺手去拿的 openssl base64 命令根本不是一个命令。它是 enc 程序在 argv[0] 里检查自己的名字并切换人格。一个靠字符串比较实现的别名,一种 C++ 式的做法,却写在 C 里。
  • YouTube 视频 ID 是 base64url:11 个字符,不带填充,在 URL 附近绝不会有 +/。这个星球上观看量最大的编码格式,跑在 RFC 4648 用一个能装进一页纸的章节加入的"URL 和文件名安全"变体上。

当你需要打包时

你刚才解码的一切,在另一边都是由同一个工具箱打包的:一次性的用 EVP_EncodeBlock,流式的用 EVP_EncodeUpdateEVP_EncodeFinal(那些 64 字符的行就是从这里来的),同样的缓冲区算术反过来算,还有同样那道 33% 的税,解码只是悄悄把它退了回来。完整的打包故事 - 逐项列出的大小算术、给输出加 NUL 结尾的编码器、从未见过填充字符的 Boost 迭代器、base64url、MIME 折行、文件,还有带着 CRLF 习惯的 Windows API - 住在姊妹站的 C++ 编码指南里。去读一读,然后回来拆一个大家伙。这就是全部的游戏:没有标准库,三家可靠但脾气各不相同的供应商,一个能指向让你受伤的那个精确字节的解码器,一个 2025 年改变了流式尾部的 bug 修复,以及一个要记一辈子的补零三元组。拆包愉快。

最后更新: 2026-09-08

相关文章: C++(Cpp)中的 Base64 编码:完整指南