上传文件
  1. base64hub
  2. 在代码中使用 Base64
  3. C++
代码

C++ 中的 Base64

C++ 标准库没有 Base64,连 C++23 也没有。本页给出一个紧凑的仅头文件实现,我们用 Python 对它做过模糊测试,另外还列出了你可能已经在用的库的 API。

仅头文件的实现(C++17)

encode 接受字母表和填充开关作为参数,所以同一个函数既能生成标准 Base64,也能生成 URL 安全的 Base64。decode 接受带填充和不带填充的输入,但会拒绝无效字符、位置不对的 =、不可能出现的长度以及不为零的填充位。

// base64.hpp
#pragma once
#include <array>
#include <cstdint>
#include <optional>
#include <string>
#include <string_view>
#include <vector>

namespace b64 {

inline constexpr std::string_view kStd =
    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
inline constexpr std::string_view kUrl =
    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";

inline std::string encode(const void* data, std::size_t n,
                          std::string_view abc = kStd, bool pad = true) {
    auto p = static_cast<const std::uint8_t*>(data);
    std::string out;
    out.reserve((n + 2) / 3 * 4);
    std::size_t i = 0;
    for (; i + 2 < n; i += 3) {
        std::uint32_t v = p[i] << 16 | p[i + 1] << 8 | p[i + 2];
        out += abc[v >> 18 & 63];
        out += abc[v >> 12 & 63];
        out += abc[v >> 6 & 63];
        out += abc[v & 63];
    }
    if (i < n) {
        std::uint32_t v = p[i] << 16 | (i + 1 < n ? p[i + 1] << 8 : 0);
        out += abc[v >> 18 & 63];
        out += abc[v >> 12 & 63];
        if (i + 1 < n) out += abc[v >> 6 & 63];
        if (pad) out.append(i + 1 < n ? 1 : 2, '=');
    }
    return out;
}

inline std::string encode(std::string_view s, std::string_view abc = kStd,
                          bool pad = true) {
    return encode(s.data(), s.size(), abc, pad);
}

inline std::optional<std::vector<std::uint8_t>> decode(
        std::string_view s, std::string_view abc = kStd) {
    std::array<int, 256> val;
    val.fill(-1);
    for (int i = 0; i < 64; ++i) val[static_cast<unsigned char>(abc[i])] = i;

    std::size_t len = s.size();
    if (len % 4 == 0 && len > 0 && s[len - 1] == '=')
        len -= (s[len - 2] == '=') ? 2 : 1;
    if (len % 4 == 1) return std::nullopt;
    if (s.size() != len && s.size() % 4 != 0) return std::nullopt;

    std::vector<std::uint8_t> out;
    out.reserve(len * 3 / 4);
    std::uint32_t acc = 0;
    int bits = 0;
    for (std::size_t i = 0; i < len; ++i) {
        int v = val[static_cast<unsigned char>(s[i])];
        if (v < 0) return std::nullopt;
        acc = (acc << 6) | static_cast<std::uint32_t>(v);
        bits += 6;
        if (bits >= 8) {
            bits -= 8;
            out.push_back(static_cast<std::uint8_t>(acc >> bits));
            acc &= (1u << bits) - 1;
        }
    }
    if (acc != 0) return std::nullopt;  // leftover bits must be zero
    return out;
}

}  // namespace b64

解码器以比特累加器的方式工作:每个字符加入 6 位,每凑满 8 位就输出一个字节。最后剩下的就是填充位,它们必须为零。原因见 Base64 的工作原理。

编码和解码字符串

#include "base64.hpp"
#include <iostream>

int main() {
    std::string enc = b64::encode("héllo wörld");
    std::cout << enc << '\n';                         // aMOpbGxvIHfDtnJsZA==

    if (auto bytes = b64::decode(enc))
        std::cout << std::string(bytes->begin(), bytes->end()) << '\n';  // héllo wörld
}

std::string 存放的是字节。源文件为 UTF-8 时(GCC 和 Clang 的默认设置;MSVC 需要加 /utf-8),字面量就是 UTF-8,输出与其他所有语言一致。对于 C++20 的 std::u8string,把 s.data() 和 s.size() 传给指针版本的重载即可。

URL 安全与无填充

std::string q = "<<?\?\?>>";                          // "<<???>>", escaped (see below)
b64::encode(q, b64::kUrl);                            // PDw_Pz8-Pg==
b64::encode(q, b64::kUrl, false);                     // PDw_Pz8-Pg
b64::decode("PDw_Pz8-Pg", b64::kUrl);                 // bytes of "<<???>>"

之所以要转义,是因为在 C++17 之前 ??> 是一个三字符组(trigraph);GCC 在严格的 -std=c++11 / c++14 模式下会替换它。JWT 和大多数令牌使用不带填充的 Base64URL,见 Base64 与 Base64URL 的区别。

校验结果

decode 的返回结果,均已验证:

输入结果
aGk=2 个字节
aGk2 个字节(接受无填充)
aA== / aA1 个字节
aGl=nullopt(填充位不为零)
aGk==nullopt(长度错误)
a===nullopt(填充过多)
aGk=!!nullopt
PDw_Pz8-Pg==使用 kStd 时为 nullopt

我们用 g++ -std=c++17 -Wall -Wextra -Wpedantic -fsanitize=address,undefined 构建,在两种字母表下对 5,000 个随机缓冲区做了往返测试,把编码器与 Python 的 b64encode 进行对比,并以 Python 参考实现为准,检查了 30,000 个随机字符串的接受或拒绝判断。没有任何不一致。想手动检查单个字符串,Base64 校验工具 会解释每次拒绝的原因。

文件

#include <fstream>
#include <iterator>

std::ifstream f("hello.txt", std::ios::binary);
std::string data((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
std::cout << b64::encode(data) << '\n';               // aGVsbG8K

auto raw = b64::decode("aGVsbG8K");
std::ofstream("copy.txt", std::ios::binary)
    .write(reinterpret_cast<const char*>(raw->data()),
           static_cast<std::streamsize>(raw->size()));

在 Windows 上 std::ios::binary 很重要,因为文本模式会转换换行符,从而损坏二进制数据。处理大文件时,每次读取大小为 3 的倍数的块,分别调用 encode,输出可以直接拼接。C 语言中的 Base64 页面给出了这个循环。如果要把结果转成 C 数组嵌入代码,可以用 Base64 转字节数组 工具生成。

你可能已经在用的库

如果下面某个库已经是项目依赖,用它的 Base64 API 就能少写一个文件:

库编码解码
OpenSSLEVP_EncodeBlockEVP_DecodeBlock(返回的长度包含填充字节)
Abseilabsl::Base64Escape、absl::WebSafeBase64Escapeabsl::Base64Unescape、absl::WebSafeBase64Unescape
QtQByteArray::toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals)QByteArray::fromBase64
PocoPoco::Base64Encoder(一个 ostream)Poco::Base64Decoder(一个 istream)
Boostboost::beast::detail::base64(内部实现,不是稳定 API)同左

在依赖某个库做校验之前,先确认它如何处理填充和空白,各个库的行为并不相同。例如 EVP_DecodeBlock 不会从返回值中减去填充,所以 aGk= 报告的是 3 个字节,而不是 2 个。

常见陷阱

  • 把有符号 char 用作索引:当 char 为有符号类型时,对于大于 127 的字节,table[s[i]] 是未定义行为。要像上面那样先转换为 unsigned char。
  • 把解码出的字节当作 C 字符串:解码出的字节可能包含 \0,千万不要把它们传给 strlen,或任何只接受裸 const char*、不带长度参数的 API。
  • 把缓冲区大小算成 n * 4 / 3:这样最多会少 3 个字符。精确公式见 体积开销指南。

常见问题

C++ 有标准的 Base64 函数吗?

没有。标准库里没有,截至 C++23 合入的提案里也没有。可以用本页这样的小型实现,或者用项目已经依赖的某个库的 API。

decode 应该返回 std::string 还是 std::vector<uint8_t>?

Base64 解码出来的是二进制数据,所以 std::vector<std::uint8_t> 更如实地表达了这一点。如果你确定载荷是文本,再用 vector 的迭代器构造一个 std::string 即可。

为什么用 std::optional 而不是异常?

来自用户输入的无效 Base64 是预料之中的情况,并不算异常。std::optional 强制调用方处理它,而且在正常路径上没有任何开销。如果你的代码库偏好异常,可以在外面包一层再抛出。

继续阅读